React Refs, Memoization 与性能优化
1. useRef
基本概念
在 React 的声明式世界中,我们通常让 React 来负责所有的 DOM 更新。但有时,我们需要一个“逃生舱口”来直接与 DOM 交互,或者在多次渲染之间“记住”一些不触发视图更新的信息。这就是 useRef
的用武之地。同时,为了应对不必要的重渲染带来的性能问题,React 提供了 useMemo
、useCallback
和 React.memo
等一系列 memoization 工具。
useRef
Hook 返回一个可变的 ref 对象,其 .current
属性被初始化为传入的参数。这个 ref 对象在组件的整个生命周期内保持不变。
把
useRef
想象成一个可以随身携带的、不会在每次渲染时重置的“记事本”或“工具箱”。我们可以往里面存放任何东西,并且修改它不会像修改 state 一样触发组件的重新渲染。这是useRef
与useState
最核心的区别。
访问和操作 DOM 节点
这是 useRef
最经典、最常见的用途。它允许我们获取对一个已渲染的 DOM 节点的直接引用,从而可以调用其上的方法(如 .focus()
、.play()
)或读取其尺寸等信息。
import { useRef, useEffect } from 'react';
function TextInputWithFocusButton() {
// 1. 创建一个 ref 对象,初始化为 null
const inputRef = useRef(null);
// 使用 useEffect 来确保 DOM 节点已经挂载
useEffect(() => {
// 3. 在 effect 中,DOM 节点已经挂载,可以通过 .current 访问
// 并调用其 focus 方法
if (inputRef.current) {
inputRef.current.focus();
}
}, []); // 空依赖数组确保这个 effect 只在组件首次挂载后运行一次
return (
<>
{/* 2. 将 ref 对象附加到 DOM 元素的 ref 属性上 */}
<input ref={inputRef} type="text" />
<button onClick={() => inputRef.current?.focus()}>
Focus the input
</button>
</>
);
}
为什么不用
document.querySelector
?因为直接使用document.querySelector
会破坏 React 的组件化和声明式范式。它使得组件依赖于外部的 DOM 结构,当组件被复用或条件渲染时,querySelector
很容易失败或选中错误的节点。useRef
将引用与组件自身的生命周期绑定,更加健壮和可靠。
默认情况下,我们不能在自定义的函数式组件上使用 ref
属性。如果父组件需要获取子组件内部某个 DOM 元素的引用,子组件必须使用 React.forwardRef
来“转发”这个 ref。
// MyInput.js
import { forwardRef } from 'react';
// 1. 用 forwardRef 包裹子组件
const MyInput = forwardRef((props, ref) => {
// 2. 将接收到的 ref 参数传递给内部的 input 元素
return <input {...props} ref={ref} />;
});
export default MyInput;
// Parent.js
import { useRef, useEffect } from 'react';
import MyInput from './MyInput';
function Form() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
// 3. 现在可以在自定义组件上使用 ref 了
return <MyInput ref={inputRef} placeholder="I will be focused" />;
}
存储任何可变值
useRef
的第二个强大用途是作为一个“实例变量”的容器。它用于存储任何需要在多次渲染之间保持不变、但其变化又不应触发重渲染的值。常见的模式如下:
- 存储上一次的 State 或 Props:这是一个非常常见的设计模式,用于追踪值的变化。
import { useState, useEffect, useRef } from 'react';
function ValueTracker({ value }) {
const [currentValue, setCurrentValue] = useState(value);
const prevValueRef = useRef(value);
useEffect(() => {
// 每次 value prop 更新后,将当前值存入 ref 中
prevValueRef.current = currentValue;
// 更新 state 以响应 prop 变化
setCurrentValue(value);
}, [value]);
return (
<div>
<h1>Current Value: {currentValue}</h1>
<h2>Previous Value: {prevValueRef.current}</h2>
</div>
);
}
在这个例子中,当父组件传入新的 value
时,组件会重渲染。useEffect
在渲染之后执行,此时我们可以读取 prevValueRef.current
得到上一次的值,然后再更新它。
- 管理定时器或订阅:
useRef
非常适合存储setInterval
或setTimeout
返回的 ID,或者 WebSocket 连接、其他订阅的实例。
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const handleStart = () => {
if (intervalRef.current !== null) return; // 防止重复启动
intervalRef.current = setInterval(() => {
setCount(c => c + 1); // 使用函数式更新,安全访问最新 state
}, 1000);
};
const handleStop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
// 组件卸载时确保清除定时器
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<h1>{count}</h1>
<button onClick={handleStart}>Start</button>
<button onClick={handleStop}>Stop</button>
</div>
);
}
- 在事件处理器中获取最新的 State:有时我们需要在
useEffect
中设置一个只运行一次的事件监听器(如window.addEventListener
),但监听器的回调函数又需要读取最新的组件 state。如果直接将 state 放入依赖数组,会导致监听器被频繁地移除和添加。useRef
可以完美解决这个问题:
function MouseLogger() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const latestPositionRef = useRef(position);
// 实时更新 ref 中的值
useEffect(() => {
latestPositionRef.current = position;
});
useEffect(() => {
const handleLog = () => {
// 从 ref 中读取最新的 position,而不是闭包中的旧值
console.log('Current position:', latestPositionRef.current);
};
// 设置一个每 3 秒 log 一次的定时器
const intervalId = setInterval(handleLog, 3000);
// 清理函数
return () => clearInterval(intervalId);
}, []); // 空依赖,确保 effect 只运行一次
// ... (设置 setPosition 的 mousemove 监听器)
return <div>Check the console for logs.</div>;
}
2. React Memory
在 React 中,当一个组件的 state
或 props
改变时,它会重新渲染。这通常会导致两种性能问题:
- 昂贵的计算:组件内部有复杂的计算逻辑,每次渲染都重新执行,即使输入没有变化。
- 引用类型的 Props:父组件每次渲染时,都会创建新的函数或对象实例。当这些函数或对象作为 props 传递给子组件时,即使它们的功能和内容完全一样,子组件也会因为接收到了“新”的 props 而重渲染。
引用相等性 (Referential Equality) 是理解优化的关键。在 JavaScript 中,每次新创建的对象或函数,即使内容完全相同,它们的引用地址也是不同的。例如
{} !== {}
,() => {} !== () => {}
。React 默认通过===
比较 props,所以它会认为这些是不同的 props。
Memoization 是一种优化技术,通过缓存函数调用的结果,当下次以相同的输入再次调用该函数时,直接返回缓存的结果,而无需重新计算。React 的优化 Hooks 正是基于此思想。
3. React.memo
React.memo
是一个高阶组件 (HOC),它会对其包裹的组件进行 props 的浅比较。如果新旧 props 相等,它就会跳过该组件的重渲染,直接复用上次的渲染结果。
const MyButton = React.memo(({ onClick, children }) => {
console.log(`${children} rendered`);
return <button onClick={onClick}>{children}</button>;
});
React.memo
只有在接收到的 props 引用地址稳定时才能发挥作用。如果父组件每次都传递一个新的函数或对象,memo
将完全失效。
4. 优化 Hooks
为了解决引用相等性问题,我们需要 useMemo
和 useCallback
来稳定函数和对象的引用。
useMemo
useMemo
用于缓存计算的结果或对象。它接收一个“创建”函数和一个依赖项数组。只有当依赖项数组中的某个值发生变化时,它才会重新执行创建函数并返回新的值。
import { useMemo } from 'react';
// 场景1: 昂贵的计算
const visibleTodos = useMemo(() => {
console.log('Filtering todos...');
return todos.filter(t => t.text.includes(filter));
}, [todos, filter]); // 仅在 todos 或 filter 变化时才重新计算
// 场景2: 稳定对象引用,传递给 memo 组件
const userInfo = useMemo(() => ({
name: 'guest',
age: 25,
}), []); // 空依赖,对象只创建一次
useCallback
: 缓存函数引用
useCallback
用于缓存一个函数本身。它同样接收一个函数和一个依赖项数组。它返回该函数的记忆化版本,该版本只有在依赖项改变时才会更新。
useCallback(fn, deps)
等价于 useMemo(() => fn, deps)
。它的主要用途是与 React.memo
配合,向子组件传递一个稳定的函数引用。
memo
+ useCallback
memo
+ useCallback
是一个常用的组合, useCallback
可以确保传入的 props 稳定、从而让 memo
避免不必要的组件渲染:
import { useState, useCallback, memo } from 'react';
// 1. 用 React.memo 包裹子组件
const MyButton = memo(({ onClick, children }) => {
console.log(`${children} rendered`);
return <button onClick={onClick}>{children}</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(0);
// 2. 用 useCallback 包裹传递给子组件的函数
const handleIncrement = useCallback(() => {
setCount(c => c + 1);
}, []); // 空依赖,函数永不改变
return (
<div>
<p>Count: {count}</p>
<p>Other State: {otherState}</p>
{/* 3. 将记忆化的函数作为 prop 传递 */}
<MyButton onClick={handleIncrement}>Increment Count</MyButton>
<button onClick={() => setOtherState(o => o + 1)}>
Change Other State
</button>
</div>
);
}
在这个例子中,当我们点击 “Change Other State” 按钮时,Parent
组件会重渲染。但由于 handleIncrement
函数被 useCallback
缓存了,它的引用保持不变,因此 MyButton
组件的 props 没有变化,React.memo
会成功地阻止 MyButton
的重渲染。
5. 优化的考量
“过早的优化是万恶之源。” —— Donald Knuth
Memoization 并非没有成本,它会占用内存并增加代码的复杂性。不要盲目地包裹每一个组件和函数。一般在下面的情况下我们会采取优化:
- 当性能问题确实存在时:使用 React DevTools Profiler 等工具分析应用,找到渲染缓慢或渲染过于频繁的组件,然后针对性地进行优化。
- 当向
memo
组件传递 props 时:如果你将一个组件用React.memo
包裹了,那么你就应该确保传递给它的引用类型 props(如函数、对象)是经过useCallback
或useMemo
记忆化的,否则memo
将毫无意义。 - 当一个值被用作其他 Hook 的依赖时:如果一个函数或对象被用在
useEffect
等 Hook 的依赖数组中,为了避免 effect 不必要地重复执行,也应该将其记忆化。