JS事件相关知识点梳理 🏀🎱⚽️

989 阅读12分钟

近几年大部分开发者都在 Vue React 生态里做上层开发,底层的知识点很容易遗忘,所以打算从DOM规范开始梳理一下事件相关的知识点。onclick是DOM0级事件,addEventListener是DOM2级事件,区别在于是否支持事件函数的重复绑定,大部分人对DOM规范也就了解这么多,所以就先从DOM规范开始,会涉及一些历史问题,帮助我们了解来龙去脉。

DOM: Document Object Model 文档对象模型 window.document,遵循W3C的标准。
BOM: Browser Object Model 浏览器对象模型 window,BOM没有相关标准。

DOM0 - DOM3

在 W3C 发布DOM规范之前各浏览器厂商(IE 和 Netscape 为主)都自行定制了自己的私有规范,这些规范之间相互存在差异,习惯上我们把这些私有规范成为 DOM0级,虽然这些规范没有被标准化但也陆续得到几乎所有的浏览器厂商支持。

W3C 为了对DOM规范进行标准化,从 1998 年开始先后发布了 DOM1 DOM2 DOM3,这3个版本的规范,每个版本都是对上一版本的加强和完善。

也就是说DOM0级规范是不存在的,官方并没有形成此标准,只是历史进程中的一条分界线,在这之后W3C为了统一规范陆续迭代了三个DOM版本。

DOM1(1998年10月发布)

对DOM1比较陌生并不是没有DOM1而是DOM1没有涉及事件相关内容,DOM1级规范主要定义了HTML和XML文档的底层结构(树),提供基本的文档操作方法,主要包括两个子模块:

  • DOM1级核心(Core):规定把文档设计成树形结构,同时定义了操作文档的基本属性和方法;
  • DOM1级HTML(HTML):针对HTML元素定义了对象、属性、方法;

DOM2(2000年11月发布)

DOM2级则在DOM1的基础上引入了更多交互能力,具体表现为下面6个模块:

  • DOM2级核心(Core):在DOM1的基础上规定了DOM文档结构模型,添加了更多的特性;
  • DOM2级HTML(HTML):在DOM1级HTML的基础上增加了更多属性、方法、和新接口;
  • DOM2级视图(Views):定义了基于样式信息的不同视图;
  • DOM2级事件(Events):规定了与鼠标相关事件的控制机制以及API;
  • DOM2级样式(Style):定义了以js编程方式来操作和获取css样式的API;
  • DOM2级便利和范围(Traversal Range):允许通过遍历的方式访问DOM,以及对指定范围的DOM进行操作;

DOM3(2004年4月发布)

DOM3级是对DOM2的加强和完善,主要包括以下3个子模块:

  • DOM3级核心(Core):在DOM2的基础上添加了更多属性和方法,并对已有内容做了一些修改;
  • DOM3级加载和保存(Load Save):提供将 XML 文档的内容加载到 DOM 文档中,以及将 DOM 文档序列化为 XML 文档的能力;
  • DOM3验证文档(Validation):提供了确保动态生成的文档有效性的能力,即如何符合文档类型声明;

事件机制

当我们在浏览器上点击一个button,事件并不只触发在button上,从button到父级元素、body、html、document、window 都依次响应了click事件,这一点 IE 和 Netscape 两家厂商达成了一致,但他们对事件触发的顺序产生了分歧,我们把事件触发的顺序叫做事件流,IE认为事件流是从里向外的也就是事件冒泡,而Netscape认为事件流是从外向里的也就是事件捕获。

因为产生了分歧,所以W3C在DOM2级规范中规定了事件流的具体行为。

事件冒泡(IE)

IE认为事件流是冒泡机制,从里向外,即事件开始时由具体的事件元素接收然后足级向上传播

button -> 父元素 -> body -> html -> document -> window

<div id="div" style="width: 100px; height: 100px; background: pink;">
  <button id="button" style="margin: 150px;">button</button>
</div>

button.onclick = function(){ console.log(1) }
div.onclick = function(){ console.log(2) }
document.querySelector('body').onclick = function(){ console.log(3) }
document.querySelector('html').onclick = function(){ console.log(4) }
document.onclick = function(){ console.log(5) }
window.onclick = function(){ console.log(6) }

上面 DEMO 的console的结果是 1 2 3 4 5 6 从里向外,有一个点要注意上面的 button 有一个 margin 样式,导致 button 超出了 div 的边界,但并没有影响到事件的向外冒泡,说明事件流的传播是基于元素的嵌套结构,和元素渲染位置无关,也就是说两个完全没有嵌套关系的元素如果通过定位叠在一起并不存在事件流的传递。(比较基础的问题,因为之前有同学问过我顺便提一下。)

上面例子中 button、div 并没有通过 document.getElementById 去获取DOM对象而是直接用ID值当做DOM对象做了绑定,这种写法是被浏览器认可的,当我们用ID属性为DOM元素命名且当前window对象上没有以此ID值为名的标识符(变量)时,会在window对象上声明以ID值为名的标识符,标识符的值指向该ID对应的DOM对象。如果DOM元素声明的ID值是一个已经存在的全局变量(浏览器内置的全局变量如 location、alert,或者用户显式声明的变量),那这个机制将会被忽略。这个最初是在IE里面实现,后续chrome等其他厂商也做了支持。window以全局对象的形式存在于作用域链的顶端,大量使用全局变量会导致变量污染,在作用域链的查找上也是比较耗时的,况且这种看似不合理的机制很可能在后续的规范中被废除,所以项目中不建议使用,写写demo还是ok的。

事件捕获(Netscape)

Netscape认为事件流是捕获机制,从外向里,即外层节点最早接收到事件,内层节点最后接收事件,这么做的用意在于可以在事件到达预定目标元素之前捕获它。

window -> document -> html -> body -> 父元素 -> button

事件捕获是Netscape在DOM2规范发布前提出的,也就是说这个时候还没有addEventListener这个api,需要在标签上写内联的onclick事件,用户如果恰好在DOM渲染完成而JS还未加载的时机触发了事件,不是就异常了吗(因为全局尚未挂载事件函数),所以基于捕获机制我们在head标签里监听document的click事件,判断body里的脚本是否运行完成来决定是不是做拦截操作。

事件流(DOM2)

DOM2级事件中规定事件流包括三个阶段:事件捕获阶段、目标阶段、事件冒泡阶段;首先发生的是事件捕获,为截获事件提供了机会,然后是实际目标接收到事件,最后一个阶段是冒泡阶段,在这个阶段对事件作出响应。

document -> html -> body -> 父元素 -> button -> 父元素 -> body -> html -> document

彩蛋

DOM2级规范中要求事件应该从document开始传播,但是各浏览器厂商都是从window对象开始,这点可能和规范有出入,我们在window上也可以监听到事件。

事件绑定

标签内联绑定

把事件名称加on关键字当做元素的属性,值为一段可执行的js脚本。

<button onclick="clickFun(this)">Click1</button>
function clickFun(_this){ 
  console.log(this)  // window
  console.log(_this) // button
  console.log(event) // event
}

特点:

  • 标签上的this指向目标元素
  • 事件函数里的this指向window

缺点:

  • 在js加载完成前触发事件会抛出异常(事件捕获描述过这个场景);
  • 事件函数的作用域链在不同的浏览器中会导致不同的结果,因为不同js引擎的标识符解析规则存在细微差异;
  • 结构与行为紧密耦合;

DOM0级事件绑定(onclick)

通过js脚本把一个匿名函数赋值给DOM元素的事件名称属性,这种方法首先在第四代(IE4)浏览器中出现,因为简单易用等原因到目前为止依然被所有现代浏览器支持。

<button id="btn">Click2</button>

btn.onclick = function(){ 
  console.log(this)  // button
  console.log(event) // event
}

特点:

  • 事件函数里的this指向目标元素
  • 可以把匿名函数的调用方式想象成通过 btn.click() 调用,根据this指向调用者的机制不难理解this为啥会指向button。
  • 通过这种方式添加的事件会以冒泡的机制触发,因为在DOM0规范里除Netscape之外都是冒泡机制。(无捕获)
  • 将事件赋值为null即可删除绑定,这样再点击按钮不会有任何动作也不会抛出异常。

缺点:

  • 多次绑定同一事件会被覆盖

DOM2级事件绑定(addEventListener)

DOM2级规范定义了两个方法分别是 addEventListener 和 removeEventListener,全部DOM元素都包含这两个方法,它们都接收三个参数分别是 事件名称、事件函数、布尔值(非必传、默认false),最后的布尔值为true表示在捕获阶段触发事件,为false表示在冒泡阶段触发事件。

<div id="div" style="width: 100px; height: 100px; background: pink;">
  <button id="button" style="margin: 150px;">button</button>
</div>

button.addEventListener('click', function(){ console.log(1) })
div.addEventListener('click', function(){ console.log(2) })
document.querySelector('body').addEventListener('click', function(){ console.log(3) })
document.querySelector('html').addEventListener('click', function(){ console.log(4) })
document.addEventListener('click', function(){ console.log(5) })
window.addEventListener('click', function(){ console.log(6) })
// 默认false 冒泡
// 结果:1 2 3 4 5 6

button.addEventListener('click', function(){ console.log(1) }, true)
div.addEventListener('click', function(){ console.log(2) }, true)
document.querySelector('body').addEventListener('click', function(){ console.log(3) }, true)
document.querySelector('html').addEventListener('click', function(){ console.log(4) }, true)
document.addEventListener('click', function(){ console.log(5) }, true)
window.addEventListener('click', function(){ console.log(6) }, true)
// true 捕获
// 结果:6 5 4 3 2 1

特点:

  • 事件函数里的this指向目标元素。
  • 同一事件可以多次绑定,按照绑定顺序依次运行。
  • 通过addEventListener添加的事件必须通过removeEventListener移除,且入参必须保持一致,也就是说通过匿名函数添加的事件将无法移除。

IE8以下兼容(attachEvent)

IE8以下浏览器不支持addEventListener 和 removeEventListener,但是提供了attachEvent 和 detachEvent方法,使用基本一样但是也有一些差异比如:

  • 只支持冒泡;
  • 事件名称要加on关键字;
  • 多次绑定同一事件,执行顺序和addEventListener相反,先绑定后执行;
  • 等(基本淘汰不做太多说明);

事件对象

触发DOM事件时,会产生一个事件对象event,无论是DOM0还是DOM2浏览器都会把event传入事件函数(IE除外),event对象中包含着所有与事件相关的信息,包括触发事件元素、绑定事件元素、事件类型、鼠标位置信息等。

target 和 currentTarget

target: event.target 指向真正触发事件的元素,因为冒泡机制的存在触发事件的元素和绑定事件的元素有时候并不是同一元素。

currentTarget: event.currentTarget 指向绑定事件的元素。

this: 指向绑定事件的元素,this始终等于currentTarget。

<div id="div" style="width: 100px; height: 100px; background: pink;">
  <button id="button">button</button>
</div>

button.addEventListener('click', function(){ 
  console.log(event.target, event.currentTarget, this) 
  // button button button
})

div.addEventListener('click', function(){ 
  console.log(event.target, event.currentTarget, this) 
  // button div div
})

preventDefault 和 stopPropagation

preventDefault: event.preventDefault方法可阻止事件默认行为

stopPropagation: event.stopPropagation方法可以阻止事件流传播(冒泡 & 捕获)

<a id="a" href="http://www.baidu.com">baidu</a>

a.addEventListener('click', function(){ 
  event.preventDefault()
  console.log(1) 
})

阻止了a标签的跳转

如果通过DOM0也就是onclick绑定事件最后在事件函数末尾写 return false 语句也可以阻止默认事件,就像这样 a.onclick = function(){ console.log(1); return false}

<div id="div" style="width: 100px; height: 100px; background: pink;">
  <button id="button">button</button>
</div>

button.addEventListener('click', function(){ 
  event.stopPropagation()
  console.log(1) 
})

div.addEventListener('click', function(){ 
  console.log(2) 
})

因为阻止了事件传播所以console.log的结果只有1

我们 console.log(event) 并没有找到 preventDefault 和 stopPropagation 这两个方法,其实这俩方法的调用是通过原型链的查找机制 console.log(event.proto.proto.proto) 就能看到了。

eventPhase

eventPhase: event.eventPhase 属性可用来确定事件当前处在事件流的那个阶段;捕获阶段值为1,目标阶段值为2,冒泡阶段值为3。

<div id="div" style="width: 100px; height: 100px; background: pink;">
  <button id="button">button</button>
</div>

div.addEventListener('click', function(){    // 事件1
  console.log(event.eventPhase, '捕获阶段 值为1') 
}, true)

button.addEventListener('click', function(){ // 事件2
  console.log(event.eventPhase, '目标阶段 值为2') 
})

div.addEventListener('click', function(){    // 事件3
  console.log(event.eventPhase, '冒泡阶段 值为3') 
}, false)

根据 DOM2级事件中规定事件流包括的三个阶段:事件捕获阶段、目标阶段、事件冒泡阶段 首先发生捕获阶段父级元素拦截事件 事件1 运行,然后是目标阶段 事件2 运行,最后是冒泡阶段又冒泡到父级 事件3 运行,最终console结果是 1、2、3。

上面例子中 事件2 事件3 虽然用DOM2的addEventListener绑定但是第三个参数为 false 在冒泡阶段触发,所以换成DOM0的方式通过onclick绑定对结果不会有任何影响。

看下面的例子

<div id="div" style="width: 100px; height: 100px; background: pink;">
  <button id="button">button</button>
</div>

button.onclick = function(){ 
  console.log(1)
} // 冒泡

button.addEventListener('click', function(){ 
  console.log(2)
}, true)  // 捕获

div.addEventListener('click', function(){ 
  console.log(3)
}, true)  // 捕获

div.onclick = function(){ 
  console.log(4)
}  // 冒泡

document.addEventListener('click', function(){ 
  console.log(5)
}, true)  // 捕获

document.addEventListener('click', function(){ 
  console.log(6)
}, false)  // 冒泡

和前面一个例子一样根据事件流的先后顺序可以分析出来正确的结果是 5 3 1 2 4 6,有一点需要注意,可能有人的答案是 5 3 2 1 4 6 认为捕获在冒泡前面,但是 2 和 1 是绑定在事件元素上的,处在目标阶段,以事件绑定的先后顺序运行。

IE8以下兼容(window.event)

在IE浏览器中操作事件对象有几个要注意的兼容问题,具体表现在以下几个方面:

  • 获取事件对象

    • DOM0级方法绑定事件时,event对象作为window对象的一个属性存在。
    • 使用 attachEvent 绑定事件时,浏览器会把event传入事件函数,同时window.event也存在。
  • 取消默认行为

    • ie在event上提供了一个returnValue属性替代preventDefault方法,来取消默认行为。
    • 要注意的是returnValue是一个属性而不是方法,给他赋值false即可取消默认行为。
  • 阻止事件流传播

    • ie在event上提供了一个cancelBubble属性替代stopPropagation方法,来阻止事件流传播。
    • 要注意的是cancelBubble是一个属性而不是方法,给他赋值false即可阻止事件流传播。

处理兼容

前面讲了 DOM0 和 DOM2 以及 低版本IE 三种事件绑定方法,我们需要做一个封装达到跨浏览器兼容的目的,假设最终暴露的方法是bindEvent(),那么这个方法需要4个入参分别是 事件元素、事件名称、事件函数、执行阶段,方法内部需要判断传入的DOM元素是否支持addEventListener,判断浏览器差异,处理重复绑定问题,事件解绑机制,对事件对象也需要做一些必要的兼容。

var evt = event || window.event; 
evt.preventDefault ? evt.preventDefault() : evt.returnValue = false; 
evt.stopPropagation ? evt.stopPropagation() : evt.cancelBubble = true; 

扩展阅读

文中提到this指向和原型链的问题可以参考下面两篇文章

this上下文
原型链