该文中整理了关于ref的官方教程的要点,包含官方文档应急方案中 使用 ref 操作 DOM – React (docschina.org) 以及 使用 ref 引用值 – React (docschina.org) 的内容。也记录了几个使用ref的示例,包括秒表,轮播图等。
使用ref引用值
添加ref
在你的组件内,调用 useRef
Hook 并传入你想要引用的初始值作为唯一参数。例如,这里的 ref 引用的值是“0”:
const ref = useRef(0);
useRef
返回一个这样的对象:
{
current: 0 // 你向 useRef 传入的值
}
可以用 ref.current
属性访问该 ref 的当前值。这个值是有意被设置为可变的,意味着你既可以读取它也可以写入它。就像一个 React 追踪不到的、用来存储组件信息的秘密“口袋”。
就像state一样,ref可以指向任何东西,同样,与 state 一样,React 会在每次重新渲染之间保留 ref。
与 state 不同的是,ref 是一个普通的 JavaScript 对象,具有可以被读取和修改的 current
属性。设置 state 会重新渲染组件,更改 ref 不会!
ref | state |
---|---|
useRef(initialValue) 返回 { current: initialValue } | useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ( [value, setValue] ) |
更改时不会触发重新渲染 | 更改时触发重新渲染。 |
可变 —— 你可以在渲染过程之外修改和更新 current 的值。 | “不可变” —— 你必须使用 state 设置函数来修改 state 变量,从而排队重新渲染。 |
你不应在渲染期间读取(或写入) current 值。(ref改变组件也不会重新渲染,看不到变化) | 你可以随时读取 state。但是,每次渲染都有自己不变的 state 快照。 |
示例:结合ref和state制作秒表
-
分析需要保存的数据
- 开始时间,用来计算渲染在屏幕上的时间长度,所以保存在state中
- 当前时间,用来计算渲染在屏幕上的时间长度,所以保存在state中
- interval ID,传给
clearInterval
使用,用来停止秒表。此 ID 是之前用户按下 Start、调用setInterval
时返回的。你需要将 interval ID 保留在某处。 由于 interval ID 不用于渲染,可以将其保存在 ref 中。
import { useState, useRef } from 'react';
export default function Stopwatch() {
const [startTime, setStartTime] = useState(null);
const [now, setNow] = useState(null);
const intervalRef = useRef(null);
function handleStart() {
setStartTime(Date.now());
setNow(Date.now());
clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
}
function handleStop() {
clearInterval(intervalRef.current);
}
let secondsPassed = 0;
if (startTime != null && now != null) {
secondsPassed = (now - startTime) / 1000;
}
return (
<>
<h1>时间过去了: {secondsPassed.toFixed(3)}</h1>
<button onClick={handleStart}>
开始
</button>
<button onClick={handleStop}>
停止
</button>
</>
);
}
深入:useRef内部如何运行
原则上 useRef
可以在 useState
的基础上 实现。 你可以想象在 React 内部,useRef
是这样实现的:
// React 内部
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
第一次渲染期间,useRef
返回 { current: initialValue }
。 该对象由 React 存储,因此在下一次渲染期间将返回相同的对象。 请注意,在这个示例中,state 设置函数没有被用到。它是不必要的,因为 useRef
总是需要返回相同的对象!
何时使用ref
当组件需要“跳出” React 并与外部 API 通信时,你会用到 ref —— 通常是不会影响组件外观的浏览器 API:
- 存储 timeout ID
- 存储和操作 DOM 元素
- 存储不需要被用来计算 JSX 的其他对象。
如果你的组件需要存储一些值,但不影响渲染逻辑,请选择 ref。
遵循以下原则将使你的组件更具可预测性:
- 将 ref 视为应急方案。
- 不要在渲染过程中读取或写入
ref.current
。 如果渲染过程中需要某些信息,请使用 state 代替。由于 React 不知道ref.current
何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。(唯一的例外是像if (!ref.current) ref.current = new Thing()
这样的代码,它只在第一次渲染期间设置一次 ref。)
ref和DOM
ref 最常见的用法是访问 DOM 元素。例如,如果你想以编程方式聚焦一个输入框,这种用法就会派上用场。当你将 ref 传递给 JSX 中的 ref
属性时,比如 <div ref={myRef}>
,React 会将相应的 DOM 元素放入 myRef.current
中。会在下一部分详细整理。
官方挑战记录:
- 发送按钮在三秒之后触发,在这期间可以点击撤销按钮撤销定时操作(用ref记录定时器ID)
- 修复防抖,防抖可以让你将一些动作推迟到用户“停止动作”之后。如何让多个防抖按钮之间互不影响?(用ref在每个防抖按钮内部记录按钮自己的定时器ID)
import { useRef } from 'react';
let timeoutID;
function DebouncedButton({ onClick, children }) {
const timeoutRef = useRef(null);
return (
<button onClick={() => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onClick();
}, 1000);
}}>
{children}
</button>
);
}
export default function Dashboard() {
return (
<>
<DebouncedButton
onClick={() => alert('宇宙飞船已发射!')}
>
发射宇宙飞船
</DebouncedButton>
<DebouncedButton
onClick={() => alert('汤煮好了!')}
>
煮点儿汤
</DebouncedButton>
<DebouncedButton
onClick={() => alert('摇篮曲唱完了!')}
>
唱首摇篮曲
</DebouncedButton>
</>
)
}
- 读取最新的state,在此示例中,当你按下“发送”后,在显示消息之前会有一小段延迟。输入“你好”,按下发送,然后再次快速编辑输入。尽管你进行了编辑,提示框仍会显示“你好”(这是按钮被点击 那一刻 state 的值)。通常,这种行为是你在应用程序中想要的。但是,有时可能需要一些异步代码来读取某些 state 的 最新 版本。你能想出一种方法,让提示框显示 当前 输入文本而不是点击时的内容吗?(把input绑到ref上)
使用 ref 操作 DOM
- 通过传递
<div ref={myRef}>
指示 React 将 DOM 节点放入myRef.current
。 - 通常,你会将 refs 用于非破坏性操作,例如聚焦、滚动或测量 DOM 元素。
深入:如何使用 ref 回调管理 ref 列表
有时候,你可能需要为列表中的每一项都绑定 ref ,而你又不知道会有多少项。以下是错误做法:
<ul>
{items.map((item) => {
// 行不通!
const ref = useRef(null);
return <li ref={ref} />;
})}
</ul>
因为 Hook 只能在组件的顶层被调用。不能在循环语句、条件语句或 map()
函数中调用 useRef
。
一种方法是用ref引用父元素然后用DOM操作方法如querySelectorAll
来寻找它的子节点。然而,这种方法很脆弱,如果 DOM 结构发生变化,可能会失效或报错。
较好的方法则是将函数传递给 ref
属性。当需要设置 ref 时,React 将传入 DOM 节点来调用你的 ref 回调,并在需要清除它时传入 null
。这使你可以维护自己的数组或 Map,并通过其索引或某种类型的 ID 访问任何 ref。
<li
key={cat.id}
ref={node => {
const map = getMap();
if (node) {
// 添加到 Map
map.set(cat.id, node);
} else {
// 从 Map 删除
map.delete(cat.id);
}
}}
>
在这个例子中,itemsRef
保存的不是单个 DOM 节点,而是保存了包含列表项 ID 和 DOM 节点的 Map。(Ref 可以保存任何值!) 每个列表项上的 ref
回调负责更新 Map:
这使你可以之后从 Map 读取单个 DOM 节点。
用ref访问另一个元素的DOM节点
-
默认情况下,组件不暴露其 DOM 节点。 您可以通过使用
forwardRef
并将第二个ref
参数传递给特定节点来暴露 DOM 节点。<MyInput ref={inputRef} />
告诉 React 将对应的 DOM 节点放入inputRef.current
中。但是,这取决于MyInput
组件是否允许这种行为, 默认情况下是不允许的。MyInput
组件是使用forwardRef
声明的。 这让从上面接收的inputRef
作为第二个参数ref
传入组件,第一个参数是props
。MyInput
组件将自己接收到的ref
传递给它内部的<input>
。
import { forwardRef, useRef } from 'react';
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
聚焦输入框
</button>
</>
);
}
注意:尽量避免更改由 React 管理的 DOM 节点
因为可能会与 React 所做的更改发生冲突,比如用ref删了一个DOM节点之后,用来控制该节点的state就会报错了。如果你确实修改了 React 管理的 DOM 节点,请修改 React 没有理由更新的部分。例如,如果某些 <div>
在 JSX 中始终为空,React 将没有理由去变动其子列表。 因此,在那里手动增删元素是安全的。
React何时添加refs?
在 React 中,每次更新都分为 两个阶段:
- 在 渲染 阶段, React 调用你的组件来确定屏幕上应该显示什么。
- 在 提交 阶段, React 把变更应用于 DOM。
通常,你 不希望 在渲染期间访问 refs。这也适用于保存 DOM 节点的 refs。在第一次渲染期间,DOM 节点尚未创建,因此 ref.current
将为 null
。在渲染更新的过程中,DOM 节点还没有更新。所以读取它们还为时过早。
React 在提交阶段设置 ****ref.current
。在更新 DOM 之前,React 将受影响的 ref.current
值设置为 null
。更新 DOM 后,React 立即将它们设置到相应的 DOM 节点。
通常,你将从事件处理器访问 refs。 如果你想使用 ref 执行某些操作,但没有特定的事件可以执行此操作,你可能需要一个 effect。
⭐深入:用 flushSync 同步更新 state
思考这样的代码,它添加一个新的待办事项,并将屏幕向下滚动到列表的最后一个子项。请注意,出于某种原因,它总是滚动到最后一个添加之前 的待办事项:
代码主要问题出在以下两行中,在 React 中,state 更新是排队进行的。通常,这就是你想要的。但是,在这个示例中会导致问题,因为 setTodos
不会立即更新 DOM。因此,当你将列表滚动到最后一个元素时,尚未添加待办事项。这就是为什么滚动总是“落后”一项的原因。
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();
要解决此问题,你可以强制 React 同步更新(“刷新”)DOM。 为此,从 react-dom
导入 flushSync
并将 state 更新包裹 到 flushSync
调用中:
flushSync(() => {
setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
这将指示 React 当封装在 flushSync
中的代码执行后,立即同步更新 DOM。因此,当你尝试滚动到最后一个待办事项时,它已经在 DOM 中了。
⭐挑战:小猫图轮播
要点一:声明一个 selectedRef
,然后根据条件将它传递给当前图像:
<li ref={index === i ? selectedRef : null}>
当index === i
时,表示图像是被选中的图像,相应的 <li>
将接收到 selectedRef
。React 将确保 selectedRef.current
始终指向正确的 DOM 节点。
要点二:请注意,为了强制 React 在滚动前更新 DOM,flushSync
调用是必需的。否则,selectedRef.current
将始终指向之前选择的项目。
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';
export default function CatFriends() {
const [index, setIndex] = useState(0);
const targetRef = useRef(null);
return (
<>
<nav>
<button onClick={() => {
if (index < catList.length - 1) {
flushSync(() => {
setIndex(index + 1);
});
} else {
flushSync(() => {
setIndex(0);
});
}
targetRef.current.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}}>
下一个
</button>
</nav>
<div>
<ul>
{catList.map((cat, i) => (
<li key={cat.id} ref={index === i ?targetRef: null}>
<img
className={
index === i ?
'active' :
''
}
src={cat.imageUrl}
alt={'猫猫 #' + cat.id}
/>
</li>
))}
</ul>
</div>
</>
);
}
const catList = [];
for (let i = 0; i < 10; i++) {
catList.push({
id: i,
imageUrl: 'https://placekitten.com/250/200?image=' + i
});
}