一、事件的用法
1. 基础绑定与传参
React 使用驼峰命名法(如 onClick),并且必须传入函数引用,不能立即调用。
基础绑定:直接传入函数名。
jsx
<button onClick={handleClick}>点击</button>
带参调用:使用箭头函数包裹,避免在渲染时立即执行。
jsx
<button onClick={() => handleDelete(item.id)}>删除</button>
2. 获取事件对象与元素信息
事件处理函数可以接收一个事件对象(通常命名为 e 或 event),React 提供的是跨浏览器的合成事件对象。
区分触发元素:
jsx
function handleClick(e) {
console.log(e.target); // 实际触发事件的元素(比如按钮内的 <span>)
console.log(e.currentTarget); // 绑定了事件处理函数的元素(比如 <button>)
}
3. 阻止默认行为与事件冒泡
这两个方法在表单处理和复杂嵌套组件中极其常用,但作用完全不同:
阻止默认行为(preventDefault) :常用于阻止表单提交刷新页面,或阻止 <a> 标签跳转。
jsx
function handleSubmit(e) {
e.preventDefault(); // 阻止浏览器默认的表单提交行为
console.log('自定义提交逻辑');
}
阻止事件冒泡(stopPropagation):常用于点击列表项时,防止触发父容器的点击事件。
jsx
function handleItemClick(e) {
e.stopPropagation(); // 阻止事件向上传播给父元素
console.log('仅执行子元素逻辑');
}
4. 结合状态(State)更新 UI
事件处理函数是修改组件状态(State)的最佳位置。当状态改变时,React 会自动重新渲染组件。
计数器示例:
jsx
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
点击次数:{count}
</button>
);
}
5. 组件间的事件通信(回调函数)
在 React 中,事件不仅可以处理当前组件,还可以作为 props 传递给子组件,实现"子传父"的数据通信。
子组件触发父组件事件:
jsx
// 父组件
function Parent() {
const handleChildClick = (data) => {
console.log('收到子组件数据:', data);
};
return <Child onChildClick={handleChildClick} />;
}
// 子组件
function Child({ onChildClick }) {
return <button onClick={() => onChildClick('Hello Parent')}>通知父组件</button>;
}
二、事件委派
事件委派的核心原理是:不直接把事件绑定在具体的子元素上,而是把事件绑定在它们的"父元素"上,利用"事件冒泡"机制,在父元素中统一处理。
在传统的 DOM 编程中,如果有一个包含 1000 个按钮的列表,给每个按钮都单独绑定一个事件监听器,会占用极大的内存(例如 1000 个监听器 × 200字节 ≈ 200KB),并导致严重的性能损耗。
React 内部自动实现了"事件委托"机制:它不会将事件绑定在具体的 DOM 元素上,而是将所有事件统一绑定在应用的根节点(React 17+ 为 #root 容器,React 16 及以前为 document)上。当用户点击任意按钮时,事件会冒泡到根节点,React 再根据事件目标(target)和内部的 Fiber 树结构,精准找到对应的组件并执行回调函数。
在实际业务中,我们依然可以主动利用事件冒泡机制来优化超长列表或动态列表的性能。
代码示例:任务列表的点击处理
jsx
import React, { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '学习 React 事件委托', completed: false },
{ id: 2, text: '编写业务代码', completed: false },
]);
// 将事件绑定在父元素 <ul> 上,而不是每个 <li> 上
const handleListClick = (e) => {
// 1. 获取实际触发事件的 DOM 元素
const target = e.target;
// 2. 通过自定义属性(如 data-id)识别具体是哪一项被点击
const targetId = parseInt(target.getAttribute('data-id'));
if (!targetId) return; // 如果点击的不是 li 元素,直接返回
// 3. 更新对应的状态
setTodos((prev) =>
prev.map((todo) =>
todo.id === targetId ? { ...todo, completed: !todo.completed } : todo
)
);
};
return (
// 无论列表里有多少项,始终只有一个事件监听器
<ul onClick={handleListClick}>
{todos.map((todo) => (
<li
key={todo.id}
data-id={todo.id}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
);
}
上例中,事件只在父元素 <ul> 上绑定一次,通过 e.target 配合 data-id 属性定位到具体的 <li>,避免了为每一项都添加监听器,特别适合长列表或动态增减列表项的场景。
三、异步访问事件对象(Event Pooling 的历史演变)
在 React 16 及以前的版本中,为了极致的性能优化,React 引入了"事件池(Event Pooling)"机制。它的核心思想是对象复用:当事件处理函数执行完毕后,React 会立刻将合成事件对象(SyntheticEvent)的所有属性置为 null,并将其放回池子中,等待下一次事件触发时复用。
这就导致了一个经典的 Bug:如果你在异步代码(如 setTimeout、Promise)中访问事件对象,由于此时事件对象已经被清空,你将拿不到任何数据。
jsx
function handleChange(e) {
console.log(e.target.value); // ✅ 同步访问:正常输出
setTimeout(() => {
console.log(e.target.value); // ❌ 异步访问:报错!因为 e.target 已经是 null
}, 0);
}
老版本的解决方案:e.persist()
如果确实需要在异步操作中访问事件属性,必须调用 e.persist() 方法,将事件对象从池中移出,阻止 React 清空它:
jsx
function handleChange(e) {
e.persist(); // 告诉 React:不要回收这个事件对象!
setTimeout(() => {
console.log(e.target.value); // ✅ 现在可以正常访问了
}, 0);
}
现代 React(17+)的改变:彻底移除事件池
随着现代浏览器 JavaScript 引擎的垃圾回收(GC)机制越来越高效,事件池带来的性能收益已经微乎其微,反而给开发者增加了巨大的心智负担。因此,从 React 17 开始,官方彻底移除了事件池机制。
jsx
const handleChange = (e) => {
const value = e.target.value; // 提前保存
setTimeout(() => {
console.log(value); // 安全使用
}, 100);
};
也就是说,在 React 17 及之后的版本中,即使不调用 e.persist(),也可以在异步代码中直接访问事件对象;不过,为了避免状态更新时机不确定等问题,更推荐的做法仍然是在同步阶段提前保存需要的值,再在异步逻辑中使用。
五、合成事件系统
React 并没有直接使用浏览器原生的 DOM 事件系统,而是实现了一套自己的"合成事件(SyntheticEvent)"系统。你可以把它理解为 React 在浏览器原生事件之上加了一层"跨浏览器包装器"。
这套系统主要有以下几个核心特点:
1. 跨浏览器一致性(抹平差异)
不同浏览器(如 Chrome、Firefox、IE 等)对原生事件的实现存在很多差异。React 的合成事件对这些差异进行了统一封装,确保 onClick、onChange 等事件在所有浏览器中的行为逻辑、API 调用方式完全一致,开发者无需再编写复杂的浏览器兼容代码。
这样做带来的直接好处是:同一套事件处理代码可以在不同浏览器中稳定运行,不必为不同浏览器分别维护实现,也减少了因浏览器差异导致的兼容性 Bug。
2. 统一的事件接口
合成事件提供了与原生事件相同的核心接口。你依然可以像以前一样使用 event.target 获取触发元素,使用 event.preventDefault() 阻止默认行为,或者使用 event.stopPropagation() 阻止事件冒泡。如果因为某些特殊原因需要使用浏览器的底层原生事件,可以通过 e.nativeEvent 属性来获取。
常用接口说明如下:
| 属性/方法 | 作用 |
|---|---|
event.target |
获取实际触发事件的元素 |
event.currentTarget |
获取绑定事件处理函数的元素 |
event.preventDefault() |
阻止浏览器默认行为 |
event.stopPropagation() |
阻止事件在合成事件系统中的冒泡 |
e.nativeEvent |
获取底层浏览器原生事件对象 |
3. 基于"事件委托"的性能优化
在底层,React 巧妙地利用了事件委托的原理。所有的合成事件并不会直接绑定到各个具体的 DOM 元素上,而是统一绑定到应用的最顶层容器(如 #root 元素)上。当用户在页面上触发事件时,事件会冒泡到根节点,React 再根据目标元素将事件派发给对应的 React 组件。这种机制大幅减少了 DOM 监听器的数量和内存消耗,提升了性能。
4. 关于"事件池(Event Pooling)"的历史演变
在早期的 React 版本中,为了极致优化性能,React 引入了"事件池"机制。事件对象在回调函数执行完毕后会被清空属性并放回池中复用。这意味着在异步操作(如 setTimeout)中访问事件属性会得到 null,必须调用 e.persist() 来取消池化。
但请注意:从 React 17 开始,由于现代浏览器性能的提升,React 官方已经移除了事件池机制。现在每次触发事件都会创建一个新的合成事件对象,开发者不再需要手动调用 e.persist(),异步访问事件属性也变得安全了。
不同版本的行为差异可以总结如下:
| React 版本 | 事件对象是否复用 | 异步访问事件属性 | 是否需要 e.persist() |
|---|---|---|---|
| React 16 及以前 | 是(事件池机制) | 默认拿不到值 | 需要 |
| React 17 及以后 | 否(每次新建对象) | 可以直接访问 | 不需要 |
5. 事件传播控制的独立性
需要特别注意的是,在 React 中调用 event.stopPropagation() 仅能阻止合成事件体系内的冒泡,它不会影响原生 DOM 事件的传播。如果需要完全阻止原生 DOM 事件的传播,需要结合 e.nativeEvent.stopPropagation() 来使用。
例如,当同一个元素上同时存在原生监听器和 React 合成事件监听器时,只调用合成事件的 stopPropagation() 可能不够:
jsx
function handleClick(e) {
// 阻止 React 合成事件继续向上冒泡
e.stopPropagation();
// 如果需要同时阻止底层原生 DOM 事件继续传播,可以额外调用:
// e.nativeEvent.stopPropagation();
}