为什么要将 Chimee 设计成一个组件化框架?

基本概念

在进行视频场景开发中,我们会接收到多种需求,我们可以按照一定维度进行划分:

交互维度需求划分

我们首先关注和我们最近的表层交互方式,以下需求按照交互程度和主视频播放的结合关系紧密程度排序。

1. 控件类需求

这种需求的代表是控制条、音量调节按钮、暂停播放按钮等视屏 UI 需求。

音量控制条

它们的特色是可以控制和展示 video 的相关属性。如控制条对应 currentTime、 音量调节按钮对应 volume、暂停播放按钮对应paused等。

与此同时,由于日久天长的用户培养,这些控件与 video 元素紧密结合。例如当你点击视频区域时,可以等价于点击暂停播放按钮,当你聚焦在元素上是,键盘控制可以反映到控制条和音量调节按钮上。

2. 展示类需求

这种需求的代表是无交互式弹幕、无交互式直播间礼物等普通刷屏式展示需求。

弹幕

它们的特色是仅仅在视频播放器上覆屏展示,但是并不阻截视频交互。当然部分情况下自身也会有相应的交互效果展示。与此同时,它们可能与 video 元素有轻度关联,例如弹幕可能在视频暂停的时候停止。

3. 覆屏式需求

这种需求的代表是右键菜单、浮层广告、有交互式弹幕等需求组成。

右键菜单

它们的特色是覆盖在视频上并自带完整的交互逻辑。一般而言对它们的交互不会影响视频播放(自定义功能除外)。与此同时

4. 阻截式需求

这种需求一般以贴片广告为主。

贴片广告

贴片广告既可以直接打入视频流中,也可以在页面中插入其他视频。此种情境下谈论的是后者。此时,主视频完全被阻截。所有操作交互均无法传递。

5. 周边需求

这种需求包括视频播放列表、弹幕列表、更多推荐等。

它们可以触发视频源更改、视频中状态组件变更等。

在 HTML 层面上它们和 video 元素基本没有关系。但是在 UI 视觉层面上,它们紧紧贴在视频旁,可以和视频一起考虑,并且某些特定情况下会产生关联。

状态维度需求划分

假如我们将原生 video 元素视作一个数据状态储存容器,那么可以看到以上四种需求需要如下权限:

当然,以上各个组件根据自身需求拓展也有可能要求完整的读写权限。

在另一种看法下,阻截式需求可以视为另一个完整的视频播放器,此播放器不能进行任何交互式操作等。

这种看法属于业务方自行实现的逻辑,不在 chimee 播放器自身需求讨论之列。

组件化的规则

在面对如此多繁复的需求时,我们都会引入“分治”的概念。组件化正是我们需要的方案。

分治的核心在于组件间隔离,保持低耦合度。在编写组件的时候我们要注意:

  1. 不关心其余组件的状态数据
  2. 不承担非自己工作范畴内的任务

只有这样我们才能创造出低耦合的组件,这有利于我们:

  1. 快速添加或移除组件
  2. 添加新组件时不需要过分关心旧组件
  3. 快速迁移已有组件

数据驱动 vs 事件驱动(待补充)

难题

回顾上一章节我们可以发现,普遍的需求都会需要一个 dom 节点展示(这不废话么)。同时,大部分需求都需要对视频元素及其状态进行操作。

这种状况下很容易会产生对页面资源的争夺和状态设定权的争夺。

常见的资源争夺如下

层级资源争夺

当一个组件需要展示自己的时候,他需要争取较高的层级,以防被其他展示者所遮挡。同样,当一个组件需要和用户交互的时候,他也需要争取较高的层级。这种每个组件都希望在最高层级的争夺会容易带来混乱。

1. 层级管理

我们回顾一下弹幕需求,当我们添加弹幕后,可以看到弹幕组件覆盖了整个视频区域。根据往常的用户习惯,用户会希望在点击视频区域时实现暂停/播放控制。我们该如何实现呢?

弹幕

有人会提出,创造一个透明的 UI 交互层,置于最顶层。弹幕处于其后方。此时我们既可以在最顶层完整展示弹幕同时又能实验视频区域交互了。

因此我们需要框架提供层级管理,并且能够动态调整层级。

2. 事件转发

于是这个时候商业产品向我们提出了在弹幕上添加广告的需求,并且希望我们在点击弹幕广告的时候,正常暂停视频并弹出弹窗等。

弹幕广告

此时我们发现弹幕也需要交互,而下方视频也需要交互。则顶层组件必须要做事件转发。

无论是保持上述的组件架构还是添加一个新的弹幕广告组件,我们都要在最顶层组件中判断,我现在所收到的交互事件是否需要下发给下层。

毫无疑问,这增加了我们组件编写的工作量也不方便我们日后迁移组件。

因此,我们需要由底层框架负责事件转发功能。

视频状态争夺

这种争夺常见于贴片广告需求。

贴片广告

当我们播放贴片广告的时候,用户希望快速跳过广告,而广告方则希望用户看完完整广告。

这种情况下,控制条插件和广告插件状态就会产生冲突。

常见的做法有:

  1. 广告插件隐藏控制条插件或者封禁控制条插件功能
  2. 控制条在察觉到贴片广告需求时自行进行功能阉割

但是这两种做法会存在如下问题:

  1. 要获知对方的存在,并根据对方的状态作不同处理

以做法1为例,我们知道常有的控制 UI 除了控制条外,还有键盘、手势触碰等。那么我们在写广告插件的时候就需要去判断这些插件是否存在并且做相关的封禁。而后期每增加一种控件,就要相应地去更改广告插件。

  1. 增加了组件复杂度

以控制条插件为例,因为广告插件的存在,则需要给自身增加封禁的状态。

那么该如何解决该问题了。幸运的是,我们所有插件都是围绕视频状态进行操作。

因此我们只要对视频状态的数据进行锁定即可,而这种状态锁定也应该只由特定组件进行操作。其他组件无需感知。

焦点抢夺

一般我们不鼓励通过 document 进行键盘事件监听,因为那样子容易与视频组件外的其他页面行为混杂在一起。但是这种情况下,需要 video 元素获得焦点。但是焦点可能会因为其他元素自身特性产生转移。如假如我的组件中有一个 button 元素,则很可能将焦点移除到自己身上。为了保证键盘事件持续,我们需要在组件中重新 foucsvideo 元素上。而这也增加了组件编写时的工作量。

因此我们希望底层框架能在特定情况下帮助我们自动转移焦点

框架职能

为了方便我们更好地组件化相关代码,所以我们封装了 chimee 框架。Chimee框架的目标是希望开发者在写插件的时候,仅需要关心自己组件本身的功能和视频元素即可。下面我将阐述 Chimee 的相关职能以及相应的设计思路。

维护视频状态

在前文我们已经解释了 Chimee 相当于是 video 元素的一个封装。而对于各个组件,它们也是对 video 的封装。因此关于视频的状态和方法都可以直接在组件实例上调用。

例如,在组件中你如果想播放视频,你可以这么做:

this.src = 'http://cdn.toxicjohann.com/lostStar.mp4';
this.play();

然而,上文我们也提到了视频状态冲突的问题,那么我们怎么解决这个问题呢?

大家可能会在第一时间想利用锁的机制,例如增加playLocked

但是这个做法其实非常不方便,你的代码会变的非常丑陋:

if(!this.playLocked) {
  this.play();
}

有的人会提议,那么你们将锁封装在方法里即可。例如:

this.play(); // play only when the playlocked is true

但是这样的做法仍然存在问题。

  1. 我们如何确保这个方法执行成功?
  2. 我们并不能确切知道是谁加的锁,谁可以解锁

因此,引入一个锁机制其实只是将方法调用的混乱转嫁到锁的身上而已。

事件机制

万幸的是,video 有很好的事件机制,这让我们很好的解决了第一个问题。

this.on('play', evt => console.log('we have played!'));
this.play(); // we have played!

我们可以将我们后续的处理都放到事件处理上。而由于 video 在 HTML 中本身就是如此设计的,所以大家的代码也写的顺理成章。

那么我们如何阻截呢?

因为 play 处理本身是事件体系,而在事件中,冒泡这个概念很好的满足了我们的需求。故我们制作了相应的事件机制。

在我们的事件体系中,有四个阶段:

阶段 意义 拦截后果 示例(以play为例)
before 事件被处理前的阶段 事件不会被触发,后续插件不知道事件发生过 beforePlay,如果拦截,则不会执行 play 操作
main 事件主阶段,一般是浏览器所抛出的事件或用户自定义事件中的主执行阶段 后续插件不知道事件被触发 play,如果拦截,后续插件不知道 play 操作被调用
after 事件处理后阶段 后续插件不能做事件后的处理 afterPlay,如果拦截,后续插件无法获知 afterPlay 事件
_ 副作用阶段,只要一个事件曾经触发,无论是否触发成功都会进入此阶段。存在意义是为了让我们知道某个事件被触发 不能拦截 _play,无论是否 play 成功,都会触发该事件。

以刚刚所说的贴片广告冲突为例。我们希望广告不能被快进。

在编写控制条的时候,我们不需要关心广告的存在。我们只需要提供控制条的快进方法,并且在监听到seek事件时作出样式变化即可。

而在广告插件中,为了不让其快进,我们作如下操作即可

this.on('beforeSeek', () => false);

我们可以轻易实现对任意操作的拦截。

事件挂起

想象一个需求场景,我们需要封装一个带广告的播放器。用户在外层调用 play 方法,此时我们首先要进行广告播放,其次要进行主视频播放,你会如何实现?

在基于视频拦截的情况下,我们的广告插件会有如下流程。

  1. 用户触发 play
  2. 广告插件在 beforePlay 阶段进行拦截
  3. 广告插件自身再次 触发 play
  4. 广告插件不拦截 beforePlay
  5. 视频 play 处理
  6. 视频播放完毕后,广告再次触发和用户 play 一样的事件。

这个流程其实看起来已经足够简便,但是它还是有以下的弊端:

  1. 广告插件需要关心并调起后述主视频播放
  2. 广告插件需要储存之前用户触发的 play 事件
  3. 用户调用的 play 其实已经被废弃,但用户不自知

就此看来,这种方式还是让广告插件与主视频播放有所耦合,并且期间伪造了指令,增加了广告插件编写的难度不纯度

因此我们引入了事件挂起的机制,当用户在事件处理时返回 Promise ,我们会等待 Promise.resolve() 后方才执行后续流程。在该机制的配合下,上述场景的流程我们可以这么编写。

  1. 用户触发 play
  2. 广告插件在 beforePlay 挂起事件
  3. 广告插件自身再次触发 play
  4. 视频 play 处理
  5. 视频播放完毕,广告插件放行原用户 play 事件

但是事实上使用 Promise 作处理还是会带来不便。

例如:

  1. 因为被放到 event loop 中,故不能阻止事件冒泡
  2. 外层调用需要考虑异步场景,最好使用 await 作处理

因此,在 Chimee 中只有 play, pause, load 三种事件和用户使用 emit 调用的事件允许挂起。

通过事件中断、事件挂起等方式,开发者现在能轻松地处理视频状态冲突的问题。

层级管理

Chimee 会自动帮我们维护层级,并且可以动态设置。Chimee 采用优先级机制。

每个插件都有自己的 level 值, level 值高者必然会在 level 值低者的顶层。然后我们可以根据此进行排序。

并且插件中可以动态更改 level 值,并存在制定方法。

const originalLevel = this.$level;
// 置顶
this.$bumpToTop();
// 回复原状
this.$level = originalLevel;

事件转发

Chimee 同时也会承担事件转发的功能。你可以通过 autoFocus 进行设置。

多种组件设置

为了方便我们覆盖各式场景,我们提供了透明插件穿透插件内层插件外层插件四种概念,具体区别请查看插件 Api

异步组件加载

为了更加优质的用户体验,部分组件可能会进行异步加载。在 Chimee 中,我们支持动态加载组件。

要使用一个组件,我们首先要安装组件,然后再使用之。而组件是可以在实例建立后才使用的。

import Chimee from 'chimee';
import asyncPlugin from './async-plugin';

const chimee = new Chimee('#wrapper');
Chimee.install(asyncPlugin);
chimee.use(asyncPlugin.name);

编写组件的注意事项

  1. 注意组件的纯度和低耦合度,尽可能避免关注其他组件数据和状态
  2. 组件自身方法应完整
  3. 组件应有适当的状态数据展示
  4. 如有调用频次和锁等问题,应组件内部解决,不应将问题抛出外层。

组件间交流

当组件的数目增多到一定程度后,必然会遇到组件交流的问题。组件交流需要我们选择合适的方式,否则很容易陷入一团乱麻。

让我看看这段代码

在传统的组件交流后有以下几种方式,大家可以根据自己的需求去选择。

直接接口调用

Chimee 的组件中可以直接通过 $plugins 获取组件实例集合,继而直接调用相应组件的方法。

这种方法直接简便,但是可能会有如下问题:

  1. 需要确定是否有相应组件存在
  2. 若组件替换则需要修改相应代码
  3. Chimee 插件实例名称允许自定义,可能会造成错误
  4. 难以阻截
  5. 组件内部修改方法时需要注意是否有被外界调用

这种方法对于紧密结合且变化程度较低的组件可以配合使用。

数据传递

可以通过自建的数据机制或 video 状态传递数据。

事件调用

当然我们也可以通过 Chimee 的事件进行沟通。

因为 Chimee 插件上并没有自定义事件处理,所有的事件都可以理解为绑定在 video 上。因此如果要进行特定的事件处理由特定插件处理,可以尝试使用命名空间。

这种方法由以下优点:

  1. 不需要理会由何种组件负责实现
  2. 可以阻截
  3. 相当于由稳定的接口机制

但也会有以下问题:

  1. 代码上不够直观, debug 难度较大
  2. 可能会出现没有组件处理事件的情况但是无人得知。