2.3 事件处理


文档摘要

2.3 事件处理 本节摘要:React 的事件处理建立在合成事件系统之上——它是浏览器原生事件的跨浏览器包装,统一了行为差异,并通过事件委托提升性能。本节讲清合成事件与原生事件的差异、类组件里 this 绑定的来龙去脉、事件对象与冒泡控制,最后给出受控组件与非受控组件的选择标准。 上手前先明确 阅读完本节,你应当能够: 解释合成事件系统与事件委托的原理 区分类组件中 bind、箭头函数两种 this 处理方式的差异 正确使用事件对象、preventDefault 与 stopPropagation 说明受控组件与非受控组件的区别,并做出选择 识别事件处理中的常见陷阱(传参、异步读取事件、多次触发) 一、问题与直觉:为什么事件处理也要「封装」 原生 DOM

2.3 事件处理

本节摘要:React 的事件处理建立在合成事件系统之上——它是浏览器原生事件的跨浏览器包装,统一了行为差异,并通过事件委托提升性能。本节讲清合成事件与原生事件的差异、类组件里 this 绑定的来龙去脉、事件对象与冒泡控制,最后给出受控组件与非受控组件的选择标准。

上手前先明确

阅读完本节,你应当能够:

  1. 解释合成事件系统与事件委托的原理
  2. 区分类组件中 bind、箭头函数两种 this 处理方式的差异
  3. 正确使用事件对象、preventDefault 与 stopPropagation
  4. 说明受控组件与非受控组件的区别,并做出选择
  5. 识别事件处理中的常见陷阱(传参、异步读取事件、多次触发)

一、问题与直觉:为什么事件处理也要「封装」

原生 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 绑定:一段 JavaScript 历史

函数组件没有 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 原文在事件处理这节就引入了这个概念。

受控组件:值由 state 控制

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 里。

非受控组件:值由 DOM 自己管

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 节闭包陷阱是同一件事的两个面——事件处理器也捕获渲染时的快照。

陷阱三:输入框 onChange 忘写 setState

受控组件的 value 绑定了 state,但 onChange 没更新,输入框会「卡住」——怎么敲都不变。这其实是受控组件的特性:值完全由 state 决定,state 不变,界面不变。排查「输入框无反应」先看这。

陷阱四:在渲染期直接绑定原生事件

有人图省事在组件函数体里直接写 document.addEventListener。这是错误的——渲染函数每次执行都会重新绑定,而且没有清理,堆积成泄漏。正确做法是把原生事件绑定放进 useEffect,并返回清理函数。合成事件能覆盖的场景用合成事件,原生事件只在 useEffect 里用,这两条是事件代码不泄漏的底线。

陷阱五:事件处理里抛错导致组件崩溃

事件处理函数里的错误不会被错误边界捕获(错误边界只捕获渲染错误,第 3.8 节会详述)。一个按钮点击后抛错,如果没人兜底,整个应用可能白屏。事件处理里的操作要用 try/catch 包裹,或者把异步操作 Promise 的 rejection 处理掉。这属于「代码会崩才知道要兜底」的教训,建议提前养成习惯。

要点串联

  • 合成事件:原生事件的跨浏览器包装,抹平差异、提供类型安全
  • 事件委托:监听器集中在根容器,冒泡后分发,性能更优
  • 事件流:捕获 → 目标 → 冒泡,onClick 在冒泡阶段,onClickCapture 在捕获阶段
  • this 绑定:类组件用箭头函数类属性或 constructor 里 bind,避免 this 丢失
  • onClick 传引用:传函数名而非调用结果,带参用箭头函数包裹
  • preventDefault vs stopPropagation:前者阻默认动作,后者阻事件传播
  • 受控组件:值由 state 控制,支持校验与联动
  • 非受控组件:值由 DOM 管理,用 ref 读取,简单表单适用
  • 事件池:尽早取出事件数据,别在异步回调里读合成事件
  • 函数式更新:依赖旧值的更新用 prev 参数,避免快速点击错位
  • 原生事件边界:只在 useEffect 里绑定原生事件并返回清理函数
  • 错误兜底:事件处理里的错误不会被错误边界捕获,要自己 try/catch

下一节看数据到界面的两种映射——条件渲染与列表渲染。事件让界面响应交互,本节让界面按数据展示:条件决定显示什么,列表决定显示多少。掌握了条件与列表,你就能把「数据数组」变成「可见界面」,这是所有真实项目里占比最高的渲染逻辑。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U