说说 React 的事件机制?
问题解析(面试官考察点)
面试官通过此问题主要考察:
- 对 React 合成事件的理解
- 事件委托机制的了解
- 事件执行顺序的掌握
- 对原生事件和合成事件区别的认识
核心概念(基础知识点)
合成事件(SyntheticEvent)
React 基于浏览器的事件机制实现了一套自己的事件系统:
- 事件注册:将事件统一注册到 document 上
- 事件合成:封装浏览器原生事件,提供跨浏览器兼容性
- 事件冒泡:使用事件委托机制处理事件
- 事件派发:统一的事件监听器处理所有组件事件
为什么使用合成事件
- 跨浏览器兼容:抹平不同浏览器的事件差异
- 性能优化:事件委托减少内存占用
- 统一接口:与原生事件相似的 API
- 事件池:React 17 之前使用事件池复用事件对象
详细解答(代码示例)
基本事件绑定
const button = <button onClick={handleClick}>按钮</button>;
const handleClick = (e) => {
console.log(e); // SyntheticEvent 合成事件对象
console.log(e.nativeEvent); // 原生 DOM 事件
};
获取原生事件
const handleClick = (e) => {
// e 是 React 的合成事件
console.log(e.target); // 触发事件的元素
// e.nativeEvent 是原生 DOM 事件
console.log(e.nativeEvent.target); // 原生事件的目标元素
console.log(e.nativeEvent.currentTarget); // 原生事件的当前目标
};
事件执行顺序示例
import React from 'react';
class App extends React.Component {
constructor(props) {
super(props);
this.parentRef = React.createRef();
this.childRef = React.createRef();
}
componentDidMount() {
console.log("React componentDidMount");
// 原生事件绑定
this.parentRef.current?.addEventListener("click", () => {
console.log("原生事件:父元素 DOM 事件监听!");
});
this.childRef.current?.addEventListener("click", () => {
console.log("原生事件:子元素 DOM 事件监听!");
});
document.addEventListener("click", (e) => {
console.log("原生事件:document DOM 事件监听!");
});
}
parentClickFun = () => {
console.log("React 事件:父元素事件监听!");
};
childClickFun = () => {
console.log("React 事件:子元素事件监听!");
};
render() {
return (
<div ref={this.parentRef} onClick={this.parentClickFun}>
<div ref={this.childRef} onClick={this.childClickFun}>
分析事件执行顺序
</div>
</div>
);
}
}
export default App;
输出顺序
1. 原生事件:子元素 DOM 事件监听!
2. 原生事件:父元素 DOM 事件监听!
3. React 事件:子元素事件监听!
4. React 事件:父元素事件监听!
5. 原生事件:document DOM 事件监听!
深入理解(原理剖析)
React 事件绑定原理
┌─────────────────────────────────────┐
│ document │ ← 所有事件绑定在这里
│ ┌─────────────────────────────┐ │
│ │ 统一事件监听器 │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ 事件映射表 │ │ │
│ │ │ 组件A: handleClickA │ │ │
│ │ │ 组件B: handleClickB │ │ │
│ │ └─────────────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
事件处理流程
- 事件触发:用户点击按钮
- 原生事件冒泡:事件冒泡到 document
- 统一监听器处理:React 的统一事件监听器捕获事件
- 查找处理函数:在映射表中找到对应的处理函数
- 调用处理函数:执行组件中定义的事件处理函数
合成事件 vs 原生事件
| 特性 | React 合成事件 | 原生 DOM 事件 |
|---|---|---|
| 事件名称 | 小驼峰(onClick) | 全小写(onclick) |
| 事件处理 | 函数引用 | 字符串或函数 |
| 绑定位置 | document | 目标元素 |
| 阻止冒泡 | e.stopPropagation() | e.stopPropagation() |
| 默认行为 | e.preventDefault() | e.preventDefault() |
事件命名对比
// 原生事件绑定方式
<button onclick="handleClick()">按钮</button>
// React 合成事件绑定方式
<button onClick={handleClick}>按钮</button>
阻止冒泡的注意事项
// 阻止合成事件间的冒泡
const handleClick = (e) => {
e.stopPropagation();
};
// 阻止合成事件与 document 上的原生事件冒泡
const handleClick = (e) => {
e.nativeEvent.stopImmediatePropagation();
};
// 阻止合成事件与除 document 外的原生事件冒泡
componentDidMount() {
document.body.addEventListener('click', e => {
if (e.target && e.target.matches('div.code')) {
return;
}
this.setState({ active: false });
});
}
React 17 的变化
React 17 之前:
- 所有事件委托到 document
- 使用事件池(SyntheticEvent 被复用)
React 17 及之后:
- 事件委托到 root DOM 容器
- 不再使用事件池,可以异步访问事件属性
// React 16 - 需要调用 e.persist() 才能异步访问
const handleClick = (e) => {
e.persist(); // 必须调用
setTimeout(() => {
console.log(e.target); // 可以访问
}, 1000);
};
// React 17+ - 不需要 e.persist()
const handleClick = (e) => {
setTimeout(() => {
console.log(e.target); // 可以直接访问
}, 1000);
};
最佳实践
事件处理函数绑定
// 推荐:类属性箭头函数
class MyComponent extends React.Component {
handleClick = () => {
console.log('clicked');
};
render() {
return <button onClick={this.handleClick}>Click</button>;
}
}
// 推荐:函数组件
function MyComponent() {
const handleClick = () => {
console.log('clicked');
};
return <button onClick={handleClick}>Click</button>;
}
传递参数
// 方式一:箭头函数(每次渲染创建新函数)
<button onClick={() => handleClick(id)}>Click</button>
// 方式二:data 属性(推荐)
<button data-id={id} onClick={handleClick}>Click</button>
const handleClick = (e) => {
const id = e.target.dataset.id;
// 处理点击
};
// 方式三:bind(构造函数中绑定)
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this, id);
}
混合使用原生事件和合成事件
class MixedEvents extends React.Component {
componentDidMount() {
// 原生事件
this.node.addEventListener('click', this.handleNativeClick);
}
componentWillUnmount() {
// 必须手动移除原生事件监听
this.node.removeEventListener('click', this.handleNativeClick);
}
handleNativeClick = (e) => {
console.log('原生事件');
};
handleReactClick = (e) => {
console.log('React 合成事件');
};
render() {
return (
<div
ref={node => this.node = node}
onClick={this.handleReactClick}
>
点击我
</div>
);
}
}
事件委托优化
// 不推荐:每个子元素都绑定事件
<ul>
{items.map(item => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
// 推荐:在父元素上使用事件委托
<ul onClick={handleListClick}>
{items.map(item => (
<li key={item.id} data-id={item.id}>
{item.name}
</li>
))}
</ul>
const handleListClick = (e) => {
const id = e.target.dataset.id;
if (id) {
handleClick(id);
}
};
面试要点
-
事件委托机制
- React 将所有事件绑定到 document(React 17 之前)或 root 容器
- 通过映射表管理组件事件处理函数
- 减少内存占用,提高性能
-
事件执行顺序
- 原生事件先执行(冒泡到 document)
- React 合成事件后执行
- document 上的原生事件最后执行
-
合成事件的优势
- 跨浏览器兼容
- 事件委托优化性能
- 统一的事件接口
-
常见陷阱
- e.stopPropagation() 不能阻止原生事件的冒泡
- React 16 中异步访问事件属性需要 e.persist()
- 原生事件需要手动清理
-
React 17 的变化
- 事件委托到 root 容器而非 document
- 移除事件池,可以异步访问事件属性
- 更易于集成多个 React 应用
-
常见面试题
- React 事件和原生事件有什么区别?
- React 事件为什么要绑定到 document?
- 为什么 React 事件中使用 e.stopPropagation() 无效?
- React 17 事件机制有什么变化?