Dark Dwarf Blog background

React Ref, Memorization 与性能优化

React Refs, Memoization 与性能优化

1. useRef

a.a. 基本概念

在 React 的声明式世界中,我们通常让 React 来负责所有的 DOM 更新。但有时,我们需要一个“逃生舱口”来直接与 DOM 交互,或者在多次渲染之间“记住”一些不触发视图更新的信息。这就是 useRef 的用武之地。同时,为了应对不必要的重渲染带来的性能问题,React 提供了 useMemouseCallbackReact.memo 等一系列 memoization 工具。

useRef Hook 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数。这个 ref 对象在组件的整个生命周期内保持不变。

useRef 想象成一个可以随身携带的、不会在每次渲染时重置的“记事本”或“工具箱”。我们可以往里面存放任何东西,并且修改它不会像修改 state 一样触发组件的重新渲染。这是 useRefuseState 最核心的区别。

b.b. 访问和操作 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" />;
}

c.c. 存储任何可变值

useRef 的第二个强大用途是作为一个“实例变量”的容器。它用于存储任何需要在多次渲染之间保持不变、但其变化又不应触发重渲染的值。常见的模式如下:

  1. 存储上一次的 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 得到上一次的值,然后再更新它。

  1. 管理定时器或订阅:useRef 非常适合存储 setIntervalsetTimeout 返回的 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>
  );
}
  1. 在事件处理器中获取最新的 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 中,当一个组件的 stateprops 改变时,它会重新渲染。这通常会导致两种性能问题:

  1. 昂贵的计算:组件内部有复杂的计算逻辑,每次渲染都重新执行,即使输入没有变化。
  2. 引用类型的 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

为了解决引用相等性问题,我们需要 useMemouseCallback 来稳定函数和对象的引用。

a.a. 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,
}), []); // 空依赖,对象只创建一次

b.b. useCallback: 缓存函数引用

useCallback 用于缓存一个函数本身。它同样接收一个函数和一个依赖项数组。它返回该函数的记忆化版本,该版本只有在依赖项改变时才会更新。

useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。它的主要用途是与 React.memo 配合,向子组件传递一个稳定的函数引用。

c.c. 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 并非没有成本,它会占用内存并增加代码的复杂性。不要盲目地包裹每一个组件和函数。一般在下面的情况下我们会采取优化:

  1. 当性能问题确实存在时:使用 React DevTools Profiler 等工具分析应用,找到渲染缓慢或渲染过于频繁的组件,然后针对性地进行优化。
  2. 当向 memo 组件传递 props 时:如果你将一个组件用 React.memo 包裹了,那么你就应该确保传递给它的引用类型 props(如函数、对象)是经过 useCallbackuseMemo 记忆化的,否则 memo 将毫无意义。
  3. 当一个值被用作其他 Hook 的依赖时:如果一个函数或对象被用在 useEffect 等 Hook 的依赖数组中,为了避免 effect 不必要地重复执行,也应该将其记忆化。