以下 react 源码版本为 18.3.0
核心原因
React hooks 使用链表存储状态,依赖调用顺序。如果 hooks 在条件语句中调用,会导致调用顺序不一致,从而引发状态错乱。
关键源码位置
1. Hooks 的链表结构定义
148:154:packages/react-reconciler/src/ReactFiberHooks.new.js
js
export type Hook = {|
memoizedState: any,
baseState: any,
baseQueue: Update<any, any> | null,
queue: any,
next: Hook | null,
|};
每个 Hook 通过 next 指针形成链表。
2. Hooks 如何通过链表存储
189:194:packages/react-reconciler/src/ReactFiberHooks.new.js
js
// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;
注释说明:Hooks 以链表形式存储在 fiber 的 memoizedState 字段上。
如何理解 currentHook 和 workInProgressHook
3. 首次渲染时创建 Hook 节点
636:655:packages/react-reconciler/src/ReactFiberHooks.new.js
js
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
首次渲染时,hooks 按调用顺序依次添加到链表末尾。
4. 更新时按顺序遍历链表
657:716:packages/react-reconciler/src/ReactFiberHooks.new.js
js
function updateWorkInProgressHook(): Hook {
// This function is used both for updates and for re-renders triggered by a
// render phase update. It assumes there is either a current hook we can
// clone, or a work-in-progress hook from a previous render pass that we can
// use as a base. When we reach the end of the base list, we must switch to
// the dispatcher used for mounts.
let nextCurrentHook: null | Hook;
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate;
if (current !== null) {
nextCurrentHook = current.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
nextCurrentHook = currentHook.next;
}
let nextWorkInProgressHook: null | Hook;
if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
} else {
nextWorkInProgressHook = workInProgressHook.next;
}
if (nextWorkInProgressHook !== null) {
// There's already a work-in-progress. Reuse it.
workInProgressHook = nextWorkInProgressHook;
nextWorkInProgressHook = workInProgressHook.next;
currentHook = nextCurrentHook;
} else {
// Clone from the current hook.
if (nextCurrentHook === null) {
throw new Error('Rendered more hooks than during the previous render.');
}
currentHook = nextCurrentHook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,
next: null,
};
if (workInProgressHook === null) {
// This is the first hook in the list.
currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
} else {
// Append to the end of the list.
workInProgressHook = workInProgressHook.next = newHook;
}
}
return workInProgressHook;
}
更新时,React 通过 currentHook.next 按顺序遍历链表。如果某个 hook 被条件跳过,会导致:
- 第 692 行:如果
nextCurrentHook === null,抛出 "Rendered more hooks than during the previous render" - 第 488-489 行:如果还有未遍历的 hooks,会检测到 "Rendered fewer hooks"
5. 错误检测和提示
269:314:packages/react-reconciler/src/ReactFiberHooks.new.js
js
function warnOnHookMismatchInDev(currentHookName: HookType) {
if (__DEV__) {
const componentName = getComponentNameFromFiber(currentlyRenderingFiber);
if (!didWarnAboutMismatchedHooksForComponent.has(componentName)) {
didWarnAboutMismatchedHooksForComponent.add(componentName);
if (hookTypesDev !== null) {
let table = '';
const secondColumnStart = 30;
for (let i = 0; i <= ((hookTypesUpdateIndexDev: any): number); i++) {
const oldHookName = hookTypesDev[i];
const newHookName =
i === ((hookTypesUpdateIndexDev: any): number)
? currentHookName
: oldHookName;
let row = `${i + 1}. ${oldHookName}`;
// Extra space so second column lines up
// lol @ IE not supporting String#repeat
while (row.length < secondColumnStart) {
row += ' ';
}
row += newHookName + '\n';
table += row;
}
console.error(
'React has detected a change in the order of Hooks called by %s. ' +
'This will lead to bugs and errors if not fixed. ' +
'For more information, read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks\n\n' +
' Previous render Next render\n' +
' ------------------------------------------------------\n' +
'%s' +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n',
componentName,
table,
);
}
}
}
}
当检测到 hooks 顺序变化时,会输出对比表格。
6. 检查 hooks 数量是否匹配
486:533:packages/react-reconciler/src/ReactFiberHooks.new.js
js
// This check uses currentHook so that it works the same in DEV and prod bundles.
// hookTypesDev could catch more cases (e.g. context) but only in DEV bundles.
const didRenderTooFewHooks =
currentHook !== null && currentHook.next !== null;
renderLanes = NoLanes;
currentlyRenderingFiber = (null: any);
currentHook = null;
workInProgressHook = null;
if (__DEV__) {
currentHookNameInDev = null;
hookTypesDev = null;
hookTypesUpdateIndexDev = -1;
// Confirm that a static flag was not added or removed since the last
// render. If this fires, it suggests that we incorrectly reset the static
// flags in some other part of the codebase. This has happened before, for
// example, in the SuspenseList implementation.
if (
current !== null &&
(current.flags & StaticMaskEffect) !==
(workInProgress.flags & StaticMaskEffect) &&
// Disable this warning in legacy mode, because legacy Suspense is weird
// and creates false positives. To make this work in legacy mode, we'd
// need to mark fibers that create in an incomplete state, somehow. For
// now I'll disable the warning that most of the bugs that would trigger
// it are either exclusive to concurrent mode or exist in both.
(current.mode & ConcurrentMode) !== NoMode
) {
console.error(
'Internal React error: Expected static flag was missing. Please ' +
'notify the React team.',
);
}
}
didScheduleRenderPhaseUpdate = false;
// This is reset by checkDidRenderIdHook
// localIdCounter = 0;
if (didRenderTooFewHooks) {
throw new Error(
'Rendered fewer hooks than expected. This may be caused by an accidental ' +
'early return statement.',
);
}
第 488-489 行检查是否还有未遍历的 hooks(didRenderTooFewHooks),如果存在则说明本次渲染调用的 hooks 数量少于上次。
总结
- Hooks 以链表存储,依赖调用顺序。
- 条件调用会导致顺序不一致,链表遍历错位,状态错乱。
- React 在开发和生产模式下都会检查 hooks 数量与顺序,不一致会抛出错误。
因此,hooks 必须在函数组件的顶层调用,不能在条件语句、循环或嵌套函数中调用。