讲解

Hooks 只有两条规则,但条条见血。规则一:只在顶层调用——不能放在 if、for、循环、嵌套函数里。规则二:只在 React 函数里调用——函数组件或自定义 Hook,普通函数不行。为什么?因为 React 是按「调用顺序」记忆每个 Hook 的状态的:组件内部维护一个 Hook 链表,第一次渲染时 useState 依次在链表上创建节点,后续渲染按同样的顺序依次读取。如果某个 useState 被 if 跳过,后面所有 Hook 的状态就全部错位——第一次渲染的第 3 个 Hook 对应第二次渲染的第 2 个位置,状态张冠李戴。

想通了链表模型,规则就成了必然推论而不是死记硬背。同理可推:自定义 Hook 里的 Hook 算入调用方的链表(自定义 Hook 没有自己的「作用域」,它只是内联展开的函数);两个组件实例各有独立的链表(状态隔离的来源);early return 之前调用所有 Hook 是合法的(关键是顺序稳定,不是必须执行完整个函数体)。

实践中不用背规则——eslint-plugin-react-hooks 会把违反规则写成编译期错误。但理解原理的价值在于调试:遇到「state 串了」「Hook 顺序错误」的报错,你能立刻明白发生了什么。条件逻辑的正确写法是把条件放进 Hook 内部(useEffect 里 if (!id) return),或者拆组件(条件渲染两个子组件,各自的 Hook 各自管)。

示例

亲手实现 Hook 链表,看「跳过调用」如何导致状态错位(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 迷你 Hooks 实现:链表 + 游标,按调用顺序读取
function createHooks() {
  const list = [];
  let cursor = 0;
  return {
    useState(initial) {
      const i = cursor++;
      if (list[i] === undefined) list[i] = { value: initial }; // 首次渲染创建节点
      return [list[i].value, (v) => (list[i].value = typeof v === 'function' ? v(list[i].value) : v)];
    },
    beginRender() {
      cursor = 0; // 每次渲染从头按顺序读
    },
    dump: () => list.map((n) => n.value),
  };
}

// 守规矩的组件:每次渲染都调 3 个 Hook,顺序稳定
const good = createHooks();
function GoodComponent(showExtra) {
  good.beginRender();
  const [name, setName] = good.useState('小明');
  const [age, setAge] = good.useState(18);
  const [city, setCity] = good.useState('北京');
  return { name, age, city, setAge };
}
const g1 = GoodComponent(true);
g1.setAge(19);
const g2 = GoodComponent(false); // showExtra 无所谓,Hook 顺序不变
assert.strictEqual(g2.age, 19);
assert.deepStrictEqual(good.dump(), ['小明', 19, '北京']);

// 违规的组件:if 跳过了中间的 Hook → 状态错位
const bad = createHooks();
function BadComponent(showAge) {
  bad.beginRender();
  const [name] = bad.useState('小明');
  let age = null;
  if (showAge) [age] = bad.useState(18); // 第二次渲染被跳过!
  const [city] = bad.useState('北京');
  return { name, age, city };
}
BadComponent(true); // 链表:[小明, 18, 北京]
const b2 = BadComponent(false); // city 读到了 age 的位置!
assert.strictEqual(b2.city, 18); // 城市变成 18 —— 这就是状态错位

console.log('守规矩: dump =', good.dump().join('/'));
console.log('违规后 city 读到了', b2.city, '(age 的状态)—— 顺序即一切');

条件逻辑的正确姿势:

import { useEffect, useState } from 'react';

// ❌ 错误:Hook 放进条件
// if (userId) { useEffect(() => fetchUser(userId), [userId]); }

// ✅ 正确:条件放进 Hook 内部
function UserPanel({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    if (!userId) return; // 条件在 effect 内部
    fetch('/api/users/' + userId)
      .then((r) => r.json())
      .then(setUser);
  }, [userId]);

  // ✅ early return 在所有 Hook 之后
  if (!userId) return <p>请选择用户</p>;
  return <p>{user ? user.name : '加载中…'}</p>;
}

// ✅ 或拆组件:条件渲染由父组件负责
function Page({ userId }) {
  return userId ? <UserPanel userId={userId} /> : <p>请选择用户</p>;
}

常见坑

  • if 里调 Hook:状态错位的根源,ESLint 会直接报错;条件放进 Hook 内部或拆组件。
  • early return 在 Hook 之前:后续 Hook 被跳过等于违规;先调完所有 Hook 再 return。
  • 循环里调 Hooklist.map(() => useState()) 次数不固定;列表项状态拆成子组件各自管。
  • 普通函数里调 Hook:工具函数不能用 Hook;把逻辑改成自定义 Hook 或传参。
  • 以为自定义 Hook 有隔离作用域:它只是内联展开,Hook 顺序计入调用方组件。

小结

Hooks 按调用顺序存取(链表模型),所以必须顶层、只在 React 函数里调用;条件逻辑进 Hook 内部或拆组件。下一章看 React 19 带来的新能力。