Files
cs-note/hhs/REACT/2. Hooks 篇/06-性能优化 Hooks.md
T
2026-05-24 11:42:38 +08:00

13 KiB
Raw Blame History

tags, create time
tags create time
React
Hooks
Performance
useMemo
useCallback
Frontend
2026-04-29 22:05

性能优化 Hooks

概述

当 React 应用出现不必要的重渲染时,开发者最先想到的是 useMemo 和 useCallback。但它们也有认知成本——不恰当的使用反而会让代码更慢更乱。理解何时该用、何时不该用是关键。

[!question] 思考:为什么大部分 React 应用根本不需要 useMemo? 现代浏览器处理一个普通 JavaScript 函数只需几微秒,而 useMemo 自身就有缓存对比 + 闭包引用的开销。在没有测量(measure)之前就加记忆化,本质上是猜。

flowchart TD
    A["发现卡顿"] --> B{"是否在 DevTools<br/>Profiler 中定位?"}
    B -->|否| C["先安装 React DevTools<br/>定位瓶颈组件"]
    B -->|是| D{"子组件是否因<br/>props 引用变化重渲染?"}
    D -->|是| E["给子组件加 React.memo"]
    D -->|否| F{"计算是否真的昂贵?"}
    F -->|是| G["用 useMemo 缓存结果"]
    F -->|否| H["不要优化 — 保持简单"]
    E --> I{"回调引用是否<br/>每次都被替换?"}
    I -->|是| J["用 useCallback 稳定引用"]
    I -->|否| K["不要优化"]
    
    style C fill:#F5A87D,color:#000
    style H fill:#F5A87D,color:#000
    style K fill:#F5A87D,color:#000
    style G fill:#4FC08D,color:#fff
    style J fill:#4FC08D,color:#fff

本文聚焦以下 Hooks,按实用场景排序:

Hook 作用 React 版本
useMemo 缓存计算结果 16.8+
useCallback 缓存函数引用 16.8+
useTransition 标记低优先级更新 18+
useDeferredValue 产生延迟值副本 18+
useDebugValue 自定义 Hook 调试标记 16.8+
useId 服务端一致的唯一 ID 18.3+

[!note] 前置知识 这些 Hooks 建立在 useState / useEffect 的基础上。如果你还不熟悉核心 Hooks,请先阅读 05-核心 Hooks。


useMemo

const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);

执行时机

时机 行为
依赖未变 返回上次缓存的值(不调用计算函数)
任一依赖变化 重新执行计算函数并缓存新结果
组件首次渲染 执行计算(无缓存可用)

适用场景

// ✅ 适用:昂贵计算 + 频繁 re-render
const sortedUsers = useMemo(() => {
  return users.sort((a, b) => a.name.localeCompare(b.name));
}, [users]);

// ✅ 适用:复杂对象/数组衍生值
const userStats = useMemo(() => ({
  total: users.length,
  active: users.filter(u => u.active).length,
  avgAge: users.reduce((sum, u) => sum + u.age, 0) / users.length,
}), [users]);

// ❌ 不适用:简单运算(开销比 useMemo 本身还大)
const double = useMemo(() => count * 2, [count]);

[!tip] 判断标准

  • 如果表达式只包含 + - * / 或简单的 .filter() / .map() → 不需要 useMemo
  • 如果涉及 API 调用、深层遍历、DOM 测量、大量排序 → 考虑 useMemo
  • 如果传给了 React.memo 包裹的子组件作为 prop → 优先考虑 useMemo

常见陷阱

[!warning] 依赖陷阱:useMemo 的第二个参数决定了一切

// ❌ 忘记放入依赖 —— 闭包捕获旧值
const memo = useMemo(() => items.filter(i => i.price > minPrice), [items]);
// minPrice 变了但 memo 没刷新!

// ✅ 列出所有外部引用
const memo = useMemo(() => items.filter(i => i.price > minPrice), [items, minPrice]);

useCallback

const memoizedCallback = useCallback(
  (arg1: string, arg2: number) => doSomething(arg1, arg2),
  [dep1, dep2],
);

本质

flowchart LR
    A["每次渲染创建新函数"] --> B["子组件收到新引用"]
    B --> C["子组件 re-render<br/>即使 props 内容没变"]
    
    D["useCallback 包装"] --> E["保持同一函数引用"]
    E --> F["memo / React.memo<br/>拦截重渲染"]
    
    style C fill:#F5A87D,color:#000
    style F fill:#4FC08D,color:#fff

典型使用模式

// 父组件:稳定 callback 引用传给子组件
function Parent() {
  const [query, setQuery] = useState("");
  
  // 不包装:每次 render 都是新函数 → Child 永远 re-render
  // const handleChange = (e) => setQuery(e.target.value);
  
  // 包装:引用稳定,Child 在 query 不变时不重渲染
  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
  }, []);  // setQuery 引用稳定(setState),可不列入依赖
  
  return <SearchInput value={query} onChange={handleChange} />;
}

// 子组件:配合 React.memo 生效
const SearchInput = React.memo(({ value, onChange }: { value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }) => {
  return <input value={value} onChange={onChange} />;
});

[!example] useCallback + React.memo 联合效果 见 05-核心 Hooks 中"三者联合:防止过度重渲染"章节,有完整示例。

依赖数组设计原则

[!summary] 不要把所有局部变量都塞进依赖数组

// ❌ 错误思路:把所有变量都列上
const handler = useCallback(() => {
  doSomething(a, b, c, d, e, f);
}, [a, b, c, d, e, f]);
// 几乎所有变量都会变 → 几乎每次都生成新函数 → 失去意义

// ✅ 正确思路:思考"哪些真正影响这个回调的行为"
const handler = useCallback(() => {
  fetchData(keyword);  // keyword 是唯一外部依赖
}, [keyword]);

关键经验:setState setter 函数和 useRef.current 引用是稳定的,不需要放入依赖数组。


useTransition(React 18)

const [isPending, startTransition] = useTransition();

function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  
  const handleChange = (q: string) => {
    setQuery(q);  // 同步更新(立即渲染,高优先级)
    
    // 标记为 transition:低优先级更新
    startTransition(() => {
      setResults(performSearch(q));  // 延迟渲染,不阻塞 UI
    });
  };
  
  return (
    <>
      <input value={query} onChange={e => handleChange(e.target.value)} />
      {isPending && <Spinner />}
      <Results list={results} />
    </>
  );
}

语义解读

useTransition 的核心思想是:将一批状态更新分为"马上显示"和"稍后显示"。startTransition 内部的 setState 会被降级为后台优先级,React 可以随时中断它去响应用户交互。

使用场景区别

quadrantChart
    title "更新优先级划分"
    x-axis "高优先级" --> "低优先级"
    y-axis "数据驱动" --> "UI 展示"
    "onClick → setState": [0.8, 0.9]
    "路由切换": [0.7, 0.7]
    "search → results": [0.3, 0.8]
    "scroll animation": [0.2, 0.3]
    "typing in input": [0.9, 0.5]

useTransition vs useDeferredValue

维度 useTransition useDeferredValue
控制粒度 包裹特定 setState 包裹整个值
语义 启动一个低优先级事务 产生一个延迟副本
适合场景 表单提交后加载详情 搜索框前后分屏
使用复杂度 需手动选择哪些更新降级 一行封装,自动推导

[!tip] 如何选择?

  • 你想让 UI 看起来流畅(输入立刻响应,结果稍后出来)→ useDeferredValue
  • 你有一组相关更新想让它们一起降级 → useTransition
  • 不确定?先用 useTransition,它在大多数场景下都能工作

useDeferredValue(React 18)

function SearchPage() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);  // query 即时更新,deferredQuery 延迟更新
  
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {/* 快速响应用户输入 */}
      <QuickSuggestions keyword={query} />
      {/* 耗时操作延迟渲染 */}
      <HeavyResultsList keyword={deferredQuery} />
    </>
  );
}

内部工作原理

sequenceDiagram
    participant U as 用户输入
    participant S as useState (query)
    participant D as useDeferredValue
    participant R as React Scheduler

    U->>S: setQuery("abc")
    S-->>R: 高优先级更新(立即渲染)
    Note over R: 输入框立刻显示 "abc"
    R->>D: query 已更新
    D->>R: 请求延迟值(可被抢占)
    Note over R: 如果有更高优先级事件<br/>会暂停 deferred 渲染
    R->>R: 空闲时渲染 HeavyResultsList

[!question] 思考:deferred 值延迟多久? React 没有固定的延迟时间。它的策略是:"如果有更高优先级的工作要处理(比如用户正在打字),就暂缓 deferred 渲染;等浏览器空闲了再补上。"这使得它既能保证流畅性,又能最终呈现结果。


useDebugValue(自定义 Hook 调试)

[!abstract] 当你编写自定义 Hook 时,如何在 React DevTools 中看到有意义的值? 这就是 useDebugValue 的用武之地 —— 它为自定义 Hook 添加自定义显示标签。

function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);
  
  // 👇 在 DevTools 中显示可读的 "🟢 Online" 或 "🔴 Offline"
  useDebugValue(online ? "🟢 Online" : "🔴 Offline");
  
  useEffect(() => {
    const onOnline = () => setOnline(true);
    const onOffline = () => setOnline(false);
    window.addEventListener("online", onOnline);
    window.addEventListener("offline", onOffline);
    return () => {
      window.removeEventListener("online", onOnline);
      window.removeEventListener("offline", onOffline);
    };
  }, []);
  
  return online;
}

延迟格式化(高性能场景)

function useFetch(url: string) {
  const [data, setData] = useState(null);
  
  // 延迟格式化:只在 DevTools 展开时才执行 format 函数
  // 避免在开发环境中对大型数据集造成额外开销
  useDebugValue(data, d =>
    d ? `${d.items?.length || 0} items loaded` : "loading..."
  );
  
  // ... fetch logic
  return data;
}

[!tip] 生产环境安全 useDebugValue 在生产构建中会被自动忽略,不会产生任何运行时开销。放心在自定义 Hook 中使用。


useId(React 18.3+)

[!important] SSR 兼容的唯一 ID useId 专为 accessibility 属性设计(如 aria-labelledby、htmlFor)。它保证了服务端和客户端生成的 ID 完全一致,解决了 hydrate mismatch 问题。

function FormField() {
  // 每次渲染生成唯一且稳定的 ID
  const labelId = useId();  
  const inputId = useId();
  
  return (
    <>
      <label id={labelId}>Username</label>
      <input id={inputId} aria-labelledby={labelId} />
    </>
  );
}

useId vs Math.random() vs 手动字符串

方式 SSR 一致 Hydrate 匹配 确定性
useId() ✅ ✅ ✅ 基于树结构
Math.random() ❌ ❌ ❌ 每次随机
手动 "field-1" ✅ ⚠️ 需人工维护 ⚠️ 易重复

[!warning] useId 不适合用于 key useId 生成的 ID 是稳定的但不一定是唯一的(同一组件多次渲染可能返回相同 ID)。列表的 key 仍应使用业务标识符。


性能调优 Checklist

[!summary] 优化顺序 不要一开始就用 useMemo/useCallback!按以下顺序排查:

  1. 减少不必要的 state — 能派生的从 state 中移除(derived state)
  2. 拆分巨型组件 — 子组件独立维护自己的 state
  3. React DevTools Profiler — 定位哪个组件在多余 re-render
  4. 给高频子组件加 React.memo
  5. 最后才考虑 useMemo / useCallback
// Step 1: 先移除所有 useMemo/useCallback 做基准测试
// Step 2: 加上 React.memo 包裹频繁更新的子组件
// Step 3: 仅针对仍存在的瓶颈点添加 useMemo/useCallback

React DevTools Profiler 实操

import { Profiler } from "react";

function onRenderCallback(
  id: string,            // "MyComponent"
  phase: "mount" | "update",
  actualDuration: number // 本次渲染耗时(ms)
) {
  console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
}

// 用法:包裹需要监控的 subtree
<Profiler id="App" onRender={onRenderCallback}>
  <App />
</Profiler>

[!tip] Profiler API vs DevTools 界面

  • Profiler API:适合持续记录生产环境中的性能数据
  • DevTools 界面:适合开发阶段交互式点击分析哪个组件导致了重渲染 两者可以结合使用。

何时绝对不要用这些 Hook?

[!question] 思考:哪些场景加了优化反而适得其反?

场景 原因
简单算术运算 count * 2 的开销远小于闭包比较
低频渲染组件 一年才重渲染一次的东西不需要优化
顶层容器组件 容器本身很少子组件,优化收益为零
嵌套过深的依赖 useCallback(() => fn(a,b,c,d,e), [a,b,c,d,e]) → 等价于不用

[!note] Google 的前端性能研究结论 在他们的实际项目度量中,超过 90% 的重渲染问题通过合理的 React.memo 就能解决,真正需要 useMemo / useCallback 的场景不足 5%。


关联笔记

  • 05-核心 Hooks — useState / useEffect / useContext / useRef 详解
  • 07-自定义 Hooks — 如何设计和组合可复用的自定义 Hook