React 脱围机制
约 5486 字大约 18 分钟
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。