【react】react hook运行原理解析

2,548 阅读18分钟

声明:本文的研究的源码是react@16.3.1

hook相关术语

hook

react在顶级命名空间上暴露给开发者的API,比如下面的代码片段:

import React , { useState, useReducer, useEffect } from 'react'

我们会把useState,useReduceruseEffect等等称之为“hook”。确切来说,hook是一个javascript函数。

请注意,当我们在下文中提到“hook”这个术语,我们已经明确地跟“hook对象”这个术语区分开来了。

react内置了以下的hook:

/* react/packages/react-reconciler/src/ReactFiberHooks.new.js */
export type HookType =
  | 'useState'
  | 'useReducer'
  | 'useContext'
  | 'useRef'
  | 'useEffect'
  | 'useLayoutEffect'
  | 'useCallback'
  | 'useMemo'
  | 'useImperativeHandle'
  | 'useDebugValue'
  | 'useDeferredValue'
  | 'useTransition'
  | 'useMutableSource'
  | 'useOpaqueIdentifier';

hook对象

/* react/packages/react-reconciler/src/ReactFiberHooks.new.js */
export type Hook = {
  memoizedState: any, 
  baseState: any, 
  baseQueue: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,
  next: Hook | null
};

从数据类型的角度来说,hook对象是一个“纯javascript对象(plain javascript object)”。从数据结构的角度来看,它是一个单向链表(linked list)(下文简称为“hook链”)。next字段的值可以佐证这一点。

下面简单地解释一下各个字段的含义:

  • memoizedState。通过遍历完hook.queue循环单向链表所计算出来的最新值。这个值会在commit阶段被渲染到屏幕上。
  • baseState。我们调用hook时候传入的初始值。它是计算新值的基准。
  • baseQueue。
  • queue。参见下面的queue对象

update对象

// react/packages/react-reconciler/src/ReactFiberHooks.new.js 
type Update<S, A> = {
  // TODO: Temporary field. Will remove this by storing a map of
  // transition -> start time on the root.
  eventTime: number,
  lane: Lane,
  suspenseConfig: null | SuspenseConfig,
  action: A,
  eagerReducer: ((S, A) => S) | null,
  eagerState: S | null,
  next: Update<S, A>,
  priority?: ReactPriorityLevel,
};

我们只需要关注跟hook原理相关的字段即可,所以update对象的类型可以简化为:

// react/packages/react-reconciler/src/ReactFiberHooks.new.js 
type Update<S, A> = {
  action: A,
  next: Update<S, A>
};
  • action。专用于useState,useReducer这两个hook的术语。因为这两个hook是借用redux概念的产物,所以,在这两个hook的内部实现源码中,使用了redux的诸多术语:“dispatch”,“reducer”,“state”,“action”等。但是此处的ation跟redux的action不是完全一样的。假如有一下代码:
const [count,setState] = useState(0);
const [useInfo,dispatch] = useReducer(reducer,{name:'鲨叔',age:0})

从源码的角度来说,我们调用setState(1)setState(count=> count+1)dispatch({foo:'bar'})传入的参数就是“action”。对于redux的action,我们约定俗成为{type:string,payload:any}这种类型,但是update对象中的action却可以为任意的数据类型。比如说,上面的1,count=> count+1{foo:'bar'}都是update对象的action。

一并需要提到的是“dispatch方法”这个术语。从源码的角度来看,useState/useReducer这两个hook调用所返回数组的第二个元素其实都是内部dispatchAction函数实例的一个引用。我们以React.useState()为例子,不妨看看它的源码:

  // React.useState()在mount阶段的实现
  function mountState(initialState) {
    // 这里省略了很多代码
    // ......
    var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
    return [hook.memoizedState, dispatch];
  }
  
  // React.useState()在update阶段的实现
   function updateState(initialState) {
    return updateReducer(basicStateReducer);
  }
  
  function updateReducer(reducer, initialArg, init) {
    var hook = updateWorkInProgressHook();
    var queue = hook.queue;
    // 这里省略了很多代码
    // ......
    var dispatch = queue.dispatch;
    return [hook.memoizedState, dispatch];
  }

可以看得出,我们开发者拿到的只是dispatch方法的一个引用。所以,下文会把useState/useReducer这两个hook调用所返回数组的第二个元素统称为“dispatch方法”。调用dispatch方法会导致function component重新渲染。

  • next。指向下一个update对象的指针。从这里我们就可以判断update对象是一个单向链表。至于它什么时候变成【循环】单向链表,后面会讲到。

queue对象

// react/packages/react-reconciler/src/ReactFiberHooks.new.js 
type UpdateQueue<S, A> = {
  pending: Update<S, A> | null,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
};
  • pending。我们调用dispatch方法,主要是做了两件事:1)生成一个由update对象组成的循环单向链表; 2)触发react的调度流程。而pending就是这个循环单向链表的头指针。
  • dispatch。返回给开发者的用于触发组件re-render的函数实例引用。
  • lastRenderedReducer。 上一次update阶段使用的reducer。
  • lastRenderedState。 使用lastRenderedReducer计算出来并已经渲染到屏幕的state。

currentlyRenderingFiber

这是一个全局变量,存在于function component的生命周期里面。顾名思义,这是一个fiber节点。每一个react component都有一个与之对应的fiber节点。按照状态划分,fiber节点有两种:“work-in-progress fiber”和“finished-work fiber”。前者代表的是当前render阶段正在更新react component,而后者代表的是当前屏幕显示的react component。这两种fiber节点通过alternate字段来实现【循环引用】对方。有源码注释为证:

// react/packages/react-reconciler/src/ReactInternalTypes.js
export type Fiber = {
  // ......
  // 此前省略了很多代码
  
  // This is a pooled version of a Fiber. Every fiber that gets updated will
  // eventually have a pair. There are cases when we can clean up pairs to save
  // memory if we need to.
  alternate: Fiber | null,
  
  // 此后省略了很多代码
  // ......

};

这里的currentlyRenderingFiber是属于“work-in-progress fiber”。但是为了避免歧义,内部源码采用了当前这个命名:

// react/packages/react-reconciler/src/ReactFiberHooks.new.js

// The work-in-progress fiber. I've named it differently to distinguish it from
// the work-in-progress hook.
let currentlyRenderingFiber: Fiber = (null: any);

对这个全局变量的赋值行为是发生在function component被调用之前。有源码为证:

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
	currentlyRenderingFiber = workInProgress;
    // 此间省略了很多代码
    // ......
    let children = Component(props, secondArg);
    // 此后省略了很多代码
    // ......
}

没错,这里的Component就是我们平常所说,所写的function component。可以看出,一开始currentlyRenderingFiber是为null的,在function component调用之前,它被赋值为该function component所对应的fiber节点了。

currentHook

这是一个全局变量,对应于旧的hook链(旧的hook链的产生于hook的mount阶段)上已经遍历过的那个hook对象。当前正在遍历的hook对象存放在updateWorkInProgressHook()方法中的局部变量nextCurrentHook上。

workInProgressHook

mount阶段和update阶段都存在。mount阶段,这是一个全新的javascript对象;update阶段,它是通过对旧hook对象进行浅拷贝得到的新的,对应与当前被调用的hook的hook对象。无论是mount阶段还是update阶段,它都是指向当前hook链中的最后一个被处理过(mount阶段,对应于最后一个被创建的hook对象;update阶段,对应于最后一个被拷贝的hook对象)的hook对象。

hook的mount阶段

等同于组件的首次挂载阶段,更确切来说是function component的第一次被调用(因为function component本质上是一个函数)。一般而言,是由ReactDOM.render()来触发的。

hook的update阶段

等同于组件的更新阶段,更确切地说是function component的第二次,第三次......第n次的被调用。一般而言,存在两种情况使得hook进入update阶段。第一种是,父组件的更新导致function component的被动更新;第二种是,在function component内部手动调用hook的dispatch方法而导致的更新。

小结

从数据类型的角度来说,上面所提到的“xxx对象”从数据结构的角度来看,它们又是相应的数据结构。下面,我们把上面所提到的数据结构串联到一块之后就是mount阶段,hook所涉及的数据结构:

几个事实

1. mount阶段调用的hook与update阶段调用的hook不是同一个hook

import React, { useState } from 'react';

function Counter(){
	const [count,setState] = useState();
    
    return <div>Now the count is {count}<div>
}

就那上面的代码,拿useSate这个hook作说明。Counter函数会被反复调用,第一次调用的时候,对useState这个hook来说,就是它的“mount阶段”。此后的每一次Counter函数调用,就是useState的“update阶段”。

一个比较颠覆我们认知的事实是,第一次调用的useState竟然跟随后调用的useState不是同一个函数。这恐怕是很多人都没有想到的。useState只是一个引用,mount阶段指向mount阶段的“mountState”函数,update阶段指向update阶段的“updateState”函数,这就是这个事实背后的实现细节。具体来看源码。react package暴露给开发者的useState,其实是对应下面的实现:

// react/packages/react/src/ReactHooks.js

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

而resolveDispatcher()函数的实现是这样的:

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  invariant(
    dispatcher !== null,
    'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
      ' one of the following reasons:\n' +
      '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
      '2. You might be breaking the Rules of Hooks\n' +
      '3. You might have more than one copy of React in the same app\n' +
      'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
  );
  return dispatcher;
}

ReactCurrentDispatcher.current初始值是为null的:

// react/src/ReactCurrentDispatcher.js

/**
 * Keeps track of the current dispatcher.
 */
const ReactCurrentDispatcher = {
  /**
   * @internal
   * @type {ReactComponent}
   */
  current: (null: null | Dispatcher),
};

那么它是在什么时候被赋值了呢?赋了什么值呢?答案是在function Component被调用之前。在renderWithHooks()这个函数里面,有这样的代码:

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
	// 这里省略了很多代码....
    ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
        
  let children = Component(props, secondArg);
  // 这里省略了很多代码.....
  return children;
}

这里,current是一个fiber节点。从这个判断可以看出,function component没有对应的fiber节点或者该fiber节点上没有hook链表的时候,就是hook的mount阶段。mount阶段,Dispatcher.current指向的是HooksDispatcherOnMount;否则,就是updte阶段。update阶段,Dispatcher.current指向的是HooksDispatcherOnUpdate。

最后,我们分别定位到HooksDispatcherOnMount和HooksDispatcherOnUpdate对象上,真相就一目了然:

// react/packages/react-reconciler/src/ReactFiberHooks.new.js

const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState, // 目光请聚焦到这一行
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useMutableSource: mountMutableSource,
  useOpaqueIdentifier: mountOpaqueIdentifier,
  unstable_isNewReconciler: enableNewReconciler,
};

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState, // 目光请聚焦到这一行
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useMutableSource: updateMutableSource,
  useOpaqueIdentifier: updateOpaqueIdentifier,
  unstable_isNewReconciler: enableNewReconciler,
};

可以看到,useState在mount阶段对应的是“mountState”这个函数;在update阶段对应的是“updateState”这个函数。再次强调,这里只是拿useState这个hook举例说明,其他hook也是一样的道理,在这里就不赘言了。

2. useState()其实是简化版的useReducer()

说这句的意思就是,相比于useReducer,useState这个hook只是在API参数上不一样而已。在内部实现里面,useState也是走的是useReducer那一套机制。具体来说,useState也有自己的reducer,在源码中,它叫basicStateReducer。请看,mount阶段的useState的实现:

// react/packages/react-reconciler/src/ReactFiberHooks.new.js

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // ......
  const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  // .....
}

可以看到,useState()也是有对应的reducer的,它就挂载在lastRenderedReducer这个字段上。那basicStateReducer长什么样呢?

// react/packages/react-reconciler/src/ReactFiberHooks.new.js

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

可以看到,这个basicStateReducer跟我们自己写的(redux式的)reducer是具有相同的函数签名的:(state,action) => state,它也是一个真正的reducer。也就是说,在mount阶段,useReducer使用的reducer是开发者传入的reducer,而useState使用的是react帮我们对action进行封装而形成的basicStateReducer

上面是mount阶段的useState,下面我们来看看update阶段的useState是怎样跟useReducer产生关联的。上面已经讲过,useState在update阶段引用的是updateState,那我们来瞧瞧它的源码实现:

// react/packages/react-reconciler/src/ReactFiberHooks.new.js

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

没错,updateState()调用的就是updateReducer(),而useReducer在update阶段引用的也是updateReducer函数!到这里,对于这个事实算是论证完毕。

3. useReducer()的第一个参数“reducer”是可以变的

废话不多说,我们来看看updateReducer函数的源码实现(我对源码进行了精简):

// react/packages/react-reconciler/src/ReactFiberHooks.new.js

function updateReducer(reducer,initialArg,init) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  // 拿到更新列表的表尾
  const last = queue.pending;

  // 获取最早的那个update对象,时刻记住,这是循环链表
  first = last !== null ? last.next : null;

  if (first !== null) {
    let newState = hook.baseState;
    let update = first;
    do {
      // 执行每一次更新,去更新状态
      const action = update.action;
      newState = reducer(newState, action);
      update = update.next;
    } while (update !== null && update !== first);

    hook.memoizedState = newState;
  }
  const dispatch = queue.dispatch;
  // 返回最新的状态和修改状态的方法
  return [hook.memoizedState, dispatch];
}

可以看到,update阶段useReducer传进来的reducer是被用于最新值的计算的。也就是说,在update阶段,我们可以根据一定的条件来切换reducer的。虽然,实际开发中,我们不会这么干,但是,从源码来看,我们确实是可以这么干的。

也许,你会问,useState可以同样这么干吗?答案是:“不能”。因为useState所用到的reducer不是我们能左右的。在内部源码中,这个reducer固定为basicStateReducer。

hook运作的基本原理

hook的整个生命周期可以划分为三个阶段:

  • mount阶段
  • 触发更新阶段
  • update阶段

通过了解这三个阶段hook都干了些什么,那么我们就基本上就可以掌握hook的运作基本原理了。

mount阶段

简单来说,在mount阶段,我们每调用一次hook(不区分类型。举个例子说,我连续调用了三次useState(),那么我就会说这是调用了三次hook),实际上会发生下面的三件事情:

  1. 创建一个新的hook对象;
  2. 构建hook单链表;
  3. 补充hook对象的信息。

第一步和第二步:【创建一个新的hook对象】和【构建hook单链表】

我们每调用一次hook,react在内部都会调用mountWorkInProgressHook()方法。而【hook对象的创建】和【链表的构建】就是发生在这个方法里面。因为它们的实现都是放在同一个方法里面,这里就放在一块讲了:

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;
}

显而易见,变量hook装的就是初始的hook对象。所以,【创建一个新的hook对象】这一步算是讲完了。

下面,我们来看看第二步-【构建hook单链表】。它的代码比较简单,就是上面的mountWorkInProgressHook方法里面的最后几行代码:

  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;
  }

术语章节已经讲过,workInProgressHook是一个全局变量,它指向的是最后一个被生成的hook对象。如果workInProgressHook为null,那就代表着根本就没有生成过hook对象,对应于当前这个hook对象是第一个hook对象,则它会成为表头,被头指针【currentlyRenderingFiber.memoizedState】所指向;否则,当前创建的hook对象被append到链表的尾部。这里,react内部的实现采用了比较巧妙的实现。它新建了一个指针(workInProgressHook),每一轮构建完hook链表后都让它指向表尾。那么,下一次追加hook对象的时候,我们只需要把新hook对象追加到workInProgressHook对象的后面就行。实际上,上面的代码可以拆解为这样:

  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState  = hook;
    workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook.next = hook; // 为什么对workInProgressHook.next的赋值能够起到append链表的作用呢?这里需要用到【引用传递】的知识来理解。
    workInProgressHook = hook;
  }

这种实现方法的好处是:不要通过遍历链表来找到最后一个元素,以便其后插入新元素(时间复杂度为O(n))。而是直接插入到workInProgressHook这个元素后面就好(时间复杂度为O(1))。我们要知道,常规的链表尾部插入是这样的:

   if (currentlyRenderingFiber.memoizedState === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState  = hook;
  } else {
   let currrent = currentlyRenderingFiber.memoizedState;
   while(currrent.next !== null){
   	currrent = currrent.next
   }
   currrent.next = hook;
  }

从时间复杂度的角度来说就是把链表插入算法的时间复杂度从O(n)降到O(1)。好,上面稍微展开了一点。到这里,我们已经看到react源码中,是如何实现了第一步和第二步的。

第三步:补充hook对象的信息

我们在第一步创建的hook对象有很多字段,它们的值都是初始值null。那么在第三部,我们就是对这些字段的值进行填充。这些操作的实现代码都是在mountState函数的里面:

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  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];
}

对memoizedState和baseState的填充:

 if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;

对queue字段的填充:

  const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });

对next字段的填充其实是发生在第二步【构建hook单链表】,这里就不赘述了。

以上就是hook对象填充字段信息的过程。不过,值得指出的是,hook对象的queue对象也是在这里初始化并填充内容的。比如dispatch字段,lastRenderedReducer和lastRenderedState字段等。着重需要提到dispatch方法:

 const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];

从上面的代码可以看到,我们拿到的dispatch方法实质上是一个引用而已。它指向的是dispatchAction这个方法通过函数柯里化所返回的函数实例。函数柯里化的本质是【闭包】。通过对currentlyRenderingFiberqueue变量的闭包,react能确保我们调用dispatch方法的时候访问到的是与之对应的queue对象和currentlyRenderingFiber。

好的,以上就是hook在mount阶段所发生的事情。

触发更新阶段

当用户调用dispatch方法的时候,那么我们就会进入【触发更新阶段】。react的源码中并没有这个概念,这是我为了帮助理解hook的运行原理而提出的。

要想知道触发更新阶段发生了什么,我们只需要查看dispatchAction方法的实现就好。但是,dispatchAction方法实现源码中,参杂了很多跟调度和开发环境相关的代码。这里为了方便聚焦于hook相关的原理,我对源码进行了精简:

  function dispatchAction<S, A>(fiber: Fiber,queue: UpdateQueue<S, A>,action: A,) {
    const update: Update<S, A> = {
      eventTime,
      lane,
      suspenseConfig,
      action,
      eagerReducer: null,
      eagerState: null,
      next: (null: any),
    };
  
    // Append the update to the end of the list.
    const pending = queue.pending;
    if (pending === null) {
      // This is the first update. Create a circular list.
      update.next = update;
    } else {
      update.next = pending.next;
      pending.next = update;
    }
    queue.pending = update;
  
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  }

触发更新阶段,主要发生了以下三件事情:

  1. 创建update对象:
 const update: Update<S, A> = {
      eventTime,
      lane,
      suspenseConfig,
      action,
      eagerReducer: null,
      eagerState: null,
      next: (null: any),
    };

这里,我们只需要关注action和next字段就好。从这里可以看出,我们传给dispatch方法的任何参数,都是action。

  1. 构建updateQueue循环单向链表:
// Append the update to the end of the list.
    const pending = queue.pending;
    if (pending === null) {
      // This is the first update. Create a circular list.
      update.next = update;
    } else {
      update.next = pending.next;
      pending.next = update;
    }
    queue.pending = update;

从上面的代码中,可以看到:

  • updateQueue是一个循环单向链表;
  • 链表元素插入的顺序等同于dispatch方法调用的顺序。也就是说最后生成的update对象处于链尾。
  • queue.pending这个指针永远指向链尾元素。
  1. 真正地触发更新

无论是之前的class component时代,还是现在的function component时代,我们调用相应的setState()方法或者dispatch()方法的时候,其本质都是向react去请求更新当前组件而已。为什么这么说呢?因为,从react接收到用户的更新请求到真正的DOM更新,这中间隔着“千山万水”。以前,这个“千山万水”是react的“批量更新策略”,现在,这个“千山万水”是新加入的“调度层”。

不管怎样,对于function component,我们心里得有个概念就是:假如react决定要更新当前组件的话,那么它的调用栈一定会进入一个叫“renderWithHooks”的函数。就是在这个函数里面,react才会调用我们的function component(再次强调,function component是一个函数)。调用function component,则一定会调用hook。这就意味着hook会进入update阶段。

那么,hook在update阶段发生了什么呢?下面,我们来看看。

update阶段

hook在update阶段做了什么,主要是看updateReducer()这个方法的实现。由于updateReducer方法实现中,包含了不少调度相关的代码,以下是我做了精简的版本:

function updateReducer(reducer,initialArg,init) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  // 拿到更新链表的表尾元素
  const last = queue.pending;

  // 获取最早插入的那个update对象,时刻记住,这是循环链表:最后一个的next指向的是第一个元素
  first = last !== null ? last.next : null;

  if (first !== null) {
    let newState = hook.baseState;
    let update = first;
    do {
      // 执行每一次更新,去更新状态
      const action = update.action;
      newState = reducer(newState, action);
      update = update.next;
    } while (update !== null && update !== first);

    hook.memoizedState = hook.baseState = newState;
  }
  const dispatch = queue.dispatch;
  // 返回最新的状态和修改状态的方法
  return [hook.memoizedState, dispatch];
}

hook的update阶段,主要发生了以下两件事情:

  1. 遍历旧的hook链,通过对每一个hook对象的浅拷贝来生成新的hook对象,并依次构建新的hook链。
  2. 遍历每个hook对象上的由update对象组成queue循环单链表,计算出最新值,更新到hook对象上,并返回给开发者。

updateReducer()方法的源码,我们可以看到,我们调用了updateWorkInProgressHook()方法来得到了一个hook对象。就是在updateWorkInProgressHook()方法里面,实现了我们所说的第一件事情。下面,我们来看看updateWorkInProgressHook()的源码(同样进行了精简):

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;
  }

  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,
  };

  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;
}

上面在讲currentlyRenderingFiber的时候讲到,当前已经显示在屏幕上的component所对应的fiber节点是保存在currentlyRenderingFiber.alternate字段上的。那么,旧的hook链的头指针无疑就是currentlyRenderingFiber.alternate.memoizedState。而nextCurrentHook变量指向的就是当前准备拷贝的标本对象,currentHook变量指向的是当前旧的hook链上已经被拷贝过的那个标本对象。结合这三个语义,我们不难理解这段代码:

let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

用一句话来总结就是:如果当前是第一次hook调用,那么拷贝的标本对象就是旧的hook链的第一个元素;否则,拷贝的标本对象就是当前已经拷贝过的那个标本对象的下一个。

下面这一段代码就是hook对象的浅拷贝:

  currentHook = nextCurrentHook;

  const newHook: Hook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  };

从上面的浅拷贝,我们可以想到,hook的mount阶段和update阶段都是共用同一个queue链表。

再往下走,即使新链表的构建,几乎跟mount阶段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;
  }

到这里,我们通过解析updateWorkInProgressHook()的源码把第一件事情算是讲完了。下面,我们接着来讲第二件事情-【遍历每个hook对象上的由update对象组成queue循环单链表,计算出最新值,更新到hook对象上,并返回给开发者】。相关源码就在上面给出的精简版的updateReducer()方法的源码中。我们再次把它抠出来,放大讲讲:

 const queue = hook.queue;

  // 拿到queu循环链表的表尾元素
  const last = queue.pending;

  // 获取最早插入的那个update对象,时刻记住,这是循环链表:最后一个元素的next指针指向的是第一个元素
  first = last !== null ? last.next : null;

  if (first !== null) {
    let newState = hook.baseState;
    let update = first;
    do {
      // 执行每一次更新,去更新状态
      const action = update.action;
      newState = reducer(newState, action);
      update = update.next;
    } while (update !== null && update !== first);

    hook.memoizedState = hook.baseState = newState;
  }
  const dispatch = queue.dispatch;
  // 返回最新的状态和修改状态的方法
  return [hook.memoizedState, dispatch];

首先,拿到queue循环链表的第一个元素;

其次,从它开始遍历整个链表(结束条件是:回到链表的头元素),从链表元素,也即是update对象上面拿到action,遵循newState = reducer(newState, action);的计算公式,循环结束的时候,也就是最终值被计算出来的时候;

最后,把新值更新到hook对象上,然后返回出去给用户。

从第二件事件里面,我们可以理解,为什么hook在update阶段被调用的时候,我们传入的initialValue是被忽略的,以及hook的最新值是如何更新得到的。最新值是挂载在hook对象上的,而hook对象又是挂载在fiber节点上。当component进入commit阶段后,最新值会被flush到屏幕上。hook也因此完成了当前的update阶段。

为什么hook的顺序如此重要?

在hook的官方文档:Rules of Hooks中,提到了使用hook的两大戒律:

  • Only Call Hooks at the Top Level
  • Only Call Hooks from React Functions(component)

如果单纯去死记硬背,而不去探究其中的缘由,那么我们的记忆就不会牢固。现在,我们既然深究到源码层级,我们就去探究一下提出这戒律后面的依据是什么。

首先,我们先来解读一下这两大戒律到底是在讲什么。关于第一条,官方文档已经很明确地指出,之所以让我们在函数作用域的顶部,而不是在循环语句,条件语句和嵌套函数里面去调用hook,目的只有一个:

By following this rule, you ensure that Hooks are called in the same order each time a component renders.

是的,目的就是保证mount阶段,第一次update阶段,第二update阶段......第n次update阶段之间,所有的hook的调用顺序都是一致的。至于为什么,我们稍后解释。

而第二条戒律,说的是只能在React的function component里面去调用react hook。这条戒律是显而易见的啦。大家都知道react hook对标的是class component的相关feature(状态更新,生命周期函数)的,它肯定要跟组件的渲染相挂钩的,而普通的javascript函数是没有跟react界面渲染相关联的。其实这条戒律更准确来说,应该是这样的:要确保react hook【最终】是在react function component的作用域下面去调用。也就是说,你可以像俄罗斯套娃那样,在遵循第一条戒律的前提下去对react hook层层包裹(这些层层嵌套的函数就是custom hook),但是你要确保最外层的那个函数是在react function componnet里面调用的。

上面的说法依据是什么呢?那是因为hook是挂载在dispatcher身上的,而具体的dispatcher是在运行时注入的。dispatcher的注入时机是发生在renderWithHook()这个方法被调用的时候,这一点在上面【几个事实】一节中有提到。从renderWithHook到hook的调用栈是这样的:

renderWithHook() -> dispatcher注入 -> component() -> hook() -> resolveDispatcher()

那么,我们看一眼resolveDispatcher方法的实现源码就能找到我们要想找的依据:

 function resolveDispatcher() {
    var dispatcher = ReactCurrentDispatcher.current;

    if (!(dispatcher !== null)) {
      {
        throw Error( "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:\n1. You might have mismatching versions of React and the renderer (such as React DOM)\n2. You might be breaking the Rules of Hooks\n3. You might have more than one copy of React in the same app\nSee https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem." );
      }
    }

    return dispatcher;
  }

也就是说,假如你不在react function component里面调用hook的话,那么renderWithHook()这个方法就不会被执行到,renderWithHook()没有被执行到,也就是说跳过了dispatcher的注入。dispatcher没有被注入,你就去调用hook,此时dispatcher为null,因此就会报错。

以上,就是第二条戒律背后的依据分析。至于第一条戒律,它不断地在强调hook的调用顺序要一致。要想搞懂这个原因,首先我们得搞懂什么是【hook的调用顺序】?

什么是hook的调用顺序?

答案是:“hook在【mount阶段】的调用顺序就是hook的调用顺序”。也就是说,我们判断某一次component render的时候,hook的调用顺序是否一致,参照的是hook在mount阶段所确定下来的调用顺序。举个例子:

// mount阶段
const [count,setCount] = useState(0)
const [name,setName] = useState('sam')
const [age,setAge] = useState(28)

//第一次update阶段
const [count,setCount] = useState(0)
const [name,setName] = useState('sam')
const [age,setAge] = useState(28)

//第二次update阶段
const [age,setAge] = useState(28)
const [count,setCount] = useState(0)
const [name,setName] = useState('sam')

参照mount阶段所确定的顺序:useState(0) -> useState('sam') -> useState(28),第一次update阶段的hook调用顺序是一致的,第二次update阶段的hook调用顺序就不一致了。

总而言之,hook的调用顺序以mount阶段所确立的顺序为准。

关于hook的调用顺序的结论

首先,二话不说,我们先下两个有关于hook调用顺序的结论:

  1. hook的【调用次数】要一致。多了,少了,都会报错。
  2. 最好保持hook的【调用位置】是一致的。

其实,经过思考的同学都会知道,调用顺序一致可以拆分了两个方面:hook的数量一致,hook的调用位置要一致(也就是相同的那个hook要在相同的次序被调用)。

官方文档所提出的的hook的调用顺序要一致,这是没问题的。它这么做,既能保证我们不出错,又能保证我们不去做那些无意义的事情(改变hook的调用位置意义不大)。

但是,从源码的角度来看,hook的调用位置不一致并不一定会导致程序出错。假如你知道怎么承接调用hook所返回的引用的话,那么你的程序还会照常运行。之所以探讨这一点,是因为我要打唯官方文档论的迷信思想,加深【源码是检验对错的唯一标准】的认知。

首先,我们来看看,为什么能下第一条结论。我们先看看hook的调用次数多的情况。假如我们有这样的代码:

// mount阶段
const [count,setCount] = useState(0)
const [name,setName] = useState('sam')
const [age,setAge] = useState(28)

//第一次update阶段
const [count,setCount] = useState(0)
const [name,setName] = useState('sam')
const [age,setAge] = useState(28)
const [sex,setSex] = useState('男')

经过mount阶段,我们会得到这样的一条hook链:

=============           =============             =============
| count Hook |  ---->   | name Hook  |  ---->     | age Hook  | -----> null
=============           =============             =============

上面也提到了,update阶段的主要任务之一就是遍历旧的hook链去创建新的hook链。在updateWorkInProgressHook方法里面,在拷贝hook对象之前的动作是要计算出当前要拷贝的hook对象,也就是变量nextCurrentHook的值:

  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

假设,现在我们来到了update阶段的第四次hook调用,那么代码会执行到nextCurrentHook = currentHook.next;。currentHook是上一次(第三次)被成功拷贝的对象,并且是存于旧链上。因为旧的hook链只有三个hook对象,那么此时currentHook对象已经是最后一个hook对象了,currentHook.next的值自然是为null了。也就是说当前准备拷贝的hook对象(nextCurrentHook)是为null的。我们的断言失败,程序直接报错:

  // 假如我们断言失败,则会抛出错误
  invariant(
    nextCurrentHook !== null,
    'Rendered more hooks than during the previous render.',
  );

以上就是hook调用次数多了会报错的情况。下面,我们来看看hook调用次数少了的情况。我们直接关注renderWithHooks方法里面的这几行代码:

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  // ....
  let children = Component(props, secondArg);
  // ....
  const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null;
  // .....
  invariant(
    !didRenderTooFewHooks,
    'Rendered fewer hooks than expected. This may be caused by an accidental ' +
      'early return statement.',
  );

  return children;
}

这里的Component就是我们hook调用所在的function component。解读上面所给出的代码,我们可以得知:如果所有的hook都调用完毕,你那个全局变量currentHook的next指针还指向别的东西(非null值)的话,那么证明update阶段,hook的调用次数少了,导致了next指针的移动次数少了。如果hook的调用次数是一样的话,那么此时currentHook是等于旧的hook链上的最后一个元素,我们的断言就不会失败。

从上面的源码分析,我们可以得知,hook的调用次数是不能多,也不能少了。因为多了,少了,react都会报错,程序就停止运行。

最后,我们来看看结论二。

为了证明我的观点是对的,我们直接来运行下面的示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>react hook</title>
</head>
<body>
    <div id="root"></div>
    <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
    <script></script>
    <script>
        window.onload = function(){
            let _count = 0;
            const {
                useState,
                useEffect,
                createElement
            } = React

            const root  = document.getElementById('root')
            let count,setState
            let name,setName

            function Counter(){
               
                if(_count === 0){ // mount阶段
                    const arr1= useState(0)
                    count = arr1[0]
                    setState = arr1[1]

                    const arr2 = useState('sam')
                    name = arr2[0]
                    setName = arr2[1]

                }else if(_count >= 1){ // update阶段
                    const arr1 = useState('little sam')
                    count = arr1[0]
                    setState  = arr1[1]
                     
                    const arr2 = useState(0)
                    name = arr2[0]
                    setName = arr2[1]
                }


                _count += 1
                return createElement('button',{
                    onClick:()=> {
                        setState(count=> count + 1)
                    }
                },count)
            }
            ReactDOM.render(createElement(Counter),root)

        }
    </script>
</body>
</html>

直接运行上面的例子,程序是会正常运行的,界面的显示效果也是正确的。从而佐证了我的结论是正确的。那为什么呢?那是因为在update阶段,要想正确地承接住hook调用所返回的引用,hook的名字是不重要的,重要的是它的位置。上面update阶段,虽然hook的调用的位置是调换了,但是我们知道第一个位置的hook对象还是指向mount阶段的count hook对象,所以,我还是能正确地用它所对应的变量来承接,所以,后面的render也就不会出错。

以上示例仅仅是为了佐证我的第二个结论。实际的开发中,我们不会这么干。或者说,目前我没有遇到必须这么做的开发场景。

以上就是从源码角度去探索react hook的调用顺序为什么这么重要的原因。

其他hook

上面只是拿useState和useReducer这两个hook作为本次讲解react hook原理的样例,还有很多hook没有涉及到,比如说十分重要的useEffect就没有讲到。但是,如果你深入到hook的源码(react/packages/react-reconciler/src/ReactFiberHooks.new.js)中去看的话,几乎所有的都有以下共性:

  • 都有mount阶段和update阶段
  • 在mount阶段,都会调用mountWorkInProgressHook()来生成hook对象;在update阶段,都会调用updateWorkInProgressHook()来拷贝生成新的hook对象。这就意味着,相同阶段,不管你是什么类型的hook,大家都是处在同一个hook链身上
  • 每个hook都对应一个hook对象,不同类型的hook的不同之处主要体现在它们挂载在hook.memoizedState字段上的值是不同的。比如说,useState和useReducer挂载的是state(state的数据结构由我们自己决定),useEffect挂载的是effect对象,useCallback挂载的是由callback和依赖数组组成的数组等等。

而在其他的hook中,useEffect太重要了,又相对不一样,无疑是最值得我们深入探索的hook。假如有时间,下一篇文章不妨探索探索它的运行原理。

总结

参考资料