写前端的朋友一定遇到过这种情况:代码明明按照文档写的,按钮就是点了没反应,控制台也没报错,你盯着屏幕怀疑人生,最后发现是事件绑定出了问题。这种问题不复杂,但对新手来说特别劝退。我刚开始写前端那会儿,光是搞懂 click、change、submit 这些事件到底是怎么触发的、怎么被拦截的,就花了不少时间。今天这篇就把“事件表”这件事彻底讲透,按着我踩过的坑和总结的套路来,看完你至少能解决 80% 的“点击没反应”问题。
这篇文章适合刚入门前端、或者在写交互时经常被事件问题卡住的同学,也适合准备前端面试但总是搞不清事件流细节的人。我会从最基础的事件机制讲起,再深入到事件委托、事件对象、性能优化这些实战内容,最后附上排查技巧和面试常见追问,等于把前端事件相关的知识一次性梳理成一张可以直接对照的表。
1. 先搞明白:事件表到底是什么
1.1 从一次点击事件说起
很多人听到“事件表”会以为是某个 API 或者框架里的配置文件,其实不是。所谓事件表,就是浏览器从接收到你的点击手势、到触发对应代码逻辑的完整流程清单。这个流程里的每一个环节都可能出问题,比如事件根本没绑定上、绑定到了别的元素、事件触发了但被上层拦截了,等等。
我们常说的“事件”指的是用户在页面上的操作,比如 click、mouseover、keydown、input 等等。浏览器有一套机制来管理这些操作:什么时候触发、依次触发哪些监听函数、能不能被取消或阻止。这套机制就是你排查问题的“地图”,也就是我理解的事件表。记住这句话:凡是交互失灵,先对照事件表的流程走一遍,大概率能定位问题。
前端面试里提到“事件表”这个词的概率也很高。面试官问“解释一下事件流”或者“说说事件冒泡和事件捕获”,考察的其实都是你对这张表的理解程度。所以把这张表刻在脑子里,对做项目和过面试都有直接帮助。
1.2 事件绑定方式的演进
事件表里第一个要掌握的就是“怎么把你的函数挂到元素上去”。常见的方式有三种,我按出现的年代依次说:
- HTML 内联事件:直接在标签上写
onclick="handleClick()",比如<button onclick="alert('clicked')">点我</button>。这种方式最老,但有很多缺点,比如逻辑写在模板里难维护、作用域容易出问题,团队项目里基本不推荐。 - DOM 属性绑定:用 JavaScript 给元素赋值,比如
document.getElementById('btn').onclick = handleClick。这种方式比内联好一点,但有个硬伤:同一个事件类型只能绑定一个处理函数,后赋值的会覆盖先赋值的。 addEventListener方法绑定:element.addEventListener('click', handleClick)。这是现代前端的主流方式,特点是同一个元素可以绑定多个同类型事件处理函数,而且支持控制捕获阶段还是冒泡阶段触发,还支持一次性绑定、被动监听等高级特性。
我之前遇到过一个很经典的坑:某个按钮先用 onclick 绑定了 A 函数,后面的代码又用 addEventListener 绑定了 B 函数,结果 A 函数没执行。当时我一查,发现是前面同事在框架初始化脚本里用 onclick 赋过值,后面 B 函数虽然正常触发了,但 A 函数因为 onclick 属性被覆盖,彻底丢了。所以我的建议是:项目里统一用 addEventListener,不要混用,尤其是老代码和新代码同时存在的时候。
1.3 事件流的基本概念:捕获、目标、冒泡
事件表里最核心的部分就是事件流。简单说,当你点了一个按钮,浏览器并不会直接把事件交给按钮,而是遵循一个路径从根节点一路往下、再一路往上,这个路径分三个阶段:
- 捕获阶段:事件从 window 开始,一层层往目标元素传递。这个阶段是“从上到下”,类似领导层层批文往下传。
- 目标阶段:事件到达你点击的那个具体元素。
- 冒泡阶段:事件从目标元素开始,一层层往上传,直到 window。这个阶段是“从下到上”,类似员工层层向上汇报。
这里的关键是:默认情况下,addEventListener 监听的是冒泡阶段。也就是说,你监听父元素的 click,子元素被点击时,父元素也能收到这个事件,因为事件会冒泡上去。捕获阶段则需要显式设置 capture: true 或者传入 true 作为第三个参数。
我用一个实际例子说明:页面上有个 <div id="parent"> 里面套了一个 <button id="child">。如果我在 parent 和 child 上都绑定了 click 监听,点击按钮时,输出顺序是这样的:
- 如果都监听冒泡阶段:child 的 handler 先执行,parent 的 handler 后执行。
- 如果 parent 监听捕获阶段,child 监听冒泡阶段:parent 先执行,child 后执行。
理解了这条链路,“点击没反应”的第一个排查点就出来了:你是不是把事件绑定的元素搞错了?比如你明明想监听按钮,结果把事件绑到了父容器上,中间某个子元素又调用了 stopPropagation(),事件根本传不到父容器那层,自然没反应。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 点击没反应?先按这个顺序排查
2.1 监听函数到底挂上没
“点击没反应”最常见的原因就是事件压根没绑定成功。新手最容易犯的错误是:HTML 结构还没渲染完,JavaScript 就开始查找 DOM 元素了。
我举个例子:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>事件问题示例</title>
</head>
<body>
<button id="btn">点我</button>
<script>
const btn = document.getElementById('btn');
btn.addEventListener('click', function () {
console.log('按钮点击成功');
});
</script>
</body>
</html>
这段代码是可以正常工作的,因为 script 标签在 button 后面,等这个脚本执行时 button 已经渲染出来了。但如果把 script 放到 <head> 里,或者放在引入的 JS 文件里、加载顺序又不对,document.getElementById('btn') 就会拿到 null,后面再调用 .addEventListener 直接报错,按钮自然没反应。
解决办法有好几个:
- 把
<script>标签放在页面底部,等 DOM 加载完再执行。 - 用
DOMContentLoaded事件包裹初始化逻辑,比如document.addEventListener('DOMContentLoaded', init)。 - 直接用
window.onload,它会等页面所有资源加载完成才触发,比 DOMContentLoaded 更晚。 - 在 Vue、React 这类框架里,把事件绑定逻辑放到
onMounted或useEffect里,确保组件已经挂载。
检查这个问题的技巧很简单:在 DevTools 的 Console 里手动输入 document.getElementById('btn'),看看返回的是不是 null。如果是 null,说明你的 JS 执行时机太早了。
2.2 被遮住了还是被移除了
另外一个高频原因是:你的按钮确实存在,事件也绑定好了,但按钮上面盖了一层透明遮罩,你的点击根本没落在按钮上。这种问题在弹窗、对话框、自定义下拉框里特别常见。
我曾经帮同事排查过一个 Bug:页面底部有个“提交”按钮,点了完全没反应。控制台也不报错,事件监听也查得到。最后打开 DevTools,把按钮的父元素选中,直接在 Elements 面板里看鼠标悬停时的元素高亮,才发现有一个位置固定的透明 div 把整个内容区覆盖了。这个 div 是某个组件异常渲染出来的,宽高都是 100%,背景透明,把底下的按钮死死挡住。
遇到这种问题,最快的排查方法是:
- 打开 DevTools 选中按钮元素,右键选择检查。
- 在 Console 里执行
document.elementFromPoint(x, y),把按钮中心点的坐标传进去,看看返回的是不是按钮本身。 - 如果不是,返回了别的元素,那说明有东西挡住了。
解决方案也比较直接:给按钮加 position: relative 和较高的 z-index,或者调整遮挡元素的层级。还有一种情况是 pointer-events: none 样式被加到了按钮或父元素上,这个属性会让元素不响应任何鼠标事件,但看起来又是正常的。排查的时候要留意 Computed 面板里的 pointer-events 值。
2.3 事件被 stopPropagation 中断了
在事件流里,冒泡阶段从目标元素一层层往上走,如果某个中间环节调用了 event.stopPropagation(),事件就不会继续向上传播。如果你把事件绑定在父容器上,而子元素又调用了 stopPropagation(),那父容器永远收不到事件。
举个例子,一个列表项里有“删除”按钮,点击删除按钮时不想让这个点击事件冒泡到列表项触发详情跳转,所以你可能写了:
javascript复制document.querySelector('.delete-btn').addEventListener('click', function (e) {
e.stopPropagation();
// 执行删除逻辑
});
这个写法本身没问题。但如果你在另一个地方用事件委托监听整个列表……对不起,删除按钮的点击事件已经被 stopPropagation 拦在半路了,委托逻辑根本收不到。这种“拦截”行为很容易出现团队协作问题:A 同事为了让某个按钮点击不触发页面跳转写了 stopPropagation,B 同事在父层做委托监听,结果 B 的代码莫名失效。
排查这一类的办法是:在事件处理函数里临时打印一下 event 对象,看看 event.target、event.currentTarget、event.eventPhase 分别是什么,然后逐层检查是不是有人调用了 stopPropagation 或 stopImmediatePropagation。这两者的区别我会在后面的章节细说。
2.4 事件被 preventDefault 阻止了默认行为
preventDefault() 和 stopPropagation() 是两回事,前者阻止默认行为,后者阻断事件传播。比如一个 <a href="https://example.com"> 元素,你希望点击时先执行自己的逻辑、再让浏览器跳转。如果逻辑里调用了 event.preventDefault(),跳转就会被取消。有时候是某些第三方库给了你一个“戴了套”的版本,你以为调的是 click 的事件处理函数,其实它内部对事件做了一层包装。
排查方法很直接:在事件处理函数第一行加 console.log(event.cancelable, event.defaultPrevented),如果 defaultPrevented 为 true,那说明这个事件在更早的监听器里已经被阻止默认行为。注意,preventDefault 不会停止事件传播,所以如果这里被拦截,你还可以继续向上逐层排查。
3. addEventListener 的完整使用方式
3.1 第三个参数:到底传 true 还是传对象
很多朋友用 addEventListener 只写两个参数,第三个参数对你来说可能一直是“魔法数字”。实际上第三个参数可以是一个布尔值 true 表示捕获阶段触发,false 或不传表示冒泡阶段触发;也可以是一个 options 对象,里面有几个非常有用的配置项:
capture:布尔值,是否在捕获阶段触发,默认 false。once:布尔值,如果为 true,事件触发一次后自动移除监听器。passive:布尔值,如果为 true,表示这个监听器永远不会调用preventDefault(),浏览器可以放心优化滚动性能。signal:传入一个 AbortSignal,可以在需要时通过AbortController批量移除监听器。
我强烈建议在实际项目里用对象写法,比如:
javascript复制const btn = document.querySelector('#submitBtn');
btn.addEventListener('click', handleSubmit, {
once: true,
capture: false,
});
这样代码可读性更高,别人一眼就能看到每个配置项的作用。曾经踩过一个坑:一个支付确认按钮,用户连点两次会导致重复提交,当时的解决方式就是在 click 里设置一个 flag,但 flag 忘记复位,后续所有用户都点不了。后来用 { once: true } 直接一次性监听,逻辑清爽多了,前提是你确认这个事件只允许触发一次。
3.2 passive: true 和 preventDefault 的冲突
passive: true 这个配置本来是给滚动类事件用的,比如 touchmove、wheel 等。因为浏览器不知道你的监听函数里会不会调用 preventDefault(),所以它必须等你的 JavaScript 执行完以后才知道要不要真正执行滚动。如果 JS 执行时间太长,滚动就会卡顿。当你声明 passive: true 以后,浏览器默认你不会阻止滚动,就可以提前开始滚动了,用户手感顺滑很多。
这里的坑在于:如果你把 passive 设为 true,又在监听函数里调用了 event.preventDefault(),控制台会报出红色警告:Unable to preventDefault inside passive event listener invocation.,而且 preventDefault() 是不会生效的。也就是说,你以为是 on 关闭了,其实监听函数跑到了,但默认行为还是照常执行。
我遇到过的真实场景:移动端的横向滑块,我用 touchmove 事件控制滑块位置,需要调用 preventDefault() 来阻止页面上下滚动,但当时参考某段代码给 touchmove 加上了 { passive: true },结果滑块拖动时页面疯狂滚动,滑块位置根本定不住。后来改成 { passive: false } 才正常。所以:需要阻止默认行为的事件,绝对不能设置 passive 为 true。
3.3 事件对象里藏着哪些关键信息
事件处理函数会收到一个 event 对象,这里面几乎包含了你排查问题需要的所有信息。我整理了一张常用的速查表:
| 属性/方法 | 作用 |
|---|---|
target |
实际触发事件的元素,可能是子元素 |
currentTarget |
当前正在处理事件的元素,也就是监听器挂载的元素 |
eventPhase |
事件当前所处的阶段,1 捕获、2 目标、3 冒泡 |
type |
事件类型,如 click、keydown、submit |
timestamp |
事件发生的时间戳 |
preventDefault() |
阻止浏览器默认行为 |
stopPropagation() |
阻止事件继续冒泡或捕获传播 |
stopImmediatePropagation() |
既阻止传播,还阻止同元素上后续监听器的执行 |
isTrusted |
如果值为 true,说明事件是用户真实操作触发的;false 说明是 JS 手动 dispatchEvent 出来的 |
其中 target 和 currentTarget 的区分必须刻在脑子里。在事件委托里,监听器挂在父元素上,target 是你实际点的子元素,currentTarget 才是父元素。如果把它们搞混,你可能会在父元素上读取子元素才有的属性,得到 undefined 后误判为数据问题。我曾经在代码里写 event.currentTarget.dataset.id,结果在委托场景下一直拿到 undefined,排查了半天。
4. 事件委托:让动态元素也能响应点击
4.1 动态添加的元素为什么点击没反应
很多前端新手会遇到这样一个问题:页面初始化时用 getElementById 找到某个容器,给容器里的按钮绑定了 click 事件。后来通过 Ajax 请求拿到了新数据,又把带有按钮的新列表追加到了容器里,这时候点新按钮,完全没反应。
原因很简单:你只给“当时已经存在的按钮”绑定了事件,新插入的按钮不在绑定范围里。事件绑定不是“实时生效”的,它就像给某个人发了工作证,后来入职的新人没领到证,进不了门。
解决方案通常有三种:
- 在每次插入新 DOM 后再手动绑定一次,但代码会变得很琐碎,维护成本高。
- 用事件委托,把监听器挂在一个稳固的祖先元素上,利用事件冒泡机制来处理。这是目前最主流的方案。
- 用框架的思路,比如 Vue 的
@click和 React 的onClick本质上帮你处理了大部分生命周期问题,但如果你在做原生 JS 项目、或者需要对接某些非框架渲染的 DOM,事件委托依然是基本功。
4.2 事件委托的实现与注意事项
事件委托的核心思路是:父元素监听事件,然后在处理函数里通过 event.target 判断实际触发事件的元素是不是我们要找的那个。
下面是一个标准写法:
javascript复制const list = document.querySelector('#todo-list');
list.addEventListener('click', function (event) {
const target = event.target.closest('.delete-btn');
if (!target) {
return;
}
const id = target.dataset.id;
console.log('删除 id 为 ' + id + ' 的待办项');
// 执行删除逻辑
});
这里用了 closest 方法,它会从当前元素开始向上查找匹配选择器的祖先元素。好处是:即使你点的是按钮里的图标,也能准确找到 delete-btn 这个按钮,然后再读取 data 属性。这种写法对嵌套结构特别友好。
事件委托的坑也不少。第一个坑是:event.target 可能是文本节点吗?在旧版浏览器里有可能,所以最好先用 closest 做防御。第二个坑是:如果子元素内部有更复杂的 DOM,比如 <button><span><i className="icon"></i></span></button>,点击的 target 可能是 i 元素,这时候必须依赖 closest('.delete-btn') 才能保证边界正确。第三个坑是:父元素不能离目标太远,否则委托的容器可能也会响应来自无关区域的点击,需要在处理函数里多做一次判断。
4.3 委托的经典使用场景
事件委托最常用来处理列表、表格、树形结构这类包含大量同类子元素的场景。我自己的一个实际经历:项目里有一个动态生成的权限树,每个节点都有展开、折叠、勾选三种操作。如果给每个节点绑定三个事件,300 个节点就是 900 个监听器,性能上的开销、代码上的复杂度都很高。后来我把点击事件委托到树的根容器上,通过判断 event.target 上的 data-action 属性来区分是展开还是勾选操作,代码从几百行缩减到几十行。
从性能角度说,事件委托能减少监听器的数量,这在长列表里尤其明显。但要注意,它并不是所有场景的银弹:如果事件需要非常高频地触发,并且要在每个元素上做不同逻辑,委托的判断逻辑会很重;当事件需要 focus/blur 这类本身不支持冒泡的事件时,委托并不适用,需要改用 focusin/focusout 这类支持冒泡的替代事件。
5. 事件机制再深挖:面试和实战都能用上
5.1 捕获与冒泡的完整流程
前面我简单介绍了捕获和冒泡,这里我们把它彻底拆开。假设页面上有 window -> document -> html -> body -> .wrapper -> .button 这么一条链,点击 .button 后,事件的生命周期是:
- 捕获阶段:window → document → html → body → .wrapper → .button。这个阶段,监听器如果设置了 capture: true,就会以“从外到内”的顺序触发。
- 目标阶段:事件到达 .button。如果监听器挂在 .button 上,不管 capture 是 true 还是 false,都在这个阶段触发。
- 冒泡阶段:.button → .wrapper → body → html → document → window。监听器默认在这个阶段触发,顺序是“从内到外”。
面试里常考的题是:两个元素嵌套,里层和外层各绑了一个 click,点击里层元素,输出结果是什么?答案取决于监听的阶段。不过我实际经验里,大部分前端工作用的都是冒泡阶段,捕获阶段被用到不多,主要用于事件拦截、全局错误捕获这类情况。
还有一个小细节:window 和 document 是有区别的。事件到达 window 之前,会先经过 document。有些同学在 document 上绑定全局 click 事件,但期望它包含 window 上的某些属性,结果没拿到,那时候就要注意链路层级。
5.2 stopPropagation 与 stopImmediatePropagation 的区别
这两个方法非常容易被混用。stopPropagation() 阻止事件继续传播,但并不影响当前元素上已经绑定的其他监听器。比如:
javascript复制element.addEventListener('click', function () {
console.log('第一个监听器');
event.stopPropagation();
});
element.addEventListener('click', function () {
console.log('第二个监听器'); // 仍然会执行
});
而 stopImmediatePropagation() 不仅阻止事件传播,还会立刻终止当前元素上尚未执行的其他监听器。也就是说,如果上面第二个监听器是在第一个监听器之后绑定的,那么调用 stopImmediatePropagation() 后,第二个监听器不会执行。
实际开发现中,绝大多数场景用 stopPropagation() 就够用了。但如果你发现某个元素上绑定了多个同类型事件,彼此还会干扰,这时候才需要回头看是否要用 stopImmediatePropagation()。另外要提醒一句:滥用这两个方法会导致事件委托不可用,团队协作时要特别注意,最好在代码注释里写清楚为什么拦截。
5.3 dispatchEvent 和自定义事件
除了用户操作触发的原生事件,你还可以手动派发事件。JS 里用 dispatchEvent 可以在任意元素上模拟触发某个事件。这个能力在单元测试、组件通信时特别有用。
javascript复制const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
});
button.dispatchEvent(event);
浏览器还会执行绑定的 click 事件处理函数,但是 event.isTrusted 会变成 false,因为这是脚本派发的事件,不是用户真实操作。如果你在分析埋点数据、判断用户行为真实性,这个字段就很关键。
自定义事件也值得一学。比如你想在数据加载完成后通知页面其他模块更新,可以创建一个自定义事件:
javascript复制const event = new CustomEvent('dataLoaded', {
detail: { list: [1, 2, 3] },
});
document.dispatchEvent(event);
其他地方监听:
javascript复制document.addEventListener('dataLoaded', function (e) {
console.log(e.detail.list);
});
这种模式在简单的原生 JS 项目里比引入完整状态管理库要轻量得多,也是一种“事件表”思想的具体应用——把系统的各种状态变化全部列成事件,统一管理。
6. 高频问题速查与调试技巧
6.1 常见问题排查速查表
下面这表格是我整理得比较顺手的一张排查表,遇到“事件不对”的问题直接对照着看:
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 点击按钮无任何反应,控制台无报错 | 事件未绑定成功或绑定的元素不对 | 检查 JS 执行时机,确认 getElementById 返回非 null |
| 点击按钮无反应,但事件监听里查得到 | 元素被其他透明元素覆盖 | 使用 elementFromPoint 检查命中的元素,调整 z-index |
| 点击子元素,父元素委托事件没触发 | 子元素调用了 stopPropagation |
搜索代码里的 stopPropagation,确认是否存在拦截 |
| 事件触发但默认行为被取消 | 某个监听器调用了 preventDefault |
检查 defaultPrevented,定位调用方 |
| 动态添加的元素事件无效 | 事件绑定只作用于已存在的 DOM | 改用事件委托或重新绑定 |
| 连续点击触发多次请求 | 事件未防重复提交 | 使用 once: true 或加锁逻辑 |
| 移动端滚动卡顿 | touchmove 事件没有设置 passive | 对不需要 preventDefault 的监听器加 { passive: true } |
| 事件只触发一次后失效 | 监听器被移除或 DOM 被替换 | 检查移除监听的逻辑,或确认是否用了 { once: true } |
很多问题不是某一个环节坏了,而是多个原因叠加。排查时我习惯按照“绑定是否成功 -> 目标是否被遮挡 -> 事件是否被拦截 -> 默认行为是否被阻止 -> 是否属于动态元素”的顺序走一遍,基本都能定位。
6.2 DevTools 里怎么确认事件是否绑定成功
如果你用的是 Chrome DevTools,这个操作非常高效:
- 在 Elements 面板里选中目标元素。
- 展开右侧的“Event Listeners”面板(不同版本可能叫不同名称,但大体位置一致)。
- 这里会列出该元素及其祖先元素上绑定的所有事件监听器,勾选“Ancestors”可以显示祖先链上的监听器。
- 点击某个监听器后面的链接,可以直接跳到对应 JS 源码位置,还能打断点。
我排查 stopPropagation 问题的时候,就会在源码位置加上断点,然后重新点击,看调用栈里执行到了哪里,顺藤摸瓜找到是谁调用了拦截方法。这个方法比盲目搜索代码快得多。
另外一个实用小技巧:在 Console 里直接执行 getEventListeners(document.querySelector('#btn')),它会返回一个对象,key 是事件类型,value 是监听器数组,里面能看到函数源码。这是调试时的神器。
6.3 实战排查案例:一个“点不透”的表单
最后分享一个我印象深刻的项目案例。当时做一个表单页面,用户每次点提交按钮,第一次点击总是没反应,第二次点击却弹出了两次提交确认框。这个 Bug 特别诡异,因为不是“完全没反应”,而是“第一次和后面的行为不一致”。
排查过程大概是这样:我先在按钮上加了断点,发现第一次点击事件其实触发了,不过处理函数里有一个异步判断逻辑还没执行完,就直接 return 了。后来发现是上次初始化时给按钮绑定了一个监听器 A,表单验证通过后 A 会把按钮的 disabled 属性去掉;但初始化时又有另一段代码给按钮设置了 disabled 属性,导致第一次点击时按钮虽然是可点的,但某类浏览器对 disabled 状态的默认行为不一致。实际是事件触发了,内部的逻辑被另一个判断分支拦截了。
这告诉我一个道理:事件没反应,不一定都是事件本身的问题。很多时候是业务逻辑中的状态、属性在干涉你的判断。遇到这种问题,要一层层拆开,用断点跟着事件处理函数走一遍,别只盯着“事件有没有触发”这一个点。
还有一次踩过的坑:接口返回后重新渲染了列表,渲染函数里把整个列表容器的 innerHTML 替换掉了,结果原来挂在子元素上的事件监听器跟着 DOM 一起被销毁了。页面看起来只是刷新了列表,但按钮全部“失灵”。如果用事件委托把监听器挂在容器上,就不会有这个问题。这也是我后来更倾向在长列表场景用事件委托的原因之一。
写在最后的一点经验
我自己从“点击没反应”这个坑里爬出来之后,形成了一套固定思维模式:遇到交互问题,不急着改代码,先打开 DevTools 看事件监听、看网络请求、看控制台报错,然后对着事件表把可能的原因一个个排除。这套方法看起来原始,但最有效。你只要把这些东西内化成习惯,遇到同类问题的时间会从一小天缩短到几分钟。前端开发里这种事很常见,表面上是某个 API 不会用,本质上是事件机制这条链路没打通。把事件表吃透,你会发现很多看起来玄学的 Bug,其实背后都是同一套逻辑在起作用。
