Chimee1 是由奇舞团开源的一套可扩展的H5组件化播放器框架。由于前段时间业务有视频播放的需求所以使用了它,并基于它提供的插件系统之上开发了一系列的插件,其中最复杂的是控制条插件。由于默认的样式无(实)法(在)满(是)足(太)设(难)计(看)需(了)求(!),所以我们重新开发了一套 lizheing/chimee-plugin-controlbar2 并总结一些心得,希望对大家有帮助。
开篇之前我们先简单的了解下如何开发一款 Chimee 的插件,文档3 提供了一个非常简单的示例告诉我们大概的流程。
const plugin = {
// 插件名为 controller
name: 'controller',
// 插件实体为按钮
el: '<button>play</button>',
data: {
text: 'play'
},
methods: {
changeVideoStatus () {
this[this.text]();
},
changeButtonText (text) {
this.text = text;
this.$dom.innerText = this.text;
}
},
// 在插件创建的阶段,我们为插件绑定事件。
create () {
this.$dom.addEventListener('click', this.changeVideoStatus);
},
// 插件会在播放暂停操作发生后改变自己的文案及相应的行为
events: {
pause () {
this.changeButtonText('play');
},
play () {
this.changeButtonText('pause');
}
}
};
// 安装插件
Chimee.install(plugin);
const player = new Chimee({
// 播放地址
src: 'http://cdn.toxicjohann.com/lostStar.mp4',
// dom容器
wrapper: '#wrapper',
// 使用插件
plugin: ['controller'],
});
通过以上示例代码我们可以发现 Chimee 的插件的规则主要有以下几点:
插件需要暴露一个对象,并带有 name 属性用来标记插件名称,方便后续实例化的时候指定。
HTML 模板相关的内容需要放在 el 属性中,在其它方法中可以通过 this.$dom 访问到 DOM 对象。
data 属性用来存放数据,methods 属性可以用来存放方法。两个属性中的数据可以直接通过 this.xx 的形式访问到。
events() 属性可以放置一些播放器的事件回调,Chimee 会自动做事件的绑定。
从以上发现的几点来看,我们可以大致估摸出插件的简单逻辑。无非就是将 data 和 methods 中的数据绑定到 this 上,然后插入 el 的 HTML 到页面中,然后执行一些对应的生命周期方法。
听起来似乎比较 Vue?虽然写法上是这样的,但是明眼人能看出来其实这个流程还是 DOM 操作,所以必然会碰上原生 DOM 开发的两个问题:
data 虽然能存放数据,但是数据无法直接映射到 el 的 HTML 模板中,导致还是需要回归原本的 DOM 操作。
事件绑定需要等待 DOM 生成后在生命周期中进行手动的绑定,又回到了 DOM 操作时一堆 addEventListener 的年代了。
由于我们的业务是使用 React 开发的,所以就想是否可以通过引入 React 来解决上述 DOM 操作的问题。
使用 React 开发虽然能解决上段提到的两个问题,但是插件本身的配置以及生命周期却是不太好在 React 中实现。要想实现插件即组件,组件即插件的场景需要对现有的一些生命周期进行映射,这成本就有点高了。所以最终退而求其次,只有 DOM 这块使用 React,外层依旧是正常的 Chimee 插件的配置。
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import Context from './Context';
export default {
Context,
name: 'plugin-demo',
el: `<div class="plugin"></div>`,
create() {
ReactDOM.render(
<Context.Provider value={this}>
<App />
</Context.Provider>,
this.$dom
);
}
};
而为了解决 React 组件内部需要获取 Chimee 的方法的问题,我们通过 Context 的方式将 this 这个 Chimee 插件实例传入,内部通过 Context 获取到实例之后就可以为所欲为了。
import React, { useContext, useState, useEffect } from 'react';
import Context from '../Context';
export default function () {
const ctx = useContext(Context);
const [cur, setCur] = useState(0);
useEffect(() => {
ctx.$on('timeupdate', () => setCur(ctx.currentTime));
}, []);
return (
<div className="play--time">{cur}</div>
)
}
不得不感谢 React Hooks 的出现,让 Context 的 API 变的如此的简单。如果要像之前一样写 <Context.Consumer /> 然后再来个 render props 估计也是够烦的。当然使用 Context 只是为了方便在组件层级比较深的情况下数据的透传,这也是 Context 的本来意义。如果插件的组件层级不深直接使用 Props 传递也是可以的。
目前这套插件的模式已经能够满足大部分日常插件的开发了,不过碰到了异步就有点问题了。我们知道的是插件的配置在 new Chimee() 那一刻就已经固定下来了,外部的数据的更新并不会影响内部的配置,也就无法造成插件的更新渲染,毕竟目前 Chimee 没有一个类似 componentWillReceiveProps() 的生命周期。如果 Chimee 自己本身配置都无法更新上,就更遑论触发 React 的更新了。
回到具体的业务场景上,我们有一个需求是控制条需要显示下一条视频的标题和封面图,而这个数据是在业务代码中通过接口获取得到的,所以在最开始实例化播放器的时候我们是没办法传入该数据。最后我们决定用事件监听的方式解决该问题,通过自定义事件进行跨组件层级的数据传递。
随着业务越来越复杂,播放器在同一事件要处理的事情越来越多,例如我们碰到的片尾需要先播放片尾广告,广告播放完之后显示倒计时,倒计时后再触发播放下一条的动作。如何在同一个事件中保证三者的顺序成了本次需求的关键。
好在当初 Chimee 在设计之初就已经考虑到了这个问题。先执行的回调只要返回 false 即可阻止整个事件,当该插件完成任务后只要重新触发事件即可。
import React, {useEffect} from 'react';
import Context from './context';
export default function() {
const ctx = useContext(Context);
useEffect(() => {
const onEnded = event => {
if(event === 'manual') {
return true;
}
setTimeout(() => ctx.$emit('ended', 'manual'), 10000);
return false;
};
ctx.$on('ended', onEnded);
return () => ctx.$off('ended', onEnded);
});
return (<div>推迟10秒后结束</div>);
}
在 ended 事件触发后使用 return false 阻止事件,然后等事件执行完毕之后使用 ctx.$emit('ended', 'manual') 重新触发事件。这里有个需要注意的地方,后者触发的事件中又会再次将当前插件注册的回调事件执行一遍,所以这里增加了第二个参数用来让回调方法判断是否是插件自己触发的,如果是自己触发的则表示无须再次执行直接跳过。
Chimee 的插件系统可扩展性比较强,如果对其比较感兴趣的可以看看官方的这篇《为什么要将 Chimee 设计成一个组件化框架?》4。使用 React 开发插件解决了事件和数据绑定的问题,该开发模式下基本上就是废弃了官方自己提供的 data, methods 和 events 三个属性,前两者自然是由 React 内部的 state 和方法提供,后者则是自行在组件内部使用 ctx.$on 的形式手动监听。