首页 / 文章

通过构建一个玩具版 useState 来理解快照、队列和效应

通过构建一个简单的 useState 来了解 React hooks 的工作原理,再利用该模型来解释更新队列、过时快照、闭包、效应清理、引用以及批量处理机制。

发布者:REACTAPP.TOP阅读约 14 分钟3107 词

来源: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work

仅仅一行钩子代码就隐藏了大量复杂的机制:

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

React必须决定count应存储在何处、它如何在每次调用后仍然存在、某个设置器对应的是哪种状态、为何钩子调用不能是条件性的,以及效应如何追踪依赖关系。本指南构建了一个简单的钩子工作模型,并用它来解释那些每天都会让人困惑的行为:setState后的过时值、函数式更新、效应的清理与批量处理。

状态并不存在于函数中

以最简单的带状态组件为例:

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

  return <h1>{count}</h1>;
}

人们很容易认为count存在于Counter内部,但实际上React在每次渲染时都会再次调用该函数:

Render 1
Counter()
count = 0

Render 2
Counter()
count = 1

Render 3
Counter()
count = 2

当函数返回时,局部变量会消失,因此 React 会将钩子状态保存在组件外部、与其 Fiber 相关联,并在每次渲染时将存储的值传回。

Fiber 存储着一连串的钩子

简化来说,它们之间的关系如下:

Component
    │
    ▼
  Fiber
    │
    ▼
Hook data
    │
 ┌──┼───────────────┐
 ▼  ▼               ▼
State Ref         Effect

Fiber 是 React 的工作单元,既可用于组件也可用于宿主元素。对于函数组件而言,它以链式结构的形式承载着钩子数据:

Counter Fiber
      │
      ▼
   Hook #1
      │
      ▼
   Hook #2
      │
      ▼
   Hook #3

实际实现中还有许多其他字段,但这样的示意图已足以帮助理解其核心行为。

钩子按调用顺序匹配

以下是一个包含三个钩子的组件:

function Component() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Salim");

  useEffect(() => {
    console.log(count);
  }, [count]);

  return ...;
}

React 并没有为这些钩子命名;它只知道第一个、第二个和第三个钩子,并假设它们在每次渲染时都会按此顺序出现:

Fiber
 │
 ├── Hook #1 → useState
 │
 ├── Hook #2 → useState
 │
 └── Hook #3 → useEffect

这一假设正是 Hooks 规则的全部基础。

条件钩子会出现什么问题

现在考虑一个被条件隐藏的钩子:

function Component({ loggedIn }) {

  if (loggedIn) {
    useState("Salim"); // ❌
  }

  useState(0);

  return ...;
}

在用户已登录的渲染过程中,会调用两个钩子:

loggedIn = true

Hook #1 → name
Hook #2 → count

如果 loggedIn 后来变为 false,则只会执行一次调用:

loggedIn = false

Hook #1 → count

原本用于存储名称的变量现在接收计数调用,导致状态与错误的钩子关联:

Before:
1 → name
2 → count

After:
1 → count

因此,每次渲染时钩子的顺序绝不能改变,这就排除了在这些地方进行调用的可能:

if / else
loops
nested functions
event handlers

应将它们放在组件和自定义钩子的最顶层;React hooks lint 插件会强制遵守这一规则。

二十行代码实现的简易 useState

构建最小版本能让该机制更加具体化。下图展示了一个包含钩子槽数组和光标的结构。每次调用useState时,都会获取光标所在位置的槽,仅当该位置为空时才进行初始化,返回一个与该索引绑定的设置函数,同时将光标向前移动:

let hooks = [];
let currentHook = 0;

function useState(initialValue) {
  const index = currentHook;

  if (hooks[index] === undefined) {
    hooks[index] = {
      state: initialValue
    };
  }

  function setState(value) {
    hooks[index].state =
      typeof value === "function"
        ? value(hooks[index].state)
        : value;

    render();
  }

  const state = hooks[index].state;

  currentHook++;

  return [state, setState];
}

有两点虽被隐含但未在图中显示:首先,render()在再次调用组件之前必须将currentHook重置为0;其次,它必须重新运行该组件。以这样的组件为例:

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Salim");

  return ...;
}

最终的存储结构如下所示:

hooks[0]
  state = 0

hooks[1]
  state = "Salim"

在下一次渲染时,每次调用都会仅通过位置来找到对应的槽。

该简化模型的局限性

真正的 React 在 Fibers 上添加了钩子对象、更新队列、带有优先级通道的调度器、挂载与更新分发器,以及协调与提交逻辑;不存在全局数组。其核心理念依然适用:

Stable Hook order
       +
React-managed Hook state
       +
Component Fiber

React 中 useState 的工作原理

对于这个调用:

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

每个状态钩子,React 需要两样东西:

State
+
Update queue

用图示表示,该钩子将当前值存储在 memoizedState 中,同时还有一个待处理更新的队列:

Hook
 ├── memoizedState → 0
 │
 └── queue
      └── pending updates

首次渲染:挂载

给定这个组件:

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

  return <button>{count}</button>;
}

首次渲染时会创建该钩子,存储初始值,设置一个空队列,然后返回这对数据:

Component renders
      ↓
useState(0)
      ↓
Create Hook
      ↓
Store initial state
      ↓
Create update queue
      ↓
Return [0, setCount]

此时的 Fiber 大致如下所示,这一阶段被称为挂载:

Fiber
 │
 └── Hook
      ├── state: 0
      └── queue

后续渲染:更新

假设某个事件处理程序调用了:

setCount(5);

React 会安排一次更新。在下一次渲染时,useState 不会创建任何新内容;它只会找到现有的钩子,应用已排队的更新并返回结果:

Component renders
      ↓
useState(0)
      ↓
React finds existing Hook
      ↓
Process queued updates
      ↓
New state = 5
      ↓
Return [5, setCount]

因此你传入的 0 是初始值,而非重置值。它仅在钩子首次创建时起作用。

setState 是一个请求,而非赋值操作

像这样的调用:

setCount(5);

并不意味着“现在就将 count 设为 5”。它只是将一个请求加入队列,由 React 按其自身的时间表来处理:

setCount(5)
     ↓
Create update
     ↓
Add update to queue
     ↓
Schedule React work
     ↓
Render component
     ↓
Process update queue
     ↓
Calculate new state
     ↓
Reconcile UI
     ↓
Commit changes

更新按顺序排队

假设连续有三次函数式更新:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

React 会从当前值开始,将它们依次放入队列中:

Update Queue

c => c + 1
c => c + 1
c => c + 1

If the current state is: 0

然后逐一应用这些更新:

0 → 1
1 → 2
2 → 3

结果为3,这就是为什么函数式更新适用于依赖前一个值的更新。

为何传递值的行为不同

比较一下传递值的版本:

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

如果当前渲染的count值为0,那么在任何更新处理之前,这三个表达式都会先被计算,因此它们的结果为:

setCount(1)
setCount(1)
setCount(1)

最终状态为1。使用更新函数时:

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

每个函数都会接收到前一个函数的计算结果,依次得到0、1、2,最终为3。

状态是一次渲染的快照

这种处理方式让许多开发者感到意外:

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

  function handleClick() {
    setCount(1);

    console.log(count);
  }

  return <button onClick={handleClick}>{count}</button>;
}

日志输出的是0,而非1。count是创建该处理函数的渲染过程所拥有的常量,调用设置器并不会改变它:

Current render
count = 0
      ↓
setCount(1)
      ↓
Update scheduled
      ↓
Current render still has count = 0
      ↓
Next render gets count = 1

闭包解释了快照现象

这种行为正是 JavaScript 的闭包语义。请看:

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

  function logCount() {
    console.log(count);
  }

  return <button onClick={logCount}>Log</button>;
}

每次渲染都会创建一个新的 logCount,并且每个 logCount 都会捕获其所在渲染的 count 值:

Render #1
count = 0
 └── logCount closes over 0

Render #2
count = 1
 └── logCount closes over 1

旧的回调函数始终只能看到其所在渲染的数值,而这正是大多数旧值问题的根源。

useEffect 如何记住其依赖项

以一个基本的效应为例:

useEffect(() => {
  console.log(count);
}, [count]);

React 需要为它跟踪三样东西:

Effect function
Dependencies
Cleanup function

在内部,这些信息会以类似这样的名称存储在效应的钩子中:

Hook
 ├── create → effect function
 ├── deps   → [count]
 └── destroy → cleanup

在每次渲染时,React 都会将新的依赖项数组与已存储的数组进行比较。

比较依赖项

使用同一个效应:

useEffect(() => {
  console.log(count);
}, [count]);

第一次渲染时会记录:

count = 0
deps = [0]

而下一次渲染则会生成:

count = 1
deps = [1]

React会逐项比较数组中的元素:

Previous: [0]
New:      [1]

Changed
 ↓
Effect needs to run

由于有变化发生,因此会执行相应的效果函数。如果后续渲染得到的值相同:

Previous: [1]
New:      [1]

Same
 ↓
Effect can be skipped

则不会执行该效果函数。每个元素都会通过Object.is进行比较,这意味着在渲染过程中重新创建的对象或数组每次都会被视为已发生变化。

清理操作

效果函数可以返回一个用于撤销其操作的函数:

useEffect(() => {
  const id = setInterval(() => {
    console.log("tick");
  }, 1000);

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

这个返回的函数就是清理函数。定时器的生命周期如下所示:

Effect setup
     ↓
Interval created
     ↓
...
Cleanup
     ↓
Interval removed

当依赖项可能发生变化时,清理操作就显得更为重要:

useEffect(() => {
  subscribe(userId);

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

每次userId发生变化时,React都会先销毁之前的效果函数,然后再启动新的效果函数;而在组件卸载时,还会执行最终的清理操作:

Old effect
    ↓
Cleanup
    ↓
New effect

效果函数用于同步数据,而非生命周期方法

一种常见的简化做法是将传入空数组的 useEffect 视为 componentDidMount。更合理的理解是,效应用于让 React 与 React 之外的某些系统保持同步,例如:

Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections

比如聊天连接:

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

  return () => {
    connection.disconnect();
  };
}, [roomId]);

当 roomId 发生变化时,该效应会离开旧房间并加入新房间,从而使连接始终与当前的属性相匹配。

useRef:一个稳定的对象

ref 的创建方式如下:

const ref = useRef(0);

React 在每次渲染时都会返回完全相同的对象:

Render #1
ref → Object A

Render #2
ref → Object A

Render #3
ref → Object A

因此,对其赋值:

ref.current = 100;

不会触发渲染,这与状态更新不同:

setCount(100);

状态更新会触发一次渲染。简而言之,两者的区别在于:

useState
 ├── React-managed state
 ├── update schedules render
 └── used for UI state

useRef
 ├── stable object
 ├── mutable .current
 └── changing .current doesn't schedule render

它的典型用途是存储定时器 ID:

function Timer() {
  const timerRef = useRef(null);

  function start() {
    timerRef.current = setInterval(() => {
      console.log("tick");
    }, 1000);
  }

  return <button onClick={start}>Start</button>;
}

该标识符必须持久存在,但绝不能显示在屏幕上,这正是引用变量的典型用途。

useMemo 和 useCallback

经过缓存的计算结果如下所示:

const result = useMemo(
  () => expensiveCalculation(data),
  [data]
);

该钩子会在概念上存储最后的结果以及用于计算它的依赖项:

Hook
 ├── memoizedValue
 └── dependencies

在首次渲染时,会执行相关计算:

data = A

calculate(A)
 ↓
result = X

如果下一次渲染传递的是相同的数据:

data = A

则这些依赖项保持不变:

A → A

因此会返回已存储的结果 X。当 data 变为 B 时,会再次执行计算。

应将此视为一种优化手段而非必然结果:React 可能会丢弃缓存,因此即便需要重新计算数值,代码也必须保持正确。它适用于耗时较长的操作或需要稳定引用的场景,而非默认使用。

useCallback 则将相同原理应用于函数:

const handleClick = useCallback(() => {
  doSomething(id);
}, [id]);

它存储函数及其依赖项:

Hook
 ├── memoized function
 └── dependencies

未改变的依赖项会返回相同的函数引用;已改变的依赖项则会返回新的引用。当回调指向被React.memo包裹的子组件时,这一点尤为重要,因为新的引用会破坏记忆化机制:

const Child = React.memo(ChildComponent);

自定义钩子其实就是函数

这是一个简单的自定义钩子:

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

  function increment() {
    setCount(c => c + 1);
  }

  return {
    count,
    increment
  };
}

被某个组件使用:

function App() {
  const { count, increment } = useCounter();

  return (
    <button onClick={increment}>
      {count}
    </button>
  );
}

自定义钩子并非独立的组件,它只是由其他钩子构成的普通函数,其useState会绑定到当前正在渲染的组件上:

App Fiber
   ↓
useCounter()
   ↓
useState()
   ↓
Hook state

因此,两个调用useCounter的组件会拥有两个独立的计数器。

调度器:一个API,两种行为

当你调用 useState 时,这个公共函数会转发给 React 在当前渲染过程中所配置的相应调度器:

useState()
   ↓
Current Dispatcher
   ↓
mountState() / updateState()

在首次渲染时,使用的是挂载实现:

Mount
 ↓
mountState()

而在后续渲染中,则使用更新实现:

Update
 ↓
updateState()

同一个钩子的挂载与更新机制

对于这次调用:

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

挂载会创建相关结构:

Create Hook
     ↓
Store initial state
     ↓
Create update queue

而更新则会重用这些结构:

Find existing Hook
     ↓
Process pending updates
     ↓
Calculate new state
     ↓
Return state + dispatcher

总结来说:

FIRST RENDER
     ↓
Mount Hooks

SUBSEQUENT RENDERS
     ↓
Update Hooks

渲染阶段与提交阶段

钩子在 React 的两阶段渲染模型中最为有用:

State / Props Update
        ↓
     Render Phase
        ↓
   Reconciliation
        ↓
     Commit Phase
        ↓
       DOM

在渲染阶段,React 会调用组件并计算出新的用户界面:

Components execute
Hooks are processed
New UI is calculated

在提交阶段,它会应用计算出的结果:

DOM mutations happen
Refs are updated
Layout effects are handled

现代 React 可能会暂停、重启或丢弃渲染任务,而 commit 操作始终会完成,正因如此渲染逻辑必须是纯函数。

保持渲染的纯度

避免在组件主体中直接引入副作用:

function Component() {
  fetch("/api/users"); // ❌

  return <div>Hello</div>;
}

渲染可能会重复执行,或在 commit 之前被丢弃,因此该请求可能会被触发多次。请改用以下方式之一:

Event handler
useEffect
Framework data-loading mechanism

渲染应仅根据输入来计算 UI:

UI = f(props, state)

整合应用:一次点击,端到端流程

这是一个使用状态和效应的组件示例:

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

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

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

点击操作会经历上述所有阶段:

Click
 ↓
setCount()
 ↓
Update added to Hook queue
 ↓
React schedules work
 ↓
Counter renders
 ↓
useState retrieves/processes state
 ↓
useEffect checks dependencies
 ↓
React reconciles new UI
 ↓
Commit DOM changes
 ↓
Effect runs

批量处理以及 DOM 为何不会立即更新

一个会设置多个状态的处理器:

function handleClick() {
  setCount(c => c + 1);
  setName("Salim");
  setVisible(true);
}

不会导致三次独立的渲染。自 React 18 起,更新会被自动批量处理,包括那些在定时器及 Promise 中的更新:

setCount()
setName()
setVisible()
      ↓
    Batch
      ↓
   Render
      ↓
   Commit

而不是:

Update → Render → Commit
Update → Render → Commit
Update → Render → Commit

批量处理本身就是一种避免冗余工作的方式。同样的逻辑也解释了为何如下调用:

setCount(10);

并不立即意味着:

state = 10
DOM = 10

相反,它会按照流程依次处理:

setCount(10)
     ↓
Schedule update
     ↓
React render
     ↓
Reconciliation
     ↓
Commit
     ↓
DOM update

延迟执行让 React 有时间进行批量处理、确定优先级并跳过冗余工作;而 react-dom 中的 flushSync 则在真正需要时强制进行同步更新。

整体模型概览

从组件到屏幕上的像素,所有内容都可以用一个图示来呈现:

                                    FUNCTION COMPONENT
                          │
                          ▼
                        Fiber
                          │
                          ▼
                    Hook sequence
                          │
          ┌───────────────┼───────────────┐
          ▼               ▼               ▼
       useState         useRef         useEffect
          │               │               │
          ▼               ▼               ▼
    Update Queue     Stable Object     Dependencies
          │
          ▼
      setState()
          │
          ▼
    Schedule Update
          │
          ▼
        Render
          │
          ▼
    Reconciliation
          │
          ▼
        Commit
          │
          ▼
         DOM
          │
          ▼
       Browser
          │
          ▼
        Pixels

核心要点

  • 钩子的状态存储在组件的 Fiber 中,并通过位置与每次调用对应,因此钩子的顺序绝不能改变。
  • setState会将更新操作加入队列;新值会在下一次渲染时出现,而当前渲染则保留其快照。
  • 每当更新依赖于之前的值时,都应使用函数式更新。
  • 效应会与外部系统同步;依赖关系会通过Object.is进行比较,清理操作会在重新运行之前以及组件卸载时执行。
  • 引用是稳定的可变对象,不会触发渲染;记忆化只是一种优化手段,并非绝对保证。
  • 自定义钩子共享逻辑而非状态,且渲染必须保持纯函数形式,因为 React 可能会重复执行或丢弃该钩子。