1、事件委托的比喻
在网上看到一个关于事件委托的例子,比如一个宿舍的同学快递同时到了,一种方法就是他们都傻傻地一个个去领取,还有一种方法就是把这件事情委托给宿舍长,让一个人出去拿好所有快递,然后再根据收件人一一分发给每个宿舍同学;
在这里,取快递就是一个事件,每个同学指的是需要响应事件的 DOM 元素,而出去统一领取快递的宿舍长就是代理的元素,所以真正绑定事件的是这个元素,按照收件人分发快递的过程就是在事件执行中,需要判断当前响应的事件应该匹配到被代理元素中的哪一个或者哪几个。
不说人话就是:事件委托(delegate),也称为事件托管或事件代理,简单描述就是把目标节点的事件绑定到祖先节点上。这种简单而优雅的事件注册方式基于:事件传播过程中,逐层冒泡总能被祖先节点捕获。
这样做的好处:优化代码,提升运行性能,真正把 HTML 和 JavaScript 分离,也能防止在动态添加或删除节点的过程中注册的事件丢失。基于上面的取快递的例子,如果不使用事件委托(舍长代取),那宿舍的每个人都要去取快递,浪费时间。将取快递的例子映射到 JavaScript DOM 事件知识中,如果不使用事件委托,那就会造成事件的一些性能和使用的问题,比如:
绑定事件越多,浏览器内存占用越大,严重影响性能
ajax 的出现,局部刷新的盛行,导致每次加载完,都要重新绑定事件
部分浏览器移除元素时,绑定的事件并没有被及时移除,导致的内存泄漏,严重影响性能
ajax 局部刷新的大部分只是显示的数据,而操作却大部分相同,重复绑定,会导致代码的耦合性过大,严重影响后期的维护
上述的限制,都是直接给元素事件绑定带来的问题,所以经过了一些前辈的总结试验,也就有了事件委托这个解决方案。
2、DOM 事件模型/机制
事件委托,通俗地来讲,就是把一个元素响应事件(click、keydown......)的函数委托到另一个元素;
一般来讲,会把一个或者一组元素的事件委托到它的父层或者更外层元素上,真正绑定事件的是外层元素,当事件响应到需要绑定的元素上时,会通过 事件冒泡机制 从而触发它的外层元素的绑定事件上,然后在外层元素上去执行函数。
那什么是事件冒泡?这就需要讲到 DOM 事件模型/机制
2.1、事件冒泡
前面提到 DOM 中事件委托的实现是利用事件冒泡的机制,那么事件冒泡是什么呢?
在 element.addEventListener('click', fn, bool) 的时候我们可以设置事件模型:事件冒泡、事件捕获:
如果 bool 不传或为 falsy 就让 fn 走冒泡,即当浏览器在冒泡阶段发现 element 有 fn 监听函数,就会调用 fn,并提供事件信息
如果 bool 为 true就让 fn 走捕获,即当浏览器在捕获阶段发现 element 有 fn 监听函数,就会调用 fn,并提供事件信息
2.2、事件模型
如上图所示,事件模型是指分为 三个阶段:
捕获阶段:在事件冒泡的模型中,捕获阶段不会响应任何事件;
目标阶段:目标阶段就是指事件响应到触发事件的最底层元素上;
冒泡阶段:冒泡阶段就是事件的触发响应会从最底层目标一层层地向外到最外层(根节点),事件代理即是利用事件冒泡的机制把里层所需要响应的事件绑定到外层;
总结:先捕获,后冒泡,捕获从上到下,冒泡从下到上(形象点说法:捕获像石头沉入海底,冒泡则像气泡冒出水面),捕获:window-> document -> body -> 当前元素,冒泡:当前元素 -> body -> document -> window;
注意:如果在元素上 `同时` 绑定捕获事件和冒泡事件,哪一个先触发是要分情况的:如果事件是通过此元素的子级元素触发,则优先触发捕获事件,若不通过此元素的子级元素触发,则按照 Javascript 执行顺序触发。
2.3、阻止事件冒泡
我们经常利用事件冒泡机制去减少给 DOM 添加过多的绑定事件,即 事件委托,但是有时候事件冒泡也会比较烦人,影响我们的事件正常处理机制,这个时候就需要阻止事件冒泡了。
冒泡和捕获是 JavaScript 事件模型的两种行为,使用 event.stopPropagation() 起到阻止捕获和冒泡阶段中当前事件的进一步传播。使用 event.preventDefault() 可以取消默认事件。
$("box3").onclick = function (event) { console.log("里面的盒子"); event.stopPropagation(); //阻止事件冒泡 event鼠标的事件对象 }
也有例外:有些事件不可取消冒泡,比如 scroll event,Bubbles:Yes 的意思是该事件是否冒泡,Cancelable:No 的意思是开发者是否可以取消冒泡,有兴趣的同学推荐去看一下 MDN 文档。
如果 scroll 滚动事件不能取消冒泡,那应该通过什么方法 阻止滚动事件 ?可阻止 wheel 和 touchstart 的默认动作,如下:
x.addEventLisenter('scroll', (event)=>{ //这样做是没用的, event.stopPropagation(); //scroll不可取消冒泡 event.preventDefault(); //滚动的默认动作是滚动后的动作 }) //1.阻止鼠标滚轮事件 - 但是还能用鼠标点击滚动条 x.addEventListener('wheel', (event)=>{ event.preventDefault() }) /*2.CSS部分:隐藏滚动条 - 但是手机端可以滚动*/ //3.阻止手机端滚动的方法 - 彻底阻止了滚动 ::-webkit-scrollbar { x.addEventListener('touchstart',(event)=>{ width:0 !important event.preventDefault()| } })
2.4、阻止默认行为
在 HTML 中有很多自带默认事件的元素,很典型的例子:a 标签,如果给 a 标签绑定点击事件,触发后页面会有一个刷新,是 a 链接默认的跳转事件,阻止这个有很多方法:
<!--方法一:给a标签中href属性添加:--> <a href="javascript:;">链接</a> <a href="javascript:void(0);">链接</a> <!--方法二:给绑定的事件添加return false:--> <a href="" id="link">链接</a> <script> document.getElementById("link").onclick = function (){ console.log("666"); return false; } </script> <!--方法三:使用event事件里的方法:--> <a href="" id="link">链接</a> <script> document.getElementById("link").onclick = function (event){ console.log("666"); e.preventDefault(); } </script>
3、事件委托的优点
3.1、减少内存消耗
试想一下,若果我们有一个列表,列表之中有大量的列表项,我们需要在点击列表项的时候响应一个事件;
<ul id="list"> <li>item 1</li> <li>item 2</li> <li>item 3</li> <li>......</li> <!--...... 代表中间还有未知数个 li--> <li>item n</li> </ul>
如果给每个列表项一一都绑定一个函数,那对于内存消耗是非常大的,效率上需要消耗很多性能;因此,比较好的方法就是把这个 点击事件绑定到他的父层,也就是 `ul` 上,然后在执行事件的时候再去匹配判断目标元素;所以事件委托可以减少大量的内存消耗,节约效率。
3.2、动态绑定事件
比如上述的例子中列表项就几个,我们给每个列表项都绑定了事件;
在很多时候,我们需要通过 AJAX 或者用户操作动态的增加或者去除列表项元素,那么在每一次改变的时候都需要重新给新增的元素绑定事件,给即将删去的元素解绑事件;
如果用了事件委托就没有这种麻烦了,因为事件是绑定在父层的,和目标元素的增减是没有关系的,执行到目标元素是在真正响应执行事件函数的过程中去匹配的;
所以使用事件在动态绑定事件的情况下是可以减少很多重复工作的。
4、事件委托的基本实现
需求 1:假如你要给 100 个按钮添加点击事件,咋办?答:监听这 100 个按钮的祖先,等冒泡的时候判断 target 是不是这100个按钮中的一个。优点:可以节省监听器,节省内存。
比如我们有这样的一个 HTML 片段,我们来实现把 #list 下的 li 元素的事件代理委托到它的父层元素也就是 #list 上:
<ul id="list"> <li>item 1</li> <li>item 2</li> <li>item 3</li> <li>......</li> <!--...... 代表中间还有未知数个 li--> <li>item n</li> </ul>
// 给父层元素绑定事件 document.getElementById('list').addEventListener('click', function (e) { // 兼容性处理 var event = e || window.event; var target = event.target || event.srcElement; // 判断是否匹配目标元素 if (target.nodeName.toLocaleLowerCase === 'li') { console.log('the content is: ', target.innerHTML); } });
在上述代码中, target 元素则是在 #list 元素之下具体被点击的元素,然后通过判断 target 的一些属性(比如:nodeName,id 等等)可以更精确地匹配到某一类 #list li 元素之上;
补充:除了 target 之外,还有 currentTarget,如果 <li> 里面还有 <span> 元素,则给 <ul> 添加点击事件后, event.target 指的是 <span>,而 event.currentTarget 指的是 <li>。
需求 2:你要监听目前不存在的元素的点击事件,咋办?答:监听祖先,等点击的时候看看是不是我想要监听的元素即可。优点:可以监听动态元素。
<div id = "div1"> </div> <script> setTimeout(() => { //动态添加元素 const button = document.createElement('button'); button.textContent = 'click 1' div1.appendChild(button) }, 1000) divl.addEventListener('click', (event) => { //通过事件委托监听动态添加的元素 const target = event.target if(target.tagName.tolowerCase() === 'button'){ console.log('button 被 click 了'); } }) </script>
5、封装事件委托函数
<div id = "div1"> </div> <script> setTimeout(() => { //动态添加元素 const button = document.createElement('button'); const span = document.createElement('span'); span.textContent = 'click 1'; button.appendChild(span); div1.appendChild(button) }, 1000) function on(eventType, element, selector, fn) { //封装一个事件委托函数 if(!(element instanceof Element)){ element = document.querySelektor(element) } element.addEventListener(eventType, e => { let el = e.target while (!el.matches(selector)) { if (element === el) { //找父节点的过程中不能超过被委托元素 el = null break } el = el.parentNode } el && fn.call(el, e, el) }) return element } on('click', '#div1', 'button', () => { console.log('button被点击了') }) </script>
6、事件委托的局限性
当然,事件委托也是有一定局限性的:比如 focus、blur 之类的事件本身没有事件冒泡机制,所以无法委托;mousemove、mouseout 这样的事件,虽然有事件冒泡,但是只能不断通过位置去计算定位,对性能消耗高,因此也是不适合于事件委托的;
注意:JavaScript 是不支持事件的,事件是 DOM 上的东西,JavaScript 和 DOM 是浏览器上的两个平行功能分支,他们两个之间没用从属关系,JS 只是调用了 DOM 提供的 addEventListener 接口而已。试着用 JS 写一个事件系统?
一句话:就是把子节点的事件绑定到最近的父节点上,利用事件冒泡来实现绑定。利用 event 对象下的 target 来寻找目标元素!