1. 为什么先讲Emitter类:jessibuca事件机制的入口
如果你是第一次接触jessibuca,打开它的文档可能会有点懵:new Jessibuca()、play()、on()、off()、emit(),各种API铺了一整屏。但你仔细看就会发现,除了播放器本身的控制方法之外,整个SDK与外部的交互几乎全是靠事件完成的——on('load')、on('play')、on('error')、on('stats'),包括手动派发一些内部消息,也都离不开事件系统。
所以我说,搞懂Emitter类,基本就摸到了jessibuca的“神经中枢”。它是事件机制的底层实现,也是jessibuca源码里最值得单独拆出来研究的模块之一。这篇就是“jessibuca入门”系列的第二篇,专门针对Emitter类做一次由浅入深的拆解。
这篇内容适合这几类人:刚把jessibuca跑起来但不知道事件该怎么绑、on和once到底该选谁、off为什么有时候“解不掉”的入门开发者;正在看jessibuca源码、想理解它内部事件流转的前端工程师;以及想参考成熟播放器SDK的事件设计,给自己组件库或项目搭一个通用事件机制的人。
我会从“事件驱动”的底层逻辑讲起,把Emitter的API、源码实现、实战用法、常见坑挨个说一遍,最后再聊一点进阶思路。读完你应该能做到:遇到jessibuca的事件问题不再靠猜,而是能根据源码和事件流自己定位原因。
1.1 jessibuca的“事件驱动”设计
先抛开代码,用生活场景打个比方。你去餐厅吃饭,不需要每秒钟盯一次厨房“菜好了吗”,只需要等着服务员喊“28号餐好了”。服务员喊的这一嗓子就是“广播”,你听到后起身去取餐就是“响应”。在这个过程里,你和服务员之间并没有建立一条一对一专线,你们用的是“公共广播系统”。
jessibuca和外部页面之间也是这么通信的。播放器内部是解码、渲染、音频输出这些重活,外部页面只关心“播放成功没”“视频卡没卡”“出错了没有”。如果每个状态都要外部去轮询,性能上不可接受,代码也会乱成一团。事件机制的价值就在这:内部状态变化时主动“广播”,外部页面按需“收听”。
这正是发布订阅模式的核心思路。发布者不关心谁会收到消息,订阅者也不关心消息从哪来,两边通过一个事件通道解耦。jessibuca把事件通道封装成了Emitter类,然后在播放器实例上直接挂载了on、once、off、emit这几个方法,外部页面用起来就是纯黑盒——不关心播放器内部怎么实现,只关心事件名和回调。
1.2 理解发布订阅模式
发布订阅模式在JS里有多普遍?window.addEventListener是它,Node.js的EventEmitter是它,状态管理库Redux的设计思路也和它有关。本质就四件事:注册、触发、移除、自动注册后触发一次。
Emiiter类的on就是“注册”:说“我对某某事件感兴趣,有消息就通知我”。emit就是“触发”:说“某某事件现在发生了,带着这些参数通知所有感兴趣的人”。off是“撤单”:说“我不用再听了,把之前注册的回调删掉”。once是“一次性订阅”:听一次,触发完自动退订。
有了这层理解,接下来看jessibuca的Emitter类的具体API,你会觉得很眼熟。它并不是什么高深的设计模式,而是把一个看似普通的工具类用在了播放器这种需要频繁异步通知的场景里,并且做得足够轻量、足够稳定。这也是为什么我建议从Emitter类入手读jessibuca源码——它够小,但信息量很大。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Emitter类核心API盘点:on、once、off、emit到底怎么用
我通常在研究一个库的时候,先不看源码,先把它的对外API用一遍,再带着疑问去看源码。这样效率高很多,因为你已经知道每个方法是干什么的,看源码就是在验证“它怎么实现的”。
jessibuca暴露给开发者的Emitter相关API不多,就四个:on、once、off、emit。看起来很简单,但里面有几个容易忽略的细节,一个一个说。
2.1 on方法:注册事件监听
on用于监听某个事件,是最常用的API。用法:
js复制const jessibuca = new Jessibuca({
container: document.getElementById('player'),
videoBuffer: 0.2,
isResize: false
});
jessibuca.on('play', () => {
console.log('播放器开始播放了');
});
jessibuca.on('error', (error) => {
console.error('播放出错:', error);
});
注意几个点:on可以同时绑定多个回调,先后顺序按照绑定的顺序执行;同一个监听函数可以被重复绑定,它会被存储在列表里,触发时会执行多次;on本身不会去重。这在一些场景下会引发“事件被重复处理”的坑,后面细说。
另外,on的第三个参数可以传ctx,用来指定回调函数内部this的指向。如果你在回调里需要访问Vue/React组件实例的方法,这一步就很有用。但如果你用的是箭头函数,ctx参数就没意义了,因为箭头函数不绑定this。
2.2 once方法:只触发一次
once和on的区别就一个字:只执行一次。执行完自动从事件列表里移除。
js复制jessibuca.once('load', () => {
console.log('播放器加载完成,只需处理一次');
});
这个API在业务里特别常用,尤其是那些只关心“第一次”的场景,比如初始化完成后上报埋点、播放器加载完成后初始化自己的业务逻辑。用once的好处是不用手动调用off,省一行代码,也少一个忘记解绑的隐患。
我自己的习惯是:如果一个回调确实只关心第一帧画面、第一次解码成功这种一次性事件,优先用once,而不是用on然后找时机手动off。代码可读性和安全性都能好一些。
2.3 off方法:解绑监听
off是事件系统里最容易让人栽跟头的API。它支持几种用法:
js复制// 1. 不传任何参数:解绑所有事件的所有监听
jessibuca.off();
// 2. 只传事件名:解绑该事件的所有监听
jessibuca.off('play');
// 3. 传事件名和回调函数:解绑该事件下指定的监听
const handler = () => { console.log('处理逻辑'); };
jessibuca.on('play', handler);
jessibuca.off('play', handler);
重点是第三种用法:传入的回调函数必须和注册时是同一个引用。如果你注册时传的是内联箭头函数,解绑时再写一个一模一样的内联箭头函数,它俩不是同一个引用,off不会生效。
js复制// 错误示范:两个箭头函数不是同一个引用,解绑无效
jessibuca.on('play', () => { console.log('play'); });
jessibuca.off('play', () => { console.log('play'); });
// 正确示范:将回调单独提出来
const handlePlay = () => { console.log('play'); };
jessibuca.on('play', handlePlay);
jessibuca.off('play', handlePlay);
2.4 emit方法:触发事件
emit在外部业务代码里用得不频繁,但理解它是理解jessibuca内部机制的关键。它是事件的“广播器”:内部状态变化时,jessibuca会调用emit来通知所有订阅者。
js复制// 内部源码类似这样:解码出第一帧时
this.emit('play');
// 出错时
this.emit('error', {
code: 1,
msg: '解码失败'
});
// 主动停止播放时
this.emit('pause');
// 自定义事件也走同一套通道
jessibuca.emit('myCustomEvent', { key: 'value' });
可以看到,emit的第一个参数是事件名,后面的参数都会透传给回调函数。所以你在on('error')回调里拿到的error对象,其实是emit('error', error)时传进去的那个参数。
2.5 实际项目中怎么区分这4个方法
用一张表总结,方便对照:
| 方法 | 作用 | 触发次数 | 是否需要手动解绑 | 典型场景 |
|---|---|---|---|---|
on |
注册监听 | 每次事件触发都执行 | 需要 | 持续监听播放状态、错误、统计信息 |
once |
注册一次性监听 | 只执行第一次 | 不需要,自动移除 | 初始化完成、首帧渲染、首次加载 |
off |
解绑监听 | - | - | 组件销毁、路由切换、页面隐藏 |
emit |
触发事件 | - | - | 业务自定义事件、内部状态广播(通常不用业务调用) |
这4个方法里,前三个是你在业务代码中会高频使用的,emit更多是阅读源码时需要理解的对象。
3. 源码层面拆解Emitter实现:一个只做四件事的类
API用熟了之后,再看源码就轻松了。我把jessibuca的Emitter源码逻辑梳理成“结构 + 关键实现”,不逐行贴完整源码,但核心逻辑我会用自己的话帮你还原一遍,保证你理解到位。
(说到源码版本,jessibuca更新比较快,具体实现细节可能在不同版本里略有出入,但核心逻辑是稳定的。我以当前主流版本的主干逻辑为例。)
3.1 jessibuca的Emitter源码结构
整体结构可以用一句话概括:用一个对象存储所有事件名到回调数组的映射。
js复制class Emitter {
constructor() {
this._listeners = {};
}
on(type, fn, ctx = this) {
if (!this._listeners[type]) {
this._listeners[type] = [];
}
this._listeners[type].push({
fn: fn,
ctx: ctx
});
return this;
}
once(type, fn, ctx = this) {
const self = this;
function listener(...args) {
self.off(type, listener);
fn.apply(ctx, args);
}
listener.fn = fn;
return this.on(type, listener, ctx);
}
off(type, fn = null) {
if (typeof type === 'undefined') {
this._listeners = {};
return this;
}
if (!this._listeners[type]) {
return this;
}
if (typeof fn !== 'function') {
delete this._listeners[type];
return this;
}
const listeners = this._listeners[type];
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
// 需要同时匹配 fn 和 listener.fn(兼容once包装)
if (listener.fn === fn || listener.fn === listener.fn) {
listeners.splice(i, 1);
i--;
}
}
if (listeners.length === 0) {
delete this._listeners[type];
}
return this;
}
emit(type, ...args) {
const listeners = this._listeners[type];
if (!listeners || listeners.length === 0) {
return this;
}
// 复制一份再遍历,防止回调里修改数组导致遍历错乱
const currentListeners = listeners.slice();
currentListeners.forEach((listener) => {
listener.fn.apply(listener.ctx, args);
});
return this;
}
}
上面这段是我按理解重写的一个功能等价版本,不代表jessibuca逐行原文,但核心逻辑是吻合的。有几个设计细节很值得说道。
3.2 源码阅读顺序和关键点
第一个关键点是_listeners的数据结构。它是对象而不是数组,因为事件名是字符串,用对象作Map的话,事件名到回调数组的查找复杂度是O(1)。每个事件名下挂着一个数组,支持一个事件多个监听者。
第二个关键点是on方法的返回值。它返回this,这是典型的链式调用设计。你可以写jessibuca.on('play', fn1).on('play', fn2).on('error', fn3),虽然实际业务中不常这么写,但源码返回this说明作者在接口一致性上考虑得比较周到。
第三个关键点是once的实现。这里有个很巧妙的设计:once不是直接把原始回调fn塞进数组,而是包装成一个新的函数listener。当事件触发时,先执行off把自己从监听列表里移除,再执行原始回调。这样不管事件触发多少次,原始回调最多执行一次,而且off删除的是包装函数,不是原始函数。
要注意的是,为了兼容“外部想通过off解绑once事件”这种需求,代码里给包装函数挂了一个listener.fn = fn属性。这样你在外面写了jessibuca.once('load', handler)之后,想用jessibuca.off('load', handler)解绑,off才能通过listener.fn === fn判断出该移除谁。
第四个关键点是emit里的slice()。为什么要复制一份再遍历?因为回调函数执行时可能会修改事件列表。比如回调A执行时调用了off把自己的监听移除了,如果不复制,遍历索会引起错位、漏执行。复制一份当前列表后,即使原数组被修改,遍历也不会乱。这是事件系统实现里的一个经典陷阱,jessibuca把这个细节处理掉了。
3.3 为什么jessibuca要自己做Emitter而不是直接用EventTarget
你可能注意到浏览器原生提供了EventTarget接口,addEventListener也能实现类似功能。那jessibuca为什么还要自己封装一个Emitter?这个问题我在看源码时琢磨过,总结下来有三点原因。
第一,API风格不同。jessibuca的on/off/emit更接近Node.js的EventEmitter风格,前端开发者对这种命名更熟,也和jessibuca整体的API命名保持一致。原生addEventListener相对啰嗦,少说也要写addEventListener('play', handler)这么长。
第二,事件对象模型不同。原生dispatchEvent要求你构造一个CustomEvent或Event对象,而emit直接把参数透传给监听函数,简单直接。比如emit('stats', stats),你在回调里拿到的是纯数据对象,不需要event.detail去包一层。
第三,轻量。jessibuca的Emitter实现就几十行,无依赖,打包体积可以忽略不计。它不需要原生事件系统的DOM事件流传播机制——那些冒泡、捕获、委托的能力在播放器SDK内部完全用不上,徒增复杂度。
4. Emitter在jessibuca实战中的典型用法:从绑定到解绑的完整链路
理论说完了,进入实战。这段我挑几个在真实项目中一定会遇到的场景,把Emitter的用法串起来讲。
4.1 播放器生命周期事件绑定
jessibuca播放器的完整生命周期大概是:load加载视频源 → play开始播放 → 播放过程中产生timeUpdate、stats等事件 → 用户或代码触发暂停/停止 → 最终销毁。每个阶段都有对应的事件。
一个比较完整的监听示例:
js复制const jessibuca = new Jessibuca({
container: document.getElementById('player'),
videoBuffer: 0.2,
isResize: false,
text: '',
loadingText: '加载中'
});
jessibuca.on('load', () => {
console.log('视频源加载完成');
});
jessibuca.on('play', () => {
console.log('播放器开始播放');
// 这里可以隐藏加载动画、开始上报播放成功率
});
jessibuca.on('pause', () => {
console.log('播放器暂停');
});
jessibuca.on('error', (error) => {
console.error('播放器错误:', error);
// 这里应该做错误提示,根据error.code区分是网络错误还是解码错误
});
jessibuca.on('stats', (stats) => {
console.log('实时统计信息:', stats);
});
每条事件都有它自己的触发时机和参数格式,文档里列得很清楚。这里单独提一下error事件:它返回的错误信息里通常包含code和msg两个字段,code是错误码,不同码代表不同错误类型,比如网络异常、解码失败、格式不支持等。建议你在项目里做一张错误码映射表,把用户能看懂的中文提示挂在对应错误码上,而不是直接展示原始msg。
4.2 事件回调参数格式
jessibuca的每个事件回调参数都不一样,我踩过不少坑,先说几个常见的:
play没有参数,触发即代表进入播放状态。error返回一个对象,包含code和msg。stats返回一个统计对象,包含fps、kbps、bufferSize、currentTime、totalTime等字段,用于监控播放质量。timeUpdate返回当前播放时间(秒)。audioInfo返回音频相关信息,比如采样率、声道数。这个事件在部分流格式里不触发,做兼容时要考虑到。
一个实际技巧:绑定回调前先console.log一下事件对象,看看真实结构再写逻辑,能减少很多“回调参数和文档对不上”的挫败感。文档更新偶尔滞后于代码,源码是最终事实来源,所以遇到参数对不上的情况,去源码里搜emit('xxx')看看实际传了什么,是最靠谱的排查方式。
4.3 内存泄漏与正确解绑
前端项目里最常见的内存泄漏来源之一,就是事件监听器绑了没解除。播放器实例虽然被销毁了,但外部页面还持有对它的引用、监听器还挂在它上面,GC没法回收,内存就一点点涨上去了。
正确写法是在组件销毁时主动解绑:
js复制// Vue 3示例
onBeforeUnmount(() => {
jessibuca.off();
jessibuca.destroy();
});
两种方式都可行:一是调用off()不传参数,解绑全部事件;二是逐个解绑,指定事件名和回调引用。考虑到组件销毁后播放器也会销毁,直接off()全部解绑是更稳妥的做法。
但如果你只是想“暂停时不接收某个事件的回调了”,而不是彻底销毁播放器,那就得用off('play', handler)这种定向解绑,并且保证handler和注册时是同一个引用。这里最省心的方法是在组件setup里定义好handler变量,然后在on和off里都用同一个变量。
另外一个容易被忽视的点:如果回调里用了this访问组件实例,解绑时和注册时必须保持一致。如果注册时通过ctx参数指定了上下文,解绑时也需要带上相同的上下文才能匹配。不过在实际项目中,我更推荐直接用箭头函数避免this问题,少一个维度的坑。
5. 常见问题与踩坑记录:Emitter用起来之后才会懂的那些事
代码写多了,坑都是踩出来的。这里把Emitter相关的经典问题整理成一份速查表,每个都是我在实际项目里遇到过的。
5.1 事件重复绑定导致回调执行多次
症状:同一个页面里,播放器错误回调执行了好几次,每次报错都是一样的。
原因:组件被重复创建,或者配置了热更新,同一个播放器实例上被绑定了多份相同的回调。尤其是那种在mounted里每次初始化都会on('error', handler)的写法,组件每次挂载都是新增一份监听,旧监听并没有被销毁。
解决:一方面,组件销毁时记得off()或者destroy();另一方面,如果需要在同一个实例上重新绑定,可以先off再on。我在实际项目中习惯封装一个bindPlayerEvents方法,每次绑定前先player.off()清一遍,再绑定全部事件。
5.2 this指向丢失
症状:回调里访问组件实例的方法时,报this.xxx is not a function。
原因:on('play', this.handlePlay)这里的handlePlay被当作普通函数调用,this不再指向组件实例。
解决:最简单的方案是定义方法时就用箭头函数:
js复制const handlePlay = () => {
this.doSomeThing();
};
jessibuca.on('play', handlePlay);
或者传入ctx参数:
js复制jessibuca.on('play', this.handlePlay, this);
两种方式都可以。我推荐箭头函数,因为团队里每个人对ctx参数的理解程度不一致,箭头函数直观,少沟通成本。
5.3 事件名拼写与文档不一致
症状:绑定了事件但打死不触发,console里也没报错。
原因:事件名拼错了,比如把timeUpdate写成timeupdate。事件名是字符串匹配,大小写敏感,拼错肯定触发不了。
解决:去官方文档复制事件名,不要手打。如果你用的是TypeScript,jessibuca提供了类型定义,事件名和回调参数类型都有约束,能规避大部分拼写错误。如果是普通JS项目,建议在代码里写一个事件名常量对象统一管理。
js复制const PLAYER_EVENTS = {
LOAD: 'load',
PLAY: 'play',
ERROR: 'error',
TIME_UPDATE: 'timeUpdate',
STATS: 'stats'
};
5.4 解绑无效
症状:调off('play', handler)后,play事件还是能触发回调。
原因:解绑时传入的handler和绑定时不是同一个引用。绝大多数情况是绑定时用了() => {}匿名函数,解绑时又写了一遍。
解决:把回调函数抽出来用变量引用。这个上面说过了,但值得再强调一遍,因为这是新手最容易踩的坑。
还有一个冷门原因:如果你用once绑定,想用off解绑,必须像前面说的那样让off的第二个参数和once的第一个参数是同一个引用,并且jessibuca的once实现里做了listener.fn = fn的兼容处理,所以理论上是可以解绑的。如果解绑不生效,先确认jessibuca的版本,旧版本可能没有这个兼容逻辑,直接升级到最新版本即可。
5.5 速查表:问题一眼定位
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 回调执行多次 | 重复绑定未解绑 | 绑定前先off,销毁时off |
| this指向错误 | 普通函数this丢失 | 用箭头函数或ctx参数 |
| 事件不触发 | 事件名拼错、大小写不一致 | 复制文档事件名,用常量管理 |
| off无效 | 回调引用不一致 | 用变量保存回调函数 |
| once不生效 | 重复绑定多个once | 检查是否在循环里绑定了事件 |
6. 一点进阶思路:自定义事件与封装建议
Emitter类本身不只服务于播放器内置事件。由于jessibuca把emit也暴露出来了,你完全可以在业务里构建自己的事件通知机制。这在多模块协作时非常有用。
6.1 用Emitter做自定义业务事件
比如你在做一个监控大屏,页面上有播放器、告警列表、地图三个模块。后端推送了一个告警消息,你希望地图定位到对应摄像头、播放器切换到这个摄像头的画面、告警列表自动高亮——如果模块之间直接相互调用,耦合度会很高。
这时可以用jessibuca上的Emitter通道做中介:
js复制// 告警模块
function handleAlarm(alarm) {
jessibuca.emit('businessAlarm', alarm);
}
// 地图模块
jessibuca.on('businessAlarm', (alarm) => {
map.locate(alarm.cameraId);
});
// 播放器模块
jessibuca.on('businessAlarm', async (alarm) => {
await jessibuca.play(alarm.streamUrl);
});
这样模块之间只用事件通信,不需要知道对方内部怎么实现。后续要加新的响应模块,只要再on('businessAlarm', handler)就行,不影响已有逻辑。
6.2 后续学习建议
学完Emitter类之后,建议你顺着这条线继续往下看源码:先看jessibuca的constructor里是怎么初始化Emitter的,再看播放器内部在哪些时机调用了emit,接着对照文档把每个事件触发的业务背景串起来。你会发现emit分布的位置,往往就是播放器状态机发生迁移的节点,理解了这些节点,你对jessibuca整个生命周期的掌控感会明显不一样。
关于自定义事件命名,我也踩过坑:不要用play、error这种和内置事件同名的自定义事件,否则事件触发时你分不清是内部逻辑触发的还是业务逻辑触发的。建议所有自定义事件用统一前缀,比如my或app开头,一眼就能区分。
我在实际项目里的体会是,事件系统的核心能力不在于“能触发”,而在于“能规范地管理”。jessibuca的Emitter类虽然简单,但on、once、off、emit这四个方法覆盖了事件机制的全部核心诉求。把这个类吃透,你不仅在用jessibuca这件事上更顺手,以后看其他库的事件系统,也会举一反三地理解得快很多。
