React 脱围机制 [1]
约 10783 字大约 36 分钟
2025-08-21
Ref 引用值
当你希望组件 “记住”某些信息,但又不希望这些信息的变化 触发组件重新渲染,可以使用 ref。
导入 useRef
import { useRef } from 'react';在组件内调用 useRef 并设置初始值
const ref = useRef(0);useRef 返回一个对象:
{ current: 0 } // 初始值为传入的参数你可以通过 ref.current 访问或修改当前值。
这个值是可变的,可以读写,但 React 不会追踪它的变化。
ref指向一个数字,但也可以是字符串、对象、函数等- 与 state 不同的是,修改
ref.current不会触发组件重新渲染。 - React 会在每次重新渲染之间保留 ref 的值。
总结:ref 是一个普通的 JavaScript 对象,适合存储组件内部信息或跨渲染保留的数据,不会像 state 一样影响 UI 更新。
示例:制作秒表(结合 state 和 ref)
使用 state 保存渲染相关数据:秒表需要显示从开始按钮按下以来经过的时间,因此要把这些用于渲染的数据保存在 state 中:
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);使用 setInterval 每 10 毫秒更新一次 now,并通过 now - startTime 计算经过的秒数。
使用 ref 保存不影响渲染的数据:
- interval ID 仅用于启动/停止定时器,不用于渲染。
- 将 interval ID 保存在
ref中:
const intervalRef = useRef(null);当按下“开始”按钮时,先清除已有 interval,再保存新的 interval ID:
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);当按下“停止”按钮时,通过 clearInterval(intervalRef.current) 停止计时。
完整逻辑
- state:保存渲染所需的数据(
startTime和now) - ref:保存不用于渲染,但在事件处理器中需要的数据(
intervalRef)
总结原则
- 用于渲染的数据 → state
- 仅被事件或内部逻辑使用,不影响 UI 的数据 → ref
这种组合方式让组件既能高效渲染,又能安全管理定时器等副作用数据。
Ref 与 State 的对比
| 特性 | ref | state |
|---|---|---|
| 创建方式 | useRef(initialValue) 返回 { current: initialValue } | useState(initialValue) 返回 [value, setValue],即当前 state 值和更新函数 |
| 渲染触发 | 修改 current 不会触发组件重新渲染 | 修改 state 会触发组件重新渲染 |
| 可变性 | 可变 —— 可以在渲染之外直接修改 current | 不可变 —— 必须通过 state 更新函数修改,排队触发重新渲染 |
| 读取时机 | 不应在渲染期间读取或写入 current | 可以随时读取,每次渲染有自己的 state 快照 |
useRef 内部原理
尽管 useRef 和 useState 都由 React 提供,原则上 useRef 可以在 useState 的基础上实现。
可以将其理解为:
// React 内部示意
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}第一次渲染时,useRef 返回 { current: initialValue }。
这个对象由 React 内部存储,因此 下一次渲染仍返回相同对象。
注意,这里返回的 state 设置函数没有被使用,因为 useRef 不需要触发重新渲染。
核心理解
useRef是一个 始终返回相同对象的容器,可以用来存储跨渲染周期的可变数据。- 在面向对象编程中,它类似于实例字段,但访问方式是
somethingRef.current,而不是this.something。
总结:useRef 本质上是一个“不可触发渲染的 state”,用于存储组件内部信息或 DOM 引用。
Ref的场景
通常,当组件需要“跳出” React 与外部 API 交互时,会使用 ref。这些情况通常 不会影响组件的 UI 渲染。常见场景包括:
- 存储 timeout 或 interval ID
- 用于控制定时器,但不影响渲染。
- 存储和操作 DOM 元素
- 如访问或修改原生 DOM 节点属性、聚焦输入框等(详见后续内容)。
- 存储其他不用于计算 JSX 的对象
- 比如保存第三方库实例、计数器或任意跨渲染周期的数据。
总结:当组件需要存储某些值,但 这些值不会影响渲染逻辑或 UI,就应该使用 ref,而不是 state。
Ref 的最佳实践
遵循以下原则可以让你的组件更可预测、易维护:
将 ref 视为脱离 React 渲染机制的工具
- 当你需要操作外部系统或浏览器 API 时,ref 很有用。
- 如果大量逻辑依赖 ref,而非 state 或 props,可能需要重新设计数据流。
避免在渲染过程中读取或修改
ref.current如果渲染时需要使用某个值,请使用 state。
React 不会跟踪
ref.current的变化,在渲染中读取或修改它可能导致不可预测的行为。唯一例外:在首次渲染时初始化 ref,例如:
if (!ref.current) ref.current = new Thing();
ref 的行为不像 state 有快照限制
改变 ref 会立即生效:
ref.current = 5; console.log(ref.current); // 5因为 ref 是普通的 JavaScript 对象,所以可以随意读写,只要这些操作不影响渲染即可。
总结:ref 用于存储 不用于渲染的可变数据,避免依赖它做核心渲染逻辑,可以保持组件行为可预测。
Ref 与 DOM
ref 可以指向任何值,但最常见的用途是 访问 DOM 元素。
例如,当你需要以编程方式聚焦一个输入框时,可以这样使用:
const inputRef = useRef(null);
function focusInput() {
inputRef.current.focus();
}
return <input ref={inputRef} />;- 当你将 ref 传给 JSX 的
ref属性(如<div ref={myRef}>)时,React 会将对应的 DOM 元素放入myRef.current。 - 当元素从 DOM 中移除时,React 会自动将
myRef.current设置为null。
这种方式让你可以在 React 中安全、可控地操作原生 DOM 元素。
使用 ref 操作 DOM
- 通常情况下,React 会自动更新 DOM 与渲染输出保持一致,因此大多数时候 不需要手动操作 DOM。
- 但是,有些场景需要直接访问由 React 管理的 DOM 元素,例如:
- 让某个节点获得焦点
- 滚动到特定元素
- 测量元素的尺寸或位置
- 在 React 中没有内置方法直接执行这些操作,这时就需要 ref 来引用 DOM 节点。
总结:通过 ref,你可以安全地访问和操作 React 管理的 DOM,实现 React 默认渲染机制之外的交互或测量功能。
获取指向 DOM 节点的 ref:
引入 useRef Hook
import { useRef } from 'react';在组件中声明一个 ref
const myRef = useRef(null);useRef 返回一个对象,具有 current 属性,初始时,myRef.current 为 null。将 ref 绑定到 DOM 元素
<div ref={myRef}>
内容
</div>当 React 为 <div> 创建 DOM 节点时,会把对该节点的引用放入 myRef.current。
访问 DOM 节点并使用浏览器 API
myRef.current.scrollIntoView();你可以在事件处理器中访问 myRef.current,调用任意原生 DOM 方法(如聚焦、滚动、测量尺寸等)。
总结:通过 ref,你可以安全地获取并操作由 React 管理的 DOM 节点,实现需要直接访问 DOM 的场景。
ref 回调
关于使用 ref 回调管理动态列表的 DOM 节点
不能在循环中直接使用 useRef
<ul>
{items.map((item) => {
const ref = useRef(null); // 错误
return <li ref={ref} />;
})}
</ul>原因:Hook 只能在组件的顶层调用,不能在循环、条件或 map() 内调用。
如果需要为动态列表的每一项绑定 ref,就需要使用 ref 回调 或其他方案。
使用父 ref + DOM 查询(不推荐)
可以给列表父元素绑定一个 ref,然后使用 querySelectorAll 获取子节点。
缺点:DOM 结构变化容易导致查询失败或报错,脆弱且难维护。
使用 ref 回调(推荐)
将一个函数传给每个列表项的 ref 属性:
<li
key={cat.id}
ref={node => {
const map = getMap();
map.set(cat, node); // 添加节点到 Map
return () => {
map.delete(cat); // 当节点卸载时移除
};
}}
>- 原理:
- 当元素挂载到 DOM 时,React 会调用 ref 回调函数,传入 DOM 节点。
- 当元素卸载时,React 会传入
null,可以在回调中清理引用。
- 为什么要
return () => map.delete(cat)- 保证当列表项被卸载时,将对应的 DOM 节点从 Map 中移除,防止内存泄漏或访问无效节点。
存储动态 ref 集合
使用一个父级 ref 保存 Map
const itemsRef = useRef(null);
function getMap() {
if (!itemsRef.current) {
itemsRef.current = new Map();
}
return itemsRef.current;
}Map 的 key 是每项的唯一标识(如 cat.id),value 是对应 DOM 节点。
通过 Map,可以随时访问任意列表项的 DOM 节点,例如滚动到某个节点:
function scrollToCat(cat) {
const node = getMap().get(cat);
node.scrollIntoView({ behavior: "smooth", block: "nearest" });
}注意事项
- 严格模式下:ref 回调在开发模式中可能调用两次。
- ref 不仅可以保存单个节点,也可以保存任意对象(如 Map、计数器、第三方实例等)。
跨组件DOM节点
核心概念
- ref 是一种 脱离 React 渲染机制的存储工具,手动操作其他组件的 DOM 可能会让代码变得脆弱。
- 但可以像传递 props 一样,将 ref 从父组件传递到子组件,从而访问子组件内部的 DOM 节点。
示例:父组件访问子组件的 input
import { useRef } from 'react';
function MyInput({ ref }) {
return <input ref={ref} />;
}
export default function MyForm() {
const inputRef = useRef(null);
function handleClick() {
// 使用父组件的 ref 访问子组件 DOM
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}- 父组件
MyForm创建了inputRef。 - 将
inputRef传给子组件MyInput,并绑定到<input>元素上。 - React 会自动将
<input>的 DOM 元素赋值给inputRef.current。 - 父组件可以通过
inputRef.current.focus()聚焦输入框。
使用命令句柄暴露部分 API
在默认情况下,将 ref 传给子组件会暴露 整个 DOM 元素,父组件可以任意操作,包括修改样式、内容等。
如果希望 限制父组件能调用的操作,可以使用 useImperativeHandle。
示例:只暴露 focus 方法
import { useRef, useImperativeHandle } from "react";
function MyInput({ ref }) {
const realInputRef = useRef(null);
// 通过 useImperativeHandle 限制父组件可以访问的 API
useImperativeHandle(ref, () => ({
focus() {
realInputRef.current.focus();
},
}));
return <input ref={realInputRef} />;
}
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus(); // 只能调用 focus
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>聚焦输入框</button>
</>
);
}realInputRef保存实际的<input>DOM 节点。useImperativeHandle(ref, createHandle)将父组件的 ref 指向你 自定义的对象。- 父组件通过
inputRef.current只能访问你在createHandle中定义的接口方法(如focus()),无法直接操作 DOM。
总结:useImperativeHandle 用于 安全地暴露子组件功能,隐藏内部实现,避免父组件滥用或误操作 DOM。
React 何时添加 refs
在 React 中,每次更新分为两个阶段:
- 渲染阶段(Render Phase)
- React 调用你的组件来确定屏幕上应该显示什么内容。
- 注意:在渲染阶段访问 refs 是不安全的,因为 DOM 节点尚未创建,
ref.current会是null。
- 提交阶段(Commit Phase)
- React 将变更应用到真实 DOM。
- 在更新 DOM 之前,React 会将受影响的
ref.current设置为null。 - 更新 DOM 后,React 会立即把
ref.current指向对应的 DOM 节点。
使用建议
- 事件处理器:通常通过事件处理器访问 refs,如点击按钮或聚焦输入框。
- 无事件操作:如果想在没有事件的情况下使用 ref(如初始化操作),应该使用 effect(
useEffect或useLayoutEffect),保证在 DOM 已经更新之后访问 refs。
总结:React 只在 提交阶段 设置 ref,因此不要在渲染阶段依赖它。
使用 flushSync 同步更新 state
在以下代码中,点击“添加”按钮后,希望滚动到 最新添加的待办事项:
setTodos([...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();- 问题原因:React 的 state 更新是异步排队的,
setTodos不会立即更新 DOM。 - 结果:当执行
scrollIntoView()时,最新的待办事项尚未渲染,导致滚动总是“落后一项”。
解决方案:flushSync
flushSync来自react-dom,用于 强制 React 同步更新 DOM。- 将 state 更新包裹在
flushSync中,保证 DOM 在下一行代码执行前已经更新。
import { flushSync } from 'react-dom';
flushSync(() => {
setTodos([...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();完整示例
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';
export default function TodoList() {
const listRef = useRef(null);
const [text, setText] = useState('');
const [todos, setTodos] = useState(initialTodos);
function handleAdd() {
const newTodo = { id: nextId++, text: text };
flushSync(() => {
setText('');
setTodos([...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
return (
<>
<button onClick={handleAdd}>添加</button>
<input value={text} onChange={e => setText(e.target.value)} />
<ul ref={listRef}>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</>
);
}
let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {
initialTodos.push({ id: nextId++, text: '待办 #' + (i + 1) });
}核心概念
- React 默认异步更新:为了性能优化,React 会批量更新 state 和 DOM。
- flushSync:强制在调用时立即同步更新 DOM。
- 使用场景:当后续代码需要立即访问更新后的 DOM 或 state,例如滚动到最新元素、获取尺寸、测量布局等。
总结:在需要 立刻操作更新后的 DOM 时,使用 flushSync 可以避免异步更新导致的“滞后”问题。
使用 refs 操作 DOM 的最佳实践
基本原则
- Refs 是“脱围机制”:只在必须“跳出 React”时使用它们。
- 常见用途:管理焦点、滚动位置、调用 React 未暴露的浏览器 API。
- 避免直接修改 DOM:手动改变 React 管理的 DOM 可能导致不可预测行为或崩溃。
import { useState, useRef } from 'react';
export default function Counter() {
const [show, setShow] = useState(true);
const ref = useRef(null);
return (
<div>
{/* 正确:使用 state 控制显示 */}
<button onClick={() => setShow(!show)}>通过 setState 切换</button>
{/* 错误:直接操作 DOM */}
<button onClick={() => ref.current.remove()}>从 DOM 中删除</button>
{show && <p ref={ref}>Hello world</p>}
</div>
);
}- 通过 state 切换:React 正确管理元素的显示和隐藏。
- 直接 remove():手动移除 DOM,React 无法感知,会导致后续渲染崩溃或不一致。
安全操作的例外
如果某个 DOM 节点 React 永远不会更新其子元素,可以在该节点上手动增删子元素。
例如,一个永远为空的
<div>,手动操作不会与 React 的渲染冲突。
核心理念:让 React 管理 DOM,refs 仅用于非破坏性访问和操作。
使用 Effect 进行同步
什么是 Effect?
在 React 中,Effect(大写 E)指的是由渲染本身触发的副作用。
也就是说,它代表那些“渲染之后必须发生”的行为,用来让 React 组件与外部系统保持同步。
例如:
- 组件第一次挂载时建立 WebSocket 连接
- DOM 渲染完成后启动动画
- 组件显隐变化时启动 / 停止订阅
- 页面渲染后上报分析日志
这些行为不能发生在“渲染期间”,因为渲染必须保持纯粹(纯函数),但它们又不是由某个特定用户事件触发,因此需要由 Effect 来处理。
渲染逻辑 vs 事件逻辑 vs Effect
理解 Effect 之前,需要理清组件中三种不同的逻辑类型:
(1)渲染逻辑(Rendering)
发生在组件的最顶层,直接写在函数体中。
它基于 props 和 state 描述 UI,必须是纯函数:
- 相同输入 → 相同输出
- 不产生副作用
- 不修改应用状态
- 不能发起请求、不能操作 DOM、不能订阅事件
React 会随时重新执行渲染,因此这些代码必须绝对可预测。
(2)事件逻辑(Event handlers)
组件中的事件处理程序,如 onClick、onChange。
它们是允许产生副作用的地方,因为事件源于用户行为。
典型事件副作用包括:
- 更新 state
- 发起网络请求(提交表单)
- 存储数据到 localStorage
- 跳转页面
事件是用户驱动的副作用。
(3)Effect(由渲染触发的副作用)
有一些操作既不属于“纯渲染”,也不是用户事件导致的,但组件又确实需要执行它们。
这类操作属于 “由组件的生命周期或渲染触发的副作用”。
例如:一个 ChatRoom 组件只要被显示,就必须自动连接聊天服务器。
这个连接并不是:
- 纯计算(不能写在渲染中),也不是
- 某个用户事件触发(用户没必要点按钮才能显示聊天室)
它是由组件“出现”这个事实触发的副作用。
因此才会有 Effect 的存在。
为什么需要 Effect?
可以理解为:
🟦 渲染代码负责“描述 UI” 🟩 事件处理负责“响应用户” 🟧 Effect 负责“让组件与外部世界同步”
Effect 的触发场景通常是:
- “组件第一次渲染完后我需要做点什么”
- “某个 state 改变后我需要同步到外部系统”
- “某些资源在组件消失时要清理”
它是 React 解决“组件生命周期相关副作用”的唯一机制。
Effect 与事件的根本区别
| 类型 | 触发方式 | 是否需要纯粹 | 可以产生副作用? | 场景举例 |
|---|---|---|---|---|
| 渲染 | React 调用组件函数 | 必须纯粹 | 不可以 | JSX 生成 |
| 事件 | 用户行为触发 | 不要求纯粹 | 可以 | 点击按钮发送请求 |
| Effect | 渲染结束后自动触发 | 渲染必须纯粹,它在渲染之后执行 | 可以 | 订阅、连接、日志、DOM 操作 |
一句话总结:事件是用户驱动,Effect 是渲染驱动。
Effect 为什么在开发环境运行两次?
React 处于严格模式(Strict Mode)时,会为了帮助开发者发现副作用 bug,把某些操作刻意“调试化”:
在开发环境中 Effect 会执行 → 清理 → 再执行一次,但生产环境只会运行一次。
这是一种“压力测试”,帮助你发现:
- 不纯的渲染逻辑
- 忘记清理订阅的 Effect
- 潜在的内存泄漏
总结
- Effect = 由 渲染本身 触发的副作用
- 渲染必须纯粹,不能包含副作用
- 事件是用户触发的副作用
- Effect 是“组件出现、更新或消失时”需要执行的同步逻辑
- 用于连接外部系统:网络、DOM、第三方库、订阅、日志
- 在开发模式下 Effect 会运行两次(安全机制)
可能不需要 Effect
Effect 是 React 中最容易被误用的特性之一。很多场景其实根本不应该使用它。
Effect 不是“逻辑挪放区”
当你刚开始接触 React 时,很容易出现一个误区:
“只要 state 改变后我需要写点逻辑,那我就写一个 Effect。”
但这是错误的。
Effect 是为了 同步外部系统 的,而不是用来写组件内部逻辑的。
React 官方甚至明确强调:
大多数你想写 Effect 的地方,其实不需要它。
Effect 的目的:同步“外部系统”
Effect 的真正职责是:
在渲染完成后,让 React 与外部世界保持一致。
外部系统包括:
- 浏览器 API(如
document.title、DOM 操作) - 第三方组件
- 订阅(WebSocket、事件监听)
- 网络连接
- 日志上报
如果你的操作不属于这些范畴,而只是组件内部的逻辑,几乎都不应该用 Effect。
明确:如果 Effect 只是为了设置另一个 state,不要用它
这是一个最常犯的错误:
useEffect(() => {
setFilteredItems(items.filter(/* ... */))
}, [items])这个 Effect 根本不应该存在。
因为它只是根据 state 推导另一个 state,React 完全可以直接在渲染中处理:
const filteredItems = items.filter(/* ... */)Effect 不应该成为“数据加工厂”。
那什么时候会出现“用 Effect 来更新 state,但其实不该用”的情况?
典型错误示例:
情况一:想在 state 改变后执行计算
useEffect(() => {
setWidth(width * 2)
}, [width])这是一个典型的“循环更新陷阱”,逻辑本不应该放在 Effect 中。
正确做法永远是:
- 派生数据 → 放在渲染里
- 事件产生的状态更新 → 放在事件里
情况二:用于数据格式化、过滤、排序
这类逻辑永远可以直接写在渲染中:
const sorted = [...list].sort(...)没有必要放到 Effect 里。
情况三:为了解决“某个 state 没同步”的错觉
很多人会写:
useEffect(() => {
console.log(value)
}, [value])以为 Effect 才能拿到“更新后的最新值”。
这是误解,因为:
- 渲染函数执行时拿到的 state 本来就是最新的
- 你根本不需要 Effect
React 官方推荐的“判断流程”
以下是一个判断你是否真正需要 Effect 的官方思维模型:
- 你想同步的是 React 内部状态吗?
→ 不需要 Effect。
- 你想同步的是外部系统状态吗?
→ 可能需要 Effect。
- 你的逻辑可以写在渲染里(纯计算)吗?
→ 不需要 Effect。
- 你的逻辑可以放在事件处理器中(用户触发)吗?
→ 不需要 Effect。
- 这是某个资源需要在组件挂载/卸载时初始化/销毁吗?
→ 你需要 Effect。
真正需要 Effect 的是这些操作:
当组件“出现”或“消失”时:
- 建立 / 断开 WebSocket
- 订阅事件
- 注册第三方库
- 绑定 DOM 事件
当渲染结果需要同步到外部时:
- 修改
document.title - 根据 state 操作 DOM(测量元素尺寸)
- 控制外部控件(比如地图实例、视频播放器)
当 UI 改变后需要触发一次性的外部行为
- 日志打点
- 接入第三方动画库
除此之外 90% 的“想写 Effect”场景其实不应该写。
总结
Effect 用于外部系统同步,不是用来处理 React 内部逻辑的。
如果只是根据 state 计算出另一个结果,请放在渲染里。
如果是用户行为触发的副作用,放在事件 handler 里。
只有当渲染完成后需要对接外部系统时,才使用 Effect。
使用 Effect
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// 每次渲染后都会执行此处的代码
});
return <div />;
}每当你的组件渲染时,React 会先更新页面,然后再运行 useEffect 中的代码。换句话说,useEffect 会“延迟”一段代码的运行,直到渲染结果反映在页面上。
指定 Effect 依赖
useEffect(() => {
// 只有当依赖项变化时才执行
}, [dependency1, dependency2]); // 依赖数组依赖数组的三种情况:
无依赖数组,每次渲染后都执行
useEffect(() => {
// 每次渲染后运行
});空依赖数组 - 仅在挂载时执行一次
useEffect(() => {
// 仅在组件挂载时执行
}, []);有依赖项 - 依赖项变化时执行
useEffect(() => {
// 当 isPlaying 变化时执行
}, [isPlaying]);React 真正执行 useEffect 的时机是:
组件第一次渲染完成(mount)后 → 一定会执行一次回调
每次组件重新渲染(update)完成后 → React 会:
- 把这次渲染中的依赖数组
[isPlaying]和 当前值 拿出来 - 和上一次保存的依赖值进行浅比较(Object.is)
- 如果任意一个依赖变了 → 重新执行回调函数
- 如果所有依赖都没变 → 直接跳过,不执行回调
注意:不是“组件更新后就开始判断,再决定要不要执行”,而是:
组件渲染完成 → 浏览器绘制(paint) → layout 结束后 → React 在 commit 阶段的被动效果阶段(passive effects)才真正执行 useEffect 的回调和依赖比较。
时间线:
1. state 改变(比如 setIsPlaying(!isPlaying))
2. 组件函数重新执行(重新渲染)
3. 生成新的 JSX(虚拟 DOM)
4. React 开始 commit 阶段:
├─ 先执行 DOM 操作(插入/更新真实 DOM)
├─ 浏览器进行 layout + paint(页面真正显示变化)
└─ 然后执行 useEffect 回调(包括清理上一次的 effect + 运行新的)
5. 在执行新 effect 之前,React 会比较依赖数组
├─ 如果 [isPlaying] 变了 → 运行回调
└─ 如果没变 → 跳过总结:每次组件渲染完成并提交到 DOM 之后,React 才会去比较依赖数组,看看有没有变化,有变化才重新执行回调,没有就跳过。
添加清理操作
useEffect(() => {
// Effect 逻辑
return () => {
// 清理函数
// 在组件卸载或下次 Effect 执行前运行
};
}, [dependencies]);组件卸载通常由以下情况触发:
条件渲染:{condition && <Component />}
路由切换:导航到不同页面
<Routes>
<Route path="/page1" element={<Page1 />} /> {/* 离开此路由时卸载 */}
<Route path="/page2" element={<Page2 />} /> {/* 进入此路由时卸载Page1 */}
</Routes>列表重新渲染,key 变化或项被移除
父组件重新渲染导致子组件结构变化
下次 Effect 执行前要运行清理函数是因为:避免副作用累积和状态不一致
常见例子:
- 定时器 - 防止重复创建
- 事件监听器 - 防止重复监听
- 数据订阅 - 防止内存泄漏
清理函数执行时机:
- 组件卸载时 - 最终清理
- 依赖变化时 - 在运行新 Effect 之前清理旧的
设计哲学:
- 每次 Effect 执行都对应一个"会话"
- 新会话开始前必须结束旧会话
- 确保资源正确释放和状态一致性
总结:
useEffect(() => {
// 1. Effect 主逻辑(订阅、定时器、手动 DOM 操作、请求等)
return () => {
// 2. 这个函数叫「清理函数」(cleanup function)
};
}, [dep]);| 时机 | 是否触发 cleanup | 触发后做什么 | 备注 |
|---|---|---|---|
| 1. 组件第一次渲染(mount) | 不触发 | 只执行主逻辑 | 还没有“旧的 effect”需要清理 |
| 2. 依赖发生变化 → 重新渲染 | 是 | 先运行上一次的 cleanup → 再运行新的 effect | 核心:防止副作用叠加或泄漏 |
| 3. 组件卸载(unmount) | 是 | 运行最后一次的 cleanup | 通常用来取消订阅、清除定时器等 |
初次渲染
└── 执行 effect() // 没有 cleanup
isPlaying: false → true(依赖变化)
├── 先执行上一次的 return cleanup()
└── 再执行新的 effect()
isPlaying: true → false(再次变化)
├── 先执行上一次的 return cleanup()
└── 再执行新的 effect()
组件卸载
└── 执行最后一次的 return cleanup() // 彻底清理资源经典实际例子
useEffect(() => {
const timer = setInterval(() => {
console.log('计时中...', isPlaying);
}, 1000);
// 清理函数
return () => {
clearInterval(timer);
console.log('清理了定时器');
};
}, [isPlaying]);运行效果:
- 打开页面 → 启动定时器
- 切换 isPlaying → 先 clear 旧定时器 → 再开新定时器(避免多个定时器同时跑)
- 组件卸载组件 → 最后再 clear 一次(防止内存泄漏)
订阅的例子
useEffect(() => {
const unsub = someEventBus.on('data', handler);
return () => {
unsub(); // 取消订阅不取消会导致内存泄漏、重复触发
};
}, [userId]);当 userId 变化时,旧用户的订阅必须先取消,否则你会同时收到两个用户的消息!
“每当依赖变化时,React 会把上一个 effect 当作『即将废弃』的来处理,先执行它的清理函数,再创建新的 effect。”
请求取消演示
请求取消 是 useEffect + cleanup 最最最经典、最最最容易出错、也是面试最爱考的例子!
useEffect(() => {
// 1. 创建一个 AbortController(现代取消请求的标准姿势)
const controller = new AbortController();
// 2. 开始发请求,把 signal 传进去
fetch(`https://api.example.com/user/${userId}`, {
signal: controller.signal, // 重点!!
})
.then(res => res.json())
.then(setData)
.catch(err => {
// 如果是用户主动取消的请求,这个错误我们不关心
if (err.name === 'AbortError') {
console.log('请求已取消');
return;
}
setError(err);
});
// 3. 清理函数:在下次 useEffect 运行前或组件卸载时执行
return () => {
controller.abort(); // 关键!取消正在进行的请求
};
}, [userId]); // 当 userId 变了,或者组件卸载了,就会触发取消| 用户操作 | 实际执行顺序 | 结果 |
|---|---|---|
| userId 从 1 → 2 | 1. 先执行 cleanup → controller.abort() 2. 取消 id=1 的请求 3. 再发 id=2 的新请求 | 不会出现「新页面显示了旧数据」的 bug |
| 用户快速切换多个页面切换 | 每次切换都会 abort 掉上一个请求 | 最终只会有最后一个请求成功返回 |
| 用户点返回或关闭页面(unmount) | 执行最后的 cleanup → abort 正在请求的网络 | 彻底避免内存泄漏和无用网络流量 |
async/await 版本
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
try {
const res = await fetch(`/api/user/${userId}`, {
signal: controller.signal,
});
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name === 'AbortError') return;
setError(err);
}
}
fetchUser();
return () => {
controller.abort(); // 必须写在 return 里!
};
}, [userId]);经典错误写法
// 错误!把 abort 写在 async 函数里没用
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal).finally(() => {
controller.abort(); // 太晚了!请求已经发完或已经拿到结果了
});
// 缺少 return cleanup!
}, [userId]);凡是在 useEffect 里发的网络请求、WebSocket、事件监听、定时器、setInterval、addEventListener…… 只要它可能在组件卸载后还有回调,或者依赖变化后旧的还在跑, 就一定要在 cleanup 里把它干掉!
useEffect 经典陷阱
死循环陷阱
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // 危险!没有任何依赖数组
});会发生什么?
- 组件初次渲染 → useEffect 执行 → setCount(1)
- state 更新 → 触发重新渲染
- 渲染完成 → useEffect 再次执行 → setCount(2)
- state 更新 → 再次重新渲染 → useEffect 再次执行 → setCount(3)
- ……直到浏览器崩溃或 React 抛出 “Too many re-renders” 错误
“这就像把电源插座的插头插回自身一样” Effect 运行 → 更新 state → 触发渲染 → 又运行 Effect → 再次更新 state → 永不停息 → 死循环!
| 原则 | 解释 | 正确做法 |
|---|---|---|
| 1. Effect 是用来“与外部系统同步”的 | 订阅、fetch、手动 DOM、定时器、WebSocket、第三方库 | 必须写 cleanup |
| 2. 纯粹根据其他 state/pros 计算新 state | 属于“派生状态”(derived state) | 不要用 Effect! |
| 3. 依赖数组必须诚实 | 用了哪个变量就写哪个,不能漏、不能多、不能骗 linter | 相信 eslint-react-hooks/exhaustive-deps |
依赖数组省略项
凡是 React 官方保证「在组件整个生命周期内绝不会变」的对象,就可以安全从依赖数组中省略,而且 linter 也不会报错。
目前只有 三种 东西满足这个条件:
| 类型 | 示例 | 是否跨渲染保持同一对象 | linter 会不会报错 |
|---|---|---|---|
| useRef() 返回的 ref | const ref = useRef() | 永远是同一个对象 | 不报错,可省略 |
| useState() 返回的 setter | const [x, setX] = useState() | 永远是同一个函数 | 不报错,可省略最优 |
| useMemo/useCallback 空依赖 | useCallback(() => {}, []) | 永远是同一个函数 | 不报错 |
React 把 ref 对象挂在当前 fiber 的 memoizedState 链表里,只要组件不被销毁,这个对象就永远是同一个引用。
function VideoPlayer({ isPlaying }) {
const ref = useRef(null);
// 写法一:官方推荐(省略 ref)
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]); // 没写 ref,linter 也不报错
// 写法二:你也可以写上,完全等价
useEffect(() => {
// ...同上
}, [isPlaying, ref]); // 写上也没错,但多此一举
}两种写法执行效果 100% 一样,性能也完全一样。
什么情况下 ref 必须写进依赖?
只有一种情况:ref 是从父组件传下来的 props
function BadParent() {
const [key, setKey] = useState(0);
const myRef = useRef(); // 每次 setKey 都重新创建组件,ref 也会变
return <VideoPlayer videoRef={myRef} key={key} />;
}
function VideoPlayer({ videoRef }) {
useEffect(() => {
// videoRef 可能变!必须写进依赖
}, [videoRef]);
}因为父组件可能重新创建 ref 对象,React 无法保证它的稳定性,所以 linter 会强制要求你写上。
setState 函数为什么也可以省略?
React 保证:setCount 这个函数从组件创建到销毁永远是同一个函数引用。
所以即使你写成:
}, [setCount]); // 也永远不会触发重新运行!当前组件自己的 state / props 必须写进依赖数组吗?
只要在 useEffect / useCallback / useMemo 里“读取”了某个 state 或 props,就必须把它写进依赖数组。
没有例外,没有侗丝,没有“性能优化”借口。
function Counter({ step }) {
const [count, setCount] = useState(0);
const [name, setName] = useState("Grok");
useEffect(() => {
console.log(count, name, step); // 读取了三个值
}, [count, name, step]); // 一个都不能少!linter 会逼你写全
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step); // 函数式更新 → 可以不依赖 count
}, 1000);
return () => clearInterval(id);
}, [step]); // 只依赖 step,count 没读到,所以不用写
}开发环境下 Effect 运行了两次
为什么开发环境 Effect 执行两次?
React 故意在开发环境下「mount → cleanup → mount」一次,帮助你发现代码中不纯的副作用
生产环境会执行几次?
只执行一次(正常行为)
应该怎么思考这个问题?
永远不要问「怎么让 Effect 只运行一次」 而要问「让我的 Effect 在被 teardown 后再 mount 依然正确」
终极解法:实现正确的 cleanup 函数(清理函数)
开发环境 StrictMode:
1. mount → 执行 Effect
2. cleanup → 执行 return 的清理函数
3. mount again → 再次执行 Effect
生产环境:
1. mount → 执行 Effect(只此一次)常见场景最佳实践一览表
| 场景 | 是否需要 cleanup | 开发环境执行两次的表现 | 正确写法(精简版) |
|---|---|---|---|
| 订阅事件 | 必须 | add → remove → add(最终只有一个监听) | return () => removeEventListener() |
| 触发动画 | 建议 | 设1 → 设0 → 设1(视觉上无闪烁) | return () => node.style.opacity = 0 |
| 操作非 React 组件 | 看情况 | 两次 setZoomLevel 相同值 → 无副作用 | 可不写 cleanup(幂等操作) |
| 打开 modal/dialog | 必须 | show → close → show(不抛错) | return () => dialog.close() |
| 建立 WebSocket/连接 | 必须 | connect → disconnect → connect | return () => connection.disconnect() |
| 发请求(fetch) | 强烈建议 | 发两次请求,但旧请求被忽略 | AbortController 或 ignore 标志位 |
| 发送埋点/logVisit | 可不写 cleanup | 发两次(开发环境无所谓) | 直接发,生产只发一次 |
| 购买、支付等副作用 | 不应放 Effect | 会买两次!致命 bug | 放点击事件里! |
经典错误写法
// 错误1:用 ref 跳过执行(假装修复)
const didRun = useRef(false);
useEffect(() => {
if (didRun.current) return;
didRun.current = true;
createConnection(); // 开发环境看似只运行一次,但根本没解决问题
}, []);
// 错误2:把「购买」放 Effect
useEffect(() => {
fetch('/api/buy', {method: 'POST'}); // 开发环境买两次,生产环境回退页面再买一次
}, []);
// 错误3:关掉严格模式或 eslint
// <React.StrictMode> 删掉 → 短期爽,长期死推荐写法
// 1. 订阅事件
useEffect(() => {
const handler = () => {...};
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
// 2. 操作第三方组件(幂等操作可不清理)
useEffect(() => {
mapRef.current?.setZoomLevel(zoomLevel);
}, [zoomLevel]); // 两次调用相同值,没问题
// 3. 打开 dialog(必须清理)
useEffect(() => {
dialogRef.current?.showModal();
return () => dialogRef.current?.close();
}, []);
// 4. 数据请求(推荐 AbortController)
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData);
return () => controller.abort();
}, [userId]);
// 5. 或者经典 ignore 方案(兼容性更好)
useEffect(() => {
let ignore = false;
fetchTodos(userId).then(res => {
if (!ignore) setTodos(res);
});
return () => { ignore = true; };
}, [userId]);
// 6. 购买、支付、发送邮件等「意图性操作」→ 绝不放 Effect
function handleBuy() {
fetch('/api/buy', {method: 'POST'}); // 只在点击时触发
}Effect 不是「组件加载时运行一次」的钩子,而是「与外部系统同步」的声明式工具。
你要保证:「用户离开页面再回来」和「生产环境只加载一次」这两种情况,用户看到的效果完全一样。
React 在开发环境故意搞破坏,就是为了逼你写出真正健壮的代码。
useEffect(() => {
mapRef.current?.setZoomLevel(zoomLevel); // ← 只是“设置一个值”
}, [zoomLevel]);为什么「可以不写 cleanup」是安全的,甚至是推荐的
| 事实 | 解释 |
|---|---|
| 操作是 幂等的(idempotent) | 调用一次 setZoomLevel(10) 和调用十次 setZoomLevel(10),地图最终状态完全一样,没有副作用叠加 |
| 依赖相同 → Effect 根本不会重新运行 | 当 zoomLevel 没变时,React 直接跳过整个 Effect,所以根本不会执行第二次 setZoomLevel |
开发环境“执行两次”的真实顺序是: zoomLevel=10 → setZoomLevel(10) → cleanup(没有) → 再次 setZoomLevel(10) | 地图还是 10 → 10 → 10,用户完全看不出来 |
| 没有创建需要销毁的资源 | 没有 timer、没有 subscription、没有 WebSocket、没有 addEventListener → 根本没东西需要“撤销” |
不是“没啥好清理的”,而是根本没有需要清理的东西产生。 这才是它安全的本质原因。
可以不写 cleanup”的经典安全操作
useEffect(() => {
// 这些都安全,不需要 cleanup
ref.current?.scrollIntoView();
ref.current?.focus();
videoRef.current?.play();
canvasCtx?.clearRect(0, 0, w, h);
thirdPartyPlayer.setVolume(volume);
editor.setContent(content);
}, [volume, content]);只要满足下面任意一条,就可以不写 cleanup:
- 操作是幂等的(重复调用结果一样)
- 或者依赖没变时 Effect 根本不重跑(React 帮你跳过)
“我这个 Effect 只是『设置一个当前状态』, 而不是『创建一个需要后续撤销的东西』, 那就不需要 cleanup。”
Effect 请求问题
在 CSR(客户端渲染)里:Effect 请求基本没大问题
浏览器加载 JS → 组件渲染 → Effect 执行 → 发请求,非常正常
不存在“服务器 HTML 没数据”的问题,因为本来就没有 SSR
“调用两次”只在开发模式(StrictMode)下,生产环境只执行 一次
所以,CSR 下完全可以用 Effect 发请求
在 SSR(服务端渲染)里:Effect 的缺点非常明显
因为 Effect 不会在服务器执行:
- 首屏 HTML 没数据,只能显示 loading
- 数据请求延后:hydrate 之后才开始
- 更容易出现网络瀑布
- 无法提前预加载数据
- 没有缓存(组件切换就重复请求)
这些问题在 CSR 都不“致命”,但在 SSR 会导致 首屏慢、SEO 差、白屏时间长。
总结:
CSR:Effect 请求没问题,生产也不会触发两次。
SSR:Effect 请求会导致一串性能问题(首屏没有数据)→ 建议用框架的专用方式。
框架大多都自带的数据获取(SSR 最佳实践)
- Next.js
- Remix
- React Router
- Vue + Nuxt
- SvelteKit
客户端缓存库(适用 CSR + SSR 混合)
React Query
功能最全:
- 缓存
- 去重
- 并行
- 自动重试
- 后台刷新
- 预取数据(prefetch)
SWR
更轻量:
- 缓存
- 去重
- SWR stale-while-revalidate
- 预加载
相关信息
SSR 中 Effect 为什么是无效的?
因为 Effect 永远不会在服务器执行。
服务器渲染 HTML 时,根本不会跑 useEffect,所以页面需要的数据不会出现在 SSR HTML 内。
useEffect(() => {
fetch('/api/user').then(...)
}, [])服务器渲染时:
- useEffect 跳过
- fetch 没有执行
- HTML 都是空的、loading 状态
浏览器收到的是这样的东西:
<div>Loading...</div>没有真实内容,因为服务器没执行 Effect → 没获取数据。
客户端接管后:
- 下载 JS → 执行 React → 执行 useEffect
- 发请求
- 等数据回来
- 重新渲染
这就导致:
- SSR 首屏快” → 被完全破坏
- HTML 不能被 SEO 收录
- 用户看到的仍然是 loading,然后等 JS 再等请求
你以为你做的是 SSR,但实际上是 SSR + CSR 双重等待,本质上接近纯 CSR。
相关信息
网络瀑布”到底是什么意思
不是并发多,而是:
数据请求“串行”发生,不是“并行”
看下面结构:
Page
├── UserInfo
└── UserPosts你如果写 Effect:
function Page() {
useEffect(() => fetch('/page').then(...), [])
}
function UserInfo() {
useEffect(() => fetch('/user').then(...), [])
}
function UserPosts() {
useEffect(() => fetch('/posts').then(...), [])
}第一次渲染是这样:
- Page SSR 出 HTML,但 Effect 不执行 → 页面是 loading
- JS 下载 & hydrate 完成
- Page 的 Effect 执行 → 发请求 A(等待)
- 返回数据 → 渲染 UserInfo + UserPosts
- UserInfo 的 Effect 执行 → 请求 B(等待)
- UserPosts 的 Effect 执行 → 请求 C(等待)
请求顺序是:A → B → C
这就叫 网络瀑布:
- A 请求完才能渲染子组件
- 子组件渲染后再发 B、C
- 整体变得非常慢
如果 B/C 再有子组件,还会继续瀑布式往下延伸。
相关信息
提前取数据”是什么?
提前取数据 = 在服务器就把所有需要的数据拿好。
你期望的是这样的流程:
- SSR Server 执行:
- 获取 user
- 获取 posts
- 等全部数据返回
- 用这些数据生成完整 HTML
- 客户端直接看到完整页面,无需再等请求
这就是:
- Next.js 的 server component
- Remix 的 loader
- SvelteKit 的 load
- Nuxt 的 useFetch
所有现代框架在 SSR 的数据获取都是围绕 “提前获取” 来设计的。
而 Effect 做不到提前获取,因为它不执行。
总结:Effect 是纯客户端行为,所以在 SSR 中它完全无法承担“获取 SSR 所需数据”的职责。
每一轮渲染都有自己的 Effect
Effect(useEffect)本质上并不是“事件”或“生命周期”,而是:
在某次渲染输出中附带的一段副作用描述。
也就是说:
- JSX 是渲染输出的 UI 描述
- Effect 是渲染输出的 副作用描述
React 会根据依赖项,决定某一轮渲染的 Effect 该不该执行、该不该清理。
export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>欢迎来到 {roomId}!</h1>;
}初次渲染(mount)
假设第一次渲染 <ChatRoom roomId="general" />
渲染输出的内容包含两部分:
UI(JSX)
<h1>欢迎来到 general!</h1>Effect(副作用描述)
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
}
依赖: ['general']React 发现依赖项第一次出现,于是执行这个 Effect → 连接到 “general” 聊天室。
渲染再次发生,但依赖不变
假设组件再次渲染(state 变了、父组件重新渲染等),但 roomId 依然是 "general"。
第二次渲染输出仍然是:
JSX:
<h1>欢迎来到 general!</h1>Effect(也是 identical 的):
依赖还是 ['general']
React 会做依赖比较:
Object.is('general', 'general') === true- React 判定 Effect 没必要重新执行
- 不会执行清理,也不会执行新的 Effect
- DOM 也不会更新
效果:渲染发生了,但 Effect 没有任何动作
渲染发生,并且依赖项变化(roomId 改了)
用户跳到 <ChatRoom roomId="travel" />
渲染的 UI 变了:
<h1>欢迎来到 travel!</h1>渲染的 Effect 也变了:
依赖变为 ['travel']
React 比较依赖项:
Object.is('travel', 'general') === false依赖不同 → React 需要更新 Effect
发生两件事:
① 清理 上一次执行过的 Effect
因为上一次运行的 Effect 是用 "general" 建的连接
所以清理逻辑会断开它:
disconnect('general')② 运行本次渲染新的 Effect
connect('travel')最终实现了:
- 从 general 聊天室断开
- 连接到 travel 聊天室
组件卸载(unmount)
组件被卸载时,React 会执行最后一次有效渲染的 Effect 的清理函数。
如果最后是 travel:
disconnect('travel')这样资源被安全释放,不会泄漏。
React 在开发环境下(StrictMode 开启)会执行一些“额外动作”,用来帮助开发者发现副作用 bug。
这些行为 只发生在开发模式,不会发生在生产环境。
StrictMode 下 Effect 的特殊行为:
- 首次挂载 → React 会模拟“挂载 → 卸载 → 再挂载”
- 目的是检查 Effect 是否正确清理
- 如果 cleanup 不写,Bug 会直接暴露
- 依赖变化 → Effect 会重新执行(正常逻辑)
- 开发模式保存文件(HMR) → Effect 会重复执行
- React 会卸载旧的 hook,再挂载新的 hook
- 也是为了保证副作用没有残留
重点:生产环境里 Effect 执行一次,不会双执行。
这是很多人误解的核心。
每次渲染都会生成 Effect 描述
│
├─ 依赖项没变 → effect 跳过
│
依赖变化 → 清理旧 effect → 执行新 effect
│
组件卸载 → 清理最后一次 effect