数组遍历的坑-Javascript
如果有这样一个简单的事件实现:
function MyEvent(){
this.list = {};
}
MyEvent.prototype.bind = function(name, handler) {
(this.list[name] = this.list[name] || []).push(handler);
};
MyEvent.prototype.unbind = function(name, handler) {
var list = this.list[name],
i;
if(list && list.length){
if((i = list.indexOf(handler)) >= 0){
list.splice(i, 1);
}
}
};
MyEvent.prototype.trigger = function(name, data) {
(this.list[name] || []).forEach(function(handler){
try{
handler.call(this, data);
}catch(e){
// ...
}
});
};
如下的使用场景:
var myEvent = new MyEvent();
// ...
var respondForChange = function(data){
// ...
myEvent.unbind('sthChanged', respondForChange);
};
myEvent.bind('sthChanged', respondForChange);
myEvent.bind('sthChanged', function(data){
alert('sthChanged!');
});
myEvent.trigger('sthChanged');
这时会有bug:alert('sthChanged!');
不会被执行到。
问题隐藏在trigger中的forEach中,(this.list[name] || []).forEach是这样的:
for(var i = 0, l = (this.list[name] || []).length; i < l; i++){
// do sth with (this.list[name] || [])[i] ...
}
在执行第一个handler(i=0)的时候,unbind中的splice修改了this.list[name]
,导致执行alert的函数变成this.list[name][0]
,而this.list[name][1]
变成了undefined。所以第二个注册函数不会被执行。而显然第一个注册函数的取消绑定不应该影响其他函数的执行,所以这是个错误。数组的forEach、splice方法会让这个问题变得难以发现。
类似这种情况,可以在unbind的时候不将数组项删除,只是置为null。但是这样会导致数组规模越来越大,不可收拾。所以可以在安全的时候(除去注册函数因事件触发而被执行的时候)对数组进行null项的清除。这样:
MyEvent.prototype.unbind = function(name, handler) {
var list = this.list[name],
i;
if(list && list.length){
if((i = list.indexOf(handler)) >= 0){
list[i] = null;
}
}
};
MyEvent.prototype.trigger = function(name, data) {
var markedHere = false,
list = this.list[name];
if(!this.running){
if(list && list.length){
list = this.list[name] = list.filter(function(item){return item !== null;});
}
this.running = true;
markedHere = true;
}
(list || []).forEach(function(handler){
try{
handler.call(this, data);
}catch(e){
// ...
}
});
if(markedHere){
this.running = false;
}
};
this.running标记当前是否事件触发中。markedHere标记是否此处修改running状态,若是,则结束后修改回去。(有可能在某个handler中触发了事件,导致trigger函数的嵌套执行,这里通过markedHere可以确保是最外层的trigger完成后才将状态置为false)
继续补充其他的解决途径,将上述方法记为1),大概还有三种:
- trigger的时候倒序执行
这是谁想出来的?根本解决不了问题好么。而且虽然说对于事件监听,使用者不应该期盼handler以绑定时的顺序执行,但倒序毕竟与使用者直觉不符。
- trigger时将原handler数组拷贝一份,为_handlers,对_handlers进行遍历执行
这个可以解决问题
- unbind的时候不修改原有数组,而是新建一个数组,将不会被移除的handler压进数组(类似filter的行为),最后用新的数组替换原有数组
本质与3)是一样的,都是通过分为两个数组避免错误。但性能比3)更好,一方面因为trigger的调用一般都比unbind更频繁,另一方面3)总是需要将整个数组拷贝一份,而4)只是将其中部分项拷贝。与1)相比,执行unbind、trigger时性能相仿,但1)需要额外的清除过程。
综上,4)是目前发现的最佳方案。代码如下:(除unbind外,其他均与最一开始的naive实现一致)
MyEvent.prototype.unbind = function(name, handler) {
var list = this.list[name];
if(list){
var nlist = [];
for(var i = 0, l = list.length; i < l; i++){
if(list[i] !== handler) nlist.push(list[i]);
}
list[name] = nlist.length ? nlist : null;
}
};