探究React Hooks的背后原理

1,658 阅读12分钟

1-简介

概念

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

简单的用法如以下代码所示。

import React, { useState } from 'react';

function Example() {
  // 声明一个新的叫做 “count” 的 state 变量
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

如上。我们可以用useState声明一个初始值为0的count变量,并且得到一个setCount方法,通过这个方法可以修改这个count变量。

动机

官方文档中提到,hooks解决了之前react存在的一些问题:

  1. 在组件之间复用状态逻辑很难: HOC、Render props都会导致组件嵌套层级过深;
  2. 复杂组件变得难以理解: 大型组件不易理解,很难拆分和重构;
  3. 难以理解的 class: this的指向问题;

简单来说,hooks让函数组件拥有了自己的内部状态,能让我们更好地进行代码逻辑复用。

2-原理分析

关于hooks规则的几个问题

官方文档中提到,hooks使用时需要以下遵循两条规则:

  1. 只在最顶层使用Hook:不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层以及任何 return 之前调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。

  2. 只在 React 函数中调用 Hook:不要在普通的 JavaScript 函数中调用 Hook。

首先第二条可能比较好理解,因为我们可以通过作用域去控制哪里可以使用这些hooks, 但是第一条就不是那么直观地能理解的了。

基于这些使用规则,在使用了hooks之后,我总结了几点疑问:

问题解答
hooks如何存储数据?...
为何不能在function component之外的其他地方使用hooks?...
为何hooks不能放在条件语句中?...
hooks如何在不同的渲染中,返回最新的值?...
......

下面将根据部分源码的理解,一一解答这些问题。

hooks的相关概念

首先要理解两个跟hooks相关概念。

第一个是 Fiber

在React 15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。为了解决这个问题,React 16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要,于是,全新的Fiber架构应运而生。

每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息,保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)。

所以可以认为,React 16的Fiber概念就是对应之前的vDom概念。

那我们来看看Fiber的定义。

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // 作为静态数据结构的属性
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // 用于连接其他Fiber节点形成Fiber树
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  // 作为动态的工作单元的属性
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  this.effectTag = NoEffect;
  this.nextEffect = null;

  this.firstEffect = null;
  this.lastEffect = null;

  // 调度优先级相关
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 指向该fiber在另一次更新时对应的fiber
  this.alternate = null;
}

其中几个跟hooks有关的属性如下:

  • Fiber.memorizedState: Function Component中存储Hooks,Class Component中存储State的地方;
  • Fiber.alternate: currentFiber 和 workInProgressFiber 相互引用的地方;
  • currentFiber: 当前已有的Fiber节点;
  • workInProgressFiber: currentFiber更新时产生的节点;

可以看到Fiber.memorizedState就是存储hooks的地方,先理解这个概念,后面再根据源码分析为什么是存储在这里的。

第二个是hooks

const hook: Hook = {
  memoizedState: null,
  baseState: null,
  baseQueue: null,
  queue: null,
  next: null,
};

属性的解释如下:

  • memorizedState: 不同类型hook的memoizedState保存不同类型数据,比如useState 对应的就是 state,useEffect 对应的就是 effect 对象,useRef 对应的就是 ref 对象;
  • baseState: 本次更新前该Fiber节点的state,每次更新都基于该state计算得出更新后的state;
  • baseQueue: 本次更新之前已有的待更新队列;
  • queue: 本次更新需要增加的待更新队列,在计算state时,会将queue的环状链表合并到baseQueue,baseQueue基于baseState计算新的state;
  • next: 指向下一个hook;

看到next这个属性,我们就可以知道hook是一个链表结构了。

如下所示的代码会生成以下数据结构:

function hello() {
    const [num, setNum] = useState(0);
    useEffect(() => {
        console.error(num);
    },[]);
    const [str, setStr] = useState('str');
    return (
        ...
    );
}

image

useState的执行经过

我们用一个简单的例子来阐述说明hooks的执行过程。

为了简洁地说明一个hooks完整的执行过程,这里只以useState为例子,阐述首次挂载(onMount)和挂载之后的一次更新过程(onUpdate),嵌套渲染、更新优先级调度等不做深入解释。

function hello() {
    const [name, setName] = useState('lufei');
    const [age, setAge] = useState(8);
    const [sex, setSex] = useState('male');
    const btnTap = () => {
        setName('wanglufei')
        setAge(9)
        setAge(10)
        setSex('female')
    }
    return (
        <button onClick={() => btnTap() }></button>
    );
}
onMount

我们已经知道,react 16是通过Fiber节点来处理生成真实dom节点的;

当任务执行到首次更新Function component时,最后是调用renderWithHooks来处理生成函数式组件的Fiber节点的memorizedState属性的,如上述,hooks相关信息存储在这个属性上;

上述代码挂载过程会生成如下节点信息:

image

处理流程如下:

  1. 从renderWithHooks的参数中可以取出两个Fiber树,已经渲染在界面上的currentFiber, 和当前正在处理待更新节点的workInProgressFiber;

  2. 判断currentFiber不存在,处于mount阶段,将HooksDispatcherOnMount赋值给ReactCurrentDispatcher.current;

  3. 执行该Function Component,遇到第一个useState并执行,即ReactCurrentDispatcher.current.useState,通过第一步可以知道最终真实执行的是HooksDispatcherOnMount.useState,即mountState;

  4. 执行mountState,生成一个hook节点newHook,workInProgressHook指向上一个节点;若workInProgressHook为空,则newHook为首个节点,将newHook赋值给当前Fiber的memoizedState;若workInProgressHook不为空,则将newHook赋值给workInProgressHook.next;最后将workInProgressHook指向新生成的newHook;

  5. 将useState传进来的值赋值给newHook的memorizedState和baseState(若传进来的是函数,则执行后再赋值),初始化待更新队列queue;初始化queue.dispatch,将其与workInProgressFiber和queue绑定;

  6. useState返回[newHook.memorizedState, queue.dispatch];

  7. 重复4-6执行完三个useState;

  8. 最后返回一个保存上述hook信息和其他节点渲染相关信息的children;

  9. 将ReactCurrentDispatcher.current置为ContextOnlyDispatcher,将workInProgressHook和currentHook置空;

简化后的主要代码如下(... 表示省略了一些dev和兜底逻辑,以及一些rerender阶段和调度优先级相关的代码):

renderWithHooks:对应流程1、2、3、8、9,确定不同阶段的ReactCurrentDispatcher.current的值,执行component获得当前节点的fiber信息;

/**
 * ../react/src/ReactFiberHooks.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;
    ...
    workInProgress.memoizedState = null;
    workInProgress.updateQueue = null;
    workInProgress.lanes = NoLanes;
	...
	// 判断是挂载阶段还是更新阶段
	ReactCurrentDispatcher.current =
		current === null || current.memoizedState === null
			? HooksDispatcherOnMount
			: HooksDispatcherOnUpdate;

    let children = Component(props, secondArg);
    ...
    ReactCurrentDispatcher.current = ContextOnlyDispatcher;
	...
    renderLanes = NoLanes;
    currentlyRenderingFiber = (null: any);
    currentHook = null;
    workInProgressHook = null;
	...
    return children;
}
 

mountState:对应流程5、6,初始化新增的hook节点的信息;

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,
        interleaved: null,
        lanes: NoLanes,
        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:对应流程4,新增hook并生成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;
}

到这里,之前提出的问题中的前两个就能得到回答了。

问题解答
hooks如何存储数据?hooks本质上是用一个object结构存储相关信息的(hookObject),使用时传入的数据存储在hookObject.memorizedState上,hooks以单链表的形式连接,hookObject.next指向下一个hook;
为何不能在function component之外的其他地方使用hooks?在执行Function component之前,ReactCurrentDispatcher.current被赋值了不同阶段的执行对象(HooksDispatcherOnMount或HooksDispatcherOnUpdate),比如在挂载阶段,执行useState实际上是执行HooksDispatcherOnMount.mountState;在执行完Function component之后,ReactCurrentDispatcher.current被赋值了ContextOnlyDispatcher,这个对象的对应hooks调用都会提示报错(throwInvalidHookError);
......

不同类型hook的memoizedState保存不同类型数据,具体如下:

  • useState:对于const [state, updateState] = useState(initialState),memoizedState保存state的值;

  • useReducer:对于const [state, dispatch] = useReducer(reducer, {});,memoizedState保存state的值;

  • useEffect:memoizedState保存包含useEffect回调函数、依赖项等的链表数据结构effect,你可以在这里看到effect的创建过程。effect链表同时会保存在fiber.updateQueue中;

  • useRef:对于useRef(1),memoizedState保存{current: 1};

  • useMemo:对于useMemo(callback, [depA]),memoizedState保存[callback(), depA];

  • useCallback:对于useCallback(callback, [depA]),memoizedState保存[callback, depA]。与useMemo的区别是,useCallback保存的是callback函数本身,而useMemo保存的是callback函数的执行结果;

有些hook是没有memoizedState的,比如:useContext;

总结起来就是,onMount阶段,每个hook都会生成一个hook节点,将初始化数据存储在节点的memorizedState上,并生成了待更新队列queue、dispatch等其他信息存储在这个节点上;所有的hook通过hook.next链接成链表,挂载在fiber.memorizedState上;

dispatch阶段

useState经过mount阶段,得到了一个变量和一个可以改变变量的dispatch,如name和setName。我们知道,例子中点击按钮,界面就会更新数据。

那么这个dispacth具体做了什么呢?其实每次调用dispatch时,并不会立刻对状态值进行修改,状态值的更新是异步的,react会创建一条修改操作——在对应hook.queue.pending挂载的链表上加一个新节点;以两个setAge为例,dipatch阶段生成了如下的数据结构:

image

大致处理流程如下:

  1. 生成一个update对象,将传进来的数据存在update.action;

  2. 将新生成的update链接成环,存在queue.pending上;

简化后的主要代码如下:

function dispatchAction(fiber, queue, action) {
	// ...创建update
	var update = {
		eventTime: eventTime,
		lane: lane,
		suspenseConfig: suspenseConfig,
		action: action,
		eagerReducer: null,
		eagerState: null,
		next: null
	};

	// ...将update加入queue.pending

	var alternate = fiber.alternate;

	if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
		// render阶段触发的更新
		didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
		const pending = queue.pending;
		// 生成update环
		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;
	} else {
		if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
			// ...fiber的updateQueue为空,优化路径
			try {
              var currentState = queue.lastRenderedState;
              var eagerState = lastRenderedReducer(currentState, action);
    
              update.eagerReducer = lastRenderedReducer;
              update.eagerState = eagerState;
             
              if (objectIs(eagerState, currentState)) {
                return;
              }
            }
		} else {
		    ...
		}
		...
	}
	scheduleUpdateOnFiber(fiber, lane, eventTime);
}

其实这里还有一部分小优化处理。

if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes))

这里的判断是,如果update实际上为该hook上第一个update,即首次执行(例子中第一次点击的setAge(9)),则计算state时也只依赖于该update,完全不需要进入任务调度阶段再计算state,可以优先计算出eagerState保存下来; 这样做的好处是:如果eagerState与该hook之前保存的state一致,那么完全不需要开启一次调度。即使eagerState与该hook之前保存的state不一致,在也可以进入任务调度阶段后直接使用已经计算出的eagerState;

==总结一下就是,在这个dispatch阶段,hook会将每一个更新--update收集起来并以链表的形式连接,等待异步调度执行。==

onUpdate

其实经过分析dispacth,可以猜测到update阶段主要所做的事情就是将dispatchAction生成的update链表按顺序依次执行,得到最终的state值,并返回。此时的ReactCurrentDispatcher.current的值是HooksDispatcherOnUpdate,最终这个阶段的usetState执行的是updateReducer;

处理流程如下:

  1. 执行到hook时,调用updateWorkInProgressHook取出当前的hook节点;取出的规则是:首次update时(如点击执行setAge(9)),从currentFiber.memoizedState中取出mount阶段生成的hooks链表,将currentHook指针指向第一个节点;从workInProgressFiber.memoizedState(即currentFiber.alternate.memoizedState)中取出mount阶段生成的hooks链表,将workInProgressHook指针指向第一个节点;此时workInProgressHook是空值,则用currentHook的值初始化workInProgressHook,并返回这个workInProgressHook;执行到下一个hook时,workInProgressHook和currentHook各往后移动一次,并继续用currentHook的值初始化workInProgressHook,然后返回workInProgressHook;

  2. 将第一个步骤生成的hook节点的baseQueue(因为优先级低而没有执行的更新)和queue.pending合并到baseQueue;

  3. 基于baseState依次执行baseQueue中的更新update得到newState;如果执行到将优先级不足的更新update,则将这个update存到newBaseQueueLast,并将此时此刻的newState存到newBaseState,等待下次执行;

  4. 将newState存在hook.memoizedState,将newBaseState存到hook.baseState,将newBaseQueueLast存到hook.baseQueue;最后返回[hook.memoizedState, queue.dispatch];

主要代码如下:

function updateReducer<S, I, A>(
    reducer: (S, A) => S,
    initialArg: I,
    init?: I => S,
): [S, Dispatch<A>] {
    // 生成hook,对应流程1;
    const hook = updateWorkInProgressHook();
    const queue = hook.queue;
    invariant(
        queue !== null,
        'Should have a queue. This is likely a bug in React. Please file an issue.',
    );

    queue.lastRenderedReducer = reducer;

    const current: Hook = (currentHook: any);

    let baseQueue = current.baseQueue;
    // 将队列合并,对应流程2;
    const pendingQueue = queue.pending;
    if (pendingQueue !== null) {
        if (baseQueue !== null) {
            const baseFirst = baseQueue.next;
            const pendingFirst = pendingQueue.next;
            baseQueue.next = pendingFirst;
            pendingQueue.next = baseFirst;
        }

        current.baseQueue = baseQueue = pendingQueue;
        queue.pending = null;
    }

    // 执行更新,对应流程3;
    if (baseQueue !== null) {
        // We have a queue to process.
        const first = baseQueue.next;
        let newState = current.baseState;

        let newBaseState = null;
        let newBaseQueueFirst = null;
        let newBaseQueueLast = null;
        let update = first;
        do {
            const updateLane = update.lane;
            // 将优先级不足的更新存起来,对应流程3;
            if (!isSubsetOfLanes(renderLanes, updateLane)) {
                const clone: Update<S, A> = {
                    lane: updateLane,
                    action: update.action,
                    eagerReducer: update.eagerReducer,
                    eagerState: update.eagerState,
                    next: (null: any),
                };
                if (newBaseQueueLast === null) {
                    newBaseQueueFirst = newBaseQueueLast = clone;
                    newBaseState = newState;
                } else {
                    newBaseQueueLast = newBaseQueueLast.next = clone;
                }
                currentlyRenderingFiber.lanes = mergeLanes(
                    currentlyRenderingFiber.lanes,
                    updateLane,
                );
                markSkippedUpdateLanes(updateLane);
            } else {
                if (newBaseQueueLast !== null) {
                    const clone: Update<S, A> = {
                        lane: NoLane,
                        action: update.action,
                        eagerReducer: update.eagerReducer,
                        eagerState: update.eagerState,
                        next: (null: any),
                    };
                    newBaseQueueLast = newBaseQueueLast.next = clone;
                }
                // 执行更新,对应流程3;
                if (update.eagerReducer === reducer) {
                    newState = ((update.eagerState: any): S);
                } else {
                    const action = update.action;
                    newState = reducer(newState, action);
                }
            }
            update = update.next;
        } while (update !== null && update !== first);

        // 判断是否有跳过的更新,对应流程3;
        if (newBaseQueueLast === null) {
            newBaseState = newState;
        } else {
            newBaseQueueLast.next = (newBaseQueueFirst: any);
        }

        if (!is(newState, hook.memoizedState)) {
            markWorkInProgressReceivedUpdate();
        }

        hook.memoizedState = newState;
        hook.baseState = newBaseState;
        hook.baseQueue = newBaseQueueLast;

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

这里也有一些代码是直接对应update阶段提到的优化,如下;当是首次更新时,直接取计算好的eagerState,从而减少了一次reducer的计算:

if (update.eagerReducer === reducer) {
    newState = ((update.eagerState: any): S);
} else {
    const action = update.action;
    newState = reducer(newState, action);
}

总结一下,update阶段是从mount阶段生成的Fiber中复制了hook的相关信息,并且依次执行了dispatch阶段存储在hook的update,计算得到最新的状态值并返回。

分析到这里,前面提出的剩下的问题也就得到解决了:

问题解答
......
为何hooks不能放在条件语句中?因为在update阶段,hook是依据之前mount阶段创建的链表(通过两个指针移动)一一对应生成的,如果放在条件语句中,或者不放在最顶层使用hook,那么就有可能在某次执行时跳过某些hook,导致取错误到的hook节点;
hooks如何在不同的渲染中,返回最新的值?hook会将dispatch阶段触发的更新存在hook节点的queue和baseQueue中,在update阶段中,会将这些更新取出来依次执行,得到最新的state并返回;

3-源码调试

优秀框架源码的逻辑都是错综复杂的,因为其中包含了大量的开发和生产环境的区分和容错,还有各种逻辑兜底和异常处理;因此若是纯粹的只看代码,只从代码去直接理解和分析原理和流程,是比较难的。

所以我们需要借助一些工具来对源码进行调试。

有几种方式调试react源码:

第一种:参考链接

  1. 从facebook/react拉取最新的代码,用yarn安装依赖;

  2. build出dev环境可以使用的cjs包,并执行yarn link创建软链react和react-dom;

  3. 使用yarn link将调试所使用的测试项目下的react react-dom指向软链react和react-dom;

  4. 在build出的cjs包中打log或者debugger进行调试

第二种:参考链接

使用VS code 插件debugger for chrome;

4-总结

通过了解react hooks的背后实现,能加深一些对hooks应用的理解,使用的时候就会更胸有成竹一些。当然本文只是通过useState的例子将hooks大概的结构流程梳理了一遍,其他类似useRef、useEffect的具体实现可能细节上有差异,但大致的原理是一致的。

这是第一次自己去翻读源码,也是第一次将自己的思考和理解记录成文章,可能某些细节理解的不是很到位,表达不是很清晰,还是需要多多揣摩,争取进一步提升自己的思维和技术水平。

最后分享一些我觉得比较好的参考文章和博客:

React技术揭秘

当我们在用Hooks时,我们到底在用什么?

「react进阶」

React@16.8.6原理浅析(hooks)

React hooks 的基础概念:hooks链表