React 基础体系 · 第 28/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Memo、useMemo 与 useCallback:引用、成本和正确边界

React.memouseMemouseCallback 都与“跳过不必要的工作”有关,但它们优化的对象不同:

  • React.memo 作用于组件是否需要再次执行渲染函数
  • useMemo 作用于某次组件渲染期间计算出的值
  • useCallback 作用于函数引用,本质上是函数形式的 useMemo

它们共同依赖一个前提:React 必须能够判断“这次输入是否仍然等价”。这个判断通常不是深度比较,而是引用比较。因此,理解引用身份、依赖数组、组件边界和比较成本,比记住几个 API 的调用形式更重要。


一、先建立核心模型:渲染不是 DOM 更新

React 组件可以抽象为一个函数:

V=R(P,S,C)V = R(P, S, C)

其中:

  • PP 是 props;
  • SS 是组件自身的 state;
  • CC 是组件读取到的 context;
  • RR 是组件函数;
  • VV 是组件返回的 React 元素树。

组件函数再次执行,称为一次渲染。渲染之后,React 还会比较新旧结果,并决定是否需要更新真实 DOM。因而:

组件函数执行了,不代表 DOM 一定发生了变化;
DOM 没有变化,也不代表组件函数没有执行。

例如:

function Greeting({ name }: { name: string }) {
  console.log("Greeting render");
  return <h1>Hello, {name}</h1>;
}

如果父组件重新渲染,普通情况下 Greeting 也会再次执行,即使 name 仍然是同一个字符串。React 之后可能发现生成的 <h1> 内容没有变化,因此不修改 DOM,但 Greeting 的函数体已经运行过。

性能优化通常针对两个不同阶段:

  1. 减少组件渲染函数执行React.memo
  2. 减少渲染函数内部昂贵计算useMemo
  3. 保持传给子组件的函数引用稳定useCallback

这三个目标不能互相替代。


二、引用相等:三个 API 共享的基础

JavaScript 中,原始值和对象的相等行为不同:

console.log(Object.is(1, 1)); // true
console.log(Object.is("a", "a")); // true
console.log(Object.is(null, null)); // true

console.log(Object.is({ id: 1 }, { id: 1 })); // false
console.log(Object.is([], [])); // false
console.log(Object.is(() => {}, () => {})); // false

两个对象即使内容相同,只要不是同一个对象,就不相等。函数也是对象,因此每次创建函数表达式通常都会产生新的引用:

const first = () => 1;
const second = () => 1;

console.log(Object.is(first, second)); // false

React 的默认 props 比较使用 Object.is 逐项比较,而不是递归深比较。形式化地说,对于 props:

Psame=kKObject.is(Pold[k],Pnew[k])P_{\text{same}} = \bigwedge_{k \in K} Object.is(P_{\text{old}}[k], P_{\text{new}}[k])

其中 KK 是 props 的键集合。只有每个 prop 都通过比较,React 才认为 props 没有变化。

这解释了下面的结果:

const sameText = "hello";

<Child label={sameText} />;
<Child label={sameText} />;

label 是同一个字符串值,可以通过比较;而下面的对象每次都是新引用:

<Child options={{ theme: "dark" }} />;
<Child options={{ theme: "dark" }} />;

两次 options 在内容上相同,但引用不同。

需要特别注意 Object.is 的边界:

Object.is(NaN, NaN); // true
Object.is(0, -0);    // false

这不是工程中最常见的问题,但说明 React 的默认判断是明确的引用和值语义,而不是“看起来相同”。


三、React.memo:缓存组件的渲染结果是否需要重新计算

3.1 基本用法

import { memo, useState } from "react";

type UserCardProps = {
  name: string;
};

const UserCard = memo(function UserCard({ name }: UserCardProps) {
  console.log("UserCard render");
  return <div>{name}</div>;
});

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount((value) => value + 1)}>
        count: {count}
      </button>
      <UserCard name="Ada" />
    </>
  );
}

点击按钮时,App 会重新渲染。由于传给 UserCardname 仍然是同一个字符串,memo 可以跳过 UserCard 的再次执行。

memo(Component) 返回一个经过记忆化处理的组件。对由父组件传入的 props,React 默认进行逐项浅比较:

父组件重新渲染
        │
        ▼
比较 memo 子组件的新旧 props
        │
   ┌────┴────┐
   │         │
相等       不相等
   │         │
跳过子组件  执行子组件

这里的“跳过”指跳过该组件因父组件更新而产生的渲染,不代表它永远不会渲染。

3.2 memo 不会阻止所有重新渲染

以下情况仍然可能使 memo 组件重新渲染:

  1. 它自己的 state 发生变化;
  2. 它读取的 context 发生变化;
  3. 父组件传入的 props 引用发生变化;
  4. 组件被卸载后重新挂载;
  5. 开发环境下某些检查机制重新调用渲染逻辑。

例如:

import { memo, useContext, useState } from "react";

const ThemeContext = /* 某个 React context */ null as never;

const Panel = memo(function Panel() {
  const theme = useContext(ThemeContext);
  const [open, setOpen] = useState(false);

  return (
    <button onClick={() => setOpen((value) => !value)}>
      {theme} / {open ? "open" : "closed"}
    </button>
  );
});

即使 Panelmemo 包裹,open 的变化仍然需要重新渲染,context 的变化也不能靠 memo 阻止。

因此,memo 的准确含义不是“组件只渲染一次”,而是:

当父组件重新渲染时,如果外部 props 没有变化,React 可以跳过该组件由这次父级更新引起的渲染。

3.3 memo 对 JSX children 也适用引用比较

下面的代码中,<Icon /> 每次父组件渲染都会创建新的 React 元素对象:

const Box = memo(function Box({
  children,
}: {
  children: React.ReactNode;
}) {
  console.log("Box render");
  return <section>{children}</section>;
});

function Page() {
  return (
    <Box>
      <span>内容</span>
    </Box>
  );
}

children 是一个新的 React 元素引用,因此 Box 的 props 可能被判断为变化。若 children 来自稳定引用,结果则不同:

const stableChildren = <span>内容</span>;

function Page() {
  return <Box>{stableChildren}</Box>;
}

这不是说应该到处缓存 JSX,而是说明 memo 比较的是 props 引用,不会理解“两个 React 元素描述的内容看起来一样”。

3.4 自定义比较函数:必须保持判断的正确性

memo 可以接收自定义比较函数:

const UserCard = memo(
  function UserCard({
    id,
    name,
  }: {
    id: string;
    name: string;
  }) {
    return <div>{id}: {name}</div>;
  },
  (previous, next) => {
    return previous.id === next.id && previous.name === next.name;
  },
);

比较函数返回:

  • true:认为 props 等价,可以跳过;
  • false:认为 props 不等价,需要重新渲染。

这里最危险的错误是只比较部分会影响输出的 props:

const BadCard = memo(
  function BadCard({ name }: { name: string }) {
    return <div>{name}</div>;
  },
  () => true,
);

如果父组件后来把 name"Ada" 改成 "Grace",比较函数仍返回 true,React 就可能继续显示旧内容。

自定义比较函数必须满足一个正确性条件:

Compare(P1,P2)=trueR(P1,S,C) 与 R(P2,S,C) 在可观察结果上等价Compare(P_1, P_2) = true \Rightarrow R(P_1, S, C) \text{ 与 } R(P_2, S, C) \text{ 在可观察结果上等价}

也就是说,只有当所有影响组件输出和行为的 props 都等价时,才能返回 true。否则优化会变成数据过期或界面错误。

此外,深比较也不是免费操作。假设:

  • 子组件重新渲染成本为 RR
  • props 比较成本为 CC
  • 父组件触发更新次数为 NN
  • 其中有 MM 次 props 实际变化。

不使用 memo 的近似成本是:

N×RN \times R

使用 memo 后的近似成本是:

N×C+M×RN \times C + M \times R

只有在:

N×R>N×C+M×RN \times R > N \times C + M \times R

时,memo 才有机会带来收益。轻量组件的 RR 很小,比较本身的 CC 可能足以抵消收益。


四、useMemo:缓存一次渲染中的计算结果

4.1 基本语义

import { useMemo } from "react";

const visibleItems = useMemo(
  () => filterItems(items, query),
  [items, query],
);

useMemo 接收:

  1. 一个计算函数;
  2. 一个依赖数组。

在依赖没有变化时,React 可以复用上一次计算得到的值;依赖变化时,重新执行计算函数。

依赖数组中的比较也基于 Object.is。可以把它抽象成:

Dsame=i=1nObject.is(Dold,i,Dnew,i)D_{\text{same}} = \bigwedge_{i=1}^{n} Object.is(D_{\text{old},i}, D_{\text{new},i})

如果依赖相同,返回缓存值;否则执行计算函数并保存新值。

4.2 完整示例:过滤列表、稳定函数和 memo 子组件

下面的例子展示三个机制如何配合:

"use client";

import { memo, useCallback, useMemo, useState } from "react";

type Product = {
  id: number;
  name: string;
  category: "book" | "tool";
};

const products: Product[] = [
  { id: 1, name: "TypeScript Handbook", category: "book" },
  { id: 2, name: "Keyboard", category: "tool" },
  { id: 3, name: "React Guide", category: "book" },
];

type ProductRowProps = {
  product: Product;
  onSelect: (id: number) => void;
};

const ProductRow = memo(function ProductRow({
  product,
  onSelect,
}: ProductRowProps) {
  console.log("render row", product.id);

  return (
    <li>
      <span>{product.name}</span>
      <button onClick={() => onSelect(product.id)}>选择</button>
    </li>
  );
});

export default function ProductList() {
  const [query, setQuery] = useState("");
  const [category, setCategory] = useState<"all" | Product["category"]>("all");
  const [selectedId, setSelectedId] = useState<number | null>(null);

  const visibleProducts = useMemo(() => {
    const normalizedQuery = query.trim().toLowerCase();

    return products.filter((product) => {
      const matchesQuery =
        normalizedQuery === "" ||
        product.name.toLowerCase().includes(normalizedQuery);

      const matchesCategory =
        category === "all" || product.category === category;

      return matchesQuery && matchesCategory;
    });
  }, [query, category]);

  const handleSelect = useCallback((id: number) => {
    setSelectedId(id);
  }, []);

  return (
    <section>
      <label>
        搜索:
        <input
          value={query}
          onChange={(event) => setQuery(event.target.value)}
        />
      </label>

      <label>
        分类:
        <select
          value={category}
          onChange={(event) =>
            setCategory(event.target.value as "all" | Product["category"])
          }
        >
          <option value="all">全部</option>
          <option value="book">书籍</option>
          <option value="tool">工具</option>
        </select>
      </label>

      <p>已选择:{selectedId ?? "无"}</p>

      <ul>
        {visibleProducts.map((product) => (
          <ProductRow
            key={product.id}
            product={product}
            onSelect={handleSelect}
          />
        ))}
      </ul>
    </section>
  );
}

这个例子中的因果链是:

  1. querycategory 变化;
  2. visibleProducts 的依赖变化;
  3. useMemo 重新执行过滤;
  4. 过滤后的数组被传给 map
  5. handleSelect 的依赖为空,因此在组件生命周期内保持同一个返回引用;
  6. 对于仍然存在且 props 未变化的 ProductRowmemo 有机会跳过渲染。

useMemo 并不是为了让“每次过滤都变快”。这里的过滤数据只有三个元素,计算成本极低,缓存本身可能没有实际收益。这个例子的主要价值是展示引用关系;真实项目中是否值得保留,应由计算成本和 Profiler 数据决定。

4.3 useMemo 缓存的是引用,不是“内容相等性”

const options = useMemo(
  () => ({ matchMode: "whole-word", text: query }),
  [query],
);

query 不变时,options 通常保持同一个对象引用:

const options1 = /* 第一次渲染得到的对象 */;
const options2 = /* 依赖未变时得到的对象 */;

Object.is(options1, options2); // 通常为 true

这可能用于稳定传给 memo 子组件的 props,或者稳定另一个 Hook 的依赖。

但是,useMemo 不会把对象变成不可变对象,也不会自动阻止代码修改它:

const config = useMemo(() => ({ enabled: true }), []);

config.enabled = false; // TypeScript 可能允许,运行时也可能发生

React 只负责缓存引用,不负责维护对象内部的业务不变性。要避免这类问题,应通过类型、封装和不可变更新约束数据,而不是把 useMemo 当作冻结机制。

4.4 依赖数组不完整会产生旧值

错误示例:

const visibleItems = useMemo(
  () => filterItems(items, query),
  [items], // 忘记 query
);

query 改变而 items 不变时,React 会认为依赖没有变化,继续返回旧的过滤结果。表现可能是输入框已经显示新关键词,列表却没有同步更新。

依赖数组的正确条件是:

计算函数读取的所有渲染期外部值依赖数组\text{计算函数读取的所有渲染期外部值} \subseteq \text{依赖数组}

“外部值”包括 props、state,以及在组件函数中定义并被计算函数读取的变量。并不意味着所有值都必须机械加入数组;例如模块级常量通常不会在渲染期间变化。但对组件作用域内的响应式值,应让依赖分析工具帮助检查。

4.5 useMemo 不是语义保证

React 官方将 useMemo 定义为性能优化工具,而不是程序正确性所必需的缓存协议。React 在某些情况下可能丢弃缓存并重新计算,例如:

  • 开发环境的检查行为;
  • 组件初次挂载过程中发生中断或挂起;
  • React 未来的调度策略;
  • 组件被卸载后重新挂载。

因此,计算函数必须是可重复执行的,不应依赖“它只会执行一次”:

const value = useMemo(() => {
  externalList.push("side effect"); // 不应这样做
  return expensiveCalculation();
}, []);

useMemo 的计算函数应尽量是纯计算:给定相同输入,得到相同结果,并且不依赖执行次数。

不要使用下面的方式保存必须长期存在的业务数据:

const session = useMemo(() => createSession(), []);

如果 session 的生命周期和组件状态相关,应考虑 useState;如果需要稳定引用但不触发渲染,可考虑 useRef;如果它是跨请求、跨组件或服务端缓存,则应使用对应的数据缓存机制,而不是把 useMemo 当作通用缓存。


五、useCallback:缓存函数引用

5.1 它解决的是“引用变化”,不是“函数执行变快”

基本写法:

const handleClick = useCallback(() => {
  setCount((value) => value + 1);
}, []);

useCallback(fn, dependencies) 可以近似理解为:

useMemo(() => fn, dependencies);

它返回一个函数引用。在依赖没有变化时,返回上一次的函数;依赖变化时,返回新的函数。

但要准确理解“缓存函数”:

const handleClick = useCallback(() => {
  setCount((value) => value + 1);
}, []);

组件每次执行时,函数表达式本身仍会被求值并产生函数对象;React 决定是否把新函数作为结果返回。useCallback 的主要收益不是消灭 JavaScript 中函数表达式的所有创建成本,而是让下游看到的返回引用保持稳定。

因此它通常只有在以下场景中有意义:

  1. 函数被传给 React.memo 子组件;
  2. 函数作为另一个 Hook 的依赖;
  3. 某个外部订阅或系统 API 需要稳定的回调身份。

如果函数只在当前组件内部使用,并没有作为依赖或 props 传递,稳定它通常没有可观察收益:

function SearchBox() {
  const [query, setQuery] = useState("");

  const normalized = query.trim().toLowerCase();

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
  );
}

这里的内联事件函数没有传给 memo 子组件,也没有作为 Hook 依赖,通常不需要为了形式统一而包裹 useCallback

5.2 闭包决定依赖数组

函数会捕获定义它时可见的变量:

function UserPanel({ userId }: { userId: string }) {
  const handleLoad = useCallback(() => {
    console.log(userId);
  }, [userId]);

  return <button onClick={handleLoad}>加载</button>;
}

如果遗漏 userId

const handleLoad = useCallback(() => {
  console.log(userId);
}, []); // 错误:会捕获初次渲染的 userId

userId 改变时,handleLoad 仍然可能引用旧值。这就是典型的 stale closure(过期闭包)。

状态更新函数的函数式写法可以减少对旧 state 的直接依赖:

const increment = useCallback(() => {
  setCount((current) => current + 1);
}, []);

因为回调没有读取组件作用域中的 count,而是把更新逻辑交给 React 提供的当前值,所以依赖数组可以为空。

对比下面两种写法:

const increment = useCallback(() => {
  setCount(count + 1);
}, [count]);
const increment = useCallback(() => {
  setCount((current) => current + 1);
}, []);

第一种回调依赖 count,每次 count 改变都会产生新函数;第二种使用函数式更新,回调不需要捕获具体的 count,引用可以更稳定。二者都可能正确,区别在于闭包和依赖关系,而不是某个写法永远更快。

5.3 useCallbackmemo 必须形成引用链

下面的 memo 不一定有用:

const Button = memo(function Button({
  onClick,
}: {
  onClick: () => void;
}) {
  return <button onClick={onClick}>保存</button>;
});

function Page() {
  const [count, setCount] = useState(0);

  const handleSave = () => {
    console.log("save");
  };

  return (
    <>
      <button onClick={() => setCount((value) => value + 1)}>
        {count}
      </button>
      <Button onClick={handleSave} />
    </>
  );
}

每次 Page 渲染,handleSave 都是新函数。因此 ButtononClick prop 变化,memo 可能无法跳过。

配合 useCallback 后:

function Page() {
  const [count, setCount] = useState(0);

  const handleSave = useCallback(() => {
    console.log("save");
  }, []);

  return (
    <>
      <button onClick={() => setCount((value) => value + 1)}>
        {count}
      </button>
      <Button onClick={handleSave} />
    </>
  );
}

Page 只因 count 改变而重新渲染时,Button 收到的 onClick 引用仍然相同,memo 才有机会发挥作用。

但是,如果 Button 还收到其他每次都变化的 props,单独稳定 onClick 仍不足以阻止渲染:

<Button
  onClick={handleSave}
  style={{ color: "blue" }} // 每次都是新对象
/>

优化是否成立,要观察完整的 props 引用图,而不是只看某一个回调。


六、三个 API 的边界对比

API 缓存对象 主要判断依据 典型用途 不解决的问题
React.memo 组件因父级更新产生的渲染 props 比较 跳过昂贵子组件渲染 state、context、变化的 props
useMemo 一个计算结果的引用 依赖数组 避免昂贵计算或稳定对象/数组 数据持久化、深度相等、业务缓存
useCallback 一个函数引用 依赖数组 稳定传给子组件或作为依赖的回调 让函数执行本身更快、自动修复闭包

可以用一个更完整的成本模型理解它们。

设:

  • RcR_c:子组件渲染成本;
  • RmR_m:父组件中某个计算成本;
  • CpC_pmemo 的 props 比较成本;
  • CdC_d:Hook 依赖比较和缓存管理成本;
  • NN:发生的渲染次数;
  • HH:依赖或输入实际变化的次数。

使用 useMemo 的近似成本:

N×Cd+H×RmN \times C_d + H \times R_m

不使用时:

N×RmN \times R_m

使用 useMemo 可能有收益的条件是:

N×Rm>N×Cd+H×RmN \times R_m > N \times C_d + H \times R_m

同理,useCallback 带来的收益通常不是直接减少回调执行,而是减少因为回调引用变化导致的下游成本:

收益被跳过的下游渲染成本依赖比较与缓存成本\text{收益} \approx \text{被跳过的下游渲染成本} - \text{依赖比较与缓存成本}

如果回调没有传给任何敏感的下游边界,那么“保持函数稳定”没有可跳过的成本,收益接近于零。


七、一个完整反例:缓存了错误的依赖

下面的代码看起来像是在优化过滤:

function TodoList({
  todos,
  query,
}: {
  todos: { id: number; title: string }[];
  query: string;
}) {
  const filteredTodos = useMemo(() => {
    return todos.filter((todo) =>
      todo.title.toLowerCase().includes(query.toLowerCase()),
    );
  }, [todos]);

  return (
    <ul>
      {filteredTodos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

状态变化过程如下:

  1. 初始输入:todos = [...]query = ""
  2. useMemo 计算全部任务;
  3. 用户输入 "react"
  4. query 变化,但 todos 引用不变;
  5. React 判断依赖数组没有变化;
  6. useMemo 返回旧数组;
  7. 输入框可能已经显示 "react",列表仍显示全部任务。

这里的问题不是 useMemo 失效,而是代码错误地声明了依赖关系。修正为:

const filteredTodos = useMemo(() => {
  const normalizedQuery = query.toLowerCase();

  return todos.filter((todo) =>
    todo.title.toLowerCase().includes(normalizedQuery),
  );
}, [todos, query]);

如果不需要缓存,也可以直接计算:

const filteredTodos = todos.filter((todo) =>
  todo.title.toLowerCase().includes(query.toLowerCase()),
);

后者更简单,且在列表很小或父组件渲染次数不高时可能更合适。


八、一个完整反例:自定义比较函数造成旧界面

type ProfileProps = {
  user: {
    id: string;
    name: string;
    avatarUrl: string;
  };
};

const Profile = memo(
  function Profile({ user }: ProfileProps) {
    return (
      <article>
        <img src={user.avatarUrl} alt="" />
        <strong>{user.name}</strong>
      </article>
    );
  },
  (previous, next) => previous.user.id === next.user.id,
);

假设服务端返回了同一个用户的新头像:

previous.user = {
  id: "1",
  name: "Ada",
  avatarUrl: "/ada-old.png",
};

next.user = {
  id: "1",
  name: "Ada",
  avatarUrl: "/ada-new.png",
};

比较函数只看 id,返回 true,于是组件可能继续使用旧的头像。虽然用户身份没有变,但组件输出依赖 avatarUrl,所以两个 props 并不等价。

更可靠的选择通常是:

const Profile = memo(function Profile({ user }: ProfileProps) {
  return (
    <article>
      <img src={user.avatarUrl} alt="" />
      <strong>{user.name}</strong>
    </article>
  );
});

让数据层保持不可变引用,当用户对象内容更新时产生新引用,默认浅比较即可工作。只有在确实测量到默认比较不足时,才应考虑自定义比较,并列出所有影响渲染的字段。


九、对象、数组和函数的引用稳定策略

9.1 不要为了 memo 而无条件制造缓存

const style = useMemo(() => ({ color: "red" }), []);

这能稳定 style 引用,但如果子组件很轻量,或者子组件本身没有使用 memo,这个缓存通常没有实际价值。

可以先采用更直接的写法:

<Label style={{ color: "red" }} />

只有当以下因果链真实存在时,稳定引用才值得考虑:

父组件频繁更新
    │
    ▼
新建对象/数组/函数
    │
    ▼
memo 子组件 props 被判定为变化
    │
    ▼
子组件执行昂贵渲染

9.2 useMemo 只能稳定当前组件生命周期内的引用

const columns = useMemo<ColumnDef<Row>[]>(
  () => [
    { key: "name", title: "名称" },
    { key: "status", title: "状态" },
  ],
  [],
);

这类写法常用于表格列定义、配置对象或派生数组。但空依赖数组表达的是:

只要这个组件实例仍然存在,就不依赖组件渲染期间的动态值。

如果列定义读取了 props,却使用空依赖:

const columns = useMemo(
  () => [{ title: `${userName} 的数据` }],
  [],
);

那么 userName 后续变化时标题可能保持旧值。应改为:

const columns = useMemo(
  () => [{ title: `${userName} 的数据` }],
  [userName],
);

或者如果计算很简单,直接写成普通表达式,避免引入缓存语义。


十、开发环境行为与并发渲染边界

React 现代渲染模型允许渲染工作被开始、暂停、继续或放弃。组件渲染函数和 useMemo 计算函数都不应承担必须只执行一次的副作用。

开发环境下启用 Strict Mode 时,React 还可能故意重复调用某些逻辑,以帮助发现不纯函数。例如:

const value = useMemo(() => {
  console.log("calculate");
  return expensiveCalculation(data);
}, [data]);

开发环境中看到计算函数打印多次,并不能直接证明 useMemo 在生产环境失效。应区分:

  • 规范保证:依赖未变化时,React 可以复用缓存;
  • 实现和开发检查行为:开发环境可能额外调用;
  • 优化假设:不能把缓存当作永久存储或副作用执行点。

在并发渲染中,一次渲染也可能没有最终提交到屏幕。因而以下行为是不安全的:

const result = useMemo(() => {
  analytics.track("calculated"); // 不应在这里上报
  return calculate();
}, [input]);

副作用应放在适合的生命周期机制中,例如 useEffect,并准确处理依赖和清理;纯计算则留在 useMemo 或普通表达式中。


十一、客户端与服务端边界

11.1 Hook 代码必须位于客户端组件边界内

在使用 React Server Components 的框架中,服务端组件和客户端组件职责不同。useStateuseEffectuseMemouseCallback 等客户端 Hook 应放在客户端组件中,通常需要在文件顶部声明:

"use client";

import { useCallback, useMemo, useState } from "react";

这里的 "use client" 是模块边界声明,不是普通运行时函数调用。它表示该模块及其客户端依赖需要进入客户端组件图。

服务端组件不能简单地把一个客户端回调传给浏览器:

// 服务端组件中不能把普通函数作为客户端 props 直接传递
<ClientButton onClick={() => console.log("click")} />

函数通常不可序列化,服务端生成的组件树需要通过框架规定的协议传递到客户端。正确方式是让客户端组件在客户端定义交互逻辑:

// ClientButton.tsx
"use client";

import { useCallback } from "react";

export function ClientButton() {
  const handleClick = useCallback(() => {
    console.log("click");
  }, []);

  return <button onClick={handleClick}>点击</button>;
}

不过,即使在客户端组件中,useCallback 也不是因为“事件处理函数必须缓存”才存在。只有当这个回调需要稳定地传给 memo 子组件、Effect、订阅系统等,缓存才可能有意义。

11.2 服务端渲染不等于服务端缓存

组件在服务端渲染时可能执行函数体,但 useMemo 的引用缓存不应被理解为跨请求缓存:

  • 它不是数据库缓存;
  • 不是 HTTP 缓存;
  • 不是跨请求共享的计算结果;
  • 也不能保证客户端水合后仍然拥有同一个 JavaScript 引用。

服务端数据缓存应使用框架提供的请求缓存、数据缓存或服务端专用 API。useMemo 的边界是组件渲染过程中的性能优化,而不是服务器级缓存策略。


十二、React 19 与自动记忆化

React 生态正在提供 React Compiler 等自动优化能力。在启用并正确配置编译器的项目中,部分值和函数可能由编译器自动进行记忆化,从而减少手写 useMemouseCallback 的需要。

但这不改变几个基础事实:

  1. 自动优化是否生效取决于项目的编译配置、代码是否满足分析条件以及框架集成方式;
  2. React.memouseMemouseCallback 的语义仍然需要理解,因为它们可能仍出现在现有代码和第三方库中;
  3. 自动记忆化不会修复错误的状态建模、错误的依赖、可变对象或不正确的自定义比较函数;
  4. 不能因为使用 React 19 就假设所有组件都会自动跳过渲染。

如果项目启用了 React Compiler,应以项目实际构建配置和官方当前文档为准。手写缓存不应作为“看起来更专业”的装饰;它应当服务于明确的数据流和测量结果。


十三、如何诊断优化是否真的成立

看到组件频繁渲染时,先确认问题属于哪一类:

13.1 组件执行成本高

使用 React DevTools Profiler 观察:

  • 哪个组件重新渲染;
  • 是什么更新触发了它;
  • 渲染耗时集中在哪里;
  • 子树是否因为某个父级状态而整体重新执行。

如果组件本身昂贵,并且父级更新频繁,可以评估 React.memo

13.2 计算成本高

把计算拆成可测量的函数:

function buildRows(
  items: Item[],
  query: string,
): Item[] {
  return items.filter((item) =>
    item.name.toLowerCase().includes(query.toLowerCase()),
  );
}

在开发诊断阶段可以测量:

const start = performance.now();
const rows = buildRows(items, query);
const elapsed = performance.now() - start;

console.log(`buildRows: ${elapsed.toFixed(2)}ms`);

不要把开发环境日志的次数直接当成生产性能结论;应结合生产构建、真实数据规模和用户交互路径判断。

13.3 props 引用不稳定

检查传给 memo 子组件的 props:

<Child
  data={data}
  options={{ dense: true }}
  onSelect={(id) => select(id)}
/>

这里 optionsonSelect 每次都会产生新引用。可以先确认它们是否真的导致了昂贵子组件重新渲染,再决定是否改成:

const options = useMemo(() => ({ dense: true }), []);

const onSelect = useCallback((id: number) => {
  select(id);
}, [select]);

如果 select 本身每次变化,onSelect 也会变化;稳定回调的依赖关系必须真实反映闭包读取的数据。


十四、正确边界:什么时候使用,什么时候不使用

适合考虑 React.memo 的情况:

  • 子组件渲染成本明显较高;
  • 父组件更新频繁;
  • 子组件 props 大多数时候保持引用稳定;
  • Profiler 能观察到重复渲染确实构成瓶颈。

适合考虑 useMemo 的情况:

  • 计算本身明显昂贵;
  • 计算结果作为 memo 子组件的 prop,且需要稳定引用;
  • 计算结果作为另一个 Hook 的依赖;
  • 依赖关系能够被准确表达。

适合考虑 useCallback 的情况:

  • 回调传给了 React.memo 子组件;
  • 回调作为 Effect、订阅或其他 Hook 的依赖;
  • 外部系统明确要求回调身份稳定。

不应仅因为以下理由使用它们:

  • “所有函数都应该用 useCallback”;
  • “所有对象都应该用 useMemo”;
  • “包上 memo 就不会再渲染”;
  • “空依赖数组等于只执行一次”;
  • “深比较一定比重新渲染便宜”。

最终可以用一个问题判断边界:

这次引用变化是否会导致一个可测量、可避免且正确地可跳过的工作?

如果没有明确的下游成本,缓存只是增加依赖维护、比较和认知负担。真正可靠的优化不是尽可能多地使用 Memo API,而是让状态边界、数据引用和组件职责清晰,然后只在成本模型支持时缓存。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。