React 脱围机制
约 3586 字大约 12 分钟
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 进行同步
贡献者
更新日志
a1f88
-tidy up于99074
-doc update于e1d8c
-doc update于29daf
-update CSS于