React(九)绑定事件
一、前置导读
1.1 本章节教学目标
学完本章后,你将能够:
| 序号 | 能力目标 | 具体表现 |
|---|---|---|
| 1 | 理解本质 | 说清楚"React 合成事件"和"原生 DOM 事件"到底有什么区别 |
| 2 | 掌握写法 | 在函数组件和类组件中都能正确绑定事件,处理 this 指向问题 |
| 3 | 处理传参 | 给事件处理函数传递自定义参数,且不会写出 bug |
| 4 | 识别坑点 | 一眼看出"事件提前执行""this 丢失""重复渲染"等常见错误 |
| 5 | 性能优化 | 使用 useCallback 减少不必要的事件处理函数重建 |
| 6 | 跟进新特性 | 了解 React 19 事件系统的新变化,写兼容新旧版本的代码 |
打比方:在 React 里绑定事件,就像在餐厅里安排服务员——每个按钮(DOM 元素)上都分配了一个服务员(事件处理函数),客人点击按钮时,服务员就要做出响应。本章教你如何精准地"安排服务员",不出乱子。
二、核心定义
2.1 原生 DOM 事件 vs React 合成事件
类比理解
想象你在一个国际化公司工作:
- 原生 DOM 事件 就像给每个国家的分公司分别发通知——用法因浏览器而异,你要记住在 Chrome 怎么写、在 Firefox 怎么写、在 Safari 又怎么写。
- React 合成事件(SyntheticEvent) 就像公司统一发布一份内部公告——React 帮你把不同浏览器的差异"翻译"成统一的格式,你只需要学一套写法就能在所有浏览器上正常工作。
本质区别
| 对比维度 | 原生 DOM 事件 | React 合成事件 |
|---|---|---|
| 绑定方式 | element.addEventListener('click', handler) |
JSX 中写 onClick={handler} |
| 事件对象 | 浏览器原生的 MouseEvent / KeyboardEvent 等 |
React 封装的 SyntheticEvent,内部包装了原生事件 |
| 跨浏览器兼容 | 需要自己处理 IE、Chrome、Firefox 的差异 | React 统一处理,一套代码通吃 |
| 命名规则 | 全小写:click、keydown、mouseenter |
驼峰命名:onClick、onKeyDown、onMouseEnter |
| 绑定目标 | 绑定到具体的 DOM 元素上 | React 17+ 绑定到根节点(#root),通过事件委托处理 |
| 事件池 | 不存在池化概念 | React 16 有事件池,React 17+ 已移除 |
代码直观对比
// ========== 原生 DOM 写法 ==========
const btn = document.getElementById("myBtn");
btn.addEventListener("click", function (e) {
console.log("按钮被点击了", e.target);
});
// ========== React 写法 ==========
function MyButton() {
const handleClick = (e) => {
console.log("按钮被点击了", e.target);
};
return <button onClick={handleClick}>点我</button>;
}
关键记忆点:React 中的事件名都是驼峰命名(小驼峰),而且你传的是一个函数引用
{handleClick},不是字符串"handleClick()"。
2.2 React 事件冒泡与捕获流程
生活中的类比
把事件冒泡想象成"向领导汇报":
- 捕获阶段(Capture Phase):总经理 → 部门经理 → 小组长 → 你(事件从最外层一路向下传递到目标元素)
- 目标阶段(Target Phase):事件到达你这里(实际触发事件的元素)
- 冒泡阶段(Bubble Phase):你 → 小组长 → 部门经理 → 总经理(事件从目标元素一路向上冒泡到最外层)
React 中的事件传播流程图(文字版)
点击 <button> 元素时的完整事件流:
捕获阶段(从上往下) 冒泡阶段(从下往上)
┌─────────────┐ ┌─────────────┐
│ document │ ──────────→ │ document │
│ ↓ │ │ ↑ │
│ <div> │ │ <div> │
│ ↓ │ │ ↑ │
│ <form> │ │ <form> │
│ ↓ │ │ ↑ │
│ <button> ★ │ ← 目标阶段 → │ <button> ★ │
└─────────────┘ └─────────────┘
React 默认在冒泡阶段处理事件(onClick)
如需在捕获阶段处理,使用 onClickCapture
代码验证冒泡与捕获
function EventFlowDemo() {
return (
<div
onClick={() => console.log("DIV - 冒泡阶段")}
onClickCapture={() => console.log("DIV - 捕获阶段")}
style={{ padding: 40, background: "#e8f5e9" }}
>
<button
onClick={() => console.log("BUTTON - 冒泡阶段")}
onClickCapture={() => console.log("BUTTON - 捕获阶段")}
>
点击我观察控制台
</button>
</div>
);
}
// 点击按钮后控制台输出顺序:
// 1. "DIV - 捕获阶段" ← 先从外向内捕获
// 2. "BUTTON - 捕获阶段"
// 3. "BUTTON - 冒泡阶段" ← 再从内向外冒泡
// 4. "DIV - 冒泡阶段"
记忆口诀:"先捕获,后冒泡;外到内是捕获,内到外是冒泡。React 默认走冒泡,加个 Capture 走捕获。"
2.3 合成事件池机制的变更
什么是事件池?
在 React 16 及更早版本中,React 为了节省内存,会把合成事件对象放入一个"池子"里循环使用。事件处理函数执行完后,事件对象的所有属性会被清空(设为 null),以便下一次事件复用。
// ❌ React 16 中的经典 bug
function handleClick(e) {
setTimeout(() => {
console.log(e.target); // React 16:null!事件已被回收
}, 1000);
}
// ✅ React 16 中需要用 e.persist() 保留事件
function handleClick(e) {
e.persist(); // 从事件池中取出,不再回收
setTimeout(() => {
console.log(e.target); // 正常获取
}, 1000);
}
React 19 中的变化
React 17 开始就逐步废弃了事件池,React 19 中已彻底移除。 这意味着:
- 不再需要
e.persist() - 在异步代码(setTimeout、Promise、async/await)中访问事件属性完全安全
- 代码更简洁,不再有"事件属性神秘消失"的 bug
// ✅ React 19:直接写,完全没问题
function handleClick(e) {
setTimeout(() => {
console.log(e.target); // 正常获取,不需要 e.persist()
}, 1000);
}
2.4 React 支持的事件类型一览
React 的合成事件覆盖了几乎所有常见的浏览器事件,以下是完整的分类对照表:
鼠标事件
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
onClick |
鼠标点击(按下+释放) | 按钮点击、链接跳转 |
onDoubleClick |
鼠标双击 | 表格行双击编辑 |
onMouseDown |
鼠标按下(未释放) | 拖拽开始 |
onMouseUp |
鼠标释放 | 拖拽结束 |
onMouseEnter |
鼠标进入元素(不冒泡) | hover 高亮 |
onMouseLeave |
鼠标离开元素(不冒泡) | hover 还原 |
onMouseOver |
鼠标进入元素(会冒泡) | 经过检测 |
onMouseOut |
鼠标离开元素(会冒泡) | 离开检测 |
onMouseMove |
鼠标在元素内移动 | 鼠标跟随效果 |
onContextMenu |
鼠标右键点击 | 自定义右键菜单 |
键盘事件
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
onKeyDown |
键盘按键按下 | 快捷键监听、游戏操作 |
onKeyUp |
键盘按键释放 | 输入完成检测 |
onKeyPress |
字符键被按下(已废弃) | 不推荐使用 |
表单事件
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
onChange |
表单元素值发生变化 | 实时输入监控、受控组件 |
onInput |
输入框内容即时变化 | 输入实时搜索 |
onSubmit |
表单提交 | 登录、注册表单 |
onFocus |
元素获得焦点 | 输入框高亮 |
onBlur |
元素失去焦点 | 表单校验 |
onSelect |
选中文本 | 文本选中操作 |
onReset |
表单重置 | 清空表单 |
触摸事件(移动端)
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
onTouchStart |
手指触摸屏幕 | 触摸反馈 |
onTouchMove |
手指在屏幕上移动 | 滑动操作 |
onTouchEnd |
手指离开屏幕 | 滑动结束 |
onTouchCancel |
触摸被中断 | 异常处理 |
Pointer 事件(React 19 重点增强)
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
onPointerDown |
指针按下(鼠标/触笔/手指) | 统一按下逻辑 |
onPointerMove |
指针移动 | 统一拖拽 |
onPointerUp |
指针释放 | 统一释放逻辑 |
onPointerEnter |
指针进入 | 统一 hover |
onPointerLeave |
指针离开 | 统一离开 |
onPointerCancel |
指针操作取消 | 异常还原 |
其他常用事件
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
onScroll |
元素滚动 | 无限滚动加载、吸顶效果 |
onWheel |
鼠标滚轮 | 缩放、翻页 |
onCopy / onPaste / onCut |
剪贴板操作 | 复制粘贴控制 |
onLoad |
资源加载完成 | 图片加载完成回调 |
onError |
资源加载失败 | 图片加载失败替换 |
onAnimationEnd |
CSS 动画结束 | 动画完成后操作 |
onTransitionEnd |
CSS 过渡结束 | 过渡效果后续操作 |
速记技巧:React 事件名 =
on+ 原生事件名的驼峰形式。比如原生mouseenter→ ReactonMouseEnter,原生pointerdown→ ReactonPointerDown。
三、基础用法
3.1 基础内联箭头函数绑定
这是最简洁的写法,适合逻辑简单、一句就能写完的场景。
// 示例1:最简单的事件绑定
function SimpleClick() {
return <button onClick={() => alert("你好,React!")}>点我打招呼</button>;
}
// 逐行注释:
// 第1行:定义函数组件 SimpleClick
// 第2-6行:返回 JSX
// 第3行:onClick 是 React 的点击事件属性
// 第3行:{() => alert('你好,React!')} 是一个箭头函数,点击时执行
// 执行效果:点击按钮后弹出浏览器原生弹窗 "你好,React!"
// 示例2:内联函数操作状态
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>当前计数:{count}</p>
{/* 内联箭头函数直接调用 setCount */}
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>重置</button>
</div>
);
}
// 执行效果:
// 点击 "+1":页面数字递增 1
// 点击 "-1":页面数字递减 1
// 点击 "重置":页面数字归零
适用场景:逻辑只有一行代码、不需要复用的事件处理。
注意:如果事件处理逻辑超过 2-3 行,建议提取成独立函数(见下节),否则 JSX 会变得杂乱。
3.2 类组件中 constructor 绑定 this
这是 React 类组件中经典的"三件套"写法。
import React from "react";
class ToggleButton extends React.Component {
// 第1步:在 constructor 中初始化 state
constructor(props) {
super(props); // 必须先调用 super
this.state = {
// 初始化状态
isOn: false,
};
// 第2步:在 constructor 中绑定 this
// 这行是关键!把 handleToggle 中的 this 永久绑定到当前组件实例
this.handleToggle = this.handleToggle.bind(this);
}
// 第3步:定义事件处理方法(普通方法)
handleToggle() {
// 这里的 this 已经通过 bind 绑定,所以可以正常访问 this.setState
this.setState((prevState) => ({
isOn: !prevState.isOn,
}));
}
render() {
return (
<div>
<p>开关状态:{this.state.isOn ? "开" : "关"}</p>
{/* 使用已绑定好 this 的方法 */}
<button onClick={this.handleToggle}>切换</button>
</div>
);
}
}
// 逐行注释:
// 第4行:定义 constructor,接收 props 参数
// 第5行:super(props) 是 ES6 class 的强制要求,调用父类构造函数
// 第6-8行:this.state 初始化组件状态,isOn 默认为 false
// 第11行:关键!用 .bind(this) 将方法中的 this 绑定到当前组件实例
// 第15行:定义事件处理方法,这是一个普通函数(不是箭头函数)
// 第16行:this.setState 更新状态,prevState 是上一次的状态值
// 第23行:在 JSX 中直接传入已绑定的方法引用,不加括号
// 执行效果:每点击一次按钮,开关状态在"开"和"关"之间切换
为什么需要 bind(this)?
// 问题根源:JavaScript 中的 this 是"运行时绑定"的
// 当你写 onClick={this.handleToggle} 时,React 内部大致是这样调用的:
// var callback = this.handleToggle; // 把方法赋值给变量
// callback(); // 作为普通函数调用
// 这时 callback 内部的 this 不是组件实例,而是 undefined(严格模式)
// 所以 this.setState 会报错:Cannot read property 'setState' of undefined
// .bind(this) 的作用:创建一个新函数,这个新函数内部的 this 永远指向传入的值
// this.handleToggle = this.handleToggle.bind(this);
// 相当于说:"不管你之后怎么调用我,我的 this 始终是当前组件实例"
3.3 类组件中类属性箭头函数绑定
这是类组件中最推荐的写法,简洁且不需要手动 bind。
import React from "react";
class ToggleButton extends React.Component {
// state 可以直接用类属性初始化(无需 constructor)
state = {
isOn: false,
};
// 用箭头函数定义事件处理方法(类属性语法)
// 箭头函数没有自己的 this,它会自动"捕获"外层作用域的 this
// 这里的外层作用域就是 class 定义本身,this 就是组件实例
handleToggle = () => {
this.setState((prevState) => ({
isOn: !prevState.isOn,
}));
};
// 如果需要传参,也超级简单
handleCustomToggle = (newStatus) => {
this.setState({ isOn: newStatus });
};
render() {
return (
<div>
<p>开关状态:{this.state.isOn ? "开" : "关"}</p>
{/* 直接使用,不需要 bind! */}
<button onClick={this.handleToggle}>切换</button>
{/* 传参时用箭头函数包装一层 */}
<button onClick={() => this.handleCustomToggle(true)}>强制打开</button>
<button onClick={() => this.handleCustomToggle(false)}>强制关闭</button>
</div>
);
}
}
// 逐行注释:
// 第6行:state 直接用类属性赋值,不需要 constructor
// 第12行:handleToggle 使用箭头函数(类属性语法)
// 箭头函数自动绑定定义时的 this,即组件实例
// 第17行:需要传参时,用箭头函数包装一层
// 注意写法是 () => this.method(args),不是 this.method(args)
// 为什么推荐这种方式?
// 1. 不需要 constructor,不需要 .bind(this)
// 2. 代码更简洁清晰
// 3. 不容易忘记绑定 this
// 执行效果:点击"切换"在开关状态间切换,
// 点击"强制打开"/"强制关闭"直接设置指定状态
类属性箭头函数 vs constructor 绑定对比:
对比维度 constructor + bind 类属性箭头函数 代码量 较多(需 constructor) 较少(直接定义) 忘记绑定的风险 高(容易遗忘) 无(自动绑定) 性能 略好(原型方法共享) 略差(每个实例一份) 可读性 一般 好 结论:绝大多数场景下,推荐使用类属性箭头函数写法。性能差异微乎其微,代码可维护性更重要。
3.4 函数组件中 useCallback 优化绑定
import { useState, useCallback } from "react";
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "学习 React 事件", done: false },
{ id: 2, text: "做课后练习", done: false },
{ id: 3, text: "复习本章内容", done: false },
]);
// ✅ 使用 useCallback 包裹事件处理函数
// useCallback 会"记住"这个函数,只有依赖项变化时才重新创建
// 依赖项是空数组 [] = 这个函数永远不会变
const handleAdd = useCallback(() => {
const newId = Math.max(...todos.map((t) => t.id), 0) + 1;
setTodos((prev) => [...prev, { id: newId, text: "新任务", done: false }]);
}, [todos]); // 依赖 todos,todos 变化时重新创建函数
// ✅ 需要传参时,用 useCallback + 闭包的组合
const handleToggle = useCallback((id) => {
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo,
),
);
}, []); // 依赖为空,因为用的是 setTodos 的函数式更新
// ✅ 删除操作同理
const handleDelete = useCallback((id) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
}, []);
return (
<div>
<h3>我的待办列表</h3>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? "line-through" : "none" }}
>
{todo.text}
</span>
{/* 传参方式:用箭头函数包装 useCallback 返回的函数 */}
<button onClick={() => handleToggle(todo.id)}>
{todo.done ? "撤销" : "完成"}
</button>
<button onClick={() => handleDelete(todo.id)}>删除</button>
</li>
))}
</ul>
<button onClick={handleAdd}>添加任务</button>
</div>
);
}
// 逐行注释:
// 第15行:useCallback 的第一个参数是要"记住"的函数
// 第18行:useCallback 的第二个参数是依赖数组
// 空数组 [] = 函数在组件生命周期内永不变化
// 有依赖 [todos] = todos 变化时才重新创建函数
// 第21行:需要传参的事件处理,先用 useCallback 包裹
// 第32行:在 JSX 中用箭头函数调用:() => handleToggle(todo.id)
// useCallback 的好处:
// 1. 函数引用稳定,避免子组件不必要的重渲染
// 2. 配合 React.memo 使用时效果显著
// 3. 作为 useEffect 等 Hook 的依赖时更安全
// 执行效果:可以添加、完成、删除待办事项,界面实时更新
useCallback 什么时候用?什么时候不用?
// ❌ 不需要 useCallback 的场景:简单组件、无子组件优化需求
function SimpleComponent() {
const handleClick = () => {
console.log("clicked");
};
return <button onClick={handleClick}>点我</button>;
}
// ✅ 需要使用 useCallback 的场景:
// 场景1:事件处理函数作为子组件的 props(子组件用了 React.memo)
// 场景2:事件处理函数是 useEffect 的依赖
// 场景3:事件处理函数创建成本很高
// 简单记忆:普通按钮点击不需要 useCallback,直接写就行
3.5 传参事件的 3 种合规方式
向事件处理函数传递额外参数是实际开发中的高频需求。
方式一:箭头函数包装(推荐,最常用)
function ItemList() {
const handleDelete = (id, name, e) => {
// e 是 React 的合成事件对象,始终作为最后一个参数
console.log(`正在删除:${name}(ID: ${id})`);
console.log("事件类型:", e.type);
};
const items = [
{ id: 1, name: "第一项" },
{ id: 2, name: "第二项" },
{ id: 3, name: "第三项" },
];
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
{/* 方式一:箭头函数包装,显式传参 */}
<button onClick={(e) => handleDelete(item.id, item.name, e)}>
删除
</button>
</li>
))}
</ul>
);
}
// 执行效果:点击任意"删除"按钮后,控制台输出被删除项的 id 和 name
方式二:bind 传参
function ItemList() {
const handleDelete = (id, name, e) => {
// 注意:用 bind 传参时,事件对象 e 自动追加到参数列表末尾
console.log(`删除 ID: ${id}, 名称: ${name}`);
};
return (
<ul>
<li>
项目A
{/* 方式二:用 .bind() 传参,事件对象自动追加 */}
<button onClick={handleDelete.bind(null, 1, "项目A")}>删除</button>
</li>
<li>
项目B
<button onClick={handleDelete.bind(null, 2, "项目B")}>删除</button>
</li>
</ul>
);
}
// 注意:bind 的第一个参数是 this 值(函数组件中通常传 null)
// 后续参数会依次传给函数,事件对象 e 自动追加到最后
方式三:data 属性 + 事件对象取值
function ItemList() {
const handleDelete = (e) => {
// 通过 e.currentTarget.dataset 获取自定义数据
const id = e.currentTarget.dataset.id;
const name = e.currentTarget.dataset.name;
console.log(`删除 ID: ${id}, 名称: ${name}`);
};
return (
<ul>
<li>
项目A
{/* 方式三:在 DOM 上存数据,事件中读取 */}
<button data-id="1" data-name="项目A" onClick={handleDelete}>
删除
</button>
</li>
</ul>
);
}
错误传参写法对比
// ❌ 错误写法1:直接调用函数(会立即执行!)
<button onClick={handleDelete(item.id)}>删除</button>
// 问题:handleDelete(item.id) 在渲染时就执行了,不是点击时才执行
// 结果:页面加载时函数就执行,点击按钮反而什么都不发生
// ❌ 错误写法2:传参位置错误
<button onClick={handleDelete}>删除</button>
// 问题:缺少必要的 id 参数,函数内部拿不到 item.id
// 结果:handleDelete 收到的参数只有事件对象 e
// ❌ 错误写法3:箭头函数中忘记传事件对象
<button onClick={() => handleDelete(item.id)}>删除</button>
// 问题:某些场景下如果需要用到事件对象 e(如 e.preventDefault()),会丢失
// 结果:handleDelete 收不到事件对象
// ✅ 正确写法汇总
// 1. 箭头函数 + 显式传参(推荐)
<button onClick={(e) => handleDelete(item.id, e)}>删除</button>
// 2. bind 传参
<button onClick={handleDelete.bind(null, item.id)}>删除</button>
// 3. data 属性
<button data-id={item.id} onClick={handleDelete}>删除</button>
记忆口诀:JSX 事件里,花括号内必须是函数引用,不能是函数调用。需要传参就用箭头函数"包一层"。
四、进阶用法
4.1 Pointer Events 全量适配(React 19 重点特性)
Pointer Events 是 W3C 的现代标准,统一了鼠标、触笔、触摸等多种输入方式。React 19 对其提供了完整的支持。
为什么需要 Pointer Events?
// 旧方式:需要同时监听 mouse 和 touch 事件,代码冗长
// onMouseDown + onTouchStart
// onMouseMove + onTouchMove
// onMouseUp + onTouchEnd
// 新方式:一套 Pointer Events 搞定所有输入设备
// onPointerDown → 统一处理鼠标按下 / 手指触摸 / 触笔接触
// onPointerMove → 统一处理鼠标移动 / 手指滑动 / 触笔移动
// onPointerUp → 统一处理鼠标释放 / 手指抬起 / 触笔离开
实战示例:跨设备拖拽
import { useState, useCallback } from "react";
function DraggableBox() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
// 记录拖拽起始位置
const dragStart = useRef({ x: 0, y: 0, startX: 0, startY: 0 });
// 使用 Pointer Events 替代 Mouse/Touch Events
const handlePointerDown = useCallback(
(e) => {
// setPointerCapture:将后续的 pointer 事件都"锁定"到这个元素
// 即使指针移出元素范围,事件也会继续触发
e.currentTarget.setPointerCapture(e.pointerId);
setIsDragging(true);
dragStart.current = {
x: e.clientX,
y: e.clientY,
startX: position.x,
startY: position.y,
};
},
[position],
);
const handlePointerMove = useCallback(
(e) => {
if (!isDragging) return;
const deltaX = e.clientX - dragStart.current.x;
const deltaY = e.clientY - dragStart.current.y;
setPosition({
x: dragStart.current.startX + deltaX,
y: dragStart.current.startY + deltaY,
});
},
[isDragging],
);
const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);
return (
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
position: "relative",
left: position.x,
top: position.y,
width: 100,
height: 100,
background: isDragging ? "#1976d2" : "#42a5f5",
borderRadius: 8,
cursor: isDragging ? "grabbing" : "grab",
touchAction: "none", // 阻止浏览器默认的触摸滚动行为
userSelect: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontWeight: "bold",
}}
>
拖我
</div>
);
}
// 关键知识点:
// 1. setPointerCapture(pointerId):将 pointer 事件锁定到当前元素
// 解决了"拖拽太快导致鼠标移出元素,事件丢失"的问题
// 2. touchAction: 'none':阻止移动端的默认触摸行为(页面滚动等)
// 3. 通过 e.pointerType 可以判断当前输入设备类型:
// 'mouse' = 鼠标 | 'pen' = 触笔 | 'touch' = 手指
// 执行效果:可以用鼠标或手指(移动端)拖拽蓝色方块在页面上任意移动
Pointer Events 与传统事件的对应关系
// 写一次 Pointer Events,兼容所有设备
// 传统的 Mouse Events 和 Touch Events 在 React 19 中仍然可用
// 但推荐新项目统一使用 Pointer Events
function InputDemo() {
const handlePointerDown = (e) => {
// e.pointerType: 'mouse' | 'pen' | 'touch'
console.log(`输入设备类型:${e.pointerType}`);
// e.pressure: 0~1,压感值(触笔支持)
console.log(`压力值:${e.pressure}`);
// e.pointerId: 区分多点触摸时的手指
console.log(`指针 ID:${e.pointerId}`);
};
return (
<div
onPointerDown={handlePointerDown}
style={{ padding: 50, border: "2px solid #333", borderRadius: 8 }}
>
用鼠标 / 触笔 / 手指点我试试
</div>
);
}
4.2 事件委托的最佳实践
什么是事件委托?
类比:学校传达室统一收快递,然后根据收件人姓名分发给每个学生,而不是每个学生都在校门口等着自己的快递。
React 内部已经在根节点做了全局事件委托,但组件级别你仍然可以做"局部分发"来优化性能。
场景一:长列表的事件委托
import { useState, useCallback } from 'react';
function LongList() {
const [items] = useState(() =>
Array.from({ length: 1000 }, (_, i) => ({
id: i + 1,
name: `项目 ${i + 1}`,
}))
);
const [selectedId, setSelectedId] = useState(null);
// ✅ 事件委托:在父元素上绑定一个事件处理函数
const handleListClick = useCallback((e) => {
// 找到被点击的 li 元素
const li = e.target.closest('li');
if (!li) return; // 点击的不是 li,忽略
// 从 DOM 上获取数据
const id = Number(li.dataset.id);
setSelectedId(id);
console.log(`选中了项目 ${id}`);
}, []);
return (
<div>
<p>当前选中:{selectedId ?? '无'}</p>
{/* 在 ul 上绑定一个事件,而不是每个 li 上绑一个 */}
<ul onClick={handleListClick} style={{ maxHeight: 400, overflow: 'auto' }}>
{items.map(item => (
<li
key={item.id}
data-id={item.id} {/* 数据存在 DOM 属性上 */}
style={{
padding: '8px 12px',
background: selectedId === item.id ? '#bbdefb' : 'transparent',
cursor: 'pointer',
borderBottom: '1px solid #eee',
}}
>
{item.name}
</li>
))}
</ul>
</div>
);
}
// 性能对比:
// ❌ 不委托:1000 个 li → 1000 个 onClick → 1000 个事件监听器
// ✅ 委托:1 个 ul + 1 个 onClick → 1 个事件监听器
// 内存节省:999 个事件监听器的内存!列表越长效果越明显
// 执行效果:点击任意列表项高亮选中,控制台输出选中信息
场景二:动态列表的事件委托
function DynamicList() {
const [items, setItems] = useState([
{ id: 1, text: "第一项" },
{ id: 2, text: "第二项" },
]);
// 事件委托:统一在 ul 上处理所有操作
const handleAction = useCallback(
(e) => {
const action = e.target.dataset.action;
const id = Number(e.target.closest("li").dataset.id);
switch (action) {
case "delete":
setItems((prev) => prev.filter((item) => item.id !== id));
break;
case "edit":
setItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, text: `${item.text}(已编辑)` }
: item,
),
);
break;
case "copy":
const item = items.find((i) => i.id === id);
if (item) {
setItems((prev) => [
...prev,
{ id: Date.now(), text: `${item.text} 副本` },
]);
}
break;
default:
break;
}
},
[items],
);
const handleAdd = () => {
setItems((prev) => [
...prev,
{ id: Date.now(), text: `新项目 ${prev.length + 1}` },
]);
};
return (
<div>
{/* 所有操作委托到 ul 上 */}
<ul onClick={handleAction}>
{items.map((item) => (
<li key={item.id} data-id={item.id}>
<span>{item.text}</span>
<button data-action="edit">编辑</button>
<button data-action="copy">复制</button>
<button data-action="delete">删除</button>
</li>
))}
</ul>
<button onClick={handleAdd}>添加新项</button>
</div>
);
}
// 说明:即使 ul 内部的 button 在渲染之后(动态添加的),
// 事件委托也能正常工作,因为事件冒泡会到达 ul 的监听器。
// 这就是事件委托的核心优势:对新添加的元素也自动生效。
// 执行效果:可添加、编辑、复制、删除列表项,所有操作通过 ul 上的一个监听器处理
4.3 阻止默认行为与阻止冒泡
e.preventDefault() —— 阻止默认行为
类比:快递小哥默认会按门铃,但你告诉小哥"别按门铃,放门口就行"。
适用场景:
- 阻止
<a>标签的页面跳转 - 阻止表单的默认提交刷新页面
- 阻止右键菜单弹出
- 阻止空格键滚动页面
function PreventDefaultDemo() {
// 场景1:阻止链接跳转(SAP单页应用中非常常见)
const handleLinkClick = (e) => {
e.preventDefault(); // 阻止浏览器的默认跳转行为
console.log("链接被点击,但不会跳转");
// 你可以用 React Router 做路由跳转
// navigate('/target-page');
};
// 场景2:阻止表单默认提交
const handleSubmit = (e) => {
e.preventDefault(); // 阻止表单提交导致的页面刷新
const formData = new FormData(e.target);
console.log("表单数据:", Object.fromEntries(formData));
// 用 fetch/axios 发异步请求
};
// 场景3:阻止右键菜单
const handleContextMenu = (e) => {
e.preventDefault();
// 显示自定义右键菜单
console.log("自定义右键菜单应在此处显示");
};
return (
<div>
<a href="https://example.com" onClick={handleLinkClick}>
点我不会跳转
</a>
<form onSubmit={handleSubmit}>
<input name="username" placeholder="用户名" />
<button type="submit">提交(不会刷新页面)</button>
</form>
<div
onContextMenu={handleContextMenu}
style={{ background: "#f5f5f5", padding: 40, marginTop: 10 }}
>
在此区域内右键,不会弹出浏览器菜单
</div>
</div>
);
}
// 执行效果:
// 1. 点击链接 → 页面不跳转,控制台输出日志
// 2. 提交表单 → 页面不刷新,控制台输出表单数据
// 3. 在灰色区域右键 → 不会弹出浏览器右键菜单
e.stopPropagation() —— 阻止事件冒泡
类比:小组长知道了一个消息,但他决定"到我为止,不向上汇报了"。
function StopPropagationDemo() {
return (
<div
onClick={() => console.log("外层 DIV 被点击")}
style={{
padding: 40,
background: "#fff3e0",
border: "2px solid #ff9800",
}}
>
<p>点击橙色边框内的任意位置</p>
{/* 不阻止冒泡:点击会先输出 Button,再输出 DIV */}
<button onClick={() => console.log("【普通按钮】被点击")}>
普通按钮(会冒泡)
</button>{" "}
{/* 阻止冒泡:点击只输出 Button,不输出 DIV */}
<button
onClick={(e) => {
e.stopPropagation(); // 阻止事件继续向上冒泡
console.log("【阻止冒泡按钮】被点击");
}}
>
阻止冒泡按钮(不冒泡)
</button>
</div>
);
}
// 执行效果:
// 点击"普通按钮" → 控制台输出:
// 1. "【普通按钮】被点击"
// 2. "外层 DIV 被点击"
// 点击"阻止冒泡按钮" → 控制台只有:
// 1. "【阻止冒泡按钮】被点击"
preventDefault 和 stopPropagation 的区别
// 核心区别对比
function DifferenceDemo() {
return (
<div>
{/* e.preventDefault():阻止浏览器的默认行为,不阻止冒泡 */}
<div onClick={() => console.log("DIV 被点击")}>
<a
href="https://example.com"
onClick={(e) => {
e.preventDefault(); // 只阻止跳转,不阻止冒泡
}}
>
点我:不会跳转,但会冒泡到 DIV
</a>
</div>
{/* e.stopPropagation():阻止事件冒泡,不阻止默认行为 */}
<div onClick={() => console.log("DIV 被点击")}>
<a
href="https://example.com"
onClick={(e) => {
e.stopPropagation(); // 只阻止冒泡,不阻止跳转
}}
>
点我:会跳转,但不会冒泡到 DIV
</a>
</div>
{/* 两者都要:既阻止默认行为,又阻止冒泡 */}
<div onClick={() => console.log("DIV 被点击")}>
<a
href="https://example.com"
onClick={(e) => {
e.preventDefault(); // 阻止跳转
e.stopPropagation(); // 阻止冒泡
}}
>
点我:既不会跳转,也不会冒泡
</a>
</div>
</div>
);
}
// 记忆对照表
// ┌──────────────────────┬────────────────────┬──────────────────────┐
// │ │ preventDefault() │ stopPropagation() │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │ 阻止什么? │ 浏览器的默认行为 │ 事件向上冒泡 │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │ 举例 │ 链接跳转、表单提交 │ 父元素也被触发 │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │ 影响父元素监听器? │ ❌ 不影响 │ ✅ 父元素收不到 │
// ├──────────────────────┼────────────────────┼──────────────────────┤
// │ 影响子元素默认行为? │ ✅ 阻止 │ ❌ 不影响 │
// └──────────────────────┴────────────────────┴──────────────────────┘
五、注意事项
5.1 this 绑定失效的 4 种常见场景与解决方案
场景一:类组件中忘记绑定 this
// ❌ 错误写法
class Counter extends React.Component {
state = { count: 0 };
handleClick() {
// 报错!this 是 undefined
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={this.handleClick}>+1</button>;
}
}
// 错误原因:
// 当 JSX 中写 onClick={this.handleClick} 时,React 拿到的是方法的引用
// 调用时 this 上下文丢失,变成 undefined(ES6 class 严格模式)
// ✅ 解决方案1:constructor 中 bind
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.handleClick = this.handleClick.bind(this); // 绑定 this
}
handleClick() {
/* ... */
}
}
// ✅ 解决方案2:类属性箭头函数
class Counter extends React.Component {
state = { count: 0 };
handleClick = () => {
// 箭头函数自动绑定
this.setState({ count: this.state.count + 1 });
};
}
// ✅ 解决方案3:JSX 中箭头函数
class Counter extends React.Component {
state = { count: 0 };
handleClick() {
/* ... */
}
render() {
return <button onClick={() => this.handleClick()}>+1</button>;
}
}
场景二:回调中将方法传给别的函数
// ❌ 错误写法
class Timer extends React.Component {
handleTick() {
console.log('tick');
}
componentDidMount() {
// 这里 this 也会丢失!
setInterval(this.handleTick, 1000);
}
}
// ✅ 解决方案
componentDidMount() {
setInterval(() => this.handleTick(), 1000); // 箭头函数包装
}
场景三:解构赋值导致 this 丢失
// ❌ 错误写法
class Form extends React.Component {
handleSubmit() {
this.setState({ submitted: true });
}
render() {
const { handleSubmit } = this; // 解构后 this 丢失!
return <button onClick={handleSubmit}>提交</button>;
}
}
// ✅ 解决方案:不要解构事件处理方法
// 或者用箭头函数定义方法
场景四:map 回调中的 this
// ❌ 错误写法
class List extends React.Component {
handleItemClick(id) {
console.log(id);
}
render() {
return this.props.items.map(function(item) {
// function 关键字定义的函数,this 指向全局 / undefined
return <button onClick={() => this.handleItemClick(item.id)}>点我</button>;
});
}
}
// ✅ 解决方案:使用箭头函数作为 map 回调
render() {
return this.props.items.map((item) => {
// 箭头函数的 this 继承自外层(render 方法的作用域)
return <button onClick={() => this.handleItemClick(item.id)}>点我</button>;
});
}
5.2 合成事件与原生事件混用的冲突问题
// ❌ 危险写法:同时绑定合成事件和原生事件
function MixedEvents() {
const divRef = useRef(null);
useEffect(() => {
const div = divRef.current;
// 原生事件监听器
div.addEventListener("click", () => {
console.log("原生 click 事件");
});
return () => div.removeEventListener("click", () => {});
}, []);
return (
<div ref={divRef} onClick={() => console.log("React 合成事件")}>
点我看看输出顺序
</div>
);
}
// 问题:
// React 17+ 中,合成事件绑定在根节点(#root),而非具体 DOM 上
// 原生事件直接绑定在 DOM 上
// 导致执行顺序可能与预期不同:
//
// 点击后输出顺序:
// 1. "原生 click 事件"(直接绑定在 DOM 上,最先触发)
// 2. "React 合成事件"(通过根节点事件委托触发)
// ✅ 解决方案:只使用一种事件系统
// 推荐:在 React 中统一使用合成事件,不混用
// 如果必须使用原生事件(如监听 window/document),注意执行时序
// ✅ 正确的原生事件监听方式(仅限必要场景)
function ScrollListener() {
useEffect(() => {
const handleScroll = () => {
console.log("页面滚动了", window.scrollY);
};
// 监听 window 上的原生事件(React 合成事件不覆盖的场景)
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return <div style={{ height: 2000 }}>往下滚动试试</div>;
}
原则:在 React 中能用合成事件就不要用原生事件。非要用原生事件时,确认你真的有这个需求。
5.3 事件处理函数中异步更新状态的取值陷阱
import { useState } from "react";
function AsyncBugDemo() {
const [count, setCount] = useState(0);
// ❌ 错误写法:异步操作中使用"当前值"
const handleClickWrong = () => {
setCount(count + 1); // count = 0 → setCount(1)
setCount(count + 1); // count 还是 0!→ setCount(1) ← 注意!
setCount(count + 1); // count 还是 0!→ setCount(1) ← 注意!
// 期望结果:3,实际结果:1
};
// ✅ 正确写法:使用函数式更新
const handleClickCorrect = () => {
setCount((prev) => prev + 1); // prev = 0 → 1
setCount((prev) => prev + 1); // prev = 1 → 2
setCount((prev) => prev + 1); // prev = 2 → 3
// 结果:3 ✓
};
return (
<div>
<p>当前计数:{count}</p>
<button onClick={handleClickWrong}>错误写法(期望+3,实际+1)</button>
<button onClick={handleClickCorrect}>正确写法(+3)</button>
</div>
);
}
// 错误原因:
// 事件处理函数在一次渲染中"捕获"了当前的 count 值
// 即使调用了 setCount,当前函数作用域中的 count 不会改变
// React 会批量处理状态更新,但每次都用同一个"闭包中的旧值"
// 函数式更新 prev => prev + 1 则不同:
// React 保证 prev 始终是上次更新后的最新值
// 另外,在 setTimeout/Promise 中也容易出现同样的问题:
const handleAsyncClick = () => {
setTimeout(() => {
// ❌ count 是闭包中"过时的"值
console.log("count:", count);
}, 3000);
};
// ✅ 解决方案:用 useEffect 同步最新值 或 使用 ref
补充:使用 useEffect 追踪最新状态
function LatestValueDemo() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// 保持 ref 与 state 同步
useEffect(() => {
countRef.current = count;
}, [count]);
const handleClick = () => {
setCount((prev) => prev + 1);
// ✅ 用 ref 在异步中读取最新值
setTimeout(() => {
console.log("3秒后的最新 count:", countRef.current);
}, 3000);
};
return (
<div>
<p>count: {count}</p>
<button onClick={handleClick}>+1(3秒后打印最新值)</button>
</div>
);
}
// 执行效果:
// 在3秒内可以连续点击多次,3秒后打印的是最新的 count 值,而非点击时的值
5.4 匿名函数、内联函数导致的不必要重渲染
import { useState, useCallback, memo } from "react";
// 使用 React.memo 的子组件:只有 props 变化时才重新渲染
const ExpensiveButton = memo(function ExpensiveButton({ onClick, label }) {
console.log(`ExpensiveButton "${label}" 渲染了`);
return <button onClick={onClick}>{label}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState("");
// ❌ 内联箭头函数:每次渲染都创建新函数
// 导致 ExpensiveButton 即使 props 逻辑没变也会重渲染
const badHandleClick = () => setCount((c) => c + 1);
// ✅ useCallback:函数引用稳定,不会触发子组件重渲染
const goodHandleClick = useCallback(() => {
setCount((c) => c + 1);
}, []); // 空依赖 → 函数永不变化
return (
<div>
<p>count: {count}</p>
{/* ❌ 坏:text 变化导致 Parent 渲染,ExpensiveButton 也跟着渲染 */}
<ExpensiveButton onClick={badHandleClick} label="坏写法(会多余渲染)" />
{/* ✅ 好:text 变化时 ExpensiveButton 不会渲染 */}
<ExpensiveButton onClick={goodHandleClick} label="好写法(已优化)" />
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="输入文字测试"
/>
</div>
);
}
// 演示流程:
// 1. 在输入框中打字 → text 变化 → Parent 重新渲染
// 2. badHandleClick 重新创建(新引用)→ ExpensiveButton props 变化 → 重渲染 ❌
// 3. goodHandleClick 是同一个引用 → ExpensiveButton props 没变 → 不渲染 ✓
// 这就是 useCallback + React.memo 组合拳的精髓
// 执行效果:打开控制台,在输入框中打字:
// "坏写法"按钮的日志每次都出现(多余渲染)
// "好写法"按钮的日志只在首次出现(已优化)
什么时候不需要优化?
// ✅ 以下情况不必优化,直接内联即可:
// 1. 组件很简单,渲染成本极低
// 2. 没有嵌套子组件
// 3. 事件处理函数不传给子组件
function SimpleButton() {
const [count, setCount] = useState(0);
// 这里不需要 useCallback,因为 onClick 没有传给子组件
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
// 过度优化反而增加代码复杂度!按需使用即可。
5.5 React 19 中移除 event.persist() 的适配说明
旧版本中的 persist 是干什么的?
在 React 16 及更早版本中,合成事件对象会被回收复用(事件池机制)。
// React 16 中的典型问题
function SearchInput() {
const handleChange = (e) => {
// ❌ React 16 中,异步访问事件会报错或返回 null
setTimeout(() => {
console.log(e.target.value); // 报错!事件已被回收
}, 100);
};
return <input onChange={handleChange} />;
}
// React 16 中的解决方案
function SearchInput() {
const handleChange = (e) => {
e.persist(); // 从池中"取出"事件对象,阻止回收
setTimeout(() => {
console.log(e.target.value); // 正常获取
}, 100);
};
return <input onChange={handleChange} />;
}
React 19 中的变化
// React 19:彻底不需要 e.persist() 了
function SearchInput() {
const handleChange = (e) => {
// ✅ 直接异步访问,完全安全
setTimeout(() => {
console.log(e.target.value); // 正常工作
}, 1000);
};
return <input onChange={handleChange} />;
}
// 但注意:虽然不需要 persist,但更好的写法是"提前取值"
function SearchInputBetter() {
const handleChange = (e) => {
const value = e.target.value; // 先存起来
setTimeout(() => {
console.log(value); // 用存好的值,意图更清晰
}, 1000);
};
return <input onChange={handleChange} />;
}
// 迁移建议:
// 1. 删除代码中所有的 e.persist() 调用
// 2. 事件对象在异步操作中可以直接使用
// 3. 不过最好养成"先存值再用"的习惯,代码更清晰
5.6 表单事件中 onChange 与 onInput 的区别
function InputEventDemo() {
const [onChangeValue, setOnChangeValue] = useState("");
const [onInputValue, setOnInputValue] = useState("");
return (
<div>
{/* onChange:值确实变化 + 元素失去焦点时才触发(原生行为) */}
{/* 但在 React 中,onChange 被"改造"为每次输入都触发 */}
<div>
<label>onChange:</label>
<input
value={onChangeValue}
onChange={(e) => {
setOnChangeValue(e.target.value);
console.log("onChange 触发");
}}
/>
<span>→ {onChangeValue}</span>
</div>
{/* onInput:每次输入即时触发(原生行为) */}
<div>
<label>onInput:</label>
<input
value={onInputValue}
onInput={(e) => {
setOnInputValue(e.target.value);
console.log("onInput 触发");
}}
/>
<span>→ {onInputValue}</span>
</div>
</div>
);
}
// 在 React 中:
// onChange 和 onInput 的行为基本相同(每输入一个字符都触发)
// React 对 onChange 做了"语义增强",使其行为等同于原生 onInput
//
// 但在原生 DOM 中:
// onInput:每输入一个字符立即触发
// onChange:输入内容变化 + 元素失去焦点后才触发
//
// 推荐:React 表单统一使用 onChange,因为它是最常见的 React 写法
// onInput 只在需要与第三方非 React 库配合时使用
// ❌ 常见错误:受控组件中忘记绑定 onChange
function BrokenInput() {
const [value, setValue] = useState("");
return <input value={value} />;
// 报错:You provided a `value` prop without an `onChange` handler
// 原因:设置了 value(受控组件)就必须提供 onChange
}
// ✅ 正确写法
function WorkingInput() {
const [value, setValue] = useState("");
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
5.7 事件绑定中传参错误导致函数提前执行
这是新手最容易踩的坑。
function PassingArgsPitfall() {
const [message, setMessage] = useState("");
const showMessage = (text) => {
setMessage(text);
console.log("显示消息:", text);
};
return (
<div>
<p>当前消息:{message || "(无)"}</p>
{/* ❌ 错误:函数在渲染时就执行了! */}
<button onClick={showMessage("你好!")}>打招呼(错误写法)</button>
{/* ✅ 正确:用箭头函数包装,点击时才执行 */}
<button onClick={() => showMessage("你好!")}>打招呼(正确写法1)</button>
{/* ✅ 正确:用 bind 包装 */}
<button onClick={showMessage.bind(null, "你好!")}>
打招呼(正确写法2)
</button>
</div>
);
}
// 错误原因图解:
// onClick={showMessage('你好!')}
// └────────┬──────┘
// 函数调用!
// 这不是传引用,而是直接调用了 showMessage 函数
// 调用发生在 JSX 解析时(即渲染阶段),而非点击时
// onClick={() => showMessage('你好!')}
// └──────────┬──────────┘
// 函数引用!
// 这是一个箭头函数的引用,点击时才会调用内部的 showMessage
// 诊断方法:如果页面一加载就看到函数被执行了(弹窗/日志/状态变化),
// 基本可以确定是 onClick={fn()} 而非 onClick={fn} 的锅
六、教学辅助
6.1 课堂实操练习
练习一:基础绑定练习 —— 评论点赞按钮
题目描述:实现一个评论区,每条评论有一个点赞按钮,点击后点赞数 +1,再次点击取消点赞(-1)。
要求:
- 使用函数组件 + useState
- 每条评论初始点赞数为 0
- 点赞状态用不同的按钮颜色区分(已点赞蓝色,未点赞灰色)
参考答案:
import { useState } from "react";
function CommentList() {
const [comments, setComments] = useState([
{ id: 1, text: "这篇文章写得真好!", likes: 0, liked: false },
{ id: 2, text: "学习了,感谢分享!", likes: 0, liked: false },
{ id: 3, text: "请问有配套的视频教程吗?", likes: 0, liked: false },
]);
const handleLike = (id) => {
setComments((prev) =>
prev.map((comment) => {
if (comment.id !== id) return comment;
return {
...comment,
liked: !comment.liked,
likes: comment.liked ? comment.likes - 1 : comment.likes + 1,
};
}),
);
};
return (
<div>
<h3>评论区</h3>
{comments.map((comment) => (
<div
key={comment.id}
style={{
border: "1px solid #ddd",
padding: 12,
marginBottom: 8,
borderRadius: 8,
}}
>
<p>{comment.text}</p>
<button
onClick={() => handleLike(comment.id)}
style={{
background: comment.liked ? "#1976d2" : "#e0e0e0",
color: comment.liked ? "#fff" : "#333",
border: "none",
padding: "6px 16px",
borderRadius: 4,
cursor: "pointer",
}}
>
👍 {comment.likes}
</button>
</div>
))}
</div>
);
}
// 执行效果:
// 每条评论下方有点赞按钮,点击数字 +1 且按钮变蓝,再点击 -1 且按钮变灰
练习二:传参事件练习 —— 多按钮操作面板
题目描述:创建一个商品管理面板,每个商品有"编辑""上架/下架""删除"三个操作按钮,要求使用事件委托方式实现。
要求:
- 使用事件委托(在父元素上统一绑定事件)
- 按钮携带商品 ID 和操作类型参数
- 编辑操作弹出商品名称
- 上架/下架按钮文字动态切换
- 删除按钮点击后移除商品
参考答案:
import { useState, useCallback } from "react";
function ProductPanel() {
const [products, setProducts] = useState([
{ id: 1, name: "React 从入门到精通", status: "上架" },
{ id: 2, name: "JavaScript 高级程序设计", status: "下架" },
{ id: 3, name: "CSS 世界", status: "上架" },
]);
// 事件委托:在父 div 上统一处理
const handleAction = useCallback(
(e) => {
const btn = e.target.closest("button");
if (!btn) return; // 点击的不是按钮,忽略
const action = btn.dataset.action;
const id = Number(btn.dataset.id);
switch (action) {
case "edit":
const product = products.find((p) => p.id === id);
if (product) alert(`编辑商品:${product.name}`);
break;
case "toggle":
setProducts((prev) =>
prev.map((p) =>
p.id === id
? { ...p, status: p.status === "上架" ? "下架" : "上架" }
: p,
),
);
break;
case "delete":
setProducts((prev) => prev.filter((p) => p.id !== id));
break;
default:
break;
}
},
[products],
);
return (
<div>
<h3>商品管理面板</h3>
<div onClick={handleAction}>
{products.map((product) => (
<div
key={product.id}
style={{
border: "1px solid #e0e0e0",
padding: 12,
marginBottom: 8,
borderRadius: 6,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{product.name}
<span
style={{
marginLeft: 8,
color: product.status === "上架" ? "#4caf50" : "#f44336",
fontSize: 12,
}}
>
[{product.status}]
</span>
</span>
<div>
<button data-action="edit" data-id={product.id}>
编辑
</button>
<button data-action="toggle" data-id={product.id}>
{product.status === "上架" ? "下架" : "上架"}
</button>
<button data-action="delete" data-id={product.id}>
删除
</button>
</div>
</div>
))}
</div>
</div>
);
}
// 执行效果:
// 点击"编辑"弹出商品名称
// 点击"上架/下架"切换商品状态
// 点击"删除"移除商品
// 所有操作通过父 div 上的一个事件监听器处理(事件委托)
练习三:性能优化练习 —— 可拖拽排序列表
题目描述:实现一个待办列表,支持使用 Pointer Events 拖拽排序。
要求:
- 使用 Pointer Events(而非 Mouse Events)
- 使用
useCallback优化事件处理函数 - 拖拽时有视觉反馈(被拖拽项半透明、插入位置有指示线)
参考答案:
import { useState, useCallback, useRef } from "react";
function DraggableTodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "学习 React 事件绑定" },
{ id: 2, text: "完成课后练习" },
{ id: 3, text: "阅读 React 19 更新日志" },
{ id: 4, text: "写项目实践代码" },
]);
const dragItem = useRef(null); // 当前拖拽的项
const dragOverItem = useRef(null); // 拖拽到的目标位置
const handlePointerDown = useCallback((e, index) => {
dragItem.current = index;
e.currentTarget.style.opacity = "0.4";
e.currentTarget.setPointerCapture(e.pointerId);
}, []);
const handlePointerUp = useCallback((e) => {
e.currentTarget.style.opacity = "1";
e.currentTarget.releasePointerCapture(e.pointerId);
dragItem.current = null;
dragOverItem.current = null;
}, []);
const handlePointerEnter = useCallback((e, index) => {
dragOverItem.current = index;
// 拖拽经过时高亮
e.currentTarget.style.borderTop = "2px solid #1976d2";
}, []);
const handlePointerLeave = useCallback((e) => {
e.currentTarget.style.borderTop = "none";
}, []);
const handlePointerOver = useCallback((e) => {
e.preventDefault();
if (
dragItem.current !== null &&
dragItem.current !== dragOverItem.current
) {
setTodos((prev) => {
const newTodos = [...prev];
const draggedItem = newTodos.splice(dragItem.current, 1)[0];
newTodos.splice(dragOverItem.current, 0, draggedItem);
dragItem.current = dragOverItem.current;
return newTodos;
});
}
}, []);
return (
<div>
<h3>待办列表(拖拽排序)</h3>
<p style={{ color: "#666", fontSize: 14 }}>
按住某一项拖动到新位置来排序
</p>
{todos.map((todo, index) => (
<div
key={todo.id}
onPointerDown={(e) => handlePointerDown(e, index)}
onPointerUp={handlePointerUp}
onPointerEnter={(e) => handlePointerEnter(e, index)}
onPointerLeave={handlePointerLeave}
onPointerOver={handlePointerOver}
style={{
padding: "12px 16px",
background: "#f5f5f5",
marginBottom: 4,
borderRadius: 4,
cursor: "grab",
userSelect: "none",
touchAction: "none",
borderTop: "2px solid transparent",
}}
>
{todo.text}
</div>
))}
</div>
);
}
// 执行效果:
// 拖拽任意待办项到新位置,列表自动重新排序
// 拖拽中的项半透明,目标位置有蓝色指示线
6.2 常见问题 FAQ
Q1:为什么我的事件处理函数在页面加载时就执行了?
答:99% 的情况是因为你在 JSX 中写了 onClick={handleClick()} 而不是 onClick={handleClick}。前者是函数调用,渲染时就执行;后者是函数引用,点击时才执行。需要传参时用 onClick={() => handleClick(param)}。
Q2:类组件中什么时候用 bind(this),什么时候不用?
答:如果你的事件处理方法是普通函数(handleClick() {}),就必须绑:在 constructor 中 bind(this) 或 JSX 中用箭头函数包装。如果你用的是类属性箭头函数(handleClick = () => {}),箭头函数自动绑定 this,不需要 bind。
Q3:useCallback 到底什么时候用?是不是所有事件处理函数都应该包一层?
答:不是!滥用 useCallback 反而可能降低性能。仅在以下场景使用:
- 函数作为 prop 传递给用了
React.memo的子组件 - 函数是
useEffect/useMemo的依赖项 - 函数创建成本真的很高
普通按钮的 onClick 直接写内联函数就行,不要过度优化。
Q4:为什么 React 中 onChange 的行为和原生 DOM 不一样?
答:React 对 onChange 做了"语义增强"。在原生 DOM 中,onChange 需要输入框失焦才触发;但在 React 中,onChange 每输入一个字符就触发(等同于原生 onInput)。这是 React 团队为了和"受控组件"模式保持一致而做的设计决策。
Q5:e.preventDefault() 和 e.stopPropagation() 有什么区别?
答:
e.preventDefault():阻止浏览器的默认行为(如链接跳转、表单提交),但不阻止事件冒泡到父元素e.stopPropagation():阻止事件向上冒泡到父元素,但不阻止浏览器的默认行为- 两者互不影响,可以同时使用
Q6:React 19 中还需要 e.persist() 吗?
答:不需要。事件池机制在 React 17 开始已被移除,React 19 中彻底不存在。你在异步代码(setTimeout、Promise)中直接访问事件属性完全安全。
Q7:为什么我连续调用多次 setState,只有最后一次生效?
答:因为你在事件处理函数中直接用了 state 变量而非函数式更新。例如 setCount(count + 1) 中 count 是闭包中的旧值。应该改为 setCount(prev => prev + 1),这样可以保证每次拿到的都是最新值。
Q8:事件委托是什么?React 已经帮我做了吗?
答:React 内部已经在根节点做了全局事件委托(React 17+ 绑定在 #root 上)。但你仍然可以在组件级别做局部的"事件委托"来优化性能——比如在一个 ul 上绑定一个事件而不是在每个 li 上各绑一个,减少事件处理函数的数量。
Q9:什么情况下事件处理函数中的 this 会是 undefined?
答:在类组件中,当你把一个普通方法直接作为事件处理函数传出时(onClick={this.handleClick}),React 内部调用该方法时 this 上下文会丢失。因为 ES6 class 默认使用严格模式,所以 this 是 undefined 而不是 window。解决方案是用箭头函数(自动绑定)或在 constructor 中手动 bind(this)。
Q10:合成事件和原生事件可以混用吗?会有什么问题?
答:技术上可以混用,但强烈不推荐。因为 React 17+ 将合成事件统一委托到根节点,而原生事件直接绑定在 DOM 上,两者的执行顺序不同,容易产生难以调试的 bug。在 React 项目中统一使用合成事件即可满足 95% 的需求。确实需要原生事件的场景(如 window.addEventListener('scroll'))在 useEffect 中处理,并记得在 cleanup 中移除。
6.3 知识点思维导图(文字版)
React 19 事件绑定 —— 知识体系总览
│
├── 一、核心概念
│ ├── 合成事件(SyntheticEvent)
│ │ ├── 定义:React 封装的跨浏览器事件对象
│ │ ├── 优势:统一 API、性能优化、跨浏览器兼容
│ │ └── 命名规则:驼峰命名(onClick、onChange、onKeyDown)
│ │
│ ├── 事件传播
│ │ ├── 捕获阶段(Capture Phase):外 → 内
│ │ ├── 目标阶段(Target Phase):到达目标元素
│ │ └── 冒泡阶段(Bubble Phase):内 → 外(React 默认)
│ │
│ ├── 事件池(已移除)
│ │ ├── React 16:事件对象被回收,需 e.persist()
│ │ ├── React 17+:逐步移除事件池
│ │ └── React 19:彻底移除,无需 e.persist()
│ │
│ └── 事件类型分类
│ ├── 鼠标事件:onClick、onDoubleClick、onMouseEnter...
│ ├── 键盘事件:onKeyDown、onKeyUp
│ ├── 表单事件:onChange、onSubmit、onFocus、onBlur
│ ├── 触摸事件:onTouchStart、onTouchMove、onTouchEnd
│ ├── Pointer 事件:onPointerDown、onPointerMove、onPointerUp(React 19 增强)
│ └── 其他:onScroll、onWheel、onCopy、onLoad、onError
│
├── 二、基础用法
│ ├── 函数组件
│ │ ├── 内联箭头函数:onClick={() => doSomething()}
│ │ ├── 独立函数引用:onClick={handleClick}
│ │ └── useCallback 优化:const fn = useCallback(() => {}, [deps])
│ │
│ ├── 类组件
│ │ ├── constructor + bind:this.fn = this.fn.bind(this)
│ │ └── 类属性箭头函数:fn = () => { this.setState(...) }
│ │
│ └── 传参方式
│ ├── 箭头函数包装:onClick={(e) => fn(id, e)}
│ ├── bind 传参:onClick={fn.bind(null, id)}
│ └── data 属性:onClick={fn} + e.target.dataset.id
│
├── 三、进阶用法
│ ├── Pointer Events(React 19 重点)
│ │ ├── 统一鼠标/触笔/触摸
│ │ ├── setPointerCapture:锁定指针
│ │ └── pointerType 区分设备类型
│ │
│ ├── 事件委托
│ │ ├── 原理:利用冒泡,在父元素统一处理
│ │ ├── 场景:长列表、动态列表
│ │ └── 实现:e.target.closest + dataset
│ │
│ └── 阻止行为
│ ├── e.preventDefault():阻止默认行为(跳转/提交)
│ └── e.stopPropagation():阻止事件冒泡
│
├── 四、注意事项(7大坑点)
│ ├── 1. this 绑定失效(4种场景)
│ │ ├── 忘记 bind → constructor 绑定或箭头函数
│ │ ├── 回调传参丢失 → 箭头函数包装
│ │ ├── 解构赋值丢失 → 不解构事件方法
│ │ └── map 中 function 关键字 → 改用箭头函数
│ │
│ ├── 2. 合成事件与原生事件混用冲突
│ │ └── 解决方案:统一用合成事件
│ │
│ ├── 3. 异步更新取值陷阱
│ │ └── 解决方案:函数式更新 prev => prev + 1
│ │
│ ├── 4. 匿名函数导致重渲染
│ │ └── 解决方案:useCallback + React.memo
│ │
│ ├── 5. event.persist() 已移除
│ │ └── 适配:删除 persist 调用,直接异步访问
│ │
│ ├── 6. onChange vs onInput
│ │ └── React 中行为相同,推荐用 onChange
│ │
│ └── 7. 传参错误导致提前执行
│ └── onClick={fn()} → onClick={() => fn()}
│
└── 五、最佳实践总结
├── 函数组件用箭头函数或 useCallback
├── 类组件用类属性箭头函数
├── 列表用事件委托
├── 需要阻止时才用 preventDefault/stopPropagation
├── 异步更新用函数式 setState
└── 不过度优化,简单场景直接内联
本章小结:事件绑定是 React 中最基础也最容易踩坑的知识点。记住三个核心公式就能覆盖 90% 的场景:
- 函数组件:
onClick={handleFn}+ 传参用onClick={() => handleFn(arg)}- 类组件:用箭头函数定义方法
handleFn = () => {}- 异步更新:用函数式更新
setState(prev => prev + 1)拿这三个公式去检验你的代码,基本能避开所有常见问题!
评论区