前端知库

Hooks 在 React 内部是怎样保存状态的?为什么靠调用顺序区分?

React 为每个组件保存一组 Hook 的数据,渲染前把位置归零,每调用一次 Hook 就取下一格。useState 调用时不传名字,所以 React 只能按调用顺序把数据对上,条件里调用 Hook 会让后面的数据全部错位。

参考答案

简答

一个组件里先写 useState(18) 存年龄,再写 useState('北京') 存城市,两次调用都没有告诉 React“我是年龄”还是“我是城市”。React 的做法是给每个组件保存一组 Hook 数据,渲染前把位置归零,每调用一次 Hook 就取下一格,所以第 1 次调用永远对应年龄,第 2 次永远对应城市。

正因为只认位置,每次渲染的 Hook 调用顺序必须一样。把 Hook 写进 if 里,某次渲染多调或少调一次,后面的 Hook 就会拿到别人那一格的数据。

这些数据挂在组件在界面树里的那个位置上,不在组件函数里。所以组件函数每次重新执行,局部变量都是新的,状态却能保留;组件从这个位置被移除,状态也跟着丢掉。

要注意,React 文档用“数组加下标”来讲这个机制,那是简化后的模型。实际实现的数据结构更复杂,面试时讲清“按顺序对应、挂在树中位置上”就够了,不要把教学模型说成源码。

用几行代码模拟 useState

React 文档在讲 useState 时说明了思路:React 为每个组件保存一组状态,渲染前把当前下标设为 0,每调用一次 useState 就返回下一格并把下标加 1。下面按这个思路写一个极简版本,它只模拟一个组件,不涉及调度和 DOM,是教学模型而不是 React 源码。重点看最后一次渲染后 city 的值。

JavaScript
// 教学模型:只模拟一个组件,不是 React 源码
let hooks = [];
let index = 0;

function useState(initial) {
  const i = index; // 记住这个 Hook 占的是第几格
  if (i >= hooks.length) hooks.push(initial); // 第一次走到这一格才用初始值
  const setState = (next) => {
    hooks[i] = next;
  };
  index += 1;
  return [hooks[i], setState];
}

function render(Component, props) {
  index = 0; // 每次渲染前把下标归零
  return Component(props);
}

function Profile({ showName }) {
  const [age, setAge] = useState(18);
  let name = '(未显示)';
  if (showName) {
    [name] = useState('张三'); // 错误示范:条件里调用 Hook
  }
  const [city, setCity] = useState('北京');
  return { age, name, city, setAge, setCity };
}

let ui = render(Profile, { showName: false });
console.log(ui.age, ui.name, ui.city); // 18 (未显示) 北京

ui.setAge(20);
ui = render(Profile, { showName: false });
console.log(ui.age, ui.name, ui.city); // 20 (未显示) 北京

ui = render(Profile, { showName: true });
console.log(ui.age, ui.name, ui.city); // 20 北京 北京

ui.setCity('上海');
ui = render(Profile, { showName: false });
console.log(ui.age, ui.name, ui.city); // 20 (未显示) 北京
console.log(hooks); // [ 20, '北京', '上海' ]

前两次渲染都正常,setAge(20) 改的是第 0 格,再渲染时第 0 格读出 20。为什么 useState(18) 第二次调用没有把年龄重置回 18?因为初始值只在这一格第一次被创建时用到,之后每次都直接返回已经存的值。

第三次渲染把 showName 设为 true,问题出现了。name 的 Hook 排在第 1 格,可第 1 格存的是城市,于是 name 读到了“北京”。城市的 Hook 被挤到第 2 格,这一格从没创建过,就用初始值“北京”新建了一格。

最后一次渲染又不显示名字,city 回到第 1 格。前一次调用 setCity('上海') 改的却是第 2 格,所以城市还是“北京”,用户刚做的修改看起来“丢了”。这就是条件调用 Hook 的后果:数据本身没坏,只是对错了位置。真实的 React 遇到前后渲染 Hook 数量不一致时会报错,具体写法和报错见《为什么 Hook 通常必须在组件顶层调用?》。

为什么不给每个 Hook 起个名字

React 文档对这个问题的回答是,useState 调用时没有任何“标识”,React 也不会去解析函数代码,它靠的是同一个组件每次渲染时稳定的调用顺序,这样 Hook 的写法才能这么简洁。

按名字存也不是不行,但名字要由使用者保证不重复。自定义 Hook 内部也会调用 useState,两个自定义 Hook 恰好用了同一个名字就会互相覆盖。按顺序存就没有这个问题,自定义 Hook 只是把几次 Hook 调用打包,展开后依然是一串按顺序排好的调用。React 文档也说明,每次调用 Hook 都与其他调用完全独立,自定义 Hook 共享的是有状态的逻辑,不是状态本身。

状态挂在哪里

模拟代码把 hooks 放在全局,真实的 React 会把每个组件的 Hook 数据和它在界面树中的位置关联起来。React 文档讲保留和重置状态时说,只要在树的同一位置渲染同一个组件,React 就会保留它的状态;组件被移除,或者同一位置换成了不同的组件,状态就会被丢弃。

这解释了两个常见现象。同一个组件渲染两次,比如两个 <Counter />,各有一份独立的状态,因为它们在树中位置不同。条件渲染把组件从 <A /> 换成 <B />,A 的状态不会留给 B。想要在同一位置强制重置状态,可以换一个 key,React 会把它当作另一个组件。

常见追问

setState 是怎么知道要改哪一格的?

在教学模型里,setState 是在创建那一格时生成的函数,通过闭包记住了自己的下标,所以不管在哪里被调用,改的都是同一格。真实的 React 里,set 函数同样绑定到它所属组件的那个 Hook 上,React 文档也说明 set 函数具有稳定的标识,多次渲染拿到的是同一个函数。

为什么 useState 的初始值只生效一次?

初始值只在这一格第一次被创建时使用,之后的渲染直接返回已存的值。所以传入 props 当初始值后,props 再变也不会自动同步到 state。初始值计算很贵时,可以传一个函数 useState(() => compute()),React 只在初始化组件时调用它(严格模式的开发环境下会调用两次,用来发现不纯的写法)。

useEffect、useRef 也是按顺序存的吗?

是的。React 文档说明 Hook 都依赖同一个组件每次渲染时稳定的调用顺序,Hook 规则对所有 Hook 都适用,所以 Effect、ref、memo 的数据同样按调用位置对应。use 是例外,它读的是传进去的 Promise 或 Context,可以条件调用。