🚀 从 DOM0 级到 React 合成事件:前端事件监听的 20 年演进史
摘要 :
onClick背后到底发生了什么?本文从最原始的 HTML 内联事件讲起,带你穿越 20 年,理解 React 合成事件系统的设计哲学。
📚 系列文章导航
- TypeScript 类型断言:为什么
e.target必须「断言」才能用?- 👉 从 DOM0 级到 React 合成事件(本文)
- React 父子组件通信:一个进度条教会我的 5 件事
??、??=和isNaN:这些 ES2021 语法你真的会用吗?
📌 前言
在写 DeepSeek-R1 WebGPU Demo 时,我写了这样一段代码:
tsx
<button onClick={() => setStatus('loading')}>Load Model</button>
<textarea onInput={(e) => { ... }} />
<textarea onKeyDown={(e) => { ... }} />
看起来很简单对吧?但你有没有想过:
- 为什么 React 用
onClick而不是onclick? - 为什么不用
addEventListener? - React 的事件绑定到底做了什么?
要回答这些问题,我们得从 20 年前说起。
🎯 本文适合谁
- 会用 React 但不理解事件系统原理的开发者
- 想搞清楚
onClickvsonclickvsaddEventListener区别的同学 - 对前端框架设计思想感兴趣的同学
📚 核心内容
一、DOM0 级事件:最原始的事件绑定
1.1 HTML 内联写法
html
<!-- 最古老的写法:直接在 HTML 标签上写 JS -->
<button onclick="alert('clicked')">Click Me</button>
特点:
- HTML 和 JS 耦合在一起
- 只能绑定一个处理函数
- 无法移除事件
1.2 DOM 属性写法
js
// 通过 JS 设置 DOM 属性
const btn = document.getElementById('myBtn');
btn.onclick = function() {
alert('clicked');
};
// 移除事件
btn.onclick = null;
特点:
- HTML 和 JS 分离了
- 但仍然只能绑定一个处理函数
- 后面的会覆盖前面的
js
btn.onclick = function() { console.log('第一个'); };
btn.onclick = function() { console.log('第二个'); };
// 只会输出「第二个」,第一个被覆盖了
二、DOM2 级事件:addEventListener 的革命
js
// addEventListener 登场!
btn.addEventListener('click', function() {
console.log('第一个');
});
btn.addEventListener('click', function() {
console.log('第二个');
});
// 两个都会执行!
核心改进:
| 特性 | DOM0 级 | DOM2 级 |
|---|---|---|
| 多个处理函数 | ❌ 后覆盖前 | ✅ 都执行 |
| 事件捕获 | ❌ 不支持 | ✅ 支持 |
| 移除事件 | onclick = null |
removeEventListener |
| 事件流 | 只有冒泡 | 捕获 → 目标 → 冒泡 |
事件流的三个阶段:
javascript
捕获阶段 目标阶段 冒泡阶段
↓ ↓ ↓
window → document → body → button → body → document → window


三、React 的设计灵感:为什么要发明合成事件?
3.1 原生事件的痛点
js
// 痛点1:1000 个按钮要绑定 1000 个事件
document.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', handleClick);
});
// 痛点2:内存浪费
// 每个事件监听器都占用内存,大量事件 = 内存爆炸
// 痛点3:事件绑定/解绑的时机管理
// 动态添加的元素需要手动绑定事件
3.2 React 的解决方案:事件委托
React 不会给每个 DOM 元素单独绑定事件,而是在 根节点 上统一监听:
tsx
// 你以为 React 做了这个:
<button onClick={handler} /> // 在 button 上绑定了 click
// 实际上 React 做了这个:
root.addEventListener('click', (e) => {
// 通过事件冒泡,找到触发事件的组件
// 执行对应的 handler
});
这就是事件委托(Event Delegation)。
3.3 合成事件(SyntheticEvent)
React 在原生事件的基础上,封装了一层 合成事件:
tsx
<button onClick={(e) => {
// e 不是原生的 MouseEvent
// 而是 React 的 SyntheticEvent
console.log(e.type); // 'click'
console.log(e.nativeEvent); // 原生的 MouseEvent
console.log(e.target); // 触发事件的 DOM 元素
}} />
合成事件的优势:
| 优势 | 说明 |
|---|---|
| 跨浏览器兼容 | React 帮你处理了浏览器差异 |
| 自动内存管理 | 组件卸载时自动解绑事件 |
| 事件池(已废弃) | React 17 之前复用事件对象提升性能 |
| 统一的事件模型 | 所有事件都遵循同一套规范 |
四、React 事件命名的设计哲学
tsx
// DOM0 级:全小写
onclick="handler"
// React:小驼峰
onClick={handler}
// 为什么不沿用 onclick?
// React 团队的骄傲:「我们不是在发明新东西,我们是在优化已有的东西」
// 小驼峰是 JS 的命名惯例,onclick 是 HTML 的属性命名
// React 选择了更符合 JS 习惯的命名方式
五、从项目代码看 React 事件
tsx
// 1. 点击事件:触发模型加载
<button onClick={() => setStatus('loading')}>Load Model</button>
// 2. 输入事件:实时获取输入值
<textarea onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
setInput(target.value);
}} />
// 3. 键盘事件:回车发送
<textarea onKeyDown={(e) => {
if (input.length > 0 && e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onEnter();
}
}} />
注意 :React 17+ 的事件不再挂载到 document,而是挂载到 React 根节点 (root),这样可以让一个页面上存在多个 React 应用。
六、React 17+ 事件系统的重要变化
React 17 做了一个看似微小但影响深远的改变:
js
// React 16:事件挂载到 document
document.addEventListener('click', dispatchEvent);
// React 17+:事件挂载到 root(createRoot 的容器)
root.addEventListener('click', dispatchEvent);
这个变化解决了什么问题?
html
<!-- 场景:一个页面上有两个 React 应用 -->
<div id="app1"></div> <!-- React 16 应用 -->
<div id="app2"></div> <!-- React 17+ 应用 -->
<script>
// React 16 的问题:两个应用的事件都挂载到 document
// 事件处理顺序不可控,可能互相干扰
// React 17+ 的解决:各自挂载到自己的 root
// 事件完全隔离,互不影响
</script>
对实际开发的影响:
- 微前端场景下,不同子应用的事件不会冲突
- 渐进式迁移旧项目时,新旧 React 版本可以共存
e.stopPropagation()不会意外阻止其他 React 应用的事件
七、踩坑实战:React 事件的 3 个常见坑
坑 1:异步访问事件对象
tsx
const handleClick = (e) => {
// ✅ 同步访问,没问题
console.log(e.type); // 'click'
setTimeout(() => {
// ❌ React 17 之前:事件池已回收,属性全部为 null
// ✅ React 17+:不再有事件池,可以正常访问
console.log(e.type); // 'click'
}, 100);
};
React 17 废弃了事件池机制,不再需要手动
e.persist()。
坑 2:合成事件和原生事件混用
tsx
useEffect(() => {
// 原生事件:直接绑定到 DOM
const handler = () => console.log('原生 click');
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
const handleClick = (e) => {
// 合成事件
console.log('React click');
// ⚠️ e.stopPropagation() 只阻止 React 事件冒泡
// 不会阻止原生事件的触发!
};
// 点击按钮时,两个都会触发
// 输出:'React click' → '原生 click'
坑 3:e.preventDefault() 在不同场景下的行为
tsx
// ✅ 在合成事件中阻止默认行为
<a onClick={(e) => e.preventDefault()}>链接</a>
// ⚠️ 在 passive 事件中无法阻止
// React 对某些事件(如 touchstart)使用 passive 模式
// e.preventDefault() 会被忽略并打印警告
八、面试高频考点
考点 1:React 事件和原生事件的区别?
React 使用合成事件(SyntheticEvent),基于事件委托在根节点统一监听,跨浏览器兼容;原生事件直接绑定到 DOM 元素。
考点 2:React 17 事件系统的变化?
事件从
document移到createRoot的容器上,解决了多 React 应用共存时的事件冲突问题。
考点 3:事件冒泡和事件捕获的区别?
冒泡从目标元素向上传播到 window;捕获从 window 向下传播到目标元素。React 的事件处理默认在冒泡阶段执行。
💡 我的看法
React 的事件系统是「工程妥协」的典范。
它没有发明新的事件模型,而是在原生事件之上加了一层抽象:事件委托解决性能,合成事件解决兼容,声明式绑定解决心智负担。
在 DeepSeek-R1 WebGPU 项目中,我用 onClick、onInput、onKeyDown 三个事件就能完成所有交互。好的框架让你忘记底层细节,但理解底层细节能让你用得更好。
💡 重点总结
| 阶段 | 技术 | 特点 |
|---|---|---|
| DOM0 级 | onclick |
简单但只能绑定一个 |
| DOM2 级 | addEventListener |
支持多个,支持捕获/冒泡 |
| React | 合成事件 | 事件委托 + 跨浏览器兼容 + 自动管理 |
React 的设计哲学:
- 不发明新东西,优化已有的东西
- 事件委托解决性能问题
- 合成事件解决兼容问题
- 声明式绑定解决心智负担
🔗 参考资料
💬 来做一道题
以下代码,点击按钮后控制台输出顺序是什么?评论区写出你的答案!
tsx
useEffect(() => {
document.addEventListener('click', () => console.log('原生'));
return () => document.removeEventListener('click', () => console.log('原生'));
}, []);
return <button onClick={() => console.log('React')}>Click</button>;
觉得有用?点个赞👍收藏⭐关注👆,下一篇讲 React 父子组件通信------父组件是数据状态,子组件是数据属性!
📚 系列文章导航
上一篇:TS 类型断言 | 本文 | 下一篇:父子组件通信