说说 React 的事件机制?
一、是什么
React 实现了一套独立于浏览器原生事件的合成事件系统(SyntheticEvent)。当你在 React 中编写事件处理代码时,你接收到的事件对象不是浏览器原生的 Event,而是 React 封装的 SyntheticEvent。
function Button() {
function handleClick(event) {
// event 是 SyntheticEvent,不是原生 MouseEvent
console.log(event.constructor.name); // SyntheticBaseEvent
console.log(event.nativeEvent); // 原生 MouseEvent
event.preventDefault(); // 合成事件上的方法
event.stopPropagation(); // 合成事件上的方法
}
return <button onClick={handleClick}>点击</button>;
}
React 合成事件的设计目标:
- 跨浏览器一致性:抹平不同浏览器事件 API 的差异
- 性能优化:通过事件委托减少事件监听器数量
- 统一管理:React 可以控制事件的优先级和调度
二、事件委托机制
2.1 基本原理
React 不会为每个元素单独绑定事件监听器,而是利用事件冒泡机制,在根节点统一监听所有事件:
function App() {
return (
<div onClick={() => console.log("div")}>
<button onClick={() => console.log("button")}>
<span onClick={() => console.log("span")}>点击</span>
</button>
</div>
);
}
// 实际上,React 只在根容器上注册了一个 click 监听器
// 当点击 span 时,事件冒泡到根容器
// React 根据 event.target 和 Fiber 树,按正确顺序调用处理函数
// 输出: span → button → div
2.2 React 16 vs React 17+ 的委托目标
这是一个重要的版本差异:
React 16:
document ← 所有事件都委托到 document
└── #root
└── App
└── button (onClick)
React 17+:
document
└── #root ← 事件委托到 React 根容器
└── App
└── button (onClick)
React 17 将事件委托的目标从 document 改为了 React 根容器(createRoot 挂载的 DOM 节点)。这个改变的意义:
// 多个 React 版本/实例共存时不再互相干扰
const root1 = document.getElementById("root1");
const root2 = document.getElementById("root2");
// React 17+: 各自的事件委托到各自的根容器,互不影响
createRoot(root1).render(<AppV18 />);
createRoot(root2).render(<AppV18 />);
// React 16: 都委托到 document,可能互相干扰
还有一个实际影响:
function App() {
useEffect(() => {
// React 16: document 上的监听器先于 React 的事件处理执行
// React 17+: 这个监听器会在 React 事件处理之后执行(因为 React 事件在 #root 上处理,先于 document)
document.addEventListener("click", () => {
console.log("document click");
});
}, []);
return (
<button
onClick={(e) => {
e.stopPropagation();
console.log("button click");
}}
>
点击
</button>
);
}
// React 16: 输出 "document click" 和 "button click"
// stopPropagation 无法阻止 document 监听器(因为 React 事件本身就在 document 上)
// React 17+: 只输出 "button click"
// stopPropagation 正确阻止了事件继续冒泡到 document
三、SyntheticEvent 对象
3.1 结构和属性
合成事件对象实现了与原生事件相同的接口:
function EventDemo() {
function handleClick(event) {
// 通用属性
event.type; // "click"
event.target; // 触发事件的 DOM 元素
event.currentTarget; // 绑定事件的 DOM 元素
event.timeStamp; // 事件时间戳
event.bubbles; // 是否冒泡
event.cancelable; // 是否可取消默认行为
// 鼠标事件特有
event.clientX;
event.clientY;
event.pageX;
event.pageY;
event.button;
// 键盘事件特有
// event.key
// event.code
// event.altKey / event.ctrlKey / event.shiftKey / event.metaKey
// 获取原生事件
event.nativeEvent; // 浏览器原生 Event 对象
// 阻止默认行为和冒泡
event.preventDefault();
event.stopPropagation();
}
return <button onClick={handleClick}>点击</button>;
}
3.2 事件池(Event Pooling)— 已在 React 17 移除
React 16 及之前版本使用了事件池机制来优化性能。合成事件对象在事件回调执行后会被回收,所有属性被重置为 null:
// React 16 的问题
function OldBehavior() {
function handleClick(event) {
console.log(event.type); // "click"
setTimeout(() => {
console.log(event.type); // React 16: null(已被回收)
// React 17+: "click"(正常)
}, 100);
}
// React 16 的解决方案
function handleClickFixed(event) {
event.persist(); // 从事件池中移除,保持引用
setTimeout(() => {
console.log(event.type); // "click"
}, 100);
}
}
React 17 移除了事件池机制,事件对象不再被复用,event.persist() 仍然存在但不执行任何操作。这个改变简化了开发者的心智模型。
四、事件传播
4.1 捕获和冒泡
React 同时支持捕获阶段和冒泡阶段的事件处理:
function PropagationDemo() {
return (
<div
onClickCapture={() => console.log("div capture")}
onClick={() => console.log("div bubble")}
>
<button
onClickCapture={() => console.log("button capture")}
onClick={() => console.log("button bubble")}
>
点击
</button>
</div>
);
}
// 点击 button 后输出顺序:
// div capture
// button capture
// button bubble
// div bubble
捕获阶段的事件处理使用 onXxxCapture 命名。
4.2 阻止冒泡
function StopPropagationDemo() {
return (
<div onClick={() => console.log("外层被点击")}>
<button
onClick={(e) => {
e.stopPropagation(); // 阻止合成事件冒泡
console.log("按钮被点击");
}}
>
点击
</button>
</div>
);
}
// 只输出: "按钮被点击"
4.3 合成事件与原生事件的执行顺序
function EventOrderDemo() {
const buttonRef = useRef(null);
useEffect(() => {
const button = buttonRef.current;
const root = document.getElementById("root");
button.addEventListener("click", () => {
console.log("1. 原生事件: button");
});
root.addEventListener("click", () => {
console.log("4. 原生事件: root");
});
document.addEventListener("click", () => {
console.log("5. 原生事件: document");
});
}, []);
return (
<button
ref={buttonRef}
onClick={() => console.log("3. 合成事件: button")}
>
<span onClick={() => console.log("2. 合成事件: span")}>点击</span>
</button>
);
}
// React 17+ 点击 span 后的输出顺序:
// 1. 原生事件: button(原生事件先冒泡到 button)
// 2. 合成事件: span(冒泡到 root 时,React 处理合成事件)
// 3. 合成事件: button
// 4. 原生事件: root(继续原生冒泡)
// 5. 原生事件: document
执行顺序规则(React 17+):
- 原生事件从 target 向上冒泡到 React 根容器之前的节点
- React 根容器上的委托监听器触发,按 Fiber 树顺序执行合成事件处理函数
- 原生事件继续从 React 根容器向上冒泡到 document
五、与原生 DOM 事件的差异
5.1 命名差异
// HTML 原生事件:全小写
<button onclick="handleClick()">
// React 合成事件:驼峰命名
<button onClick={handleClick}>
// HTML 原生:字符串
<form onsubmit="return false">
// React:函数引用,且阻止默认行为必须显式调用
<form onSubmit={(e) => { e.preventDefault(); }}>
5.2 阻止默认行为
HTML 中可以 return false 阻止默认行为,但 React 中必须显式调用 e.preventDefault()。同样,原生 DOM 事件的 this 指向触发事件的元素,React 合成事件中没有这种隐式绑定。
5.4 onChange 的特殊行为
onChange 在 React 中的行为与原生 change 事件不同——React 的 onChange 在每次输入时都会触发(类似原生 input 事件),而原生 change 事件只在失焦时触发。
六、访问原生事件
通过合成事件的 nativeEvent 属性可获取原生事件对象。如果需要直接使用原生事件(如集成第三方库),可通过 useRef + addEventListener 在 useEffect 中绑定:
function NativeEventAccess() {
const divRef = useRef(null);
useEffect(() => {
const div = divRef.current;
const handler = (e) => console.log("原生事件", e);
div.addEventListener("click", handler);
return () => div.removeEventListener("click", handler);
}, []);
return (
<div ref={divRef}>
<button onClick={(e) => console.log(e.nativeEvent)}>点击</button>
</div>
);
}
七、总结
React 合成事件系统是 React 架构的重要组成部分,核心要点:
- 事件委托:React 17+ 将事件委托到根容器(非 document),支持多 React 实例共存
- 合成事件:跨浏览器一致的事件接口,通过
nativeEvent 属性访问原生事件
- 事件池已移除:React 17 移除了事件池机制,事件对象可以正常异步访问
- 命名规范:驼峰命名,传递函数引用而非字符串
- 执行顺序:原生事件(target → root 前)→ 合成事件 → 原生事件(root → document)
面试时需要特别注意 React 16 和 17+ 在事件委托目标、事件池行为上的版本差异,以及合成事件与原生事件混用时的执行顺序。