事件,可以理解为用户与浏览器(网页)交互产生的一个动作。比如点击(click)、双击(dblclick)、聚焦(focus)、鼠标悬停(mouseover)、松开键盘(keyup)等动作。浏览器内部包含了非常复杂的事件系统,事件种类非常多,JavaScript 与 HTML 的交互就是通过事件实现的。前面列举这些仅仅是冰山一角。
综上,我们可以简单地,将 IE8 及以下浏览器的事件流处理方案称为“IE 事件流”,其余的称为标准事件流(或 DOM 事件流)。
4. 事件处理程序
为响应事件而调用的函数被称为“事件处理程序”(也可称为事件处理函数、事件监听器、监听器)。通常我们会在发生事件之前,需提前为某个 DOM 元素编写事件处理监听器并进行绑定。待用户与浏览器产生交互后,事件对象会通过事件流机制在 DOM 中进行传播,一旦命中目标,事件监听器就被调用,并接收事件对象作为其唯一的参数。(注意,IE8 需要通过 window.event 全局对象来获取事件对象)
5. 事件委托
事件委托,它是利用事件流机制来提高页面性能的一种解决方案,也被称为事件代理。
6. 其他
文中还会提到事件目标一词,是指触发事件的 DOM 元素,但不一定是事件处理程序所在的 DOM 元素。当生成事件对象之后,事件目标(在 JavaScript 就是一个对象)将会被存储在事件对象的 target 属性(只读,IE8 则是 srcElement 属性)。
还有,我们给某个 DOM 元素注册一个点击事件监听器,假设其后代元素未阻止冒泡行为,只要点击该元素本身或其后代任意子元素,最后都会触发该事件监听器。因此,我们可以得出一个结论:事件监听函数的作用范围,包含元素本身所占空间及其后代元素所占空间。不论后代元素是否溢出当前元素范围(长宽),或者是否脱离文档流(指绝对布局等)。
function delegate() {
const el = document.getElementById('directory')
el.addEventListener('click', e => {
const { page } = e.target.dataset
if (page === undefined) return
// do something...
// 处理业务逻辑,比如:
console.log(`Please turn to page ${page} of the book.`)
})
}
// 也要用 Babel 转换一下
function getDataset(el) {
if (el.dataset) return el.dataset
const attrs = el.attributes
const dataset = {}
for (let i = 0, re1 = /^data-(.+)/, re2 = /-([a-z\d])/gi, len = attrs.length; i < len; i++) {
// data-camel-case to camel-case
const matchName = re1.exec(attrs[i].name)
if (!matchName) continue
// camel-case to camelCase
const name = matchName[1].replace(re2, (...args) => {
return args[1].toUpperCase()
})
// add to dataset
dataset[name] = attrs[i].value
}
return dataset
}
不需要兼容 IE8 的版本如下,将会使用 class 写法,会简洁很多。辣鸡 IE...
2. 兼容现代浏览器版本(包括 IE9 ~ IE11)
class CreateMyEvent {
constructor({ el, type, fn }) {
this.el = el
this.type = type
this.fn = fn
this.listener = fn.bind(el)
this.added = false // 是否已添加监听器
}
static closestPolyfill() {
Element.prototype.myClosest = function (s, root) {
let el = this
let i
const matches = root.parentElement.querySelectorAll(s)
do {
i = matches.length
// eslint-disable-next-line no-empty
while (--i >= 0 && matches.item(i) !== el) {}
} while (i < 0 && (el = el.parentElement))
return el
}
}
addEventListener() {
if (this.added) {
console.warn('Please note that you have added event handler before and will not be added again.')
return
}
const { type, el, listener } = this
el.addEventListener(type, listener, false)
this.added = true
}
removeEventListener() {
const { type, el, listener } = this
el.removeEventListener(type, listener, false)
this.added = false
}
delegate(selectors) {
const { el, fn } = this
if (!selectors) {
this.addEventListener()
return
}
// 在重写 listener 监听器之前,确保移除此前的监听器
if (this.added) console.warn('Please note that the previously registered event handler will be deleted and a new event handler will be added.')
this.removeEventListener()
// 重写监听器
this.listener = function (e) {
// 在 Element 原型上添加 myClosest 方法
if (!Element.prototype.myClosest) CreateMyEvent.closestPolyfill()
const matchEl = e.target.myClosest(selectors, el)
matchEl && fn.call(matchEl, e)
}
// 重新注册监听器
this.addEventListener()
}
}
本文将会介绍事件、事件流、事件对象、事件处理程序、事件委托、以及兼容 IE 浏览器等内容。
一、概念
就本文一些“术语”作简单介绍,后续章节再详述。
1. 事件
事件,可以理解为用户与浏览器(网页)交互产生的一个动作。比如点击(click)、双击(dblclick)、聚焦(focus)、鼠标悬停(mouseover)、松开键盘(keyup)等动作。浏览器内部包含了非常复杂的事件系统,事件种类非常多,JavaScript 与 HTML 的交互就是通过事件实现的。前面列举这些仅仅是冰山一角。
2. 事件对象
在产生事件之后,浏览器会生成一个对象并存储起来,它包含了当前事件的所有信息,比如发生事件的类型、导致事件的元素以及其他与事件相关的数据。例如鼠标操作,该对象还会记录事件产生时鼠标的位置等信息。待事件完成使命(即事件完结)之后,它将会被销毁。这个对象,被称为“事件流”。
3. 事件流
事件对象在 DOM 中的传播过程,被称为“事件流”。
经常能看到类似 IE 事件流、标准事件流(DOM 事件流)的说法,主要原因是早期浏览器厂商各干各的,没有一个“中间人”去进行统一。随着 Web 的飞速发展,相关标准就由特定机构制定,浏览器厂商负责按照标准去实现。例如 W3C、WHATWG、ECMAScript 等。但由于历史遗留原因,不得不写出一大堆的兼容方法以适配所有的浏览器。
说到事件流,就不得不提“事件冒泡”和“事件捕获”了。它们分别由微软、网景团队提出,是两种几乎完全相反的事件流方案。前者被所有浏览器支持,后者则是被所有的现代浏览器所支持(包括 IE9 及以上)。
由于事件捕获不被旧版本浏览器(IE8 及以下)支持,因此实际中通常在冒泡阶段触发事件处理程序。
综上,我们可以简单地,将 IE8 及以下浏览器的事件流处理方案称为“IE 事件流”,其余的称为标准事件流(或 DOM 事件流)。
4. 事件处理程序
为响应事件而调用的函数被称为“事件处理程序”(也可称为事件处理函数、事件监听器、监听器)。通常我们会在发生事件之前,需提前为某个 DOM 元素编写事件处理监听器并进行绑定。待用户与浏览器产生交互后,事件对象会通过事件流机制在 DOM 中进行传播,一旦命中目标,事件监听器就被调用,并接收事件对象作为其唯一的参数。(注意,IE8 需要通过
window.event
全局对象来获取事件对象)5. 事件委托
事件委托,它是利用事件流机制来提高页面性能的一种解决方案,也被称为事件代理。
6. 其他
文中还会提到事件目标一词,是指触发事件的 DOM 元素,但不一定是事件处理程序所在的 DOM 元素。当生成事件对象之后,事件目标(在 JavaScript 就是一个对象)将会被存储在事件对象的
target
属性(只读,IE8 则是srcElement
属性)。还有,本文将大量使用到“现代浏览器”、“主流浏览器”、“标准浏览器”、“IE 浏览器”等词语。若无特殊说明,前三个指的是包括 IE9 ~ IE11 及其他常见的浏览器。而“IE 浏览器”通常指 IE8 及更低版本的浏览器。
二、事件流
前面提到,事件流就是事件对象在 DOM 中的传播过程。标准事件流过程,如下:
举个例子:
结合事件流,当我们点击
<p>
元素,产生一个点击事件。事件对象的捕获阶段的过程,如下:目标阶段过后,是冒泡阶段的过程,如下:
因此,事件流过程可以简单绘成如下表格:
1. 事件捕获、事件冒泡
上面提到的捕获阶段和冒泡阶段,所对应的就是事件捕获、事件冒泡。
事件冒泡和事件捕获,分别由微软和网景团队提出,这是几乎完全相反的两个概念,是为了解决页面中事件流而提出的。
想象一下:气泡从水中冒出水面的过程,它是从里(底)到外的。事件冒泡跟这个过程很相似,事件对象会从最内层的元素开始,一直向上传播,直到
window
对象。因此冒泡过程如下:注意,并非所有事件都支持冒泡行为,比如
onblur
、onfocus
等事件。既然事件捕获与事件冒泡是相反的,捕获过程如下:
总的来说,可以简单概括为:冒泡过程是由里到外,而捕获过程则是由外到里。两者刚好相反。
2. 示例
我们分别给
div
和p
元素添加了两个事件处理程序,如下:3. 注意点
通过 DOM2 Event 提供的
addEventListener()
方法,可以在捕获阶段触发事件监听器。在事件监听器中,除了可以写业务逻辑外,还经常做阻止事件冒泡、取消元素默认行为等处理。如果不支持某个阶段,或事件对象已停止传播,则将跳过该阶段。例如,将
addEventListener()
方法的第三个参数useCapture
设为true
,则将跳过冒泡阶段(但注意它是会到达目标阶段的)。如果事件监听器中调用了stopPropagation()
,则将跳过后续的所有阶段。还有,我们给某个 DOM 元素注册一个点击事件监听器,假设其后代元素未阻止冒泡行为,只要点击该元素本身或其后代任意子元素,最后都会触发该事件监听器。因此,我们可以得出一个结论:事件监听函数的作用范围,包含元素本身所占空间及其后代元素所占空间。不论后代元素是否溢出当前元素范围(长宽),或者是否脱离文档流(指绝对布局等)。
四、事件对象
前面提到,当用户与浏览器发生交互会产生一个事件,接着会创建生成一个事件对象。
1. 获取事件对象
在标准浏览器中,无论是以哪种方式(DOM0 或 DOM2)注册事件处理程序,事件对象都是传给事件处理程序的唯一参数。但是在 IE8 及以下浏览器下事件对象只能通过
window.event
获取。在标准浏览器里,可以通过
window.event
或者事件对象的eventPhase
属性来验证,如下:2. 内置事件类型
在现代浏览器中,内置了很多事件类型:
以上这些都是基于 Event 接口的派生类,而事件对象一般是派生类的实例化对象。比如:
3. Event 对象
前面提到,所有事件对象都源自 Event 基类,意味着 Event 基类(对象)本身包含适用于所有事件类型实例的属性和方法。主要是为了兼容性。
以上这些方法(包括标准浏览器、IE 浏览器)列举出来是为了方便下文封装方法时,注意兼容处理。
五、事件冒泡与默认行为
前面提到,并非所有事件都支持冒泡行为。因此,若给不支持冒泡行为的事件去
stopPropagation()
是多次一举,且没有意义。取消元素默认行为同理。阻止事件冒泡和元素默认行为经常放在一起讨论。上一章节已经清楚介绍了标准浏览器与 IE 浏览器的兼容性。如下:
还有,在事件处理程序中慎用
return false
语句,不同环境下会发生非预期行为。例如:六、事件处理程序
事件处理程序,也常称为事件监听器或监听器。可通过以下几种方式给 DOM 元素注册事件处理程序:
前两种方式不推荐,后两种就能覆盖 99.9% 的浏览器了。如果不用兼容 IE,那么 DOM2 就更香了,至少可以减少 70% 的代码量...
1. HTML 事件处理(不推荐使用)
这是最早的事件处理方式,说实话在项目中没见过这种写法。简单了解下即可,不推荐使用。
2. DOM0 事件处理程序
使用方法简单,也很常见。就是将一个函数赋值给一个 DOM 元素的事件属性。其中元素的事件属性通常是
on + type
(事件类型),比如onclick
、ondblclick
、onfocus
、onload
等。举个例子:
3. DOM2 事件处理程序
在 DOM2 Event 标准中,新增了
addEventListener()
和removeEventListener()
方法来注册或移除事件处理程序。它的优势有两点:语法:
type
:表示事件类型(字符串)。比如click
。listener
:通常是一个函数,即事件处理程序。被触发时将会接收到一个事件对象作为参数。useCapture
:布尔值,默认为false
。该参数决定了listener
是否在“捕获阶段”被触发。举个例子:
4. IE 事件处理程序
尽管 IE8 及以下浏览器不支持
addEventListener()
和removeEventListener()
方法,但它有两个类似的方法来注册或移除事件处理程序,那就是attachEvent()
和detachEvent()
。区别是不支持在事件捕获阶段触发,因为 IE8 事件流只含事件冒泡。语法如下:
ontype
:表示事件属性(字符串),即on + type
,比如onclick
。这点与addEventListener()
中的type
参数是不同的。listener
:同样接受一个函数作为事件处理程序。请注意listener
函数内部this
将指向window
对象。举个例子:
5. 跨浏览器事件处理程序
跨浏览器,就是说要同时兼容 IE 浏览器和现代浏览器。其实上面已经将所有方式都介绍了一遍,写起来就很简单了。
6. DOM3 自定义事件(扩展内容)
前面介绍的,全都是浏览器内置事件,当用户与浏览器发生交互时,事件(对象)就诞生了,它接着会在 DOM 中进行传播,命中目标后会触发相应的时间处理函数。
在 DOM3 Event 标准上,除了在 DOM2 Event 的基础上,新增了很多事件类型,而且它还允许自定义事件。但是自定义事件,需要“手动”触发,即主动调用
dispatchEvent()
方法才可以。至于用处嘛,假设动态加载脚本,需在加载完成后才能做一些事情,例如配置什么的。那么我们需要监听脚本什么时候加载完,这时候自定义事件就能发挥其作用了。举个例子:
关于
CustomEvent
的一些语法注意点:7. 小结
可能有人会好奇为什么不介绍 DOM1,原因是 DOM1 没有关于事件的新增或改动,因而没提及。
若同时存在 HTML 事件处理程序和 DOM0 处理函数,后者会覆盖掉前者。 DOM2 不会覆盖 HTML 事件处理程序或 DOM0 事件处理程序。
为同一事件目标注册多个事件处理程序时,执行次序为:HTML 事件处理程序或 DOM0 > DOM2 或 IE 事件处理程序。跟事件注册先后顺序无关。
若有兴趣想了解 DOM0 ~ DOM3 新增了哪些内容,可看文章。另外附上 WHATWG 的 DOM 标准:DOM Living Standard。
七、总结(上半部分)
就前面关于事件处理程序所有兼容性问题,我们来进一步封装下。
先写个构造函数吧:
一些注意点在注释有标注体现,然后进行实例化并调用,如下:
八、事件委托
除了面试中常被问及,实际应用场景里也是优化性能的一种手段。
前面提到,事件流的传播路径:捕获阶段 -> 目标阶段 -> 冒泡阶段。我们都知道,所有浏览器都支持事件冒泡,但事件捕获并不是都支持的,例如 IE8。
为什么要事件委托?
想象一个生活场景:一本书的目录包含大章节、小章节,每个小章节都会对应一个页码,然后根据页码就可以翻到对应的内容。
如果用程序实现的话,“最笨”的做法是:给每个小章节注册一个点击监听器并实现跳转。似乎也没太大问题,是吗?如果这本书有 1000 个小章节,意味着要注册 1000 个事件监听函数。先不说性能问题,写代码的是不是得疯掉。假设还没疯,哪天产品经理又新增 500 章节,是不是又得改,总有一天会逼疯你的!
如果用“事件委托”怎么做呢?我把监听器注册到目录上。当点击某章节时,利用点击事件的冒泡行为,事件会被传递到目录并命中来触发监听器。而且,这是一劳永逸的事情,无论产品经理如何增删章节,都无需再改动了。这不就有时间摸鱼了对吧。
举个例子:
以上示例中,有多组
<ol>
、<li>
的目录,想要点击<li>
(即section
部分)的时候,打开书本对应页面。根据上述给出的 HTML 示例,事件委托最粗糙、最不灵活、最简单的实现如下:
上述示例,只要点击
<div id="directory">
及其后代元素都会命中事件处理程序,按需求是点击<li>
才要执行业务逻辑。像点击<div class="chapter">
其实是没有实际意义的。而且明显上面的方法并不灵活。我们再改一下:
按上述方式封装的好处是方便灵活,一些实现思路或注意事项,在相应位置的注释已标注。
思路就这样,其实很简单,麻烦在于兼容各浏览器而已。若不需要兼容 IE 浏览器,那简直不能太爽了,后面会给出一个简化版。
九、最终总结
这里会将本文所有的内容都封装在一起,包括
addEventListener()
、attachEvent()
、事件委托以及兼容问题等等。1. 兼容所有浏览器的版本(包括 IE8)
先给一个可兼容 IE8 的版本。可以轻松地按现代浏览器的方式去注册、移除事件监听器,以及方便处理冒泡、默认行为等。
使用方式如下:
哎,丑陋的代码......由于 IE8 不支持类似
Object.assgin()
、Object.defindProperty()
等 ES5 方法,上面只能使用最原始的构造函数去写了。另外,前面示例是将页面数据定义在
data-*
上的,但由于 IE10 及更低版本浏览器并不支持Element.prototype.dataset
属性,因此也要处理一下。例如:不需要兼容 IE8 的版本如下,将会使用
class
写法,会简洁很多。辣鸡 IE...2. 兼容现代浏览器版本(包括 IE9 ~ IE11)
到这里,好像就完了,改了好几版,想吐血...
The end.