2.3 事件处理 本节摘要:React 的事件处理建立在合成事件系统之上——它是浏览器原生事件的跨浏览器包装,统一了行为差异,并通过事件委托提升性能。本节讲清合成事件与原生事件的差异、类组件里 this 绑定的来龙去脉、事件对象与冒泡控制,最后给出受控组件与非受控组件的选择标准。 上手前先明确 阅读完本节,你应当能够: 解释合成事件系统与事件委托的原理 区分类组件中 bind、箭头函数两种 this 处理方式的差异 正确使用事件对象、preventDefault 与 stopPropagation 说明受控组件与非受控组件的区别,并做出选择 识别事件处理中的常见陷阱(传参、异步读取事件、多次触发) 一、问题与直觉:为什么事件处理也要「封装」 原生 DOM
本节摘要:React 的事件处理建立在合成事件系统之上——它是浏览器原生事件的跨浏览器包装,统一了行为差异,并通过事件委托提升性能。本节讲清合成事件与原生事件的差异、类组件里 this 绑定的来龙去脉、事件对象与冒泡控制,最后给出受控组件与非受控组件的选择标准。
阅读完本节,你应当能够:
原生 DOM 事件系统本身是够用的——addEventListener 挂在元素上,事件触发执行回调。React 为什么还要包一层合成事件?
第一个理由是跨浏览器一致性。不同浏览器对事件对象的属性、冒泡行为、触发时序有细微差异,React 的合成事件抹平了这些差异,你在所有浏览器里拿到的是同一套行为。
第二个理由是性能。如果每个按钮都单独绑定一个监听器,大量组件挂载卸载时,监听器也要跟着大量创建和销毁。React 的事件委托把监听器统一挂到根节点上,事件冒泡上来再分发,减少监听器数量。
SOURCE 原文把合成事件定义为「原生浏览器事件的跨浏览器包装器」,并列出三个优点:跨浏览器兼容、批量处理减少重绘回流、类型安全。前两个我们刚讲过,第三个「类型安全」在 TypeScript 场景下尤其明显——合成事件对象有明确的类型定义。
React 不把事件处理器直接绑在目标 DOM 元素上,而是把所有监听器挂在最外层的容器(通常是根节点)。事件触发后,从目标元素沿 DOM 树冒泡到根节点,React 在冒泡过程中检查路径上每个元素是否绑定了对应的事件处理器,找到就执行。
这意味着:一个事件处理器,无论界面上有多少个按钮,监听器只有一份。组件卸载时也不需要挨个移除监听器——它们本来就不在具体元素上。
完整的事件流分三个阶段:捕获(从根到目标)、目标(到达目标元素)、冒泡(从目标回根)。React 的 onClick 在冒泡阶段触发,onClickCapture 在捕获阶段触发。绝大多数场景用冒泡阶段就够,捕获阶段是少数需要「先于子元素处理」时的选择。
在事件流里理解 stopPropagation 最清晰:它停在当前阶段,不再继续传播。如果你在一个捕获阶段的监听器里 stopPropagation,连目标元素自己都收不到事件——这是很多人没料到的细节。
事件处理器接收的不是原生事件对象,而是合成事件对象。它实现了原生事件的通用接口,所以 target、preventDefault、stopPropagation 这些都能用:
function MyForm() { const handleChange = (event) => { console.log('输入值:', event.target.value); }; const handleSubmit = (event) => { event.preventDefault(); // 阻止表单提交默认行为 alert('提交!'); }; return ( <form onSubmit={handleSubmit}> <input type="text" onChange={handleChange} /> <button type="submit">提交</button> </form> ); }
event.preventDefault() 阻止默认行为(表单提交导致页面刷新),event.target.value 取输入框的值,event.stopPropagation() 阻止冒泡到父元素。这三个是最高频的合成事件操作。
合成事件不只是「为了跨浏览器」,它还承担了「统一界面」的职责。比如 onChange 事件,在原生 DOM 里 input、textarea、select 的变更事件触发时机各不相同,React 把它统一成 onChange 在值变化时触发。这意味着你可以用一套写法处理所有表单元素,不用记每个元素的事件差异。
还有一个设计细节:合成事件的 currentTarget 在事件处理完会被置空。如果你在事件处理函数内部访问 event.currentTarget 没问题,但把它存下来异步访问,拿到的会是 null。这也是「尽早取数据」的又一个理由。
| 对比项 | 原生事件 | 合成事件 |
|---|---|---|
| 监听位置 | 目标元素 | 根容器统一委托 |
| 跨浏览器 | 有差异 | 行为一致 |
| 事件对象 | 原生对象 | 包装器 |
| 性能 | 每元素一监听 | 监听器集中 |
| 命名 | onclick | onClick(驼峰) |
| 表单事件 | 各元素时机不一 | onChange 统一时机 |
函数组件没有 this,这段历史只属于类组件——但存量代码里大量存在,你必须认识它。
看这个「经典翻车」案例:
class MyButton extends React.Component { handleClick() { alert('Button clicked!'); } render() { return <button onClick={this.handleClick}>Click Me</button>; } }
点击后多半会报错或 this 为 undefined。原因:onClick 传的是 this.handleClick 这个函数的引用,React 在事件触发时直接调用它,此时函数里的 this 已经和组件实例无关——在严格模式下就是 undefined。
解决方式有三种:
// 方式一:在构造函数里 bind constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); } // 方式二:使用箭头函数类属性 handleClick = () => { alert('Button clicked!'); }; // 方式三:调用处包箭头函数(不推荐,每次渲染新建函数) <button onClick={() => this.handleClick()}>Click Me</button>
方式二(箭头函数类属性)最简洁,是现代类组件的主流。方式三每次渲染都创建新函数,虽然功能正常,但会影响子组件记忆化(第 3.1 节)。
事件处理器往往需要参数——比如知道点了哪个按钮:
class MyButton extends React.Component { handleClick(id) { alert(`Button ${id} clicked!`); } render() { return ( <div> <button onClick={() => this.handleClick(1)}>按钮 1</button> <button onClick={() => this.handleClick(2)}>按钮 2</button> </div> ); } }
函数组件版本同样用箭头函数包裹:
function ButtonList({ items }) { const handleClick = (id) => { alert(`Clicked ${id}`); }; return ( <div> {items.map(item => ( <button key={item.id} onClick={() => handleClick(item.id)}> {item.name} </button> ))} </div> ); }
💡 关键直觉:onClick 接收的是「事件发生时再调用的函数」,不是「现在立刻执行的函数」。所以传
handleClick而非handleClick()。带参数时用箭头函数包一层,把「现在取参数」和「将来执行」分开。
事件会冒泡:子元素的点击会一路传到父元素。有时需要阻止。
function Child() { const handleClick = (event) => { event.stopPropagation(); // 阻止冒泡,父组件不触发 alert('点击了子元素'); }; return <div onClick={handleClick}>子元素</div>; } function Parent() { const handleClick = () => { alert('点击了父元素'); }; return ( <div onClick={handleClick}> 父元素 <Child /> </div> ); }
点击 Child 时,stopPropagation 让事件停在子元素,父元素不触发。阻止默认行为用 preventDefault——比如阻止表单提交、阻止链接跳转、阻止拖拽默认行为。
两者的区别一句话:preventDefault 阻止浏览器的默认动作,事件照常冒泡;stopPropagation 阻止事件继续传播,浏览器默认动作不受影响。
一个实际的应用场景:点击卡片打开详情,但卡片里有个「删除」按钮。删除按钮的点击会冒泡到卡片触发打开详情——这显然不对。在删除按钮的处理里 stopPropagation,就把「删除」和「打开」两个动作隔离开了。这类「嵌套可点击区域」是 stopPropagation 最常见的用途。反过来,如果一个组件内部有多个可交互子元素,你反而希望事件能冒泡出去统一处理——用事件委托的思想,把逻辑放在父层,子元素不处理,这也是一种常见的组织方式。
这是表单处理的地基,SOURCE 原文在事件处理这节就引入了这个概念。
import { useState } from 'react'; function NameForm() { const [name, setName] = useState(''); const handleChange = (event) => { setName(event.target.value); }; const handleSubmit = (event) => { event.preventDefault(); alert('提交的名字:' + name); }; return ( <form onSubmit={handleSubmit}> <input type="text" value={name} onChange={handleChange} /> <button type="submit">提交</button> </form> ); }
输入框的 value 绑定 state,onChange 更新 state。数据流是:用户输入 → onChange → setState → value 更新 → 界面显示。React 完全控制输入框的值,这就是「受控」的含义。
受控组件的直接收益:实时校验、联动其他字段、提交前统一处理——因为值始终在 state 里。
import { useRef } from 'react'; function NameForm() { const nameInput = useRef(null); const handleSubmit = (event) => { event.preventDefault(); alert('提交的名字:' + nameInput.current.value); }; return ( <form onSubmit={handleSubmit}> <input type="text" ref={nameInput} /> <button type="submit">提交</button> </form> ); }
不设 value,不写 onChange,通过 ref 在提交时直接读 DOM 值。React 不干预输入过程,只在需要时取数。
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 数据来源 | React state | DOM 自身 |
| 实时校验 | 天然支持 | 困难 |
| 联动逻辑 | 方便 | 繁琐 |
| 代码量 | 稍多 | 少 |
| 适用 | 需要校验/联动/复用 | 简单一次性表单、第三方集成 |
⚠️ 常见坑:把受控组件的 value 设为 undefined。比如 value 绑定到一个还没初始化的变量,React 会把输入框当非受控处理,行为混乱。受控组件的 value 必须始终有值,不确定时就给空字符串。
React 的合成事件对象是池化复用的——事件处理完后对象会被重置。所以不能把 event 保存下来异步使用:
// 错误:异步时 event 已被重置 function Bad({ onChange }) { return <input onChange={(e) => setTimeout(() => console.log(e.target.value), 100)} />; } // 正确:先取出需要的数据 function Good({ onChange }) { return <input onChange={(e) => { const v = e.target.value; setTimeout(() => console.log(v), 100); }} />; }
这个坑在 React 17 之前的版本尤其明显,17 之后池化机制改了,但「尽早取出所需数据」依然是更稳的写法。
在事件里读 state 再更新,快速连续点击会拿到旧值:
// 错误:连续快速点击,count 可能一直读到旧值 <button onClick={() => setCount(count + 1)}>加一</button> // 正确:函数式更新,永远基于最新值 <button onClick={() => setCount(prev => prev + 1)}>加一</button>
这和第 2.2 节闭包陷阱是同一件事的两个面——事件处理器也捕获渲染时的快照。
受控组件的 value 绑定了 state,但 onChange 没更新,输入框会「卡住」——怎么敲都不变。这其实是受控组件的特性:值完全由 state 决定,state 不变,界面不变。排查「输入框无反应」先看这。
有人图省事在组件函数体里直接写 document.addEventListener。这是错误的——渲染函数每次执行都会重新绑定,而且没有清理,堆积成泄漏。正确做法是把原生事件绑定放进 useEffect,并返回清理函数。合成事件能覆盖的场景用合成事件,原生事件只在 useEffect 里用,这两条是事件代码不泄漏的底线。
事件处理函数里的错误不会被错误边界捕获(错误边界只捕获渲染错误,第 3.8 节会详述)。一个按钮点击后抛错,如果没人兜底,整个应用可能白屏。事件处理里的操作要用 try/catch 包裹,或者把异步操作 Promise 的 rejection 处理掉。这属于「代码会崩才知道要兜底」的教训,建议提前养成习惯。
下一节看数据到界面的两种映射——条件渲染与列表渲染。事件让界面响应交互,本节让界面按数据展示:条件决定显示什么,列表决定显示多少。掌握了条件与列表,你就能把「数据数组」变成「可见界面」,这是所有真实项目里占比最高的渲染逻辑。