前端原生 JavaScript 事件机制

基本介绍

现代化的网页里面有许多原生事件,他们普遍被框架包装甚至再实现事件,原生事件使用方法有数种:

GlobalEventHandlers

通过GlobalEventHandlers接口来绑定事件,例如onclickonchange等,实现此接口的有HTMLElementDocumentWindowWorkerGlobalScope

1
2
3
4
5
6
7
8
9
10
11
12
13
var btn = document.querySelector('button')

function foo() {
...doSomething
}
function bar() {
...doSomething
}

btn.onclick = foo
btn.onclick = bar

//最后只有 bar 绑定,若要移除只能置空

行内绑定

通过行内直接绑定事件

1
<button onclick="foo()">bar</button>

EventTarget

通过EventTarget接口绑定事件,最常用实现接口的对象有ElementDocumentWindow,但是很有其他对象有实现,比如XMLHttpRequestAudioNodeAudioContext等等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var btn = document.querySelector('button')

function foo() {
...doSomething
}
function bar() {
...doSomething
}

//可以绑定多个,队列先进先出
btn.addEventListener('click', foo)
btn.addEventListener('click', bar)
//可以自己决定发布事件
//btn.dispatchEvent(event)
btn.removeEventListener('click', foo)

小结

由于EventTarget可以给单一事件绑定多个Listener,实现对象也不仅仅HTMLElement,手动触发事件和自定义事件,还有现在没有提到的更为精细的控制捕获还是冒泡触发,所以一般都是选择EventTarget的方式。

事件触发过程

1
2
3
4
5
6
7
function foo() {
// doSomething
}
// 假设 bar 是事件触发(onclick 什么的
function bar() {
foo();
}

一开始接触事件可能认为是类似上述代码,用bar来模拟事件触发,然后执行,但是实际上复杂得多,JS 事件有一个概念是捕获与冒泡,这种传递模型在Android上也有类似的实现。
但不是所有事件类型都有冒泡

1
2
3
4
5
6
7
8
9
10
<body>
<div>
<button>button</button>
</div>
</body>
<script>
var btn = document.querySelector("button");

btn.addEventListener("click", foo);
</script>

捕获和冒泡是怎样运作的呢?用上面的代码来举个例子

捕获

假如click事件发生了之后,浏览器会从最外层元素去检查有没有注册捕获阶段事件,如果有,执行他,然后再到内一层元素不断循环直至已经没有子元素 ,什么意思呢,就是这边
我们所能看到的捕获流程是 body > div > button,但是实际上不仅仅如此,body外层还有htmldocument,所以其实最终的捕获流程是 document > html > body > div > button

冒泡

冒泡其实就是反其道而行之,当到达已经最核心元素,开始冒泡,从内到外相反顺序去检查是否注册了冒泡阶段事件,如果有执行他,然后继续。

不同阶段事件

刚刚提到有捕获阶段冒泡阶段事件,那么这两种事件到底是怎么定义呢?其实之前所说的三种注册方法,全都是冒泡阶段事件,只有EventTarget.addEventListener(type, listener[, useCapture]) useCapture为 true 才可以注册捕获阶段事件

事件方法

stopPropagation

card

很多时候我们会有一些类似这样的需求,在一个可点击的 item 里面又有不同的操作,可能是点赞分享等,但是如果不阻止冒泡,很有可能就点赞的时候就点进去了 item detail 页面,这个时候就需要用到阻止传递,方法是event.stopPropagation,看了许多网上文章都说是阻止冒泡,实际上就是字面意思阻止传递,意思就是如果父元素注册了捕获阶段的方法,再调用event.stopPropagation,子元素也不会接收到任何捕获和冒泡。

preventDefault

1
2
3
4
<form>
<!-- some input -->
<button type="submit">submit</button>
</form>

或许也有也有用到form的时候,不希望点击马上submit,可以使用event.preventDefault来阻止默认行为,即是不会提交,同理这个也可以应用非常多地方,比如<a>,比如平常的键盘输入,全都是冒泡结束之后才会执行的。
这个方法主要判断是event.cancelable,如果自己实现自定义事件,一些情况也要考虑,当分发事件返回的是 false,就是被阻止了。
但是阻止默认行为,仅仅是不会提交或者不会跳转,而不是阻止了传递。

return false

还有一个更方便的方法可以阻止传递和阻止默认行为,就是直接在方法里面return false,它内部实际上做了三件事:

  • event.preventDefault();
  • event.stopPropagation();
  • 停止回调函数执行并立即返回。

stopImmediatePropagation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var btn = document.querySelector('button')

function foo(e) {
...doSomething
e.stopImmediatePropagation()
// bar 此时不会执行
}
function bar() {
...doSomething
}

//可以绑定多个,队列先进先出
btn.addEventListener('click', foo)
btn.addEventListener('click', bar)

event.stopImmediatePropagation用的比较少,用途就是阻止事件传递并且阻止相同事件的Listener被调用。
一开始介绍EventTarget接口的时候介绍到,同一事件可以绑定多个队列模式先进先出,如果遇到复杂需求可能用的比较多。

事件委托

1
2
3
4
5
6
7
<ul>
<li>foo</li>
...
<li>bar</li>
</ul>

// ...表示未知个

了解上述内容之后就可以了解一下事件委托,事件委托不直接把冒泡阶段事件绑定到元素上,而是绑定到他的父元素或者更上层爷爷什么的,因为是冒泡阶段事件,所以会event最终会找到点击的元素,然后再到上层元素触发,上层元素再指派给对应子元素。有点绕,但是那么折腾为啥呢?

减少内存消耗

我们经常会遇到很多列表需求,如果一个一个绑定,而这个列表长度未知,就有点浪费内存了。

动态绑定

也是这个列表来举例子,比如有需求是动态增加减少 item 数量,每次都需要手动addEventListenerremoveEventListener就很麻烦了,可以减少重复工作

jQuery 事件委托

浏览器原生并没有实现事件委托,这里用 jQuery介绍:

  • on( events [, selector ][, data ], handler(eventObject) )
1
2
3
$('.parent').on('click', 'child', function() {
...doSomething
});

绑定到.parent然后触发的时候找到child

  • delegate( selector, eventType, handler(eventObject) )
1
2
3
$('.parent').delegate('child', 'click', function() {
...doSomething
});

区别不大,值得一提是使用undelegate()方法来移除。
弃用 > 3.0 版本弃用的 API

  • live( events, handler(eventObject) )
1
2
3
$('child').live('click', function() {
...doSomething
});

他可以绑定将来和现在的元素,就是依靠selector实现,如需移除unbind('child')会移除所有通过live()绑定的事件
弃用 > 1.7 版本弃用的 API | 已删除的函数

实现一个事件委托

首先肯定是把事件绑定在上层,然后通过event来判断子元素:

1
2
3
4
5
6
7
8
9
10
// 给上层层元素绑定事件
document.getElementById("parent").addEventListener("click", function(e) {
// 低版本没有 event 返回,但是事件触发的时候全局只有一个 event 就是 window.event
var event = e || window.event;
var target = event.target || event.srcElement;
// 判断
if (target.nodeName.toLocaleLowerCase === "li") {
...doSomething
}
});

这样就简单的实现了一个事件委托,但是一般情况还是不建议重复造轮子

局限性

  • 事件委托基于事件冒泡,不适用所有事件,比如focusblur等就没办法了
  • mousemovemouseout这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,计算量极大,特别需求可以通过抖动解决
  • 原则上最好就近委托,避免阻止冒泡等逻辑影响