由event target引发的关于事件流的一连串思考(一)

518 阅读13分钟

**前言:**之前的上传图片用到了event.target,但是后来仔细思考了一下,自己对event.target,this,event.currentTarget的区别完全不清楚,然后发现越学越多,为了搞清楚这个问题,把DOM事件,事件流,事件捕获,事件冒泡等全部学了一遍,收获颇丰,特别总结记录下来。

事件

事件是文档或者浏览器窗口中发生的,特定的交互瞬间。

事件是用户或者浏览器自己执行的动作,比如click(用户左键单击鼠标),load(页面加载完成),JavaScript可以通过绑定触发事件来和DOM进行交互(当然也可以直接操作DOM),由于在底层JavaScript和DOM是独立的,所以多次绑定进行绑定操作会影响页面的性能(后边会提到解决方案:事件委托)。

事件流

事件流描述的是从页面中接收事件的顺序。

以下开始以例子详细分析事件流。 HTML:

<div>
	<ul>
		<li></li>
	</ul>
</div>

CSS:

*{
	margin: 0;
	padding: 0;
}
div{
	width: 300px;
	height: 300px;
	position: absolute;
	top: 50%;
	left: 50%;
	margin-top: -150px;
	margin-left: -150px;
	background-color: #2578b5;
	border-radius: 50%;
}
ul{
	width: 200px;
	height: 200px;
	position: absolute;
	top: 50%;
	left: 50%;
	margin-top: -100px;
	margin-left: -100px;
	background-color: #f2de76;
	border-radius: 50%;
}
li{
	list-style: none;
	width: 100px;
	height: 100px;
	position: absolute;
	top: 50%;
	left: 50%;
	margin-top: -50px;
	margin-left: -50px;
	background-color: #afc8ba;
	border-radius: 50%;
}

效果如下:

示例效果图
如图所示,思考一下:如果我们点击内层圆,就仅仅点击了内层圆吗? 很明显,我们不止点击了内层圆,而且点击了中层圆,外层圆,html,body和document。这就引出了事件流的详细定义:

事件发生时会在元素节点与根节点之间按照特定的顺序传播,路径所经过的所有节点都会收到该事件,这个传播过程即DOM事件流。

那么问题又来了,如果我们给这些元素都绑定事件,那么这些事件的执行顺序是什么?

两种事件流模型

  • 冒泡型事件流:事件的传播是从最特定的事件目标到最不特定的事件目标。即从DOM树的叶子到根。
  • 捕获型事件流:事件的传播是从最不特定的事件目标到最特定的事件目标。即从DOM树的根到叶子。

例子中的事件传播顺序:

  • 在冒泡型事件流中,是li > ul > div > body > html > document
  • 在捕获型事件流中,是document > html > body> div > ul > li

实际中的事件流并没有完全按照标准事件流实现, 所有现代浏览器都支持事件冒泡,但在具体实现中略有差别:

  • IE5.5及更早版本中事件冒泡会跳过html元素(从body直接跳到document)。
  • IE9、Firefox、Chrome、和Safari则将事件一直冒泡到window对象。
  • IE9、Firefox、Chrome、Opera、和Safari都支持事件捕获。尽管DOM标准要求事件应该从document对象开始传播,但这些浏览器都是从window对象开始捕获事件的。
  • 由于老版本浏览器不支持,很少有人使用事件捕获。建议使用事件冒泡。
实际使用中的DOM事件流

之所以会存在两种事件流,是由于微软和网景之间的竞争造成的,幸运的是,W3C决定组合使用这两种方法,并且大多数新浏览器都遵循这两种事件流方式,所以现在完整的DOM事件流分为3个阶段:

  • 事件捕获阶段
  • 目标阶段(事件在目标上发生并处理,但事件处理被认为发生在冒泡阶段)
  • 事件冒泡阶段 尽管理论上(DOM2中规定)事件捕获阶段不会涉及事件目标,但是IE9、Safari、Chrome、Firefox和Opera9.5及更高版本都会在捕获阶段触发事件对象上的事件。结果,就是有两次机会在目标对象上面操作事件

并非所有的事件都会经过冒泡阶段 。所有的事件都要经过捕获阶段和处于目标阶段,但是有些事件会跳过冒泡阶段,如获得输入焦点的focus事件和失去输入焦点的blur事件。

在网上找到了一张原型图,但是出处没找到,感谢无名氏同学。

实际使用中的DOM事件流原型图
默认情况下,事件使用冒泡事件流,不使用捕获事件流。然而,在现代浏览器中(IE9+,Chrome,Firefox),你可以显式的指定使用捕获事件流,方法是在注册事件时传入useCapture参数,将这个参数设为true。 下面我们来测试绑定事件的不同方式会有什么区别。

DOM事件绑定
DOM0

通过javascript制定事件处理程序的传统方式,就是将一个函数赋值给一个事件处理属性。 **优点:**当前所有浏览器均支持,简单且具有跨浏览器的优势。 **缺点:**一个事件处理程序只能对应一个处理函数。

下面我们用之前的同心圆例子对DOM0事件绑定进行测试: JavaScript:

var div = document.querySelector("div");
var ul = document.querySelector("ul");
var li = document.querySelector("li");

div.onclick = function(){
	console.log("div");
}
ul.onclick = function(){
	console.log("ul");
}
li.onclick = function(){
	console.log("li");
}

此时我们点击内层圆,控制台输出如下:

DOM0绑定事件测试
可以看到,输出的顺序是从内到外,所以可以得出结论,DOM0绑定事件是在冒泡阶段执行的。

删除DOM0事件处理程序,只要将对应事件属性置为null即可。如将div上绑定事件删除:div.onclick = null

另外DOM0还有个很神奇的特性,如果我们像这样绑定div的点击事件:

div.onclick = function(){
	console.log(this);
}

那么输出的this是div,也就是执行该方法的DOM对象,但是如果定义一个函数,然后在HTML中绑定,那么输出的this是window,这是由于在HTML中绑定相当于动态绑定,所以定义函数的this永远都是window,不会随着上下文改变。

DOM1

DOM1级主要定义的是HTML和XML文档的底层结构。DOM2和DOM3级别则在这个结构的基础上引入了更多的交互能力,也支持了更高级的XML特性。为此DOM2和DOM3级分为许多模块(模块之间具有某种关联),分别描述了DOM的某个非常具体的子集。

DOM2

DOM2级事件绑定方式指定了,添加事件绑定程序和删除事件绑定程序的方法。

addEventListener(ev,fn,useCapture);
removeEventListener(ev,fn,useCapture);

**优点:**可以在同一DOM对象绑定多个相同事件,可以控制是在捕获阶段触发还是在冒泡阶段触发。 **缺点:**IE8及以下不支持这种写法,而是使用独有的绑定多事件方法,所以需要自己写兼容模式(之后会提到)。

还是之前同心圆的例子来测试。

第三个参数为空(即默认的false)的情况: JavaScript:

var div = document.querySelector("div");
var ul = document.querySelector("ul");
var li = document.querySelector("li");

div.addEventListener("click",function(){
	console.log("div");
});
ul.addEventListener("click",function(){
	console.log("ul");
});
li.addEventListener("click",function(){
	console.log("li");
});

点击内层圆,结果如下:

DOM2绑定事件,第三个参数为空的情况
第三个参数为true的情况: JavaScript:

var div = document.querySelector("div");
var ul = document.querySelector("ul");
var li = document.querySelector("li");

div.addEventListener("click",function(){
	console.log("div");
},true);
ul.addEventListener("click",function(){
	console.log("ul");
},true);
li.addEventListener("click",function(){
	console.log("li");
},true);

点击内层圆,结果如下:

DOM2绑定事件,第三个参数为true的情况
可以看到,第三个参数为空(false)则在冒泡阶段触发,第三个参数为true则在捕获阶段触发。

如果我们给同一个DOM对象同时在捕获和冒泡阶段绑定同一个类型的事件,还是同心圆的例子,给div绑定DOM0的click事件,DOM2的click事件(捕获阶段触发,冒泡阶段触发),如下。 JavaScript:

var div = document.querySelector("div");
var ul = document.querySelector("ul");
var li = document.querySelector("li");

div.onclick = function(){
	console.log("dom0 冒泡");
}
div.addEventListener("click",function(){
	console.log("dom2 捕获");
},true);
div.addEventListener("click",function(){
	console.log("dom2 冒泡");
},false);

点击内层圆,输出结果如下:

点击内层圆

可以看到,先触发捕获事件,然后触发冒泡事件,并且DOM0和DOM2的事件互不影响,谁先绑定就先执行。

点击中层圆,输出结果如下:

点击中层圆

结果和点击内层圆一样,没毛病。

点击外层圆,输出结果如下:

点击外层圆

很神奇,外层圆的事件顺序不是按照事件流了,这是为什么呢。其实原因在于一直没提的目标(target)阶段。我们给外层圆绑定点击事件,点击内层圆,实际上的target是内层圆,中层圆同理。但是如果我们点击外层圆,外层圆自己就是target,这时就不分事件捕获和事件冒泡了,谁先绑定谁先执行。

addEventListener和removeEventListener有几点需要注意:

  • 如果使用匿名函数的方式执行addEventListener,则无法使用removeEventListener删除该绑定事件。
  • 如果使用具名函数的方式addEventListener,则该函数内部的this指向执行该方法的DOM对象,另外匿名函数也是指向执行该方法的DOM对象。
  • 如果addEventListener和removeEventListener第三个参数不同,则不认为是同一个事件,即removeEventListener不可以删除addEventListener绑定的事件。

IE8及以下不支持标准的addEventListener和removeEventListener,而是使用了私有方法attachEvent和detachEvent。值得注意的是,这种方法的第一个参数需要加on。

attachEvent(ev,fn);
detachEvent(ev,fn);

使用之前同心圆的例子,在IE8测试,代码如下。 JavaScript:

var div = document.querySelector("div");
var ul = document.querySelector("ul");
var li = document.querySelector("li");

div.attachEvent("onclick",function(){
	console.log("div");
});
ul.attachEvent("onclick",function(){
	console.log("ul");
});
li.attachEvent("onclick",function(){
	console.log("li");
});

结果如下:

attachEvent
吐槽一下,IE8及以下不支持border-radius属性,所以已经不能算是同心圆了。

IE8下的同心圆
由输出结果可以看出,attachEvent会在冒泡阶段触发。

attachEvent和detachEvent也有几点需要注意:

  • 使用匿名函数作为第二个参数的attachEvent是无法被删除的。
  • 无论是使用具名函数还是匿名函数作为第二个参数,函数内部的this都会指向window。

其实我本身很反感兼容低版本IE的事情,也感谢我司对前端兼容性的要求是IE9+,但是毕竟不是每个公司都像我司一样,甚至有时候都不考虑IE9了,所以还是写一下兼容写法。还是之前同心圆的例子,代码如下。 JavaScript:

var div = document.querySelector("div");
var ul = document.querySelector("ul");
var li = document.querySelector("li");

function addEvent(element,type,callback){
    if(element.addEventListener){
        element.addEventListener(type,callback,false);
    }else if(element.attachEvent){
        element.attachEvent('on' + type,callback);
    }
}
addEvent(div,'click',function(){
	console.log("绑定点击事件1");
})
addEvent(div,'click',function(){
	console.log("绑定点击事件2");
})

在Chrome上输出结果如下:

Chrome
在IE8上输出结果如下:
IE8
还是有些区别的,输出顺序这个到现在我还是没想明白,很奇怪,所以如果对顺序有要求,就还是放弃IE8及以下吧。

另外就是要注意,使用addEvent这个函数的时候,匿名函数的this在不同的浏览器是有区别的,总之IE依然是个大坑。

DOM3

DOM浏览器中可能发生的事件有很多种,不同事件类型具有不同的信息,DOM3级事件规定了一下几种事件:

  • UI事件,当用户与页面上的元素交互时触发。
  • 焦点事件,当元素获得或者失去焦点时触发。
  • 鼠标事件,当用户通过鼠标在页面上执行操作时触发。
  • 滚轮事件,当使用鼠标滚轮(或类似设备)时触发。
  • 文本事件,当在文档中,输入文本时触发。
  • 键盘事件,当用户通过键盘在页面上执行操作时触发。
  • 合成事件,当为IME(Input Method Editor,输入法编辑器)输入字符时触发。
  • 变动事件,当底层Dom结构发生变化时触发。

DOM3级事件模块在DOM2级事件的基础上重新定义了这些事件,也添加了一些新事件。包括IE9在内的主流浏览器都支持DOM2级事件,IE9也支持DOM3级事件。

另外DOM3级还定义了自定义事件,自定义事件不是由DOM原生触发的,它的目的是让开发人员创建自己的事件。

事件流的target,currentTarget和this

说了这么久,终于说到了当初写这篇博客的起因了,为了弄清楚target,currentTarget和this,不断的查资料,然后发现不只是弄清楚了这三者的区别,还对整个事件流有了初步的认识,是时候重拾起只看完第七章的《JavaScript高级程序设计》恶补基础了。

target在事件流的目标阶段。currentTarget在事件流的捕获,目标及冒泡阶段。只有当事件流处在目标阶段的时候,两个的指向才是一样的, 而当处于捕获和冒泡阶段的时候,target指向被单击的对象而currentTarget指向当前事件活动的对象(注册该事件的对象)(一般为父级)。this指向永远和currentTarget指向一致(只考虑this的普通函数调用)。

我们来进行测试,还是同心圆的例子,首先只考虑W3C标准的浏览器(此处是Chrome),代码如下。 JavaScript:

var div = document.querySelector("div");
div.onclick = function(ev){
	console.log(ev.target.nodeName);
	console.log(this.nodeName);
	console.log(ev.currentTarget.nodeName);
}

点击内层圆,输出结果如下:

点击内层圆
点击中层圆,输出结果如下:
点击中层圆
点击外层圆,输出结果如下:
点击外层圆
可以得出结论,在W3C标准的浏览器上,ev.target指向的是事件流的target,而currentTarget和this的指向保持一致,指向当前事件活动的对象。

如果涉及到兼容性问题,兼容IE8,那么在IE8上会有一些问题,首先需要使用target的兼容写法,其次IE8的event对象上是没有currentTarget属性的。

因为各个浏览器的事件对象不一样, 把主要的事件对象的属性和方法列出来:

属性/方法 介绍
bubble 表明事件是否冒泡
cancelable 表明是否可以取消冒泡
currentTarget 当前时间程序正在处理的元素, 和this一样的
defaultPrevented false ,如果调用了preventDefualt这个就为真了
detail 与事件有关的信息(滚动事件等等)
eventPhase 如果值为1表示处于捕获阶段, 值为2表示处于目标阶段,值为三表示在冒泡阶段
target or srcElement 事件的目标
trusted 为ture是浏览器生成的,为false是开发人员创建的(DOM3)
type 事件的类型
view 与元素关联的window, 我们可能跨iframe
preventDefault() 取消默认事件
stopPropagation() 取消冒泡或者捕获
stopImmediatePropagation() (DOM3)阻止任何事件的运行

IE下的事件对象是在window下的,而标准应该作为一个参数, 传为函数第一个参数。IE的事件对象定义的属性跟标准的不同,如:

属性/方法 介绍
cancelBubble 默认为false, 如果为true就是取消事件冒泡
returnValue 默认是true,如果为false就取消默认事件
srcElement 这个指的是target, Firefox下的也是srcElement

言归正传,使用同心圆的例子,并且使用兼容写法,代码如下。 JavaScript:

var div = document.querySelector("div");
var ul = document.querySelector("ul");
var li = document.querySelector("li");

function addEvent(element,type,callback){
    if(element.addEventListener){
        element.addEventListener(type,callback,false);
    }else if(element.attachEvent){
        element.attachEvent('on' + type,callback);
    }
}
addEvent(div,'click',function(ev){
	var event = ev || window.event;
	var target = event.target || event.srcElement;
	console.log(target.nodeName);
	if(event.currentTarget){
		console.log(event.currentTarget.nodeName);
	}else{
		console.log("IE8及以下不支持currentTarget");
	}
	console.log(this.nodeName);
});

在W3C标准的浏览器上,点击内层圆,中层圆和外层圆,输出结果与之前只考虑W3C标准的浏览器的结果相同。

在IE8上,点击内层圆,输出结果如下:

在IE8上点击内层圆
点击中层圆,输出结果如下:
在IE8上点击中层圆
点击外层圆,输出结果如下:
在IE8上点击外层圆
可以看到,target是和W3C浏览器结果保持一致的,IE8及以下不支持currentTarget, 另外attachEvent的this指向window,没有nodeName这个属性,所以一直是undefined。

颜色参考: http://zhongguose.com/ http://nipponcolors.com/