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

506 阅读11分钟

看到了一个开源的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

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

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'])