数组遍历的坑-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),大概还有三种:

  1. trigger的时候倒序执行

这是谁想出来的?根本解决不了问题好么。而且虽然说对于事件监听,使用者不应该期盼handler以绑定时的顺序执行,但倒序毕竟与使用者直觉不符。

  1. trigger时将原handler数组拷贝一份,为_handlers,对_handlers进行遍历执行

这个可以解决问题

  1. 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;
  }
};