涌现
在《失控——机器社会与经济的新生物学》一书中,作者用蜂群的例子向读者们阐述了“涌现”(emergence)的概念,即一个复杂系统中由次级组成单元间简单的互动所造成的复杂现象[1]。
在业务开发中,“涌现”的现象非常常见。产品的功能在持续的迭代不断追加,这些功能可能是不同的开发者增加的、可能是在不同的时间阶段增加的。众多看似简单或复杂的功能,纠缠在一起,逐渐进化成一个可怕的怪兽。
播放视频是一个在很多业务领域都非常复杂的业务功能。比如视频网站:有弹幕、有广告;比如前段时间很火的、回形针出品的、基本操作:用canvas模拟视频交互等等。在线教育也是其中之一。
一个在线教育产品通常会有多种多样课程,每种课程都会有不同的上课形式:
- 不能暂停的、可以暂停的;
- 不能拖动进度的、可以拖动进度的、按给定预设点跳转进度的、用快进快退的跳转进度的;
- 支持各种互动形式:红包、摄像头、作业、调查问卷等;
- 穿插各种各样的动画、声音效果;
面对这种业务需求,通常开发者会使用一个基础的视频播放器组件,支持常用的播放、停止操作,抛出进度事件等。然后,将业务功能做成一个个业务组件(vue/react)。最后,在不同的视频播放场景,用不同的参数初始化播放器组件,按业务需求逐一插入对应的业务组件。这种做法会引起以下几个问题:
1. 重复工作
对每一种播放场景,都要一一将组件插入,然后处理播放器和业务组件、业务组件和业务组件之间的交互、处理组件层级关系、增加动画效果等等。
2. 系统易腐化
因为业务需求是随着产品迭代渐渐产生的,如果开发者的经验不够,很容易出现将业务功能强耦合在当前播放场景中、下一次在其它播放场景用到这个业务功能的时候,又要把功能剥离出来或者直接把代码复制过来。
3. 强行用代码的树形结构去描述业务场景的网状结构
组件的嵌套关系、事件传递路线都是树形的。但是在复杂的业务场景中,程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。用树形的组件结构、事件路线去翻译、处理网状结构,显然就会比较吃力。
面对这些问题,Chimee提出了一套解决方案。
Chimee是什么
Chimee是奇舞团开源的一个视频播放器框架。
在使用方法上,它由若干个包组成。使用的时候要根据需求进行功能配置组合、二次开发,形成一个符合业务需求的播放器。
在简单的视频播放场景,官方提供了web端和wap端、已经封装好的播放器,可以直接拿来使用。在复杂的视频播放场景,则可以根据需求的复杂程度来自定义播放器。
对于较为简单的自定义需求,如修改播放图标,定制播放弹窗插件等,可以在官方提供的封装好的播放器上进行二次开发。
对于复杂的自定义需求,可以在引入Chimee的本体包后,进行全方位的自定义:设定所需要的编解码器、开发并配置业务功能插件。
这是使用Chimee的一个简单示例:
import Chimee from 'Chimee';
import forwardRewindPlugin from './forwardRewindPlugin'
Chimee.install(forwardRewindPlugin);
const player = new Chimee({
wrapper: '#player',
src: 'demo.mp4',
plugins: [forwardRewindPlugin.name]
});
首先,我们引入的是Chimee的原生裸包:import Chimee from 'Chimee';
它不包含任何基础UI:进度条、开始暂停等等。但是和常见的视频播放器一样,可以监听事件:播放、暂停等,调用相关的API:seek,load等。
为了实现快退快进功能,我们安装了一个快进快退插件Chimee.install(forwardRewindPlugin);
forwardRewindPlugin
插件的定义如下:
import { PluginConfig } from 'Chimee';
import ForwardRewind from './ForwardRewind.vue';
export const name = 'forward-rewind';
export const plugin: PluginConfig = {
name,
el: `<div id="${name}"></div>`,
data: {
forwardStep: 20,
rewindStep: 20,
},
methods: {
forward() {
this.$emit('seek',
this.currentTime + this.forwardStep
)
},
rewind() {
this.$emit('seek',
this.currentTime - this.rewind
)
},
},
create() {
const component = new ForwardRewind();
component.$on('forward', () => {
this.forward();
});
component.$on('rewind', () => {
this.rewind();
});
component.$mount(`#${name}`);
},
};
从代码里可以看到,这里定义的“插件”其实只是一个对象PluginConfig
,包含了如下键值:
name
,插件名称,在Chimee内部的标识参数;el
,插件申请的dom节点,插件初始化的时候,会创建并挂载这个根元素到页面结构上;data
,语法糖,会动态绑定到插件实例上;methods
,语法糖,会动态绑定到插件实例上;可以看到,在forward
和rewind
两个方法里会抛出一个seek
事件。播放器能够监听到这个事件,执行进度跳转;create
,生命周期钩子
除了这些参数,还有其它生命周期钩子、events
:监听事件的语法糖等等。
将插件的名字在初始化播放器的时候作为参数传递,Chimee就会将插件中申请的dom节点挂载在页面上,监听插件中指定的事件,并执行插件相应的生命周期。从而实现快进快退功能。
从以上示例可以知道,Chimee中的插件是一个拥有生命周期、支持事件机制、可以直接调用播放器API的对象。正是Chimee的插件化架构,让它可以解决我们上一节中提出的、业务中的诸多问题。
Chimee的插件化架构
“插件”是一个并不新鲜,但也并不简单的概念。Chimee中的插件、Chrome浏览器的插件、webpack的插件等等,看似功能各有不同,其实都是插件化架构的具体应用。
插件化架构,也叫做微内核架构,是一种面向功能进行拆分的可扩展性架构,允许开发者将其他功能作为插件添加到核心应用程序,从而提供可扩展性以及分离和隔离功能。变化部分封装在插件里,达到快速灵活扩展的目的,而又不影响整体系统的稳定[2]。
插件化架构的基本组成和核心功能应当包括:
- 核心系统组件:负责和具体业务功能无关的通用功能
插件管理:当前有哪些插件可用,如何加载这些插件,什么时候加载插件插件连接:插件如何连接到核心系统、插件实现规范插件通信:插件间的通信
- 插件模块组件:负责实现具体的业务逻辑
本节从插件化架构的角度解读Chimee源码。
Chimee本体包的目录结构如下:
.
├── config
├── const
├── dispatcher
├── helper
├── plugin
├── kernels
└── index.ts
在第一级目录从后往前看(跳过不需要关注的部分):
- index.ts:Chimee类,也是整个Chimee本体包的导出对象;
- dispatcher:定义了调度器类和一些其它的核心类;
disptcher文件夹中定义了Chimee的核心功能,index.ts将dispacher中对外提供的功能组织为Chimee类并导出。
.
├── binder.ts
├── bus.ts
├── dom.ts
├── video-wrapper.ts
├── kernel.ts
├── plugin.ts
└── index.ts
在dispatcher目录从后往前看(跳过不需要关注的部分):
- index.ts:Dispatcher类
- plugin.ts:插件类
- video-wrapper.ts:实现VideoWrapper类,封装浏览器中原生的video标签,屏蔽处理video的兼容问题
- dom.ts:实现DOM类,负责进行框架中涉及到的DOM操作;
- bus.ts:实现(Event)Bus类,也就是俗称的事件总线,实现了一套事件的触发和监听机制
- binder.ts:实现Binder类,封装了框架中涉及到的事件监听、事件触发操作;
这些类实现了上文中提到的插件化系统应有的核心功能:
- 在Dispatcher类中
- 模块内变量pluginConfigSet保存开发者定义的插件配置
- 实例的成员变量plugins中,以name为key保存插件的实例
- 在Chimee的构造函数中初始化Dispatcher,在Dispatcher的构造函数中初始化各个插件、
- 在初始化时,各个时机中执行插件的生命周期
- 在Plugin类中
- 利用Object.defineProperty,代理一些参数、API的访问到dispatcher上,实现插件和核心系统的连接
- 读取methods、events等值,执行事件监听、this的绑定等等
- 在Binder和Bus类中,定义了事件总线机制,用来实现插件间的通信
事件总线模式
上节中提到对于“插件通信“这个问题,Chimee采用了事件总线模式实现。
常见的设计模式可以分为三类[3]:
- 创建型:主要解决“对象的创建”问题;
- 结构型:主要解决“类或对象的组合或组装”问题;
- 行为型:主要解决的就是“类或对象之间的交互”问题;
Chimee采用的事件总线EventBus模式,是行为型设计模式:观察者模式中的一种。
观察者模式是一个比较抽象的模式,根据不同的应用场景和需求,有完全不同的实现方式。但是大体的设计思路通常是,维护一个监听者列表,实现添加监听者、移除监听者、通知监听者功能。
Chimee的事件总线机制,是一种支持同步和异步,阻塞和非阻塞、带生命周期的事件总线机制。在Bus类中实现。相关API包括:
on/once
:绑定事件off
:解绑事件emit
:触发异步事件emitSync
:触发同步事件
同步和异步
emit
会触发异步事件,当事件是异步的时候,监听者可以返回一个pending状态的Promise,将事件挂起。
emitSync
会触发同步事件,当时事件是同步的时候,监听者只能返回布尔值,将事件阻截或通过。
事件的生命周期
一个事件的触发,会分为五个阶段,也就是事件的生命周期:before、process、main、after、_。
before阶段:触发beforeXXX事件,该事件的监听者可以通过返回Promise或者布尔值将事件挂起或取消;
process阶段:判断事件是否需要对核心系统,也就是video进行操作,如果需要则执行。比如play事件,是需要操作video的,video.play()会在这个阶段执行;
main阶段:执行各个监听者对XXX事件的监听回调;
after阶段:触发afterXXX事件,该事件的监听者可以通过返回Promise或者布尔值将事件挂起或取消;
_阶段:副作用阶段,触发_XXX事件,该事件在main阶段后必然会执行,可以用于检测事件是否被成功触发;
Chimee事件总线机制的实现
为了实现以上功能,Bus首先定义了核心的数据结构events
,保存事件名-事件阶段-插件id-回调函数数组的树形结构。
private events: {
[eventName: string]: {
[eventStage: string]: {
[pluginId: string]: Array<(...args: any[]) => any>,
},
},
};
基于以上数据结构,Bus
定义了方法如下:
绑定事件
绑定事件的逻辑比较简单,从on/once
入口传入事件名、事件阶段和事件监听函数,通过addEvent
方法传入数据结构events
中。
有两点值得注意的地方:
- 拆分
on
方法和addEvent
方法。因为once
中也需要调用addEvent
,所以进行了拆分。 - 函数劫持:在once方法中,为了实现“一次触发后自动解绑”的功能,需要增加一个
函数劫持
的步骤:将传入的回调函数包裹在一个新的函数中,新的函数里会执行解绑操作。最后将这个新的函数作为真正的回调函数保存。
newHandler = () => {
handler();
unbind();
}
“一次性绑定”的功能在很多框架和库的事件机制中都有提供,比如Vue中的$once
也是用函数劫持的思路实现的。
解绑事件
解绑事件的逻辑也比较简单,从off
入口传入事件名,事件阶段,调用removeEvents
把它从数据结构中删除
触发异步事件
这是整个事件总线机制中最复杂的部分。
第一步:使用事件名,从events
中获取事件event。
注意events
的数据结构是:事件名-事件阶段-插件id-回调函数数组的树形结构,所以这里获取到的event
的数据结构是:事件阶段-插件id-回调函数数组。
第二步:执行getEventQueue
筛选出event中所有before阶段的监听函数,形成数组beforeQueue
。
第三步:执行beforeQueue
中的监听函数。
Chimee提供了一个runRejectableQueue
方法来执行beforeQueue
,会按照数组索引,从0开始,递归执行所有函数。前一个函数resolve
/返回true
后才会执行下一个。如果其中一个reject
或者返回false
,则停止执行。
runRejectableQueue
方法和Promise.all
似乎有一些相似之处。但是:
Promise.all
中的所有Promise
全部都会执行,无论中间是否有reject
的Promise
。只是最终返回的Promise状态为reject
;Promise.all
中的Promise
不会等到上一个resolve
后才执行,而是立即执行;
第四步:当beforeQueue
执行完毕(均resolve
/返回true
),会执行eventProcessor
,在eventProcessor中,会执行事件的process
阶段。
第五步:执行trigger
。
在trigger
中会执行事件的main阶段、after阶段、副作用阶段。其中main阶段和after阶段的执行和before阶段一致,从event中取出mainQueue、afterQueue,用runRejectableQueue
执行。
副作用阶段会特殊一些,依次同步调用。
触发同步事件
同步事件的触发和异步事件的触发过程大体一致。主要区别在于所有的事件阶段队列xxxQueue
,在异步触发中使用runRejectableQueue
执行,但是在同步触发中,使用runStoppableQueue
执行。runStoppableQueue
会按照数组索引,从0开始,递归执行所有函数。前一个函数返回true
后才会执行下一个。如果其中一个返回false
,则停止执行。
可以看到,两者的区别在于是否接受Promise。
生命周期
在上一节中,Chimee在经典的事件总线模型上增加了生命周期。Chimee的插件同样也有生命周期。
生命周期是钩子函数的一种体现。在一个有序的步骤中的特殊位置(挂载点),插入自定义的内容。这就叫"钩子",它给了开发者在某个阶段做某些处理的机会。
和钩子函数类似的概念的还有模板方法模式、回调函数。
- 模板方法模式。在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤[3]。
- 回调函数。回调是一种双向调用关系。A用F作为参数调用B,B反过来调用F,这种调用机制就叫作“回调”。
钩子函数的执行,常常要用到回调、钩子函数的注入也常常在模版代码中执行。它们在框架设计、实际应用中都有着广泛的使用。比如:
- Vue/React类组件中的生命周期,是钩子函数的一种;
- 子类对父类的继承,就是最典型的模板方法模式;
- JavaScript作为一门函数式编程语言,将函数作为参数传递并执行的方式是家常便饭。
总的来说,生命周期的机制,常常在各种框架的设计中出现,可以很好的起到扩展框架功能点的作用。
装饰器
在Chimee的代码中,还大量的用到了装饰器。装饰器是一种经典的设计模式,属于解决“类或对象的组合或组装”问题的结构型设计模式,其出现的意图是对类的原始方法进行增强。
以Chimee中的一段具体代码为例:
class Bus {
// ...
@runnable(secondaryChecker)
public emit(key: string): Promise<any> {
//...
}
// ...
}
function secondaryChecker(key: string) {
// 检查key是否合法,并在生产环境输出非法提示
}
@runnable(secondaryChecker)
就是对emit
方法的增强:它在emit
方法执行的时候,检查传入的key值是否合法。
JavaScript中的装饰器,目前处于建议征集的第二阶段,但是在TypeScript中已做为一项实验性特性予以支持。其具体用法不再展开介绍。
Chimee实现了若干个装饰器,并将它们包含在了一个单独的包[toxic-decorators](https://github.com/toxic-johann/toxic-decorators "toxic-decorators")
里,如:
runnable
:传入一个可执行的函数包裹装饰的函数readonly
:定义一个属性为只读
在class-style的Vue组件中,装饰器也被广泛使用,如:
import { Vue, Component, Watch } from 'vue-property-decorator';
@Component
export default class YourComponent extends Vue {
@Watch('child')
onChildChanged(val: string, oldVal: string) {}
}
@Component
装饰类,将这个类增强为组件。
@Watch('child')
装饰类方法,将这个方法增强为对child
属性的监听。
总结
Chimee的设计中,运用了插件化架构、事件总线模型、生命周期、装饰器等多种架构方法和设计模式。
借助合理的架构和设计模式,实际业务中许多问题都能得到解决。
- 重复工作
插件化架构可以大大的减少重复工作,面对新的播放场景,将插件以数组的方式组合即可形成满足业务新需求的播放器。
- 系统易腐化
在插件化的架构下,业务功能天然被隔离在一个个插件中,在顶层的框架中形成了强制的开发规范,整个视频播放系统呈现出良性增长的趋势。
- 强行用代码的树形结构去描述业务场景的网状结构
将业务功能抽象成插件后,依靠事件总线模型,每个插件只和事件总线打交道。最后按照业务流程把插件组装起来。这种思考路径更适合复杂程序的开发。
很多开发者会把业务功能的开发简单粗暴的定义为CRUD,认为其缺乏复杂度和技术挑战。事实上,在日常的业务开发中,可以去抽象可复用的功能,乃至总结领域内的业务模式,积极的将它们提炼为通用的工具、库、框架,最终就能得到系统稳定性的提升、人效的提高、技术的成长。
参考文献
[1] 陆睿.《CRUD工程师晋级之路》.zhihu.com
[2] 李运华.《从0开始学架构》.极客时间
[3] 王争.《设计模式之美》.极客时间
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!