15 July 2014

需求场景

标题好像不太容易理解,场景是这样子的:一个事件处理程序clickHander会被注册到多个不同的div上,而这些div之间可能存在嵌套,即祖先后代关系。需求是用户在这些div内某个具体的元素上点击一次,这些不同div上注册的相同的事件处理程序clickHander中的业务逻辑只执行一次。每次点击都要执行,且只执行一次

限制条件:不能在事件处理程序里面阻止冒泡,因为上面可能仍然有其他模块的业务代码绑定的事件处理程序,而且很多基础功能(如事件代理,页面点击地图log)的运行严重依赖冒泡。

实现方式

事件触发过程中的冒泡阶段,从target元素到document节点路径上所注册的clickHandler会依次执行,只要冒泡没有被阻止。在某个dom节点的clickHandler里面,如何判断本次点击事件已经被(某个子节点的clickHandler)处理过了呢?

event对象加标记位

首先想到的是event对象,即clickHandler执行时传入的第一个参数。如果事件冒泡时,target到document路径上每一个元素节点的clickHandler接受的参数eventArg都是同一个对象的话,那只需要在eventArg加一个标志即可。

var clickHandler = function(event){
    event = event || window.event;
    if(event._clickHandled){
        return;
    }
    event._clickHandled = true;

    // bussiness logic 
    // bla bla bla ...
}

到chrome浏览器中验证了一下,确实是每次点击业务逻辑都只执行一次

但到IE 8下就挂了,一次点击,业务逻辑执行了好几次。

这说明了chrome里面事件处理过程中event对象是共享的,而IE 8浏览器中是不共享的(其它的浏览器没测,TODO吧)。

target元素加标记位

继续找共享的对象:事件的target元素,即event的target属性(or srcElement, for old IE),这个属性在冒泡过程中是只读且不变的,只要在target元素上加标记位,冒泡到下一个(祖先)节点的事件处理函数里面就可以读取到标记位。

咋一看,没问题,且解决了兼容性问题。但下一次点击同一个target的时候,标记位仍然存在,没被重置,这样后续的点击事件都没有被处理。需要在下一次冒泡开始之前,重置target上的标记位。

再想想,冒泡(Bubbling Phase)之前是捕获阶段(Capture Phase),和目标阶段(Target Phase)。只要在往target元素上添加标记位之后,再在target元素上注册一个重置标记位的监听器,那么下次点击的时候,先执行重置标记位的监听器(属于目标阶段),后执行target元素父节点和祖先节点(属于冒泡阶段)的读取和修改标记位的监听器。这样后续的点击事件处理结果就和第一次点击时间的处理结果保持一致了。

逻辑上看起来没问题,下面是代码:

var clickHandler = function(event){
  event = event || window.event;
  var target = event.target || event.srcElement;
    
  // 在Bubbling Phase,读取并修改标记位
  if(target._c >= 1){//本次点击事件已经被处理过
    target._c++;
    return;
  }
  if(typeof target._c !== "number"){
    target._c = 1;//第一次点击
  }else{
    target._c++;
  }

  // 在 Target Phase,重置标记位
  if(!target._resetCounter){
    var resetCounter = function(){
      this._c = 0;
    };
    target._resetCounter = resetCounter.bind(target);
    if(target.addEventListener){
      target.addEventListener("click", target._resetCounter, false);
    }else{
      target.attachEvent("onclick", target._resetCounter);
    }
  }
    
  // bussiness logic 
  // bla bla bla ...
}

用现在的方案测了一下,主流浏览器都OK(QA反馈的):每次点击都只执行一次业务逻辑!

总结

最后附上一张DOM Level 3标准的事件分发流程示意图:

Graphical representation of an event dispatched in a DOM tree using the DOM event flow

链接:Event dispatch and DOM event flow


-------------- 2014-07-16 分割线 --------------

今天和zyt讨论了这个问题,他提出来的两种方案解决target元素上的标记位重置问题:

1. setTimeout里面执行重置。

2. 点击事件最终冒泡到最顶层document上再进行重置。

点击事件触发之后冒泡过程是同步执行的,setTimeout里面的代码是在本次点击事件处理完毕,下次点击事件开始处理之间执行的,所以setTimeout方案看上去应该可行。

document最终会对页面上所有的点击事件进行兜底处理,只要冒泡没被阻止。逻辑上看这种方案也没有问题。

这两种方案看上去都比我自己想的在最底层的target上绑定一个重置标记位的事件监听器要好,因为可点击的target非常多,用户随便点的越多,注册的重置标记位监听器越多,性能上应该没有只在document注册一个兜底事件处理器好(setTimeout没额外注册事件,应该更好)。

但就可靠性而言,在document上注册兜底事件处理器并不如在对底层的target上绑定“可靠”。因为页面上其他人写的业务代码,or历史遗留代码,or某个特殊bug的hack代码,都有可能会阻止冒泡,这样兜底的事件处理器就不会有机会执行。

TODO:验证下setTimeout方案



blog comments powered by Disqus