♻️ 前面多篇文章中提及:state 可以 ① 保存渲染间的数据; ② state setter 函数更新变量会触发 React 重新渲染组件。
// 子组件:显示当前时间
function Time() {
return (
<p>{new Date().toLocaleString()}</p>
)
}
export default () => {
const [counter, setCounter] = useState(0);
return (<>
<span>{counter}</span>
<button onClick={() => setCounter(counter + 1)}>+</button>
<Time />
</>)
}
默认情况下,当一个组件重新渲染时,React 会递归地重新渲染它的所有子组件。
每一次点击按钮, counter + 1
,都会导致整个组件渲染(包括 <Time>
),因此总是显示当前时间。
如何使得 state 每次加 1,但子组件
<Time>
不变 ?
方式一:子组件使用 state
该方式:只修改子组件
function Time() {
let [time, setTime] = useState(new Date().toLocaleString());
return (
<p>{time}</p>
)
}
点击按钮,counter + 1
,但 <Time>
组件不被重新渲染,保持第一次的值。
⚠️ 相同位置的相同组件会使得 state 被保留下来! 具体可见「续篇:展开聊下 state 与 渲染树中位置的关系」
方式二:子组件使用 memo 包裹
该方式:只修改子组件
const Time = memo(() => {
return (
<p>{new Date().toLocaleString()}</p>
)
})
实现效果同上述「方式一」。
通过此更改, <Time>
的所有 props 都与上次渲染时相同(这里都为空), <Time>
跳过重新渲染。关于useMemo
可参阅官网 1
⚓ 方式三:父组件使用 ref
该方式:只修改父组件
export default () => {
const counterRef = useRef(0);
return (<>
<span>{counterRef.current}</span>
<button onClick={() => counterRef.current = counterRef.current + 1}>+</button>
<p>{new Date().toLocaleString()}</p>
</>)
}
同上述「方式一」&「方式二」的差异:当前DOM不发生任何变化(依然为0,其 counterRef.current
的值已经变成了 1
)。
当希望组件“记住”数据,又不想触发新的渲染时,便可以使用 ref
ref 是一种脱围机制2,用于保留不用于渲染的值:有些组件可能需要控制和同步 React 之外的系统。
例如,可能需要使用浏览器 API 聚焦输入框,或者在没有 React 的情况下实现视频播放器,或者连接并监听远程服务器的消息。
- 存储 timeout ID
- 存储和操作 DOM 元素
- 存储不需要被用来计算 JSX 的其他对象
ref 与 state 不同之处
✈️ 与 state 一样,React 会在每次重新渲染之间保留 ref。但是,设置 state 会重新渲染组件,更改 ref 不会 !
ref | state |
---|---|
useRef(initialValue) 返回 { current: initialValue } | useState(initialValue) 返回 state 变量的当前值和一个 state 设置函数 ( [value, setValue] ) |
更改时不会触发重新渲染 | 更改时触发重新渲染。 |
可变 —— 可以在渲染过程之外修改和更新 current 的值。 | “不可变” —— 必须使用 state 设置函数来修改 state 变量,从而排队重新渲染。 |
不应在渲染期间读取(或写入) current 值。 | 可以随时读取 state。但是,每次渲染都有自己不变的 state 快照。 |
useRef 内部是如何运行的?3
// 原则上 useRef 可以在 useState 的基础上 实现
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}
注意:不要在渲染过程中读取或写入 ref.current
如果渲染过程中需要某些信息,请使用 state 代替。【如上述方案三,点击按钮并未渲染最新的值】
function Test1 () {
let [counter, setCounter] = useState(0);
return (<>
<p>{counter}</p>
<button onClick={() => {
setCounter(n => n + 1);
console.log(counter);
}}>点我</button>
</>)
}
function Test2 () {
let counterRef = useRef(0);
return (<>
<p>{counterRef.current}</p>
<button onClick={() => {
counterRef.current++;
console.log(counterRef.current);
}}>点我</button>
</>)
}
数据 state | 显示 <p>{counter}</p> | 数据 ref.current | 显示 <p>{counterRef.current}</p> | |
---|---|---|---|---|
第1次点击 | 0 | 1 | 1 | 0 |
第2次点击 | 1 | 2 | 2 | 0 |
第3次点击 | 2 | 3 | 3 | 0 |
ref 本身是一个普通的 JavaScript 对象,具有一个名为 current
的属性,可以对其进行读取或设置。
由于 React 不知道 ref.current
何时发生变化,即使在渲染时读取它也会使组件的行为难以预测。
获取指向节点的 ref
export default () => {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>
聚焦输入框
</button>
</>
);
}
获取列表节点的 ref
当 ref 数量不确定(如列表),需要为每一项都绑定 ref。
方案一:用一个 ref 引用其父元素,然后用 DOM 操作方法(如 querySelectorAll
)来寻找子节点。该方案比较脆弱,当 DOM 结构发生变化,则会失效或报错。
✅方案二:将函数传递给 ref 属性,ref 回调4。当需要设置 ref 时,React 将传入 DOM 节点来调用你的 ref 回调,并在需要清除它时传入 null
。这使你可以维护自己的数组或 Map,并通过其索引或某种类型的 ID 访问任何 ref。
/* 动态添加 input 元素,并让最新添加的 input 元素获取焦点 */
const List = () => {
const [data, setData] = useState<string[]>([])
const inputsRef = useRef<Map<number, HTMLElement>>(null);
function getMap(){
if (!inputsRef.current) {
// 首次运行时,初始化 map
inputsRef.current = new Map();
}
return inputsRef.current
}
return (<>
<ul style={{display: 'flex', flexDirection: 'column'}}>
{data.map((item, index) => {
return (
<li key={index}>
<input
ref={(node) => {
const map = getMap();
// 存在追加,null删除
node ? map.set(index, node) : map.delete(index);
}}
type="text"
value={item}
onChange={console.log}/>
</li>
)
})}
</ul>
<button onClick={() => {
flushSync(() => {
setData([...data, `${Date.now()}`]);
});
const map = getMap();
// 获取焦点
map.get(data.length)?.focus();
}}>添加</button>
</>)
}
setData([...data, Date.now()]);
不会立即更新 DOM(state 更新是排队进行的),这里使用 flushSync(() => { ... })
强制 React 同步更新(“刷新”)DOM。
获取自定义组件的 ref
将 ref 放在像 <input />
这样输出浏览器元素的内置组件上时,React 会将该 ref 的 current
属性设置为相应的 DOM 节点。
默认情况下,自定义组件不会暴露它们内部 DOM 节点的 ref。
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
// forwardRef 允许组件使用 ref 将 DOM 节点暴露给父组件(父组件中按常规方式引用)
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
延伸: 子组件内部可以使用 useImperativeHandle
5 限制暴漏的功能。
const MyInput = forwardRef((props, ref) => {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
// 只暴露 focus,没有别的
focus() {
realInputRef.current.focus();
},
}));
return <input {...props} ref={realInputRef} />;
});
总结
ref 是一种脱围机制,用于保留不用于渲染的值。同时,ref 是一个普通的 JavaScript 对象,具有一个名为 current
的属性,可以对其进行读取或设置。与 state 不同,设置 ref 的 current
值不会触发重新渲染。不要在渲染过程中读取或写入 ref.current
。这使组件难以预测。
https://react.docschina.org/reference/react/useMemo useMemo ↩︎
https://react.docschina.org/learn/escape-hatches 脱围机制 ↩︎
https://react.docschina.org/learn/referencing-values-with-refs#how-does-use-ref-work-inside useRef 内部是如何运行的? ↩︎
https://react.docschina.org/reference/react-dom/components/common#ref-callback ref 回调函数 ↩︎
https://react.docschina.org/reference/react/useImperativeHandle useImperativeHandle ↩︎