React Hooks深入解析

张玥 2026年9月19日 阅读时间 70分钟
React Hooks 函数组件 性能优化 最佳实践
前端开发技巧之React Hooks深入解析

React 16.8 在 2019 年 2 月正式发布 Hooks。七年过去,它早已不是“新特性”,而是 React 的默认写法。但打开一个真实项目,你大概率仍会看到这样的代码:useEffect 里塞满了本该在渲染期直接计算出来的派生数据,依赖数组靠“凭感觉”填写,useMemo 和 useCallback 被当成“性能开关”到处乱贴,自定义 Hook 变成了一堆以 use 开头的普通函数。Hooks 带来的不是语法糖,而是一整套新的心智模型:把组件的状态与副作用,按照“功能”而不是“生命周期”来组织。不理解这一点,写出来的 Hooks 代码只会比类组件更难维护。本篇 70 分钟长文,从设计动机讲到实现原理,从每一个内置 Hook 的适用边界讲到自定义 Hook 的设计方法,从常见陷阱讲到测试与调试,最后给出一份可以贴在工位上的检查清单。

一、Hooks 之前的世界:类组件的三座大山

要真正理解 Hooks 的价值,得先回到它诞生之前。在 React 15/16 时代,编写一个带状态的组件意味着必须写一个 class。这不是风格问题,而是当时唯一的选择。而类组件在实践中暴露出了三个难以回避的问题。

1.1 逻辑复用:HOC 与 render props 的嵌套地狱

React 的复用单位从来都是“组件”,而不是“逻辑”。于是社区发明了高阶组件(HOC)和 render props 两种模式,试图把一段逻辑“借”给另一个组件用。结果就是著名的 wrapper hell:

// 一个需要 数据订阅 + 主题 + 国际化 + 权限 的组件
export default withPermission(
  withI18n(
    withTheme(
      withSubscription(
        withRouter(MyComponent)
      )
    )
  )
));

每一层包装都会在 DevTools 里多出一层组件节点,props 的来源变得不透明,命名冲突需要靠约定去规避。更麻烦的是,这些逻辑彼此之间无法自然地组合——你没法在一个 HOC 内部“调用”另一个 HOC 的行为。

1.2 生命周期:把一件事拆成三处写

类组件要求你按“生命周期阶段”组织代码。于是一个功能(比如订阅数据)的建立、更新、清理,会被强制拆散到三个不同的方法里:

class FriendStatus extends React.Component {
  componentDidMount() {
    ChatAPI.subscribe(this.props.friend.id, this.handleStatusChange);
    this.timer = setInterval(this.tick, 1000);
  }

  componentDidUpdate(prevProps) {
    if (prevProps.friend.id !== this.props.friend.id) {
      ChatAPI.unsubscribe(prevProps.friend.id, this.handleStatusChange);
      ChatAPI.subscribe(this.props.friend.id, this.handleStatusChange);
    }
  }

  componentWillUnmount() {
    ChatAPI.unsubscribe(this.props.friend.id, this.handleStatusChange);
    clearInterval(this.timer);
  }
}

订阅逻辑被切成了三块,中间还夹着计时器逻辑。当组件增长到几百行,同一个功能的代码散落在各处,改动一个功能需要同时修改三个地方——这是 bug 的温床。

1.3 this 与编译优化

类组件还有两个“隐性成本”:一是 this 的绑定问题,事件处理函数必须手动绑定或者写成箭头函数类字段,初学者极易踩坑;二是类组件对编译器与工具链不够友好——this 的语义使得静态分析更困难,热更新时类实例的状态保留也更棘手。

复用难 逻辑复用只能靠 HOC / render props,导致嵌套地狱与来源不透明
组织难 同一功能被生命周期切碎,相关代码被迫分散
心智负担 this 绑定、类字段、生命周期顺序,都是额外要学的东西
优化受限 类实例妨碍编译器做更激进的优化与内联

Hooks 的回答

Hooks 的核心主张只有一句:让函数组件拥有状态与副作用能力,并让逻辑按“功能”而非“生命周期”聚合。于是上面那段订阅逻辑,可以完整地写在一块:

function useFriendStatus(friendId) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribe(friendId, handleStatusChange);
    return () => ChatAPI.unsubscribe(friendId, handleStatusChange);
  }, [friendId]);

  return isOnline;
}

建立与清理写在同一个闭包里,依赖清晰地列在数组里,逻辑可以被任意组件通过一行 useFriendStatus(id) 复用,不产生任何多余的组件节点。

二、两条规则与它们背后的实现原理

每个 React 开发者都背过那两条规则:只在函数组件或自定义 Hook 的顶层调用 Hooks,以及只在 React 函数中调用 Hooks。但真正理解“为什么”,才能在写代码时不假思索地遵守它们。

2.1 Hook 是一条链表

React 并没有为每个 Hook 使用名字来存储状态——因为在压缩后名字会丢失。它用的是调用顺序。每个函数组件实例对应的 fiber 节点上,挂着一个 memoizedState 字段,它指向一个单向链表,链表上的每个节点对应一次 Hook 调用:

// 概念示意:React 内部的 Hook 对象结构
{
  memoizedState: 0,       // useState 的值 / useEffect 的 effect 对象
  baseState: 0,
  queue: { /* 更新队列 */ },
  next: {           // 指向下一个 Hook
    memoizedState: false,
    next: {
      memoizedState: { deps: [...], create: fn, destroy: fn },
      next: null
    }
  }
}

渲染时,React 内部维护一个“当前读取到第几个 Hook”的游标。首次渲染时按顺序创建节点;更新渲染时,按同样的顺序从链表上取下节点,把上一次的状态或缓存返回给你。所以调用顺序一旦变化,后面的所有 Hook 都会读错数据。

条件调用
if (props.enabled) { useEffect(...) }
第一次渲染时 props.enabled 为 true,链表上多了一个节点;第二次为 false,后面的 Hook 全部错位,状态读串。
条件放到内部
useEffect(() => { if (!props.enabled) return; ... }, [props.enabled])
Hook 本身永远被调用,条件写在回调内部,顺序保持稳定。

2.2 一次渲染中 Hook 的执行顺序

点击下面的每个节点,看看 React 在渲染一个函数组件时,内部到底发生了什么。

1
render
调用组件函数
React 进入 render 阶段,函数组件被当作普通函数调用。此时还没有任何 DOM 操作,整个过程必须是纯的——不能有副作用、不能修改外部变量。
2
Dispatcher
选择 Hook 实现
React 通过 ReactCurrentDispatcher.current 指向不同的实现:首次渲染用 HooksDispatcherOnMount,更新用 HooksDispatcherOnUpdate。这就是为什么在普通函数里调用 Hook 会报错——此时 dispatcher 指向的是抛异常的版本。
3
mountState
首次渲染创建节点
第一次渲染时,每个 Hook 调用都会在链表尾部新建一个节点,并把初始状态写入 memoizedState。此时传入的初始值只被使用这一次。
4
updateState
更新渲染顺序读取
更新时,React 顺着链表按调用顺序取下节点,返回上一次保存的状态。所以顺序即身份,这就是“不能条件调用”的根本原因。
5
deps 比较
Object.is 逐项对比
useEffect、useMemo、useCallback 都会把本次依赖数组与上一次逐项比较,使用的是 Object.is。注意它是浅比较:对象字面量、数组字面量每次都是新引用,必然判定为“变了”。
6
commit
提交 DOM 变更
React 把计算出的变更一次性写入 DOM。此时 useLayoutEffect 会同步执行——它发生在浏览器绘制之前,所以适合做测量与同步修正布局。
7
passive effect
绘制后异步执行
useEffect 在浏览器绘制之后异步执行。执行顺序是:先运行上一次的清理函数,再运行本次的 effect。这个顺序保证了每一次 effect 都能看到一致的订阅状态。

2.3 用 20 行代码模拟 useState

理解原理最好的方式是亲手实现一遍。下面这段代码用数组模拟了 Hook 链表的“顺序即身份”:

let hookStates = [];
let cursor = 0;

function useState(initial) {
  const index = cursor;
  // 首次渲染写入初始值,之后沿用已存的值
  if (hookStates[index] === undefined) {
    hookStates[index] = typeof initial === 'function' ? initial() : initial;
  }

  const setState = (next) => {
    // 函数式更新:基于最新值计算
    hookStates[index] =
      typeof next === 'function' ? next(hookStates[index]) : next;
    render();
  };

  cursor++;  // 游标后移,顺序就是身份
  return [hookStates[index], setState];
}

function render() {
  cursor = 0;   // 每次渲染前必须重置游标
  ReactDOM.render(<App />, root);
}

这段代码当然不是 React 的真实实现(真实的还要处理更新队列、优先级、批处理、并发中断等),但它精确地解释了那条规则:如果你有条件地调用 Hook,游标就会错位,后面的状态全部串位。

三、useState:比你想象的更有讲究

useState 是使用频率最高的 Hook,也是最容易被用“浅”的一个。它至少有四个值得深挖的细节。

3.1 惰性初始化:只在首次渲染时计算

如果初始状态需要昂贵计算,直接写 useState(computeExpensiveValue()) 会导致每次渲染都白算一遍——即使这个值根本不再被使用。传入函数可以避免:

// 每次渲染都会执行 computeExpensiveValue(),结果被丢弃
const [state, setState] = useState(computeExpensiveValue());

// 只在首次渲染时执行一次
const [state, setState] = useState(() => computeExpensiveValue());

// 典型场景:从 localStorage 读取初始值
const [theme, setTheme] = useState(() => {
  return localStorage.getItem('theme') ?? 'light';
});

3.2 函数式更新:解决“同一批里的连续更新”

当一次更新中需要基于前一个状态计算新状态时,务必使用函数式更新。经典的例子是“一次点击加三”:

// ❌ 只会加 1,因为三次都读到同一个 count 快照
function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

// ✅ 正确:React 会依次把更新函数串起来执行
function handleClick() {
  setCount(c => c + 1);
  setCount(c => c + 1);
  setCount(c => c + 1);
}

原理在于:setState 并不立即修改状态,而是把一个更新对象(或更新函数)入队。渲染时 React 依次取出队列中的更新,用前一个结果计算下一个。函数式更新让你永远拿到“最新值”,而不是本次渲染时的旧快照。

3.3 批处理:React 18 之后的统一行为

在 React 17 及以前,只有 React 事件处理函数中的更新会被自动批处理;在 setTimeout、Promise、原生事件监听器里的更新会触发多次渲染。React 18 引入自动批处理(Automatic Batching)后,这些场景也被统一了:

// React 18:以下三个 setState 只会引起一次渲染
setTimeout(() => {
  setLoading(false);
  setData(result);
  setError(null);
}, 1000);

// 如果确实需要同步刷新 DOM(极少需要)
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1));

3.4 状态是替换,不是合并

这是从类组件迁移过来时最容易踩的坑。类组件的 this.setState({ a: 1 }) 会自动与旧 state 浅合并;而 useState 的 setter 是完全替换:

const [form, setForm] = useState({ name: '', age: 0 });

// ❌ age 会丢失
setForm({ name: '张玥' });

// ✅ 手动展开,或用 useReducer 管理复杂对象
setForm(prev => ({ ...prev, name: '张玥' }));

如果状态对象的结构比较复杂、更新逻辑较多,就应该考虑 useReducer——它把“怎么改”收敛到一个纯函数里,可测试性也更好。

写法 行为 推荐场景
useState(value) 每次渲染都求值一次 初始值是字面量或简单表达式
useState(() => value) 只在首次渲染求值 昂贵计算、读 localStorage、解析 URL
setX(v) 直接替换为 v 值与旧值无关时
setX(prev => ...) 基于最新值计算 连续更新、在异步回调中更新
useReducer 集中式状态迁移 多字段联动、更新逻辑复杂

四、useEffect:最容易写错的一个 Hook

如果说 Hooks 只有一个难点,那一定是 useEffect。它看似简单——传个函数、传个依赖数组——但绝大多数 React 性能问题与诡异 bug 都源自对它的误解。

4.1 先纠正一个观念:useEffect 不是生命周期

把 useEffect(fn, []) 理解为 componentDidMount、把 useEffect(fn) 理解为 componentDidUpdate,是理解一切偏差的源头。React 官方文档给它的定义是:把组件与外部系统同步。它不是“某个时刻要执行的代码”,而是“在依赖变化时,重新建立这种同步关系”。

一旦接受这个定义,很多疑问就自然消解了:为什么依赖变了要先清理再执行?因为同步关系要重新建立;为什么不能在里面做派生状态的计算?因为那是渲染期就该完成的事。

4.2 依赖数组的三种形态

不写 deps 每次渲染后都执行:清理上一次 → 执行本次。极易造成无限循环
deps 为空数组 只在挂载后执行一次,卸载前清理一次。用于“只建立一次”的订阅
deps 有值 依赖中任一项通过 Object.is 比较发生变化就重新执行

4.3 执行时序:一张图看懂

React 渲染(调用组件函数)
    ↓
React 提交 DOM 变更(commit)
    ↓
useLayoutEffect 同步执行  ← 浏览器尚未绘制
    ↓
浏览器绘制(paint)
    ↓
useEffect 异步执行      ← 先清理旧 effect,再执行新 effect

这条时序解释了三个常见现象:

  • 在 useEffect 里读取 DOM 尺寸,可能会看到“闪一下”——因为绘制已经发生。
  • 在 useLayoutEffect 里做重计算会阻塞绘制,造成掉帧。
  • 在服务端渲染时 useLayoutEffect 会报警告,因为服务端没有绘制阶段。

4.4 清理函数到底什么时候跑

清理函数的执行时机是:组件卸载时,以及下一次 effect 执行之前。它捕获的是上一次 effect 那次渲染的闭包变量——这一点非常关键:

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();

  return () => {
    // 这里的 roomId 是“上一次渲染”的那个值
    connection.disconnect();
  };
}, [roomId]);

如果 roomId 从 'A' 变成 'B',执行顺序是:用旧闭包('A')断开连接 → 用新闭包('B')建立连接。这个设计保证了资源的正确配对释放,也避免了“断错连接”的问题。

4.5 竞态条件:异步请求的头号杀手

当 effect 里发起异步请求,而依赖会快速变化时,先发出的请求可能后返回,导致界面显示错误的数据。经典的修复方式是使用“忽略标志”:

useEffect(() => {
  let ignore = false;

  setLoading(true);
  fetchUser(userId)
    .then(data => {
      if (ignore) return;  // 已经过期,丢弃结果
      setUser(data);
      setLoading(false);
    })
    .catch(err => {
      if (ignore) return;
      setError(err);
    });

  return () => { ignore = true; };
}, [userId]);

另一种更现代的方案是使用 AbortController,在清理函数里直接取消请求。React 19 之后,也可以配合 use() 与 Suspense 让框架层处理这类问题。

4.6 依赖数组的完整性:不要对 linter 撒谎

eslint-plugin-react-hooks 的 exhaustive-deps 规则经常被开发者“用注释关掉”,理由是“加上会死循环”。但这通常意味着代码本身有问题,而不是规则太严格。三种正确做法:

  • 函数依赖:把函数定义搬到 effect 内部,或使用 useCallback 包裹(并保证它自己的依赖也是完整的)。
  • 对象依赖:不要依赖整个对象,改为依赖它的具体字段;或在 effect 内部解构出需要的值。
  • 确实只想执行一次:使用 useRef 保存最新值,或用 useEffectEvent(React 实验特性)读取“非响应式”的值。
反模式
// eslint-disable-next-line react-hooks/exhaustive-deps
关掉 lint 之后,闭包读到的是旧值,出现“数据不更新”“状态卡住”等难以复现的问题。
正解
使用 useRef 保存最新值,并在 effect 中读取 ref:
const latestRef = useRef(value);
useEffect(() => { latestRef.current = value; });

4.7 无限循环的三种常见成因

  1. 在 effect 里更新了自己依赖的状态:useEffect(() => setCount(count + 1), [count])。
  2. 依赖了每次渲染都新建的对象/数组/函数:useEffect(fn, [{ id }]) 或 useEffect(fn, [items.filter(...)])。
  3. 不写依赖数组却每次都 setState:等于每次渲染都触发一次新渲染。

五、useLayoutEffect:绘制前的那一次机会

useLayoutEffect 与 useEffect 的签名完全一致,区别只在执行时机:前者在 DOM 变更后、浏览器绘制前同步执行。

什么时候必须用它

  • 测量 DOM 并立即修正布局:比如根据内容高度调整 tooltip 的位置,若放在 useEffect 里,用户会看到 tooltip 先出现在错误位置再跳过去。
  • 需要同步读取布局信息再渲染:例如虚拟列表的可见区域计算。
  • 第三方 DOM 库的初始化:有些库要求容器尺寸已经确定。
function Tooltip({ targetRef, children }) {
  const [pos, setPos] = useState({ top: 0, left: 0 });
  const tipRef = useRef(null);

  useLayoutEffect(() => {
    const target = targetRef.current.getBoundingClientRect();
    const tip = tipRef.current.getBoundingClientRect();

    // 在绘制之前就把位置算好,用户看不到跳动
    setPos({
      top: target.top - tip.height - 8,
      left: target.left + target.width / 2 - tip.width / 2,
    });
  }, [children]);

  return <div ref={tipRef} style={{ position: 'fixed', ...pos }}>{children}</div>;
}

代价与约束

useLayoutEffect 是同步的,它会阻塞浏览器绘制。里面做了重计算,用户就会直接感受到卡顿。同时它在服务端渲染时会输出警告(服务端没有布局阶段)。因此经验法则是:能用 useEffect 就用 useEffect,只有当“绘制前必须完成”时才用 useLayoutEffect。

对比项 useEffect useLayoutEffect
执行时机 浏览器绘制之后,异步 DOM 提交之后、绘制之前,同步
是否阻塞绘制 否 是
服务端渲染 不执行,无警告 不执行,有警告
典型用途 数据请求、订阅、日志 DOM 测量、同步修正布局

六、useRef:跨渲染的储物柜

useRef 返回一个在组件整个生命周期内保持同一个引用的对象 { current: ... }。修改 current 不会触发重新渲染——这是它与 useState 最本质的区别。

6.1 三种典型用法

// 1. 引用 DOM 节点
const inputRef = useRef(null);

function focus() {
  inputRef.current.focus();
}

return <input ref={inputRef} />;


// 2. 存放不参与渲染的可变值
const timerRef = useRef(null);

useEffect(() => {
  timerRef.current = setInterval(tick, 1000);
  return () => clearInterval(timerRef.current);
}, []);


// 3. 保存“最新值”以打破闭包陷阱
const latestOnClick = useRef(onClick);
useEffect(() => {
  latestOnClick.current = onClick;
});
DOM 引用 焦点管理、滚动定位、媒体控制
可变实例值 定时器 ID、上一次的值、渲染计数
最新值容器 在 effect / 回调中读取最新 props 或 state
注意 不要在渲染期读写 ref.current,它会让渲染不纯

6.2 ref 与 state 的取舍

问题 useState useRef
修改后是否重新渲染 会 不会
是否出现在 UI 上 是,是渲染的输入 否,是渲染之外的存储
渲染期间读取是否安全 安全 不安全(应放到 effect / 事件中)
典型场景 用户可见的状态 定时器、DOM、缓存标记

七、useContext:跨层级传值,以及它的性能陷阱

useContext 让你跳过中间层,直接读取上层 Provider 提供的值。它的用法很简单,但性能行为需要格外注意:当 Provider 的 value 引用发生变化时,所有消费该 Context 的组件都会重新渲染,无论它们是否真的用到了变化的那部分数据。

7.1 两个必做的优化

// ❌ 每次父组件渲染都会创建新对象,导致所有消费者重渲染
<ThemeContext.Provider value={{ theme, setTheme }}>
  {children}
</ThemeContext.Provider>

// ✅ 用 useMemo 稳定 value
const value = useMemo(() => ({ theme, setTheme }), [theme]);
<ThemeContext.Provider value={value}>
  {children}
</ThemeContext.Provider>

但 useMemo 只解决了“value 引用稳定”的问题。如果 value 里既有频繁变化的 theme,又有稳定的 setTheme,那么 theme 一变,所有消费者仍会重渲染。真正的解法是拆分 Context:

// 把“读”和“写”拆成两个 Context
const ThemeValueContext = createContext('light');
const ThemeActionsContext = createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  // actions 永远不会变,订阅它的组件永不因主题变化而重渲染
  const actions = useMemo(() => ({ setTheme, toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') }), []);

  return (
    <ThemeActionsContext.Provider value={actions}>
      <ThemeValueContext.Provider value={theme}>
        {children}
      </ThemeValueContext.Provider>
    </ThemeActionsContext.Provider>
  );
}

7.2 Context 不是状态管理库

Context 只解决“跨层级传递”,不解决“如何管理状态”。用它做全局状态管理时,常见的问题是:任何一处更新都会导致整棵子树重渲染。当状态复杂、更新频繁时,考虑引入 Zustand、Jotai、Redux Toolkit 等专门方案——它们都基于“订阅”模型,能做到只有用到该状态的组件才更新。

八、useMemo 与 useCallback:不要滥用的优化

这两个 Hook 大概是 React 里被使用得最随意的一对。很多项目里,几乎每个函数都被 useCallback 包了一层,每个对象都被 useMemo 包了一层,性能却没有任何提升,代码可读性反而大幅下降。

8.1 它们真正的作用

  • useMemo(fn, deps):缓存计算结果,避免在依赖未变时重复执行昂贵计算。
  • useCallback(fn, deps):缓存函数引用,本质上等价于 useMemo(() => fn, deps)。

注意,它们优化的不是“渲染次数”,而是“渲染时的计算量”和“引用稳定性”。而且缓存本身也有成本:React 需要保存依赖数组、做逐项比较。对于简单的计算,这个成本可能比重新计算还高。

8.2 什么时候才真的有用

该用 计算确实昂贵(大数组排序/过滤、复杂计算)
该用 作为依赖传给其他 Hook,需要引用稳定
该用 传给被 React.memo 包裹的子组件
不该用 便宜的字符串拼接、简单算术
不该用 子组件根本没有被 memo 包裹
// ❌ 无意义的优化:拼接本身极便宜
const title = useMemo(() => `${a} - ${b}`, [a, b]);

// ✅ 有意义的优化:大数据量过滤
const visible = useMemo(
  () => items.filter(matches).sort(byScore),
  [items, keyword]
);

// ✅ 有意义的优化:稳定回调引用
const handleSelect = useCallback((id) => {
  onSelect(id);
}, [onSelect]);

// 只有子组件被 memo 包裹,引用稳定才有意义
const Row = React.memo(function Row({ item, onSelect }) { ... });

8.3 性能问题的定位顺序

遇到卡顿,正确的排查顺序是:

  1. 用 React DevTools Profiler 录制一次交互,看清楚到底哪些组件重渲染了、耗时多少。
  2. 判断重渲染的原因:是父组件状态变化?是 Context value 变化?还是列表 key 不稳定导致的重建?
  3. 优先解决结构问题(拆分组件、下移状态、稳定 key),再考虑加 memo 与 useMemo。

盲目加 useMemo 最常见的结果是:性能没变,依赖数组写错反而引入了新 bug。

九、useReducer:把状态迁移写成纯函数

当状态字段变多、更新逻辑出现分支时,useState 会让组件里塞满零散的 setter 调用。useReducer 把这些逻辑收敛到一个纯函数中:

const initialState = { status: 'idle', data: null, error: null };

function reducer(state, action) {
  switch (action.type) {
    case 'loading':
      return { ...state, status: 'loading', error: null };
    case 'success':
      return { status: 'success', data: action.payload, error: null };
    case 'error':
      return { ...state, status: 'error', error: action.payload };
    default:
      throw new Error(`未知 action: ${action.type}`);
  }
}

function useRequest(fetcher) {
  const [state, dispatch] = useReducer(reducer, initialState);

  const run = useCallback(async (params) => {
    dispatch({ type: 'loading' });
    try {
      const data = await fetcher(params);
      dispatch({ type: 'success', payload: data });
    } catch (err) {
      dispatch({ type: 'error', payload: err });
    }
  }, [fetcher]);

  return { ...state, run };
}

useReducer 的三个好处

  • 可测试:reducer 是纯函数,不依赖 React,直接传入 state 与 action 断言输出即可。
  • 可预测:所有状态迁移都被显式命名为 action,阅读 switch 就能看清完整的状态机。
  • 易传递:dispatch 的引用永远稳定,可以安全地放进依赖数组,也可以配合 Context 下发给深层组件。

reducer 必须是纯函数

和渲染函数一样,reducer 里不能有副作用:不能发请求、不能写 localStorage、不能修改传入的 state 对象。React 在开发模式下(StrictMode)会故意重复调用 reducer 来帮你发现这类问题。如果你在 reducer 里 push 了数组,就会看到数据翻倍——这不是 React 的 bug,而是它在提醒你违反了规则。

十、其余内置 Hook:各司其职

10.1 useImperativeHandle:把 ref 的接口收窄

默认情况下,父组件通过 ref 能拿到子组件整个 DOM 节点,这破坏了封装。 useImperativeHandle 让你只暴露必要的方法:

const VideoPlayer = forwardRef(function VideoPlayer(props, ref) {
  const videoRef = useRef(null);

  useImperativeHandle(ref, () => ({
    play() { videoRef.current.play(); },
    pause() { videoRef.current.pause(); },
    seek(t) { videoRef.current.currentTime = t; },
  }), []);

  return <video ref={videoRef} {...props} />;
});

父组件拿到的对象只有 play、pause、seek 三个方法,无法直接操作 DOM。注意:useImperativeHandle 属于“逃生舱”,能用 props 与回调表达就别用它。

10.2 useId:生成稳定的唯一 ID

表单的 label 需要与 input 的 id 对应,服务端渲染时又不能随便生成随机数(会导致 hydration 不一致)。useId 正是为此设计:

function EmailField() {
  const id = useId();

  return (
    <>
      <label htmlFor={id}>邮箱</label>
      <input id={id} type="email" />
    </>
  );
}

注意:useId 生成的值不能用作列表的 key,它只用于需要稳定标识的无障碍属性。

10.3 useSyncExternalStore:安全订阅外部数据源

当你需要订阅 React 之外的数据源(全局 store、浏览器 API、第三方库)时,useSyncExternalStore 是官方推荐方案。它能在并发渲染下避免“撕裂”(tearing)——即同一帧内不同组件读到不一致的数据:

function useOnlineStatus() {
  return useSyncExternalStore(
    // 订阅
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    // 读取快照
    () => navigator.onLine,
    // 服务端快照
    () => true
  );
}

10.4 useDebugValue:给自定义 Hook 加标签

它只在 React DevTools 中生效,用于给自定义 Hook 显示可读的调试信息:

function useFriendStatus(friendId) {
  const [isOnline, setIsOnline] = useState(null);
  // ...订阅逻辑

  useDebugValue(isOnline ? '在线' : '离线');
  return isOnline;
}

如果格式化值的计算成本较高,可以传第二个参数做惰性格式化:useDebugValue(date, d => d.toISOString()),这样只有 DevTools 打开时才会执行。

十一、并发时代的 Hook:useTransition 与 useDeferredValue

React 18 引入的并发渲染,给 Hooks 家族增加了两个新成员。它们的共同目标是:让“紧急更新”不被“非紧急更新”阻塞。

11.1 useTransition:把更新标记为“可打断”

function SearchPage() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    // 紧急:输入框必须立刻响应
    setKeyword(e.target.value);

    // 非紧急:大列表更新可以被中断
    startTransition(() => {
      setResults(search(e.target.value));
    });
  }

  return (
    <>
      <input value={keyword} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultList items={results} />
    </>
  );
}

关键点:startTransition 里的更新不会阻塞输入框的渲染;如果用户在结果渲染完成前又输入了新内容,旧的渲染会被丢弃,只有最新的状态会被提交。

11.2 useDeferredValue:只延迟某个值

当你无法把更新包进 transition(例如值来自 props),可以用 useDeferredValue 得到一个“滞后版本”:

function SearchResults({ keyword }) {
  // deferredKeyword 会“稍微慢一拍”跟上 keyword
  const deferredKeyword = useDeferredValue(keyword);
  const stale = deferredKeyword !== keyword;

  return (
    <div style={{ opacity: stale ? 0.6 : 1 }}>
      <HeavyList keyword={deferredKeyword} />
    </div>
  );
}

11.3 两者的区别

对比项 useTransition useDeferredValue
作用对象 更新操作 某个值
使用位置 触发更新的地方 消费该值的地方
能否获得 pending 状态 能(isPending) 需要自己比较新旧值
典型场景 搜索框 + 大列表过滤 值来自 props 或 Context 时

另外,React 官方建议:在大多数场景下,如果你已经用上了 useDeferredValue 或 useTransition,就不再需要手写防抖。因为并发渲染是按帧让路的,响应会比固定时间窗口的防抖更自然。

十二、自定义 Hooks:Hooks 真正的威力所在

内置 Hook 只是积木,自定义 Hook 才是 Hooks 设计中最有价值的部分。它让“逻辑复用”第一次变得像调用函数一样自然。

12.1 三条约定

  • 名字必须以 use 开头。这不仅是风格,React 的 lint 规则与编译器都依赖这个前缀来判断它是不是一个 Hook。
  • 内部只能调用其他 Hook 或纯函数,并且要在顶层调用,不能有条件分支。
  • 它不共享状态,只共享逻辑。两个组件调用同一个自定义 Hook,得到的是两份独立的状态。

12.2 返回值:数组还是对象

// 数组:调用方可以自由重命名,适合返回少量值
const [count, setCount] = useCounter(0);

// 对象:调用方按需解构,适合返回较多值
const { data, loading, error, refetch } = useFetch(url);

// 返回对象时建议用 useMemo 稳定引用
return useMemo(() => ({ data, loading, error, refetch }),
  [data, loading, error, refetch]);

12.3 五个实战自定义 Hook

useDebounce:延迟值

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    // 值在 delay 内再次变化时,清掉上一次的定时器
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

useLocalStorage:与本地存储同步

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? JSON.parse(raw) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

useEventListener:自动清理的事件监听

function useEventListener(type, handler, target = window) {
  // 用 ref 保存最新 handler,避免因 handler 变化而反复解绑
  const handlerRef = useRef(handler);

  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);

  useEffect(() => {
    const listener = (e) => handlerRef.current(e);
    target.addEventListener(type, listener);
    return () => target.removeEventListener(type, listener);
  }, [type, target]);
}

useIntersectionObserver:滚动可见性

function useInView(options = {}) {
  const ref = useRef(null);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => setInView(entry.isIntersecting),
      options
    );
    observer.observe(el);

    return () => observer.disconnect();
  }, [options.root, options.rootMargin, options.threshold]);

  return [ref, inView];
}

useFetch:带竞态保护的数据请求

function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState(s => ({ ...s, loading: true, error: null }));

    fetch(url, { signal: controller.signal })
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name === 'AbortError') return;
        setState({ data: null, loading: false, error: err });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

12.4 设计自定义 Hook 的四条原则

单一职责 一个 Hook 只做一件事,不要在 useUser 里同时管请求、缓存和表单
可组合 在自定义 Hook 内部调用其他自定义 Hook,像函数一样层层组合
引用稳定 返回的函数与对象要稳定,否则调用方放进依赖数组就会反复触发
可测试 把纯粹的计算抽出去用普通函数测试,Hook 只负责与 React 打交道

十三、十个最常见的 Hooks 陷阱

陷阱 1 条件调用 Hook,或在循环、嵌套函数里调用
陷阱 2 用 useEffect 计算可以由 props / state 直接推导出的值
陷阱 3 依赖数组写不完整,或者直接关掉 exhaustive-deps 规则
陷阱 4 依赖了每次渲染都新建的对象、数组或匿名函数
陷阱 5 setState 之后立刻读取 state,拿到的还是旧值
陷阱 6 在渲染期间写副作用:改 ref、发请求、写 localStorage
陷阱 7 给列表使用索引作为 key,导致状态错位
陷阱 8 为了“优化”给所有函数套上 useCallback,反而增加开销
陷阱 9 在 useEffect 里发起请求却不处理竞态与清理
陷阱 10 把 Context 当成全局状态管理,导致大范围重渲染

重点展开:不要用 useEffect 计算派生数据

反模式
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(first + ' ' + last);
}, [first, last]);

多了一次渲染,还引入了状态不同步的风险。
正解
const fullName = first + ' ' + last;
直接在渲染期计算。真正昂贵的计算才用 useMemo。

重点展开:key 的选择

key 不是普通的 prop,它是 React 用来识别“这个元素在上一次渲染中是谁”的身份标识。用数组下标做 key,在列表发生插入、删除、排序时,React 会认为“第 0 项还是第 0 项”,从而错误地复用组件实例与状态:

// ❌ 删除第一项后,输入框里的内容会“跑到”错误的位置
items.map((item, index) => <Row key={index} item={item} />)

// ✅ 使用稳定且唯一的业务 ID
items.map(item => <Row key={item.id} item={item} />)

十四、测试、调试与 StrictMode

14.1 用 renderHook 测试自定义 Hook

Testing Library 提供了 renderHook,可以在不写组件的情况下直接测试 Hook:

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('递增计数', () => {
  const { result } = renderHook(() => useCounter(0));

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

// 测试依赖变化:rerender 会传入新的 props
const { result, rerender } = renderHook(
  ({ id }) => useUser(id),
  { initialProps: { id: 1 } }
);
rerender({ id: 2 });

14.2 StrictMode:为什么我的 effect 执行了两次

在开发模式下,React 会故意对组件进行“挂载 → 卸载 → 再挂载”的双重调用,目的是暴露那些清理不彻底的副作用。如果你看到请求发了两次、订阅建立了两次,正确的反应不是关掉 StrictMode,而是检查:

  • 清理函数有没有正确解绑、取消、清除定时器?
  • 请求是不是被 AbortController 正确取消?
  • 在 effect 里有没有直接修改外部可变状态?

只要副作用是“可重复、可清理”的,双重调用就不会带来问题。生产构建中不会发生双重调用。

14.3 DevTools 的实用技巧

  • Components 面板可以查看每个组件的 Hooks 列表与当前值,自定义 Hook 会以嵌套形式展示。
  • Profiler 面板录制的每一帧都能看到“为什么渲染”——是 props 变了、state 变了,还是父组件重渲染。
  • 开启 “Highlight updates when components render”,可以直观看到哪些区域在不必要地闪烁。
  • 为自定义 Hook 添加 useDebugValue,让它在面板中显示有意义的标签。

十五、Hooks 使用检查清单与代码基线

把全文的结论浓缩成一份可以贴在工位上的清单。每次提交代码前扫一眼,能挡掉绝大多数问题。

  • Hook 永远在顶层调用,不在条件、循环、嵌套函数里出现。
  • 自定义 Hook 以 use 开头,内部遵守同样的规则。
  • 能从 props / state 直接推导的值,就不要用 state 存,更不要用 effect 同步。
  • 依赖数组完整,不随意关掉 exhaustive-deps。
  • 不依赖新建的对象、数组、匿名函数;需要时用 useMemo / useCallback 稳定。
  • 连续更新用函数式写法:setX(prev => ...)。
  • 对象状态要手动展开合并,或改用 useReducer。
  • effect 里的异步操作处理竞态:ignore 标志或 AbortController。
  • 每个 effect 都有对应的清理逻辑(如果需要的话),保证可重复执行。
  • 需要测量 DOM 并同步修正布局时才用 useLayoutEffect。
  • 不参与渲染的可变值放 useRef,不触发重渲染。
  • Context 的 value 用 useMemo 稳定,频繁变化的数据单独拆一个 Context。
  • 不做无意义的 useMemo / useCallback,先测性能再优化。
  • 列表 key 使用稳定唯一的业务 ID,不用数组下标。
  • 自定义 Hook 保持单一职责,返回值引用稳定,可组合、可测试。
  • 不依赖 useEffect 去“监听”事件——那是事件处理函数该做的事。
  • StrictMode 保持开启,把双重调用当作免费的副作用检查器。
  • 用 Profiler 定位性能问题,而不是凭感觉加 memo。
// 一份可直接复用的自定义 Hook 基线模板

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';

export function useResource(id, fetcher) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
  });

  // 用 ref 保存最新的 fetcher,避免它进入依赖数组
  const fetcherRef = useRef(fetcher);
  useEffect(() => {
    fetcherRef.current = fetcher;
  });

  // 主 effect:带竞态保护的数据加载
  useEffect(() => {
    if (id == null) return;

    let ignore = false;
    setState(s => ({ ...s, loading: true, error: null }));

    fetcherRef.current(id)
      .then(data => {
        if (ignore) return;
        setState({ data, loading: false, error: null });
      })
      .catch(error => {
        if (ignore) return;
        setState({ data: null, loading: false, error });
      });

    return () => { ignore = true; };
  }, [id]);

  // 返回稳定引用,方便调用方放进依赖数组
  return useMemo(() => state, [state]);
}

Hooks 最难的地方从来不是 API,而是它所要求的心智转变:渲染是纯的,副作用是同步,依赖是显式的。当你习惯用“这次渲染会计算出什么”而不是“这时候该执行哪段生命周期”来思考,绝大多数曾经诡异的 bug 都会变得一目了然。

所以,下一次你准备写下 useEffect 的时候,不妨先停半秒问自己一句:“这真的是一次副作用吗,还是我可以在渲染期直接算出来?”——答案往往会让你少写十行代码。