伪傻React Hook只在最顶层使用

2,182 阅读8分钟

引言

ReactV16.8 中引入 Hook 的概念。它又是 React 的一项重大革新。

使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。

Hook 在无需修改组件结构的情况下复用状态逻辑。

官方也推荐使用 Hook 进行开发,但是它有有一些限制,那就是 只在最顶层使用

杜绝在 循环,条件或嵌套函数中调用 Hook

因为 Hook 的每一次渲染都按照同样的顺序被调用,主要是为保证在多次的 useStateuseEffect 调用之间保持 Hook 状态的正确。

本篇就从源码上分析 Hook 的执行顺序。

示例代码

先贴出示例代码,根据示例做源码分析。

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('naonao', count);
  });

  let handleCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p onClick={handleCount}>{count}</p>
    </div>
  );
}

ReactDom.render(<App />, document.getElementById('root'));

很简单,就不解释了。

第一次渲染

第一次渲染在 beinWork 阶段进入到 updateFunctionComponent 方法中。

updateFunctionComponent 方法中会调用 renderWithHooks 方法。

renderWithHooks 方法才会真正调用函数组件方法即示例中的 App 函数。

我们贴出 renderWithHooks 源码

// 本段代码在 packages/react-reconciler/src/ReactFiberHooks.old.js 中
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;

  if (__DEV__) {
    // ... 省略开发环境逻辑
  }

  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;

  
  if (__DEV__) {
    // ... 省略开发环境逻辑
  } else {
    // 重置 hooks 的指针对象,区分 mount 和 update阶段调用不同的 hooks 方法
    ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }
  // 调用函数组件
  let children = Component(props, secondArg);

  
  if (didScheduleRenderPhaseUpdateDuringThisPass) {
     // ...省略代码,如果在渲染阶段触发更新,重新触发函数组件执行
    } while (didScheduleRenderPhaseUpdateDuringThisPass);
  }

  // 重置 hooks 的指针对象
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;

  if (__DEV__) {
    // ... 省略开发环境逻辑
  }

  // 检查hook是否全部调用
  const didRenderTooFewHooks =
    currentHook !== null && currentHook.next !== null;

  renderLanes = NoLanes;
  currentlyRenderingFiber = (null: any);
  // 重置为null,便于下次调用hooks时,重新生成
  currentHook = null;
  workInProgressHook = null;

  if (__DEV__) {
    // ... 省略开发环境逻辑
  }

  didScheduleRenderPhaseUpdate = false;
  // 如果hook没有全部调用,抛出错误
  invariant(
    !didRenderTooFewHooks,
    'Rendered fewer hooks than expected. This may be caused by an accidental ' +
      'early return statement.',
  );

  return children;
}

看一下几个关键逻辑:

  1. let children = Component(props, secondArg);,此步就是执行函数 App 方法,就会调用方法内的 useStateuseEffect
  2. const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null;,此步就是检查 hook 是否全部执行。如果没有全部执行,会在下面 invariant(!didRenderTooFewHooks,'Rendered fewer hooks than expected. This may be caused by an accidental ' +'early return statement.',); 抛出错误。
  3. currentHook = null; workInProgressHook = null; 重置变量为nullcurrentHookworkInProgressHook 都是全局变量。 在 packages/react-reconciler/src/ReactFiberHooks.old.js 的第152行。
// 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 被当作链表存储在 fibermemoizedState属性上。 currentHook 属于当前 fiber 的链表。workInProgressHook 是一个需要添加到 workInProgress 上的新链表。

好了,下面就看一下函数 App 的执行。

useState

函数 App 先执行 useState

第一次渲染时,useState 指向 mountState

// 此段代码在packages/react-reconciler/src/ReactFiberHooks.old.js中
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // 调用构建链表
  const hook = mountWorkInProgressHook();
  // 初始值如果是函数,执行它
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  // 赋值hook的基础状态
  hook.memoizedState = hook.baseState = initialState;
  // 构建hook的queue
  const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  // 构建触发函数
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

重点看一下 mountWorkInProgressHook 函数,它构建了 hook 链表。

function mountWorkInProgressHook(): Hook {
  // hook 链表对象
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // 第一个hook
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 从第二个hook开始挂在链表的末尾
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

currentlyRenderingFiber 就是当前渲染的 fiber

第一个 hook 时,workInProgressHooknull 就把 当前hook对象赋值给 workInProgressHook 同时赋值给 currentlyRenderingFiber.memoizedState

第二个hook时,挂在第一个 hookworkInProgressHooknext 属性上,因为 workInProgressHookcurrentlyRenderingFiber.memoizedState 地址指向相同,所以也就修改了 currentlyRenderingFiber.memoizedState 的链表。

第三个,第四个同样的逻辑最终构建成一个单项链表。

接着函数 App 继续执行 useEffect

useEffect

第一次渲染时,useEffect 指向 mountEffect

mountEffect 直接调用 mountEffectImpl 并返回它的执行结果。

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

函数第一行就调用了 mountWorkInProgressHook 方法。

这样我们示例代码就构建出了拥有两个节点的链表 Hook

在函数 App 执行完毕,即返回到 renderWithHooks 方法。

执行到 const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null; 时,因为 currentHook 并没有被修改赋值的过程,所以仍然是 null

后面重置 currentHook = null; workInProgressHook = null; ,接着就是正常流程调和子节点,构建子节点的 fiber 及对应的真实 DOM ,完成构建,到最后渲染到页面上。

我们点击 p 元素,触发修改 state 。这就进入了更新渲染逻辑。

setCount 方法执行 dispatchAction 方法,一系列逻辑后再次进入 beginWork 逻辑。

同样最终会调用 renderWithHook 方法。 但这次 ReactCurrentDispatcher.current 会指向 HooksDispatcherOnUpdate

继续 let children = Component(props, secondArg); 又执行函数 App

函数 App 执行,首先执行 useState 方法。

这时候的 useState 指向 updateState

updateState

updateState 执行并返回 updateReducer 的结果。

// 此函数完整代码在packages/react-reconciler/src/ReactFiberHooks.old.js中
function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  // 更新hook链表
  const hook = updateWorkInProgressHook();
  // ...省略其他逻辑

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

我们隐去其他逻辑,重点看一下开头的 updateWorkInProgressHook 方法,后面都是对 hook 的修改了。

这和 mountState 的逻辑不同。

updateWorkInProgressHook 就是赋值 currentHook 和更新 workInProgressHook 的地方。

updateWorkInProgressHook 方法我们等会再分析,我们先看一下 useEffect 因为它也会调用 updateWorkInProgressHook

updateEffect

这时候 useEffect 指向 updateEffect

updateEffect 方法执行并返回 updateEffectImpl 的结果。

// 此函数完整代码在packages/react-reconciler/src/ReactFiberHooks.old.js中
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook();
  // ...省略其他逻辑
}

看到函数第一句就是调用 updateWorkInProgressHook 方法。

好,我们重点看 updateWorkInProgressHook

updateWorkInProgressHook

function updateWorkInProgressHook(): Hook {
  
  let nextCurrentHook: null | Hook;
  // 第1个if
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  let nextWorkInProgressHook: null | Hook;
  // 第2个if
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  // 第3个if
  if (nextWorkInProgressHook !== null) {
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // 从current hook上clone

    invariant(
      nextCurrentHook !== null,
      '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,
    };
    // 第4个if
    if (workInProgressHook === null) {
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

我把逻辑分为了四个 if 逻辑,并在代码中标注了序号。

因为函数 App 中调用了 useStateuseEffect ,所以会执行两次。

我们就按第一次执行走的逻辑和第二次走的逻辑进行分析。

第一次走的逻辑

此时 currentHooknull,第1个if 会进入到 if 分支。

currentlyRenderingFiber 是当前渲染的 fiber 也就是 App,取出它的 alternate

因为 alternate 不为 null 会把它的 memoizedState 取出赋值给 nextCurrentHook。 还记得吧,应用第一次渲染会把 hook 链表挂在 memoizedState 上。这样 nextCurrentHook 就指向了拥有两个 hook 的链表。

第1个if 逻辑处理完毕。接着第2个if

此时 workInProgressHook 也是 null,会进入第2个ifif 分支。 而 currentlyRenderingFiber.memoizedStatenull,并赋值给 nextWorkInProgressHook

接着第3个 if 逻辑。

nextWorkInProgressHooknull,会进入第3个 if 逻辑的else分支。

先把 nextCurrentHook 赋值给 currentHook,这样 currentHook 也就指向了拥有两个节点的链表。

const newHook 就是 clone 了链表中第一个节点的属性。

workInProgressHook 仍是 null 进入第4个 if 逻辑的 if 分支。

newHook 赋值给 workInProgressHookcurrentlyRenderingFiber.memoizedState。 这样 workInProgressHook 就有了值,同时新的 fiber 属性 memoizedState 也有了值,但是只是拥有一个节点的链表,nextnull

以上就是 useState 执行过程中调用 updateWorkInProgressHook 方法的流程。

最终获得的结果是:currentHook 指向拥有两个节点 的链表,workInProgressHook 指向拥有一个节点的链表,且属性值是从 currentHook 第一个节点 clone 出来的。

第二次走的逻辑

第二次调用 updateWorkInProgressHook 是由 useEffect 触发。

此时 currentHook 不是 null,第1个if 逻辑 else 分支。

nextCurrentHook 指向 currentHook.next,其实就是 useEffect Hook

接着进入第2个if逻辑,此时 workInProgressHook 不是 null,进入 else 分支。

因为 workInProgressHooknextnull,所以 nextWorkInProgressHooknull

第3个if逻辑,nextWorkInProgressHooknull,所以会进入else分支。

nextCurrentHook 赋值给 currentHook 后,再次 clone 出新的 newHook

再进入第4个if逻辑,workInProgressHook 不是 null,进入else分支,把 newHook 赋值给 workInProgressHook.nextworkInProgressHook,这样就构建了拥有两个节点的链表,currentlyRenderingFiber.memoizedState 也就拥有了两个节点的链表。

对于 App 函数,useStateuseEffect 执行完毕,就是到 return 了,也就是 App 函数执行完毕。然后回到 renderWithHooks 方法。

我们再看 const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null; ,此时 currentHook 不是 null 但是它的 nextnull,所以 didRenderTooFewHooksfalse

这样在下面的 invariant(!didRenderTooFewHooks,'Rendered fewer hooks than expected. This may be caused by an accidental ' +'early return statement.',); 中就不会抛出错误。

hook 放入条件语句中

我们修改一下 useEffect 的执行条件

function App() {
  const [count, setCount] = useState(0);
  
++  if (count === 0) {
    useEffect(() => {
      console.log('naonao', count);
    });
++  }

  let handleCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p onClick={handleCount}>{count}</p>
    </div>
  );
}

ReactDom.render(<App />, document.getElementById('root'));

这样在 updateWorkInProgressHook 方法就会缺少【第二次走的逻辑】,currentHook 就是拥有两个节点的链表,在 renderWithHooks 方法中 const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null; 运算结果 didRenderTooFewHooks就是 true啦,所以会抛出错误。

结语

Hook 最终是构建了一个单项的链表,而每次更新执行都是按同样的顺序被调用,如果某个 Hook 被放在循环,条件或嵌套函数中执行,定会破坏它的顺序型导致问题。

还好 React 内部已经做了提示,最好使用 eslint-plugin-react-hooks 插件,它可以在编译阶段就检测出是否符合规则。