为什么React Hooks会有两条使用规则

2,926 阅读5分钟

两条规则

React文档中介绍了React Hooks的如下使用规则。

只在最顶层使用 Hook

不要在循环,条件或嵌套函数中调用 Hook,  确保总是在你的 React 函数的最顶层调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。这让 React 能够在多次的 useState 和 useEffect 调用之间保持 hook 状态的正确。

只在 React 函数中调用 Hook

不要在普通的 JavaScript 函数中调用 Hook。 你可以:

  • ✅ 在 React 的函数组件中调用 Hook
  • ✅ 在自定义 Hook 中调用其他 Hook

我们在使用hooks时需要遵循这两条规则,那么为什么有这两条限制呢?我们可以从源码中寻找答案。

Hooks基础

class组件的setState方法类似,我们使用的hooks函数中都没有直接实现代码,而是调用一个叫做dispatcher的对象中相应函数。源码地址

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return dispatcher;
}

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

export function useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}
...

函数组件是在 renderWithHooks 函数内执行的。

// 删除了与本文无关的代码
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  // workInProgress表示本次更新中函数组件对应的Fiber
  currentlyRenderingFiber = workInProgress;
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  
  // current表示上次更新中函数组件对应的Fiber
  // current === null 表示组件处于挂载阶段,否则处于更新阶段
  // 不同阶段 ReactCurrentDispatcher.current等于不同的对象
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // 执行Component, Component即我们的函数组件
  let children = Component(props, secondArg);
  
  // 函数组件执行完,将ReactCurrentDispatcher.current 置为 ContextOnlyDispatcher
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;

  currentlyRenderingFiber = null;
  currentHook = null;
  workInProgressHook = null;

  return children;
}

// 挂载相关的dispatcher
const HooksDispatcherOnMount: Dispatcher = {
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  ...
};

// 更新相关的dispatcher
const HooksDispatcherOnUpdate: Dispatcher = {
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  ...
};

// 执行会抛错的dispatcher
export const ContextOnlyDispatcher: Dispatcher = {
  useCallback: throwInvalidHookError,
  useContext: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  useImperativeHandle: throwInvalidHookError,
  useLayoutEffect: throwInvalidHookError,
  useMemo: throwInvalidHookError,
  useReducer: throwInvalidHookError,
  useRef: throwInvalidHookError,
  useState: throwInvalidHookError,
  ...
};

为什么只在 React 函数组件中调用 Hook

useState为例,useState内部会调用ReactCurrentDispatcher.current.useState,由于只有在函数组件执行前才会将ReactCurrentDispatcher.current设置为HooksDispatcherOnMountHooksDispatcherOnUpdate,当函数组件执行完后ReactCurrentDispatcher.current马上被设置为ContextOnlyDispatcher,所以在 React 函数组件外使用useState时,useState内部会调用ContextOnlyDispatcher.useState,该函数是会报错的。其他 hooks 同理。

为什么只在最顶层使用 Hook

每次调用 hooks 都会生成 hook 对象,其结构如下

export type Hook = {|
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,
  next: Hook | null,
|};

着重关注memoizedStatenext属性,不同 hookshook 对象的memoizedState保存着不同对象

  • useStatehook.memoizedState = state;
  • useEffecthook.memoizedState = effect
  • useMemohook.memoizedState = [nextValue, nextDeps];
  • useCallbackhook.memoizedState = [callback, nextDeps];
  • useRefhook.memoizedState = ref;

一般我们会在函数组件中多次使用hooks,这会产生多个 hook 对象,这些对象通过next属性连接形成链表,连接是在挂载时进行的,主要函数是 mountWorkInProgressHook

function mountWorkInProgressHook() {
  const 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 都会调用该函数生成 hook 对象并连接。如果 workInProgressHook === null,说明是hook链中的第一个,将其赋值给currentlyRenderingFiber.memoizedState并更新workInProgressHook,这说明函数组件的Fiber的memoizedState保存着第一个hook的引用。当workInProgressHook !== null,则将新生成的 hook 赋值给上个hooknext属性并更新workInProgressHook

更新时,hooks 都会调用 updateWorkInProgressHook 函数生成新的hook对象并连接形成hook链以供下次更新时使用。

function updateWorkInProgressHook(): Hook {
  let nextCurrentHook: null | Hook;
 
  if (currentHook === null) { // currentHook === null 说明当前是hook链的第一个
    // current 为上次更新的组件对应的Fiber
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      // current.memoizedState保存着hook链的第一个hook
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    // 取出 currentHook.next,即上次更新中hook链的下一个hook
    nextCurrentHook = currentHook.next;
  }
  // 此时nextCurrentHook代表着本hooks在上次更新中的hook对象
  
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) { //workInProgressHook === null 说明当前是hook链的第一个
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }
  // 此时nextWorkInProgressHook绝大多数时候为null,因为 renderWithHooks 函数在执行函数组件前会将
  // currentlyRenderingFiber.memoizedState置为null,workInProgressHook.next也为null
  
  if (nextWorkInProgressHook !== null) { // 忽略这条分支
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    // 绝大多数时候会进入此分支
    
    // currentHook即上次更新中相应顺序的hook对象
    currentHook = nextCurrentHook;
    
    // 复用上次更新的hook对象的属性,生成新的hook对象
    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,
      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,
      next: null,
    };
    
    //形成新的hook链,和挂载时一样
    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;
}

正确使用hooks时,遍历hook链,复用hook上次更新的hook,没有问题,但如果某个hooks的调用与否是有条件的,那么情况就不同了。举个简单例子,如下使用hooks:

function FunComponent(props) {
    if (props.condition) {
        useEffect()
    }
    const [counter, setCounter] = useState(0)
    const memo = useMemo()
}

假设第一次更新时props.condition === true,那么 hook链是这样的:useEffect hook => useState hook =>useMemo hook

第二次更新时props.condition === false,我们走下更新过程。

useState首先执行,它会调用updateWorkInProgressHook 函数,currentHook为上次更新的hook链的第一个hook,也就是 useEffect hook,然后复用该hook属性得到新的hook并 return 给 useState使用,useState 再 return hook对象的memoizedState,但是此时的hook对象的memoizedState不是数字而是effect对象,counter从数字变成了对象,渲染或利用counter计算时肯定会报错的。

同理,useMemo执行也会调用updateWorkInProgressHook函数,得到的是useState hook,这个错误更严重,应为 useMemo会把 hook对象的 memoizedState当做数组使用,此时的 hook.memoizedState是个数字。看下更新时useMemo的源码就明白了。

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null,
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (prevState !== null) {
    if (nextDeps !== null) {
      // 上次的依赖
      const prevDeps: Array<mixed> | null = prevState[1];
      // 依赖没有变化
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

总结

  1. 不要在普通的 JavaScript 函数中调用 Hook 的原因是在其他函数中使用的hooks函数实际上都是名为throwInvalidHookError的函数。

  2. 不要在循环,条件或嵌套函数中调用 Hook的原因是如果hooks的执行顺序发生变化会导致 hooks 中使用错误的 hook对象。