各位同学,大家好!欢迎来到今天的“React 内核深潜”研讨会。
把你们手里的咖啡放下,把手机调成静音,咱们今天不聊组件怎么写,不聊 Hooks 怎么用,咱们来聊聊 React 那个隐藏在幕后的、负责处理“嘀嗒嘀嗒”点击声的神秘部门——事件系统。
今天要聊的话题,可能很多老铁都知道:“哦,React 事件代理是从 document 移到了 Root。”
但是,为什么要移?移之前有多痛苦?移之后又有多爽?这中间发生过什么惨案?今天我就带着大家,扒开 React 的源码层皮,把这事儿给你们讲得明明白白,顺便带点“老司机”的幽默感。
第一部分:那个“上帝模式”的 Document
在 React 15 时代,或者说在很长一段时间里,React 的做法非常简单粗暴,甚至可以说有点“中二病”。
那时候,React 假设你是这样写的:
// React 15 的世界
ReactDOM.render(<App />, document.getElementById('root'));
React 看到 document.getElementById('root'),心里想:“行吧,这事儿包在我身上。”
于是,在 React 15 的底层,它会在 document 这个全局对象上挂载一个超级监听器。不管你的 App 里有多少个按钮、多少个输入框、多少个点击事件,React 统统不管。它就像一个站在山顶上、戴着墨镜的保安,对着整个山头大喊一声:
“只要有点击,我就听!只要有点击,我就听!”
这就是所谓的事件代理。
优点:简单粗暴,绝对命中
这种做法的好处是显而易见的。React 不需要去追踪每个 DOM 节点的挂载和卸载。你点一下,事件冒泡上来,React 在 document 上截获,然后根据 event.target(事件目标)去比对虚拟 DOM 树,找到对应的组件,执行 onClick。
这就像是所有的信件都投递到一个巨大的中央邮局,邮局负责分拣,你只需要在门口等信。
缺点:这就好比在垃圾堆里找钥匙
但是,随着项目越来越复杂,这种“上帝模式”开始露出了獠牙。
- 性能浪费:你在
document上监听了一个click事件。当你点击一个<span>的时候,浏览器会一直往上冒泡,经过div、经过section、经过main,最后才到document。虽然 React 会在中间拦截,但在那个瞬间,大量的无用事件流依然在传播。 - 命名冲突的噩梦:这是最致命的。假设你写了一个通用的 CSS 类
.button,用在你的 App 里。然后你引入了一个第三方组件库,或者使用了 Shadow DOM,里面也有一个<button>。因为 React 监听在document,这个按钮的事件会冒泡到 document,而 React 在处理事件时,可能会错误地匹配到你的 CSS 规则,或者在某些特定场景下(比如 CSS Modules 的配合下),导致事件处理逻辑混乱。
第二部分:CSS Modules 与 Shadow DOM 的“相爱相杀”
咱们来做一个思想实验,这能帮你彻底理解为什么要移到 Root。
场景一:CSS Modules 的“悲剧”
假设你有一个 App 组件,你为了防止样式污染,给你的按钮加了一个 CSS Modules 的类名:
/* App.module.css */
.button {
background: blue;
}
JSX 里:
// App.js
import styles from './App.module.css';
return <button className={styles.button}>Click me</button>;
现在,React 15 的事件代理在 document 上。当这个按钮被点击时,事件冒泡到 document。React 拿到 event.target,开始遍历虚拟 DOM 树。
这时候,问题来了。
如果你的页面结构很深,或者有多个组件,React 在遍历虚拟 DOM 树时,可能会匹配到 App 组件的虚拟节点。由于 App 组件的样式是 CSS Modules 生成的哈希值(比如 button_a1b2c3),React 在比对时,发现 event.target 的 className 并不等于 button_a1b2c3。
React 会继续向上找。如果它找到了 document,它发现没有任何虚拟节点匹配。于是,React 可能会直接忽略这个事件,或者触发一些意想不到的回调。
虽然现代 React 做了一些兼容处理,但这在本质上是一种“全局污染”。React 不知道你的 CSS 类名是不是全局的,也不知道你的 DOM 结构是不是唯一的。
场景二:Shadow DOM 的“隔离墙”
这是现代 Web 组件(Web Components)的核心技术。Shadow DOM 就是一堵墙,把组件的样式和行为完全封装起来。
想象一下,你有一个 Button 组件,它使用了 Shadow DOM:
class Button extends React.Component {
constructor(props) {
super(props);
this.attachShadow = this.attachShadow || this.constructor.prototype.attachShadow;
this.shadow = this.attachShadow({ mode: 'open' });
this.shadow.innerHTML = `<button id="shadow-btn">Click</button>`;
}
render() {
return <div ref={this.setRef}>Container</div>;
}
setRef = (node) => {
// React 15: 很难处理,因为 shadow 内部的节点不是 React 的子节点
}
}
在 React 15 的世界里,document 上的监听器虽然能捕获到 ShadowRoot 内部的事件(因为事件会冒泡出来),但 React 无法正确地通过虚拟 DOM 树去追踪这些在 Shadow DOM 内部的事件源。
这就导致了一个很尴尬的局面:React 15 对 Shadow DOM 的支持非常糟糕。 它就像一个外星人,试图用一本中文字典去理解外星人的语言。
第三部分:性能优化——“把监听器搬回家”
React 团队意识到,document 太大了,太远了。与其在 document 上听个响,不如直接在你家门口听。
于是,在 React 16 及以后,React 做了一个重大的架构调整:将事件监听器从 document 移动到了 #root 元素上。
代码视角的变迁
在 React 15 的源码里,你大概能看到这样的逻辑(伪代码):
// React 15 时代
ReactEventListener.install({
target: document, // 监听整个文档
isPropagationStopped: isPropagationStoppedFn,
isDefaultPrevented: isDefaultPreventedFn,
dispatchEvent: dispatchEventFn,
});
而在 React 16 中,代码变成了这样:
// React 16 时代
ReactEventListener.install({
target: rootContainer, // 监听根容器,通常是 document.getElementById('root')
isPropagationStopped: isPropagationStoppedFn,
isDefaultPrevented: isDefaultPreventedFn,
dispatchEvent: dispatchEventFn,
});
这有什么好处?
- 更精准的命中:现在,React 只需要在你的
#root下面的 DOM 树里找事件。如果点击的是#root外面的元素,React 甚至不会去处理。这大大减少了无效的遍历。 - 更好的 Shadow DOM 支持:当事件在 Shadow DOM 内部触发时,它会冒泡到 Shadow Host(宿主),然后冒泡到 Root。React 在 Root 上监听,能够完美捕获这个事件流,并且能够正确地通过 DOM 树回溯到 Shadow 内部的具体节点。
- CSS 作用域的隔离:既然监听器在 Root,React 在分发事件时,可以更自信地认为
event.target在当前的 React 挂载范围内,减少了跨组件的误判。
第四部分:dispatchEvent —— 事件是如何“翻译”的?
光说动了监听器还不够,咱们得看看 React 在 Root 上监听到事件后,到底干了什么。这可是重头戏,代码量很大,咱们挑重点。
1. 截获事件
当你在 #root 上点击鼠标时,浏览器首先触发原生的 DOM 事件。
// 伪代码
root.addEventListener('click', (nativeEvent) => {
// React 接手了
ReactEventListener.handleTopLevel(topLevelType, nativeEvent);
});
2. 识别事件类型
React 需要把原生的 MouseEvent、TouchEvent、KeyboardEvent 统一封装成它自己的 SyntheticEvent(合成事件)。
React 内部维护了一个映射表,比如 SimpleEventPlugin:
const eventTypes = {
click: { phasedRegistrationNames: { bubbled: 'onClick', captured: 'onCaptureClick' } },
input: { phasedRegistrationNames: { bubbled: 'onInput', captured: 'onCaptureInput' } },
// ... 更多
};
React 会解析 nativeEvent.type,然后根据这个表,找到对应的 React 事件名称。
3. 构建合成事件对象
这是 React 最聪明的地方之一。它创建了一个 SyntheticEvent 对象池。
// React 内部逻辑
const event = new SyntheticEvent(
eventConfig,
topLevelType,
nativeEvent,
targetInst
);
// 比如点击一个按钮,targetInst 可能是 Button 组件的 Fiber 节点
4. 挂载到 Fiber 节点
React 会把这个事件对象挂载到对应的 DOM 节点(或者 Fiber 节点)上。
// 伪代码
event.persist = function() {
// React 15 有个著名的 bug,就是 event 对象被复用后,属性丢失。
// React 16+ 通过 persist 解决了这个问题,确保 event 对象在回调执行期间保持有效。
canUseDOM && (event.isPersistent = event.isPersistent = true);
};
5. 冒泡与捕获
这是最关键的一步,也是从 Root 监听的优势所在。
因为监听器在 Root,React 需要模拟事件流。它会从 event.target 开始,向上遍历 DOM 树,找到对应的 React Fiber 节点。
- 捕获阶段:从 Root -> … -> Target。React 会调用目标节点上注册的
onCaptureClick。 - 冒泡阶段:从 Target -> … -> Root。React 会调用目标节点上注册的
onClick。
// React 内部遍历逻辑
function traverseTwoPhase(inst, event, listener) {
// 捕获阶段
let ancestor = getClosestInstanceFromNode(event.target);
while (ancestor) {
invokeGuardedCallback(null, listener, null, event);
ancestor = getParent(inst, ancestor);
}
// 冒泡阶段
ancestor = getAncestor(inst, event.target);
while (ancestor) {
invokeGuardedCallback(null, listener, null, event);
ancestor = getParent(inst, ancestor);
}
}
这里有一个细节:React 16 使用了 Fiber 树。这意味着它不再依赖真实的 DOM 树来进行事件分发,而是通过 Fiber 节点来模拟 DOM 结构。这极大地提高了事件处理的效率和并发模式的兼容性。
第五部分:并发模式下的挑战
咱们现在到了 React 18,也就是并发模式时代。
如果你还在用 document 监听事件,并发模式会给你带来巨大的麻烦。
为什么?
并发模式允许 React 中断渲染,比如在用户点击按钮时,React 正在渲染一个复杂的页面,突然用户又点了一下。React 需要暂停当前渲染,处理新的事件,然后再恢复。
如果事件监听器挂载在 document 上,而 React 的渲染状态和事件状态不同步,就会出现“竞态条件”。
举个栗子:
- 用户点击按钮 A。
- React 触发了
onClick,开始异步请求后台数据。 - 在请求还没回来的时候,React 切换了状态,用户点击了按钮 B。
- React 触发了
onClick,又发了一个请求。
如果没有 Root 层的统一管理,React 很难判断这些事件是否属于同一个“提交阶段”,也很难处理“取消”操作。
通过将事件监听器绑定在 Root 上,React 可以更精确地控制事件流的时序,确保在并发模式下,事件的处理依然符合逻辑。
第六部分:实战中的坑与解法
讲了这么多理论,咱们来点实战。当你升级 React 版本时,可能会遇到一些奇怪的问题。
问题一:事件丢失
有时候你会发现,在 React 16+ 中,某些事件不响应了。
原因:可能是因为你的 Root 容器不是 React 挂载的唯一容器。或者,你的 Root 容器被 display: none 隐藏了。
解决:确保你的 Root 容器是可见的,并且是 React 唯一挂载的 DOM 节点。
问题二:setTimeout 里的 this
这是一个经典的老问题,虽然和监听器位置无关,但和事件系统有关。
class Button extends React.Component {
handleClick() {
console.log(this); // 在 React 15 中,这里可能是 null,因为事件对象被复用了
}
render() {
return <button onClick={this.handleClick}>Click</button>;
}
}
React 15 的坑:因为事件对象是复用的,如果你在 handleClick 里把 event 保存到了变量里,然后异步调用,变量里的 event 会变成 null。
React 16+ 的解法:虽然 React 依然使用对象池,但它引入了 event.persist()。只要你调用了这个方法,这个事件对象就会从池子里“借”出来,不会被复用。
handleClick(event) {
event.persist(); // 关键!
setTimeout(() => {
console.log(event); // 现在可以正常打印了
}, 100);
}
问题三:第三方库的干扰
如果你的项目里混入了 jQuery 或其他库,它们可能在 document 上绑定了事件。
- jQuery:jQuery 3.x 默认使用事件委托,它也会监听 document。这通常是兼容的,因为它们都是冒泡事件。
- 原生 addEventListener:如果你手动在
document上加了addEventListener('click', ...),React 就会在 Root 上加第二个。
这通常没问题,但如果你的逻辑依赖于“只有 React 处理这个点击”,那就麻烦了。你需要确保你的监听器使用了 { capture: true } 或者 { passive: true } 来确保优先级,或者干脆手动控制 React 的监听器开关。
第七部分:总结——为什么我们要做这种“搬砖”的工作?
好了,同学们,咱们把时间轴拉长一点。
从 document 到 Root,这不仅仅是代码行数的变化,这是 React 设计理念的一次进化。
- 从“上帝视角”到“局部视角”:React 以前假装自己是上帝,俯瞰整个文档。现在 React 承认自己只是你页面的一部分,它只关心自己的领地(Root)。这种“边界感”是现代前端框架架构的核心。
- 拥抱 Web Components:随着 Shadow DOM 的流行,React 必须进化才能在同一个页面上混得风生水起。移到 Root,让 React 能够完美融入 Web Components 生态。
- 性能的极致追求:在每秒 60 帧的动画世界里,少去 document 跑一趟,可能就是几微秒的性能提升。对于 React 这样庞大的库来说,这些微秒的积累就是用户体验的巨大飞跃。
所以,下次当你看到 ReactEventListener 的源码,或者看到 React 在你的控制台里输出那些复杂的 dispatchEvent 日志时,请记住:这一切的复杂性,都是为了让你在写 <button onClick={...}> 这行代码时,能够更爽、更稳、更少出 Bug。
这,就是技术的浪漫。
(讲座结束,Q&A 环节开始)
Q: 如果 Root 元素是一个 Portal 传出来的怎么办?
A: 问得好!React 16+ 的 Root 监听器是动态的。如果你使用了 ReactDOM.createPortal(child, container),React 会把监听器动态挂载到 container 上,而不是固定的 #root。所以,Portal 的场景完全没问题,React 的这套机制是自适应的。
Q: 那如果 Root 下面还有 Shadow DOM,React 16 的处理机制是监听 Root 还是监听 Shadow Root?
A: 依然是监听 Root。这是为了保持一致性。React 通过事件冒泡机制,即使事件在 Shadow DOM 内部触发,也能被 Root 捕获,然后 React 会智能地判断出这个事件的目标在 Shadow DOM 内部,并正确地回溯路径。
Q: 以后会移到更细粒度的节点吗?比如每个组件一个监听器?
A: 不会。那将导致性能灾难(内存爆炸,GC 压力大)。事件委托的核心思想就是“少而精”。Root 是一个很好的平衡点:它足够大能覆盖所有,又足够小能减少无效操作。这就是架构设计的艺术。
好了,下课!大家回去把代码改一改,别再盯着 document 发呆了!