为什么只能在最顶端层呼叫 Hook?从 useState 实作原理来回答

2022年10月24日

💎 加入 E+ 成長計畫 與超過 500+ 位軟體工程師一同在社群中成長,並且獲得更多的軟體工程學習資源

在 React 的官方文件中,有特别一篇文章在讲述 Hook 的使用规则,其中提到「不要在回圈、条件式或是巢状的 function 内呼叫 Hook」以及「只能在最顶端层呼叫 Hook 」 的原因。为什么有这些规则呢? 让我们一起了解这个问题。

React 是如何知道哪个 state 会对应到哪个 useState 呢?

在讨论 Hook 的使用规则前,我们先来看一段带有 useState Hook 的代码。读完后想想看,React 要怎么知道哪个 state 是对应到哪个 useState 呢?

import { useState } from "react";

export default function MyComponent() {
  const [number, setNumber] = useState(0);
  const [showMore, setShowMore] = useState(false);

  function handleNextNumber() {
    setNumber(number + 1);
  }

  function handleMoreClick() {
    setShowMore(!showMore);
  }

  return (
    <>
      <button onClick={handleNextNumber}>Next</button>
      <h3>{number + 1}</h3>
      <button onClick={handleMoreClick}>
        {showMore ? "Hide" : "Show"} details
      </button>
      {showMore && <p>Hello World!</p>}
    </>
  );
}

上方代码中,使用了两个useState 的 Hook,我们并不会将可识别的值传入useState,那 React 是如何知道哪个 state 会对应到哪个useState 呢? 答案是,如果每一次 Hook 的调用顺序是稳定的,React 就能够知道哪个state 对应到哪个useState

如上方例子所示,每一个 Hook 在每一次元件渲染时的调用顺序都一样,只要 Hook 的调用顺序在每次渲染时保持一致,React 就能正确地将内部 state 和对应的 Hook 进行关联。但如果今天有一个 Hook 没有遵守 React 规范,例如:写在if…else 判断式中,那每次渲染的顺序可能就会产生变化,这会使得 React 无法得知每个 Hook 对应的值应该返回什么,这将导致 state 的顺序可能错乱。这也是为什么,我们不该在回圈、条件式或是巢状的 function 内呼叫 Hook,以及只能在最顶端层呼叫 Hook。

在面试中,能够回答到上面,基本分已经拿到。如果要跟深入说明,建议在面试中当场用代码举例。我们在接下来的段落一起来深究。

React Hook 背后实作机制

在底层,React 透过一个数组去储存每一个元件的 state pairs,并且还会维护当前数组的 index,在渲染之前先设定为 0。每次调用 useState 时,React 都会产生一组新的 state pair 并递增 index。透过 index,React 就能有效知道哪个 state 是对应到哪个 useState

我们直接透过简单的代码范例来模拟useState 的机制(代码来源:React hooks: not magic, just arrays)。

// 初始化 state 空数组
let state = [];
// 初始化 setters 空数组
let setters = [];
// 首次渲染
let firstRun = true;
// 初始化指标值 0
let cursor = 0;

function createSetter(cursor) {
  return function setterWithCursor(newVal) {
    state[cursor] = newVal;
  };
}

// 实作 useState
export function useState(initVal) {
  // 只有首次渲染会进入以下代码
  // state push 进 state 的数组当中
  // setters push 进 setters 的数组当中
  if (firstRun) {
    state.push(initVal);

    setters.push(createSetter(cursor));
    // 执行完之后,将首次渲染值改为 false
    firstRun = false;
  }

  // 透过对应纪录好的顺序,可以取出该 setter 在数组中的值
  const setter = setters[cursor];
  // 透过对应纪录好的顺序,可以取出该 state 在数组中的值
  const value = state[cursor];

  // 指标值 +1
  cursor++;
  // 最后回传 state 值和 setter 函式
  return [value, setter];
}

// Our component code that uses hooks
function RenderFunctionComponent() {
  const [firstName, setFirstName] = useState("Rudi"); // 指标值: 0
  const [lastName, setLastName] = useState("Yardley"); // 指标值: 1

  return (
    <div>
      <Button onClick={() => setFirstName("Richard")}>Richard</Button>
      <Button onClick={() => setFirstName("Fred")}>Fred</Button>
    </div>
  );
}

function MyComponent() {
  cursor = 0; // 每次渲染前都会重置指标值为 0
  return <RenderFunctionComponent />; // 渲染
}

console.log(state); // 渲染前: []
MyComponent();
console.log(state); // 第一是渲染: ['Rudi', 'Yardley']
MyComponent();
console.log(state); // 后续渲染: ['Rudi', 'Yardley']

// 点击 Fred 按钮

console.log(state); //点击完结果: ['Fred', 'Yardley']

从上面的代码,我们可以看到 useState 的历程

  1. 初始化: 两个空数组分别储存 settersstate,设置指标 (cursor) 值为 0
  2. 首次渲染: 遍历所有的 useState,并将 setters 放到 setters 的数组当中,将 state 放进 state 的数组当中
  3. 重新渲染: 每次重新渲染都会重置指标值为 0,并依次从数组中取出之前的state,因为先前在存放settersstate 是依序放入的,因此只要这个顺序没变,就可以确保重新渲染后,是拿到对的settersstate
  4. 事件触发: 每个setter 都有对应指标的state 值,因此只要有事件触发调用到任何setter ,都会修改到此setter 到应到state 数组中的值。因此只要顺序没有变,就会改到对的值。

上方代码的例子可以看到,React 背后透过指标值来记录对应的statesetter,但如果今天我们没有遵照 React 规范编写 Hook 而导致 Hook 调用顺序错误,显而易见的,指标值也会错误,在这种情况下,我们得到的state 值或 set 的state 值也会错误,这就会造成 Bug 的产生。

参考资料

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們