DOM 事件模型:一个弹窗点击穿透 bug,逼我把捕获、冒泡和事件委托全捋了一遍
点弹窗遮罩,弹窗关了,底下的商品卡片却跟着被点开;点确认框里的输入框,整个弹窗反而直接关闭。两个现象看起来各不相同,本质上其实都在问同一个问题:这次点击到底沿着哪条事件路径走了。
平时写 Vue,@click 能用就够了,很少有人会一直想着 target、currentTarget、捕获、冒泡这些底层细节。可一旦碰上点击穿透、事件委托、遮罩关闭这类 bug,框架语法糖就不够用了,问题一定要回到浏览器原生事件模型里看。
后面就顺着这两个弹窗现场,把捕获、冒泡、委托、stopPropagation 和点击穿透为什么会发生重新捋一遍。
点内容也关弹窗
先破简单的那个。PC 后台的弹窗结构大致是遮罩包着内容:
1<div class="mask"> 2 <div class="dialog"> 3 <input type="text"> 4 <button>确定</button> 5 </div> 6</div>
关闭逻辑是同事早先写的:
1mask.addEventListener('click', closeModal)
点遮罩关闭,逻辑没错。但点 dialog 里的输入框,弹窗也关——因为点击发生在 input 上之后,事件会沿着 DOM 树向外冒泡,冒到 mask 上,把 closeModal 触发了。这就是冒泡最经典的「误伤」。
要说清楚为什么会这样,得先把事件流的全貌摆出来。
事件流三个阶段
浏览器分发一次点击,大致经历三段:
- 捕获阶段:从
window沿着 DOM 树往目标元素走 - 目标阶段:到达真正被点击的元素
- 冒泡阶段:从目标元素原路往外冒
绑定事件时第三个参数决定你站在哪一段。指定捕获:
1element.addEventListener('click', handler, true)
默认是冒泡:
1element.addEventListener('click', handler, false)
第三个参数除了 true/false,这两年的浏览器还支持传一个配置对象,能用上 once、passive 这些选项,挺实用:
1// once: 触发一次后自动解绑,省得自己 removeEventListener 2button.addEventListener('click', handler, { once: true }) 3 4// passive: 承诺这个 handler 不会调 preventDefault, 5// 滚动事件加上它,浏览器不用等 JS 跑完再滚,移动端滚动顺很多 6container.addEventListener('touchmove', handler, { passive: true })
passive: true 在我上个月优化活动页长列表滚动时帮了大忙——以前滚动监听不加 passive,浏览器为了等你可能 preventDefault 会推迟滚动,体感就是滑动一卡一卡的,加上之后立竿见影。不过要注意兼容性,老浏览器不认配置对象,会把整个对象当真值处理成 true(捕获),所以要先做特性检测,或者干脆 addEventListener('touchmove', h, false) 兜底。
这三个阶段嘴上说不如自己跑一遍。父子各绑捕获和冒泡两个监听器,贴进控制台点一下子元素,顺序一目了然:
1<div id="parent" style="padding:30px;background:#eee"> 2 父 3 <button id="child">子</button> 4</div>
1var parent = document.getElementById('parent') 2var child = document.getElementById('child') 3 4parent.addEventListener('click', function () { console.log('capture parent') }, true) 5child.addEventListener('click', function () { console.log('capture child') }, true) 6parent.addEventListener('click', function () { console.log('bubble parent') }, false) 7child.addEventListener('click', function () { console.log('bubble child') }, false)
点击「子」按钮,控制台依次打印:
1capture parent 2capture child 3bubble child 4bubble parent
捕获阶段自外向内(先父后子),冒泡阶段自内向外(先子后父),目标元素 child 夹在中间。把 child 上某个监听器里加一句 event.stopPropagation(),从那一刻起后面的阶段就不会再打印——比如在 capture child 里调它,bubble child 和 bubble parent 都收不到了。
大多数业务代码用冒泡就够了。捕获用得少,但有个场景它无可替代:你想在事件到达目标元素之前就拦截,比如做一个全局的「点击任意位置先记录埋点再说」,或者要抢在子元素自己的 handler 之前处理,这时候在外层用捕获绑定就能比冒泡更早拿到事件。我们埋点 SDK 就是在 document 上用捕获阶段收点击的,业务代码里再怎么 stopPropagation 也拦不住它。
回到 PC 弹窗案:点 input,事件冒泡到 mask,closeModal 被触发。案情明了,但怎么修,先按下不表——修法里还有坑,讲完 target 再说。
target 和 currentTarget
排查时我在 mask 的 handler 里加了两行 log:
1mask.addEventListener('click', function (event) { 2 console.log(event.target) // 点 input 时打印 <input> 3 console.log(event.currentTarget) // 永远是 <div class="mask"> 4})
event.target 是真正触发事件的元素——点哪儿是哪儿。event.currentTarget 是当前这个 handler 绑在谁身上,在整个 handler 执行期间恒等于绑定者。
事件委托里千万别混淆这两个。我去年刚上手写委托时就栽过:在绑定到 ul 的 handler 里写 event.currentTarget.dataset.id 想拿被点击那一项的 id,结果永远拿到 ul 的。要拿真正被点的元素,得用 target。
还有一个更隐蔽的坑:currentTarget 只在事件处理过程中有效,事件分发结束它会被置为 null。如果你在 handler 里 setTimeout 或者 Promise.then 异步访问 event.currentTarget,拿到的就是 null。我有次在异步回调里用它,控制台一直报 Cannot read property of null,查了好久才反应过来是这个机制。要在异步里用,同步阶段先存到局部变量:
1list.addEventListener('click', function (event) { 2 var el = event.currentTarget // 同步先存下来 3 setTimeout(function () { 4 console.log(el) // 而不是 event.currentTarget,那时候已经是 null 了 5 }, 0) 6})
有了 target,PC 弹窗的修法就有了两条路。第一条是老套路,给内容区拦一刀冒泡:
1mask.addEventListener('click', closeModal) 2 3dialog.addEventListener('click', function (event) { 4 event.stopPropagation() 5})
点遮罩关闭,点内容不关。能用,但我最后没这么修,原因在后面「阻止冒泡」一节。第二条是不拦冒泡,在遮罩的 handler 里判断点的到底是不是遮罩本身:
1mask.addEventListener('click', function (event) { 2 // 只有点中遮罩自己才关闭,从内部冒泡上来的不管 3 if (event.target === mask) { 4 closeModal() 5 } 6})
我采用了第二条。能用 event.target 判断解决的,就别动 stopPropagation。
穿过遮罩的点击
PC 那个问题一小时解决了,H5 那个「点遮罩跳详情」难缠得多。我第一反应也是冒泡:是不是事件从遮罩冒到了列表上?但对着 DOM 树想了想不对——遮罩和商品列表是兄弟关系,不是祖先后代,冒泡是沿着祖先链往上走的,从遮罩冒不到列表头上。
在 DevTools 里给列表的 handler 打断点,真机连上一复现,断点命中时看 event.type 是 click,event.target 是列表里的商品图。也就是说:这是一次货真价实、独立分发的 click,目标就是底下那个商品。它不是从遮罩「传」过去的,是后发生的。
翻了关闭弹窗的代码才找到根:写这块的同事为了「响应快」,遮罩上绑的是 touchend 而不是 click:
1mask.addEventListener('touchend', closeModal)
移动端一次触摸,浏览器先派发 touchstart、touchend,再在大约 300ms 后补一个模拟的 click——这 300ms 是历史包袱,浏览器要等着判断你是不是双击缩放。touchend 一响弹窗立刻关闭、遮罩从 DOM 里移除,300ms 后那个迟到的 click 按坐标派发时,那个位置上站着的已经是商品列表了。点击就这么「穿」了过去。点击穿透的本质:touch 事件和延迟的 click 是两次独立分发,中间 DOM 变了,click 就落在新来的元素上。
修法有几层,从治标到治本:
一是最省事的,遮罩别用 touchend,统一用 click。慢那 300ms 用户基本无感,事件序列里只有一次分发,没有穿透。我最后就这么改的。
二是如果非要 touch 的即时响应,就在 touchend 里把默认行为拦掉,浏览器就不会再补发 click:
1mask.addEventListener('touchend', function (event) { 2 event.preventDefault() // 拦掉后续合成的 click 3 closeModal() 4})
三是从源头消灭 300ms。现代浏览器在页面声明了 <meta name="viewport" content="width=device-width"> 时已经取消这个延迟,但我们要兼容的一些国产 ROM 的 webview 行为不稳。CSS 一行也能表态:
1html { 2 touch-action: manipulation; /* 允许滚动和缩放以外的手势立即响应,去掉双击等待 */ 3}
再不行还有 fastclick 这个老牌库,原理就是自己监听 touch、立刻合成 click、再把真 click 拦掉。不过它跟第三方输入组件偶有冲突,我们评估之后没引,靠 viewport + touch-action + 统一用 click 已经够了。
那个列表本身是委托的
排查时通读了活动页列表的代码,发现商品列表用的是事件委托——这正是它能「接住」穿透点击的原因,也顺便让我把委托的家底盘了一遍。
动态列表里,不推荐给每个按钮单独绑定:
1buttons.forEach(button => { 2 button.addEventListener('click', handleClick) 3})
更稳的是委托给父容器:
1list.addEventListener('click', function (event) { 2 if (event.target.className === 'delete-btn') { 3 var id = event.target.getAttribute('data-id') 4 removeItem(id) 5 } 6})
事件委托的本质就是利用冒泡:不管点的是哪个子元素,事件都会冒到父容器,所以父容器上绑一个 handler,就能管住所有现在和将来的子元素。好处有两个实打实的——一是动态增删的元素不用反复绑/解绑,二是省内存。活动页这种无限滚动的长列表,如果给每一行的按钮单独绑事件,滚到几百条时监听器数量很可观;委托给容器后内存和流畅度都好一截。想验证监听器挂了多少,DevTools 控制台有个命令行 API 很好用:
1getEventListeners(document.querySelector('.goods-list')) 2// 打印这个元素上挂的所有监听器,按事件类型分组 3// 配套的还有 monitorEvents(el, 'click'),事件一触发就打日志
这两个是 Chrome 控制台专属的调试函数,写进业务代码会报错,但排查「这个元素到底绑了什么」时比翻代码快得多。
委托里判断点的是什么,最直接的是看 event.target.tagName——注意它返回大写标签名:
1<ul id="list"> 2 <li>第一项</li> 3 <li>第二项</li> 4</ul>
1document.getElementById('list').addEventListener('click', function (event) { 2 console.log(event.target.tagName) // 点在某个 li 上,打印 'LI'(大写) 3})
点在 li 上打印的是 'LI' 而不是 'li',拿它做判断别写成小写比较,要么统一 === 'LI',要么 .toLowerCase() 之后再比。这个大写是 HTML 文档里 tagName 的固定行为,我早年用小写比对死活不进分支,就是栽在这儿。
而上面那种 className === 'delete-btn' 的全等判断也很脆,按钮多个 class 就挂了:class="delete-btn btn-sm" 时 className 是整串 "delete-btn btn-sm",全等不成立。现在我都用 classList.contains 或者直接 matches:
1list.addEventListener('click', function (event) { 2 if (event.target.matches('.delete-btn')) { 3 removeItem(event.target.dataset.id) 4 } 5})
如果按钮里面有图标,event.target 可能是 i 或 svg,matches('.delete-btn') 又不成立了——真正被点中的是图标,不是按钮本身。这是委托里最常见的翻车点:明明点在按钮上却没反应,一看 event.target 是里面那个 <i>。解法是用 closest 从目标往上找最近的匹配祖先:
1var button = event.target.closest('.delete-btn') 2 3if (button) { 4 removeItem(button.dataset.id) 5}
closest 从 event.target 自己开始一路往上找,直到匹配或到根,所以点图标、点文字、点按钮空白处都能正确命中。这个写法是我现在做委托的默认姿势。要注意 closest 也就这两年才算普及,IE 完全不支持,要兼容老浏览器得引 polyfill 或者手写一个往上爬的循环。
阻止冒泡:那一刀砍在哪
回头交代 PC 弹窗案里我为什么没选 stopPropagation。
1event.stopPropagation()
它会阻止事件继续向外传播,弹窗里给内容区加一刀确实能解决「点内容也关闭」。但它有个隐患:内容区里如果还有依赖冒泡的逻辑——比如弹窗里嵌了个也用委托的列表,或者像我们埋点 SDK 那样在外层收事件——你这一刀可能把它们一起切断。stopPropagation 最坑的地方在于副作用是「隔空」的:你在子组件里加一行,可能让八竿子打不着的某个外层委托失灵,排查时根本想不到是它。
所以我们组这两周立了个约定:能用 event.target 判断解决的,就不要用 stopPropagation;非用不可的,必须写注释说明拦的是什么、为什么拦。
另外提一个相关但更狠的:stopImmediatePropagation。stopPropagation 只是不往外传,同一个元素上绑的其他 handler 还会执行;stopImmediatePropagation 连同元素上后续注册的 handler 也一并掐掉。用得极少,但知道有这么个东西,碰到「同一元素绑了多个 handler 只想跑前面的」时能想起来。
阻止默认行为:另一根轴
穿透案的第二种修法用到了 preventDefault,它跟 stopPropagation 完全是两根轴,值得单独说清。
1event.preventDefault()
preventDefault 不阻止冒泡,只阻止浏览器的默认行为——表单提交、链接跳转、右键菜单、复选框勾选,以及前面说的 touch 之后合成 click。它和 stopPropagation 一个管「浏览器自带的动作」,一个管「事件在 DOM 树上的传播」。新手特别容易搞混,遇到事件 bug 一股脑两个都加上,治标不治本。
我常用它的几个场景:
1// ajax 提交表单,拦掉浏览器的整页提交 2form.addEventListener('submit', function (event) { 3 event.preventDefault() 4 submitByAjax() 5}) 6 7// a 标签想自己接管跳转逻辑(比如 SPA 路由),拦掉默认跳转 8link.addEventListener('click', function (event) { 9 event.preventDefault() 10 router.push(this.getAttribute('href')) 11}) 12 13// 自定义右键菜单,拦掉浏览器默认的右键菜单 14el.addEventListener('contextmenu', function (event) { 15 event.preventDefault() 16 showCustomMenu(event.clientX, event.clientY) 17})
有个细节值得记:preventDefault 只对「可取消」的事件有效,事件对象上 cancelable 为 true 才行。而前面说的 passive: true 监听器里调 preventDefault 不仅无效,浏览器还会在控制台报警告——你已经承诺过不拦了。passive 和 preventDefault 是直接冲突的,别同时用。
顺手排查出的解绑问题
两个问题都处理完,我顺手把活动页的事件代码全审了一遍,又揪出一个隐患:解绑。
解绑必须传入同一个函数引用:
1function handleClick() {} 2 3button.addEventListener('click', handleClick) 4button.removeEventListener('click', handleClick)
这样不行:
1button.addEventListener('click', function () {}) 2button.removeEventListener('click', function () {})
两个匿名函数不是同一个引用,removeEventListener 拿新建的匿名函数去比对,根本找不到之前绑的那个,于是默默什么都不做——不报错,但也没解绑。静默失败,最坑。
这个坑还有个变体:.bind(this) 每次都返回新函数,所以下面这样也解不掉:
1// 错误:add 和 remove 各 bind 一次,是两个不同的函数 2el.addEventListener('click', this.handle.bind(this)) 3el.removeEventListener('click', this.handle.bind(this)) 4 5// 正确:bind 后存起来,add/remove 用同一个引用 6this.boundHandle = this.handle.bind(this) 7el.addEventListener('click', this.boundHandle) 8el.removeEventListener('click', this.boundHandle)
组件销毁时没解绑,可能造成重复绑定和内存问题。我去年真实踩过一次内存泄漏:一个弹窗组件在 mounted 时给 window 绑了 resize 监听,销毁时忘了解绑。弹窗反复开关,window 上的 resize handler 越积越多,每个 handler 的闭包都引用着那个早该被回收的组件实例,这些实例全留在内存里。表现就是开关弹窗几十次后页面越来越卡,Chrome 的 Performance / Memory 面板里能看到 detached DOM 节点和监听器数量一路涨。
所以凡是绑到 window、document 这种长生命周期对象上的监听,组件销毁时一定要成对解绑。Vue 里我习惯在生命周期钩子里配对写:
1export default { 2 mounted() { 3 this.onResize = () => this.recalc() // 存成实例属性,方便解绑 4 window.addEventListener('resize', this.onResize) 5 }, 6 beforeDestroy() { 7 window.removeEventListener('resize', this.onResize) 8 } 9}
绑在组件自己模板内元素上的监听不用操心,元素随组件一起销毁,监听也跟着没了;真正要警惕的永远是绑到外部全局对象上的那些。
修完上线
修完上线,QA 又拿手机怼过来点了十几下,弹窗关得干净利落,再没跳过详情页。
回看这两个问题,代码改动加起来不到十行,排查花的功夫全在把事件模型想明白:点击从 window 捕获下去、从目标冒泡上来;target 是点到的人、currentTarget 是绑事件的人;委托吃的是冒泡的红利,所以别乱 stopPropagation 断人财路;移动端的 touch 和延迟的 click 是两次独立分发,DOM 一变就会穿透。这些机制一个都不复杂,复杂的是它们叠在一起出 bug 时,你能不能一层层剥开。
「DOM 事件模型」经常被当成背一背就过的八股题,但这次两个真实 bug 让我确认了一件事:它被反复拿来考,恰恰因为它能看出一个人是真理解浏览器,还是只会写框架语法。