🚀 从 DOM0 级到 React 合成事件:前端事件监听的 20 年演进史

🚀 从 DOM0 级到 React 合成事件:前端事件监听的 20 年演进史

摘要onClick 背后到底发生了什么?本文从最原始的 HTML 内联事件讲起,带你穿越 20 年,理解 React 合成事件系统的设计哲学。
📚 系列文章导航

  1. TypeScript 类型断言:为什么 e.target 必须「断言」才能用?
  2. 👉 从 DOM0 级到 React 合成事件(本文)
  3. React 父子组件通信:一个进度条教会我的 5 件事
  4. ????=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 但不理解事件系统原理的开发者
  • 想搞清楚 onClick vs onclick vs addEventListener 区别的同学
  • 对前端框架设计思想感兴趣的同学

📚 核心内容

一、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 项目中,我用 onClickonInputonKeyDown 三个事件就能完成所有交互。好的框架让你忘记底层细节,但理解底层细节能让你用得更好。


💡 重点总结

阶段 技术 特点
DOM0 级 onclick 简单但只能绑定一个
DOM2 级 addEventListener 支持多个,支持捕获/冒泡
React 合成事件 事件委托 + 跨浏览器兼容 + 自动管理

React 的设计哲学

  1. 不发明新东西,优化已有的东西
  2. 事件委托解决性能问题
  3. 合成事件解决兼容问题
  4. 声明式绑定解决心智负担

🔗 参考资料

💬 来做一道题

以下代码,点击按钮后控制台输出顺序是什么?评论区写出你的答案!

tsx 复制代码
useEffect(() => {
  document.addEventListener('click', () => console.log('原生'));
  return () => document.removeEventListener('click', () => console.log('原生'));
}, []);

return <button onClick={() => console.log('React')}>Click</button>;

觉得有用?点个赞👍收藏⭐关注👆,下一篇讲 React 父子组件通信------父组件是数据状态,子组件是数据属性!

📚 系列文章导航

上一篇:TS 类型断言 | 本文 | 下一篇:父子组件通信

相关推荐
kyriewen1 小时前
我给前端项目的接口请求套了6层保护——才发现以前一直在裸奔
前端·javascript·面试
颜酱1 小时前
04 | 召回前置准备:搭好召回所需的四个数据库
前端·人工智能·后端
wdfk_prog1 小时前
嵌入式面试真题第 15 题:不可恢复异常后的通用崩溃快照、调用栈保存与离线分析架构
linux·开发语言·面试·架构
郝亚军3 小时前
如何安装webstorm、Node.js和vue CLI
前端·javascript·vue.js
IT_陈寒3 小时前
React的useEffect依赖项把我坑惨了
前端·人工智能·后端
东方小月3 小时前
从零开发一个Coding Agent:monorepo项目搭建
前端·后端·node.js
葬送的代码人生3 小时前
别再让 AI 瞎写代码了!Vibe Coding 三步法教你写出靠谱代码
前端·设计模式·架构
Shirley~~3 小时前
Code-Review-Graph:面向 AI 辅助代码审查的结构化上下文引擎
前端·ai编程
AI多Agent协作实战派4 小时前
AI多Agent协作系统实战(十七):凌晨4点,我的AI系统在“假装工作“——3个bug同时爆炸的5小时
java·前端·bug