从事件冒泡到事件代理再到性能优化的一点总结
之前的学习中整理了有关使用 JavaScript 处理事件冒泡和事件捕获的一些细节,从事件冒泡引出了事件代理的概念,也是前端开发中国经常使用的一个编程手法,这篇将简单围绕事件代理来介绍一下为什么使用事件代理,以及事件代理对前端页面性能的影响。
事件冒泡
事件冒泡在上篇文章做了简要介绍,举例:
<div class="content">
<div class="box" onclick="onBoxClick('box1')">
box1
<div class="box" onclick="onBoxClick('box2')">
box2
<div class="box" onclick="onBoxClick('box3')">
box3
</div>
</div>
</div>
</div>
<script>
function onBoxClick (boxname) {
console.log(boxname, "clicked");
}
</script>
可点击不同的 “box” 来查看弹出效果
当单击 box3 时,事件会从 box3 开始依次传播到 box1 ,这是一个事件冒泡的过程,在这个过程中有三个div都绑定了 onclick 事件,所以可以在程序上处理这三层回调。
这个过程中有两个小概念。首先,事件并不是冒泡到 box1 就结束了,而是会继续向上传播,指导最顶层的 document 为止;其次,没有为对应元素绑定 onclick 事件时,视觉上不会看到传播的过程。
所以这里带来了一个小问题,如果有一个DOM结构是多层嵌套的,由于功能上的需要必须在嵌套之前处理 onclick 事件,那么这样一来事件冒泡就必不可免,同时也需要 onclick 进行多次绑定。
不过,既然不管要不要绑定 onlick 事件回调,事件都正常传播,那么直接监听最顶层的DOM元素也能实现必要功能,这个就是事件代理,或者事件委托的概念。
事件代理
事件代理就是用一个代理事件来代为处理其他元素的事件。例如,上面的结构可以变成下面的样子,同时将 click 监听放在最外层,通过判断目标的 innerText 来判断是哪个元素被点击:
<div class="content">
<div class="box" id="box">
box1
<div class="box">
box2
<div class="box">
box3
</div>
</div>
</div>
</div>
<script>
document.querySelector("#box").addEventListener("click", function (event) {
event = event || window.event;
let nodeText = event.target.innerText.split("\n")[0];
console.log(nodeText);
});
</script>
下面是一个可运行的脚本,可以通过单击来查看效果:
可点击不同的 “box” 来查看弹出效果
可以发现此时通过在最外层添加一个监听器,也可以通过过滤获取到子节点的事件,这是最常见的一种时间代理。
性能优化
既然事件可以被代理,那么为什么要用事件代理?
假设有如下一种情况:一个列表有100个列表元素,每一个列表元素都可以被点击,且具备点击效果。按照以往的做法,需要遍历 ul 下的所有 li 元素,然后依次添加点击事件,但是这种做法带来了一个问题。
先看一下当某个 li 元素绑定了点击事件后,用户产生click的时候是如何触发这个点击事件的。
- 浏览器的窗口是一定的,所以鼠标在浏览器窗口中活动的位置就是一定的,当用户产生点击事件的时候,点击的位置也是一定的;
- 由于浏览器页面存在层级结构,并不是所有元素平铺,所以当一个点上发生了点击时候后,这个点下可能对应了对多个层;
- 浏览器先确定这个点下的哪些层 被点击 然后按照DOM的事件流依次进行捕获和冒泡,这个过程会产品大量的DOM遍历;
上述只是三个简单的步骤,如果想理解整个过程,可以试想有这样一个场景:一个 Canvas 有限的空间里随机绘制了 100 个正方形,每个正方形都可以响应鼠标单击事件,现在请设计一种机制来实现对每个正方形的单击响应。
因为存在上述的过程,所以便产生了消耗,由于第一步和第二步必不可免,所以主要的性能消耗便发生在第三步。如果某个点击位置下面的每一个层的元素都绑定了事件,那么就会加深对DOM的遍历,也就是事件目标本身的定位,每一次定位都将产生资源的消耗。
事件代理可以在一定程度上解决这个问题,让多次遍历变成一次遍历。当box1被点击时,由于 box2 和 box3 并没有绑定事件,所以对DOM的查找在 box1 就停止了,box1 的事件参数里携带了渲染DOM时的节点信息,所以基于这个信息就可以知道是哪个 box 被点击。
列表渲染也是同理,只需要在最外成的 ul 上绑定一次事件函数,即可实现对子元素所有 li 的监听。
循环绑定还有一个问题就是对内存的消耗。
<div>
<ul>
<li>list item</li>
<!-- 省略1000个 -->
<li>list item</li>
</ul>
</div>
<script>
document.querySelectorAll("li").forEach(function (element, index) {
element.onclick = function (event) {
event = event || window.event;
console.log(index, event);
}
});
</script>
如上,对每一个 li 进行遍历绑定 onclick 事件,需要创建多个匿名函数,每一个匿名函数都需要在内存中开辟一定空间,将函数指针关联到对应元素的 onlick 事件上,这对内存无疑是一种浪费。
若要修改上述代码,有两种改进方法:
- 使用函数引用代替匿名函数;
- 使用时间代理代替循环绑定;
这两种方式都比较简单,先看第一种使用函数引用的方式,可以这样修改代码:
<div>
<ul>
<li>list item</li>
<!-- 省略1000个 -->
<li>list item</li>
</ul>
</div>
<script>
// 定义一个回调函数
function onLiClick (event, index) {
event = event || window.event;
console.log(index, event);
}
document.querySelectorAll("li").forEach(function (element, index) {
// 这里使用具名函数的引用,而不用创建匿名函数,节省了资源
element.onclick = onLiClick(event, index);
});
</script>
上面的方式可以使用具名函数的引用代替匿名函数,避免循环创建匿名函数的问题。但这种方式也不是非常完美,因为1000 个 li 依然需要有 1000 个函数指针指向 onLiClick 。
第二种方式,使用时间代理,将 li 的单击事件通过 ul 的点击来代理执行:
<div>
<ul>
<li>list item</li>
<!-- 省略1000个 -->
<li>list item</li>
</ul>
</div>
<script>
document.querySelector("ul").addEventListener("click", function (event) {
event = event || window.event;
console.log(event);
});
通过这种方式,只创建了一个匿名函数,且只有一个 onclick 的事件回调指向这个匿名函数,可以避免浏览器资源的浪费。