Aitter's Blog

使用ES6一步一步重构Events模块

看到了一个开源的Events模块 pubsub.js,源代码大概三百多行,本来以为是很简单的一个工具类,但细看之下,发现代码中还是有很多晦涩之处,今天使用ES6将源码重构一遍,其中还是有很多值得学习的地方,不得不说,一个开源的模块要考虑的东西还是很多很多的。

先来看一下这个模块的特殊用法(常用的pubsub就不列举了)

//取消订阅
var subscription = pubsub.subscribe('hello.world', function() {
console.log('hello world!');
});
//unsubscribe
pubsub.unsubscribe(subscription);
//publish event on 'hello.world' namespace
pubsub.publish('hello.world');
//继承
var subscription = pubsub.subscribe('hello', function() {
console.log('hello world!');
});
//publish event on 'hello/world' namespace
pubsub.publish('hello/world', [], {
recurrent : true
});
//注册事件数组和回调数组
var number1 = 0;
var number2 = 0;
var subscription = pubsub.subscribe(['hello/world', 'goodbye/world'], [function() {
number1++;
}, function() {
number2 += 2;
}]);
pubsub.publish('hello/world');
console.log(number1 + ',' + number2); //1,2
pubsub.publish('goodbye/world');
console.log(number1 + ',' + number2); //2,4
pubsub.unsubscribe(subscription);
//指定回调运行时的上下文
var contextArgument = ["object"];
var privatePubsub = pubsub.newInstance({
context : contextArgument
});
privatePubsub.subscribe('hello/context', function() {
var that = this;
console.log(that === contextArgument); //true
});
privatePubsub.publish('hello/context');

还有命名空间的通配符匹配*,异步事件配置async, 命名空间深度限制 depth, 这几个功能在下面的实现中省略了,因为感觉用(tai)处(lan)很(le)少

按照这个Events模块的文档(他叫pubsub.js,我习惯了jQuery的事件模型和NodeJs的事件命名,这里我的命名为Events模块),我们先来建立一个TodoList

TODO

  1. 事件名可以添加命名空间,如 parent.child1.child2
  2. 自定义命名空间的分隔符,如:'.''/'
  3. 事件名可以继承,如 parent.child1.child2 除了可以触发本身的事件之外,还可以触发父级 parent.child1parent 注册的事件,默认不可继承
  4. 指定回调函数的上下文 context, 可以全局配置也可以给回调单独配置。
  5. 可注册 one 事件,注册后,只能执行一次
  6. 回调函数可以是数组,一个事件可触发多个回调
  7. 事件名可以是数组,多个事件名可以注册同一个回调或多个回调
  8. 运行环境兼容处理:node / require / browser

类的结构

首先我需要有以下几个常用方法

on() 用于注册事件
off() 用于注销事件
emit() 用于触发事件
one() 用于注册一次性事件

那么这个类的结构如下

class Events{
constructor(config={}){
//用于存储注册过的事件信息
this.cache = {};
this.options = {
//命名空间分隔符配置
separator: config.separator || '.',
//是否继承
inherit: !!config.inherit,
//用于指定上下文,默认为设置为回调函数本身
context: config.context || null
}
}
on(){}
off(){}
one(){}
emit(){}
}

为这个这个类指定有两个属性

  • cache 用于存储注册过的事件信息, 如果有命名空间,它将是一个树状结构的对象。
  • options 类的配置
    • separator 用于指定命名空间的分隔符,默认为 . 号符
    • inherit 事件名是否可以继承,默认不可以
    • context 用于指定上下文,默认为设置为回调函数本身

一个一个Todo的来看

命名空间与分隔符

关于分隔符的处理,我们只要在 处理命名空间分隔字符串的时候,使用属性opttions中的配置就可以了,使用配置代替,不写死就OK。

事件名可以添加命名空间,如 parent.child1.child2

首先如果我们来看数据结构,如果不加命名空间,那么数据结构可以设计成这样

cache = {
'eventName':{
events:[{fn:fn1, context: null}]
}
}

这样设计的好处就是可以给 eventName 这个事件,添加多个回调,而且可以为每一个回调指定上下文

那如果有命名空间的情况下怎么办呢?因为是层级关系,这就需要嵌套了

cache = {
'eventName':{
events:[{fn:fn1, context: null}],
'child1':{
events:[{fn:fn1, context: null}],
'child2':{
events:[{fn:fn1, context: null}]
//....childn
}
}
}
}

这样就实现了命名空间下的事件存储关系,那么下一步,就是怎样将有命名空间的事件存到 cache 里去了

这一步操作显然是在我们注册事件时完成的,那么就来看这个 on() 方法吧

以这种实际中的用法为例

event.on('parent.child1.child2',function(){
console.log(this.name)
},{
context: {name: 'jack'}
})

先来看看,怎么样根据命名空间生成对象树呢?

这里的方法是,把 key 按指定的分隔符分隔成数组,遍历数组,为每个事件对象添加 events 属性,属性的值是 {},遍历完成后,最终将回调函数添加进 child2events 对象中

var cache = {};
var last = cache;
'parent.child1.child2'.split('.').forEach(v=>{
if(!last[v]){
last[v] = {};
last[v]['events'] = []
}
last = last[v];
})
//遍历之后的结果
//cache: {"parent":{"events":[],"child1":{"events":[],"child2":{"events":[]}}}}
//last: {"events":[]}

这里利用了引用的传递的特点,last 本身是引用的 cache,遍历完成,cache 中存的是完整树,而 last 中存的是最后一个叶子节点,因为是引用传递,last 中的数据操作会传递到 cache 中, 这种利用引用类型修改数据的特点在后面还会有更多的运用。

on() 函数的实现如下

on(key, fn, config={}){
//先将key用配置的分隔符,分隔成数组 ['parent', 'child1', 'child2']
const keys = key.split(this.options.separator);
//获取上下文 优先级:自定义配置 > 全局配置 > 回调本身
const context = config.context || this.options.context || fn;
//获取已有的事件信息缓存
let keyObj = this.cache;//引用cache对象
// 这个对象记录了回调与回调的this
const eventObj = {fn: fn, context: context};
//这一步很关键,它将为我们创建一棵树,用于存储事件相关信息
keys.forEach(v=>{
if(!keyObj[v]){
keyObj[v] = {};
keyObj[v]['events'] = [];
}
keyObj = keyObj[v];
})
//经过上面的遍历,这里已经定位到了最里层的,给最终于的事件添加回调信息对象
keyObj.events.push(eventObj);
//这里返回的对象,用于注销方法`off()`使用
return {
namespace : key,
event : eventObj
};
}

经过on() 方法的运行,成功解析了有命名空间的事件注册,并将数据转成了树结构的对象缓存到了 cache

事件注册转成数据存储之后,后面就是触发操作,接来处理一下触发 emit() 方法

实际调用

events.emit('parent.child1.child2')

这里需要处理的是,第一,是否考虑继承,如果不考虑,那么直接触发 child2 的回调,如果考虑,则应该先触发parent 的回调,再触发child1的回调,最后触发child2的回调

emit(key, args, config={}){
if(!key) return;
const keys = key.split(this.options.separator);
const inherit = typeof config.inherit != 'undefined' ? !!config.inherit : this.options.inherit;
var temp = this.cache;
//如果是继承,那么逐级触发注册的回调
//这里使用every,而不用forEach,是因为forEach内使用return不能跳出循环,而every或some是可以的
keys.every(v=>{
if(!temp[v]) return (temp=null);
inherit && temp[v].events.forEach(e=>{
e.fn.apply(e.context, args)
})
temp = temp[v];
//every的回调里,如果没有返回值或返回值是false就会中断遍历,这里一定要返回true才能继续遍历
return true;
})
//如果不是继承,那么上面一行不会触发事件,但temp得到了最内层的事件对象
!inherit && temp && temp.events.forEach(v=>{
v.fn.apply(v.context, args)
})
}

off() 方法的处理,参数是 on() 方法的返回值,由事件名和事件对象组成的对象,off() 方法是注销事件或叫删除事件,无论是有命名空间,还是没有命名空间,我们都应该注销最内层的事件就可以了,所以这里处理比较简单

off(obj){
if(!obj) return;
const keys = obj.namespace.split(this.options.separator);
const currEventObj = obj.event;
let last = this.cache;
//得到最内层的事件对象信息
keys.forEach(v => last = last[v]);
//修改引用对象为影响到cache中,这正是我们想要的,结果会同步到cache中
last.events = last.events.filter(v=>{ return v!== currEventObj})
}

one的实现

经过这三个主方法的处理,就基本上完成一大半了,再来看一下我们的TODO

X 事件名可以添加命名空间,如 `parent.child1.child2`
X 自定义命名空间的分隔符,如:`'.'` 、 `'/'`
X 事件名可以继承,如 `parent.child1.child2` 除了可以触发本身的事件之外,还可以触发父级 `parent.child1` 与 `parent` 注册的事件,默认不可继承
X 指定回调函数的上下文 `context`, 可以全局配置也可以给回调单独配置。
5. 可注册 `one` 事件,注册后,只能执行一次
6. 回调函数可以是数组,一个事件可触发多个回调
7. 事件名可以是数组,多个事件名可以注册同一个回调或多个回调
8. 运行环境兼容处理:node / require / browser

继承和指定上下文其实上面的代码已经处理了,继承时遍历每一个层级的回调数组进行触发,触发时,使用apply使用了在 on() 方法传递的 context 参数进行了this 指定。

好了,只剩下 5678

先来看看 one() 方法,这个方法用于注册一次性事件,触发一次后就不能再触发了,使用方式如下

event.one('cus',function(name){
console.log(name)
})
event.emit('cus',['jack']) //正常打印 jack
event.emit('cus',['jack']) //没有任何输出

这里实现的方法可以是这样:在注册 one() 事件时,将回调使用一个匿名函数包装一层,用这个函数代替回调,并在这函数内添加注销这个事件的操作,具体如下:

one(key, fn, config){
const context = this.options.context || config.context || fn;
let obj = null
//使用匿名函数将callback包装一层
const oneFn = (...args)=>{
//执行完回调之后
fn.apply(context, args);
//立即将这个事件注销掉
this.off(obj);
}
//注册一个一次性事件
obj = this.on(key, oneFn, config)
return obj;
}

事件名与回调数组

再看 67 处理事件名数组与回调数组
先看看应用场景

events.on(['cus1','cus2'], [function(text) {
console.log('cus1 ' + text);
},function(text){
console.log('cus2 ' + text);
}]);
events.emit('cus1', ['111']);
events.emit('cus2', ['222']);
输出:
cus1 111
cus2 111
cus1 222
cus2 222

这里相当于是一个笛卡尔乘积,[1,2]x[3,4]=>[[1,3],[1,4],[2,3],[2,4]],多个事件名,每一个事件名对应多个回调。

这里的处理应该是这个模块最难的地方,关于算法,我并不擅长,pubsub.js 的作者使用了两个交替数组遍历加递归的处理方式,感受一下

on() 函数改造如下,这里添加了一个 _register 用于递归时返回数据。

on(keys, fns, config={}){
let res = [];
//遍历回调数组
if(Array.isArray(fns)){
fns.forEach(v=>{
res = res.concat(this.on(keys, v, config))
})
//遍历事件名数组
}else if(Array.isArray(keys)){
keys.forEach(v=>{
res = res.concat(this.on(v, fns, config))
})
}else{
//调用_register返回注册结果
return this._register(keys, fns, config)
}
return res;
}
_register(key, fn, config={}){
const keys = key.split(this.options.separator);
const context = config.context || this.options.context || fn;
let last = this.cache;
const currEvent={fn: fn, context: context};
keys.forEach(v=>{
if(!last[v]){
last[v] = {};
last[v]['events'] = [];
}
last = last[v];
})
last.events.push(currEvent);
return {
namespace : key,
event : currEvent
};
}

这里的执行顺序比较绕,还是用一张图来看一下

  1. 判断 fns 是数组,进到 1 中,递归 on() 方法,回调数组变成第一个回调
  2. 判断 keys 是数组, 进到 2 中,递归 on() 方法,此时 keysfns 都不再是数组
  3. 进到 3 中,调用 _register 注册事件,并返回结果,回到 3 中,继续遍历 keys,调用 on() 方法
  4. 进到 3 中,返回结果后,回到 3 中,keys 遍历结束,进到 4 中,返回存有两个事件对象的 res
  5. 回到 1 中,继续遍历 fns,又是一个循环,2->3->2->3->4,又返回了存有两个事件对象的 res
  6. 最后 fns 遍历结束,res.concat(res) 之后,res 就存储了四个事件对象,再最后到 4, 返回结果res

至此功能基本完成,再来看一下TODO

X 事件名可以添加命名空间,如 `parent.child1.child2`
X 自定义命名空间的分隔符,如:`'.'` 、 `'/'`
X 事件名可以继承,如 `parent.child1.child2` 除了可以触发本身的事件之外,还可以触发父级 `parent.child1` 与 `parent` 注册的事件,默认不可继承
X 指定回调函数的上下文 `context`, 可以全局配置也可以给回调单独配置。
X 可注册 `one` 事件,注册后,只能执行一次
X 回调函数可以是数组,一个事件可触发多个回调
X 事件名可以是数组,多个事件名可以注册同一个回调或多个回调
8. 运行环境兼容处理:node / require / browser

环境兼容处理

最后一项很简单,至于browser,支持ES6的就自然兼容了

(function(scope){
class Events(){}
function eventEmitter(config){
return new Events(config);
}
// nodejs & requirejs
if(typeof module === 'object' && module.exports) {
module.exports = eventEmitter['default'] = eventEmitter.eventEmitter = eventEmitter;
}else{
//browser
scope.eventEmitter = eventEmitter;
}
})(this)

OK,所有TODO都close掉了,这样具备了一个完整功能的事件模块可以尝试运用于项目中了,当然肯定还有一些小的bug或未实现的功能,这个留着在项目使用中慢慢去完善吧

另外,我们还需要写一个单元测试,看看是否所有功能都是正常可行的,留着下一次来实现吧,今天就到这里,休息,休息一下

完整代码

;(function(scope){
class Events {
constructor(config = {}){
this.cache = {};
this.options = {
separator: config.separator || '.',
inherit: !!config.inherit,
context: config.context || null
}
}
_register(key, fn, config={}){
const keys = key.split(this.options.separator);
const context = config.context || this.options.context || fn;
let keyObj = this.cache;
const eventObj={fn: fn, context: context};
keys.forEach(v=>{
if(!keyObj[v]){
keyObj[v] = {};
keyObj[v]['events'] = [];
}
keyObj = keyObj[v];
})
keyObj.events.push(eventObj);
return {
namespace : key,
event : eventObj
};
}
on(keys, fns, config={}){
let res = [];
if(Array.isArray(fns)){
fns.forEach(v=>{
res = res.concat(this.on(keys, v, config))
})
}else if(Array.isArray(keys)){
keys.forEach(v=>{
res = res.concat(this.on(v, fns, config))
})
}else{
return this._register(keys, fns, config)
}
return res;
}
one(key, fn, config){
const context = this.options.context || config.context || fn;
let obj = null
const oneFn = (...args)=>{
fn.apply(context, args);
this.off(obj);
}
obj = this.on(key, oneFn, config)
return obj;
}
off(onObj){
if(!onObj) return;
const keys = onObj.namespace.split(this.options.separator);
const eventObj = onObj.event;
let resEvent = this.cache;
keys.forEach(v=> resEvent = resEvent[v])
resEvent.events = resEvent.events.filter(v=>{return v!== eventObj});
}
emit(key, args, config={}){
if(!key) return;
const keys = key.split(this.options.separator);
const inherit = typeof config.inherit != 'undefined' ? !!config.inherit : this.options.inherit;
var temp = this.cache;
keys.every(v=>{
if(!temp[v]) return (temp=null);
inherit && temp[v].events.forEach(e=>{
e.fn.apply(e.context, args)
})
temp = temp[v];
return true;
})
!inherit && temp && temp.events.forEach(v=>{
v.fn.apply(v.context, args)
})
}
}
function eventEmitter(config){
return new Events(config);
}
// nodejs & requirejs
if(typeof module === 'object' && module.exports) {
module.exports = eventEmitter['default'] = eventEmitter.eventEmitter = eventEmitter;
}else{
//browser
scope.eventEmitter = eventEmitter;
}
})(this)

使用方式

var events = eventEmitter({inherit:true, context:{name:'jack'}});
events.on('hello.world', function(text) {
console.log('hello.world ' + text+ this.name);
});
var os = events.on('hello.world.lt', function(text) {
console.log('hello.world.lt ' + text+ this.name);
});
events.on('hellome', function(text) {
console.log('hellome ' + text+ this.name);
});
events.emit('hello.world.lt',['aaa']);
events.emit('hellome',['aaa']);
events.one('cus', function(text) {
console.log('cus ' + text);
});
events.emit('cus',['n1'])
events.emit('cus',['n2'])