React源码分析 - Hooks 设计初探

1,662 阅读12分钟

作者:来对鸡翅谢谢

文章作者授权本账号发布,未经允许请勿转载

本篇文章主要从 React 源码的角度探索底层 Hooks 系统的设计,主要探索方向为 Hooks 的底层运行机制以及 React 是如何处理 Hooks的,探索过程中也会解释一些 Hooks 的使用误区和可能会遇到的坑。源码分析对应 React 16.13版本。

一、Hooks 简介

从概念上讲,HooksReact 16.8 才全面支持的一个新特性,全面拥抱函数,能够使开发者在不使用 Class 的前提下使用状态和 React 其他的功能,从此函数式组件的地位直线飙升,不再仅仅是纯函数。

从写法上讲,Hooks 是一堆名为 useXXX 的函数,且这些函数不可动态调用。

优点

关于 Hooks 的优点官网阐述的很详细,体现为以下几点:

  • 抽离公共逻辑。使用自定义 Hook 代替传统的 HOCrenderProps 等,自定义 Hook 可以理解为通用函数,但是区别于普通通用函数的是它可以帮组件维护一些状态,而不是简单的接收输入返回输出,带来的额外收益是组件的嵌套结构非常的清晰,避免了大量的 HOC 带来的嵌套地狱。
  • 轻量,代码量少。相对于夹杂一堆生命周期的 Class 组件,函数式组件有着天然的优势,组件变的很轻量,更容易抽离出细小通用的组件,同时,代码中没有任何 this 指向的干扰

缺点

事情是两面性的,Hooks 也存在一些缺点,主要体现为以下两点:

  • 旧状态。轻量级的函数带来的一个显著问题是作用域闭包的问题,也就是 Hooksdeps 问题,需要开发者把握需要添加的依赖项,把握不准确的话会导致我们获取到的是旧的状态,盲目添加 eslint 提示的所有的依赖也是不可取的,会增加维护成本,也会导致代码不直观。
  • useEffect 的执行时机。对于没有深入了解过 React 底层运行机制的开发者,往往会默认为是异步的,会在 render 后执行,对其具体的运行时机把握并不准确。

建议

  • 不滥用"记忆性"的 Hooks。典型的代表就是 useMemo useCallback,主要有两方面需要注意:

    • 仔细思考依赖。在使用这两种类型 Hooks 时,依赖问题尤其重要,往往少几个依赖,就会导致在计算时或者执行记忆函数时拿到旧的值。
    • 性能优化是有代价的。如果计算不复杂,或者不需要将函数当成 props 传递给其他的组件的话,往往是没有任何必要去使用的,因为执行 Hooks 本身需要一定的消耗,用和不用之间相差的消耗完全可以忽略不计,并且实际代码是被 Hooks 包裹起来的,本身就会不直观。

    该怎么使用?

    • 计算耗时较长,请使用 useMemo 并仔细思量依赖问题,且如果依赖项数组为空,不如使用 useRef 代替。

      // 依赖为空的话没有必要使用 `useMemo`
      const computed = useRef(data.map(item => item)).current;
      
    • 函数需要作为 props 传递给其他的组件 且 组件的渲染成本较高,一定要使用 useCallback 包裹。如果函数只是在当前组件内定义且在当前组件消费,而不是被当成一个 props ,没有任何必要使用,其中相差的性能完全可以忽略不计。在需要传递给其他组件使用且接收函数的组件渲染成本不高,是否使用取决于开发者,使用的话代码少一点直观,但是性能会好一点。

  • 组合 state。产生这个问题的原因是在异步回调中调用 setState 不会批处理,常见的场景为表格组件,我们在得到数据请求的响应后,往往会关闭表格的加载态、设置数据源、设置数据总数以便于分页。而这几个数据如果分散存储的话,就会导致表格组件 render 3 次。当然拆分状态会导致组件逻辑非常的清晰,但是也需要考虑在异步回调里的 setState,就像上述表格的场景就应该将表格的相关状态合为一个 state

  • useEffect 空依赖不是真正的 componentDidMountuseEffect 是开发者经常使用的一个 Hooks ,我们往往会习惯使用其去模拟 componentDidMount 生命周期,但是需要谨记,useEffect 并不能模拟,只是大多数情况下,它的执行时机看上去像是 didMount,在和 Class 组件混合使用且需要父子组件在初始化时进行通信的情况下尤其需要注意。至于 useEffect 真正的执行时机,后续会进行介绍。

二、如何处理Hooks

在说 React 如何处理前,我们首先需要知道 React 是怎么区分函数式组件和 Class 组件的。之所以会有这个问题是因为 babelclass 语法转换为 es5 时,得到的也是一个 Function,只不过这个函数等价于 Classconstructor。在这个前提下, React 区分的方法就很简单了。

// ...
const value = Component(props, secondArgs);
if (
  typeof value === 'object' &&
  value !== null &&
  typeof value.render === 'function' &&
 	// $$typeof 是 ReactElement 对象独有的属性,React会调用 createElement 方法为每一个DOM,每一个文本创建一个 ReactElement,而 $$typeof 标记着这是一个 React元素
  value.$$typeof === undefined
) {
  // ClassComponent
} else {
  // FunctionComponent
}
// ...

对于 Class 组件得到的 value 是组件实例,通俗点讲就是构造函数的 this 指针,其原型链上必定有一个 render 函数,对于函数式组件而言,返回值就有很多的可能性了,可能是一个 ReactElement ,也可能是一个字符串。 React 就是通过上述的几个判断来判定该组件是哪种类型的组件。接下来,我们来看一下 React 是如何处理 Hooks 的。

Dispatcher

React 内部会维护一个名为 Dispatcher 的对象,这是所有 Hooks API 的调度中心,每当 React 执行到 Hooks API 的时候,都会解析当前的调度对象,之后使用当前调度对象内部对应的 Hooks API,因此也可以理解为这是调用 Hooks 的入口。

image-20201112104537008

也正是因为这一设计, React 才可以更加优雅的检测 Hooks 是否在函数式组件外使用以及是否嵌套使用, Dispatcher 会随着 React不同的执行环境被赋予不同的值 ,分为如下几种:

  • ContextOnlyDispatcher :大多数情况下都是这种,用来检测 Hooks 的执行环境是不是函数式组件内,当调用 ContextOnlyDispatcher.useXXX 时, React 就会抛出一个异常。
  • HooksDispatcherOnMount :用于挂载阶段
  • HooksDipatcherOnUpdate :用于更新阶段
  • HooksDispatcherOnRerender :用于 rerender 阶段

在开发阶段,还有一种特殊的 DispatcherInvalidNestedHooksDispatcherOnMountInDEV,命名中的 onMount 对应着不同的阶段,和生产环境的三种阶段保持一致,这种类型的调度器专用于检测 Hooks 是否嵌套使用。

Hook

当解析到当前的调度器后, React 才开始执行 Hooks 内部的逻辑,挂载阶段时,每一个 Hooks API 内部都会首先创建一个 hook 对象,而一些状态、缓存函数、副作用都会绑定在这个 hook 对象上来实现组件状态的存储,这边以 useMemo 为例。

function mountMemo(nextCreate, deps) {
  // 创建一个 hook
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 生成计算值
  const nextValue = nextCreate();
  // 缓存当前计算值及依赖
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

显然, React 有一个专门的函数来生成 hook ,名为 mountWorkInProgressHook

function mountWorkInProgressHook() {
  const hook = {
    // 用于存储  `state` 、计算值及依赖、缓存函数、Effect对象等。
    memoizedState: null,
		// useState 用到的更新队列、缓存批处理的state等
    baseState: null,
    baseQueue: null,
    queue: null,
    
    next: null,
  };
	
  // 逐个挂载到 hook 链表上
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

一个 hook 对象的结构非常的简单,从 next 属性就可以看出每次都会生成一个 hook 都会挂载到一个链表上,而光有一个孤独的链表是无法生存的,它还需要 Fiber 的支持, React 会为每一个组件生成一个 Fiber,我们这里的 currentlyRenderingFiber 就标记着当前正在渲染的组件,而Fiber.memoizedState 就保存了 hook 链表的头节点。

在更新阶段时, React 不会再去生成 hook ,为维护一个指针从当前组件保存的 hook 链表中依次取出 hook 节点。

Tips:为什么不可以改变 hooks 调用顺序?

我们来看一个 base case 来看一下改变 hooks 的调用顺序会导致什么样的问题。

// bad case
const App = () => {
  const [count, setCount] = useState(0);
  
  if (count === 0) {
    useEffect(() => { console.log('effect2'); }, [count]);
  }
  
  const hasCount = useMemo(() => count > 0, [count])
  
  return (...)
}

这是一个简单的组件,经过挂载后,我们可以得到一个 hook 链表,并且其挂载在当前 Fiber.memoizedState

image-20201112104526215

count 变化且不为 0 时, App 组件需要进入更新阶段,当前 Dispatcher 赋值为 HooksDipatcherOnUpdate ,当执行到第 3 行时, React 执行 HooksDipatcherOnUpdate.useState,会从当前挂载的 hooks 链表中取出第一个节点并执行 updateState 逻辑,同时 workInProgressHook 指针下移 ,至此是没有任何问题的,因为当前 count 不为 0,所以跳过 5-7行,当执行到第 9 行时,执行 HooksDipatcherOnUpdate.useState ,这时取出来的 hookEffect Hook,并使用该 hook 对象执行 updateMemo 的逻辑,这显然是不对的, memoHook.memoizedState 存储的是一个数组,而 effectHooks.memoizedState 存储的是一个 effect 对象,下面为 updateMemo 的处理逻辑。

function updateMemo<T>(nextCreate, deps) {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // memoHook 中的 memoizedState 保留了上一次的计算值及上一次的依赖,并且这是一个数组。[prevValue, prevDeps]
  const prevState = hook.memoizedState;
  // 1. 比较依赖,依赖没变的话,直接返回上一次的计算值
  if (prevState !== null) {
    if (nextDeps !== null) {
      const prevDeps = prevState[1];
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        return prevState[0];
      }
    }
  }
  // 2. 依赖变化的话,执行 create 重新生成计算值,并将新的值及依赖存储在 memoizedState 上
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

Effect

在处理 Hooks API 时,有两个 hooks 比较特殊, useEffect 以及 useLayoutEffect ,专用于处理副作用的 hooks,在执行副作用 hooks 时, React 会额外创建一个 effect 对象。


// 生成一个 effect 并挂载到 effect 链表的末尾
function pushEffect(tag, create, destroy, deps) {
  const effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  // currentlyRenderingFiber.updateQueue.lastEffect 永远指向最后一个 effect 对象,
  let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
  // 将 updateQueue 整理成 { lastEffect: null } 的结构
  if (componentUpdateQueue === null) {
    componentUpdateQueue = { lastEffect: null };
    currentlyRenderingFiber.updateQueue = componentUpdateQueue;
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      // 单向循环链表新增一个节点
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

简单分析一下这段代码,我们可以了解到如下几点:

  • effect 的结构,属性不多,值得关注的是 tag 属性,该属性不仅仅标记着 effect 的类型,也标记着 effect 是否需要被刷新,这里的被刷新指的是是否需要执行 create 也就是我们传入的第一个参数。 effect.tag 取值大致分为如下几种:

    export const NoEffect = /*  */ 0b000;
    
    // Represents whether effect should fire.
    export const HasEffect = /* */ 0b001; // 1
    
    // useLayoutEffect 生成的 effect 被称为 Layout
    export const Layout = /*    */ 0b010; // 2 
    // useEffect 生成的 effect 被称为 Passive,因为 useEffect 常常是说成被动刷新,主动setState,被动刷新 effect
    export const Passive = /*   */ 0b100; // 4
    
    // 如果一个 LayoutEffect 需要被刷新,那么生成的 effect.tag = HasEffect | Layout
    // PassiveEffect 同理
    // React 在遍历 effect 链表时,就通过 effect.tag === HasEffect | Layout 来判定当前 effect 是否需要被刷新
    
  • 单个组件内生成的所有 effect 会形成一个单向循环链表并关联在当前 Fiber 上, Fiber 上具有一个 updateQueue 属性,对于函数式组件而言,该属性内部会维护一个 lastEffect 变量,该变量永远指向最后一个 effect

但是 updateQueue 会在组件执行完调度后被清空掉,这意味着 effect 链表不仅仅是只挂载到 Fiber 上,它还需要存储在一个不会被清空的对象上,也就是我们上文说的 hook.memoizedState 字段。

function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) {
  // 生成 hook
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  // 标记 Fiber 具有副作用
  currentlyRenderingFiber.effectTag |= fiberEffectTag;
  // 将生成的 effect 挂载到 hook.memoizedState 上,用于更新阶段判断是否依赖发生变化
  hook.memoizedState = pushEffect(
    HookHasEffect | hookEffectTag,
    create,
    undefined,
    nextDeps,
  );
}

Tips:为什么是单向循环

有同学可能会好奇,为什么 effect 链表采用单向循环的结构,主要考虑两个逻辑,一个是新增节点一个是从头部开始遍历链表,单向不循环链表想要实现这两者需要维护两个变量,一个指向第一个节点来保证可以从头部遍历,也就是 fiber.memoizedState,一个游标指向最后一个节点,也就是 workInProgresshook ,而单向循环链表实现只需要维护一个变量,也就是游标,始终指向最后一个节点,也就是 fiber.updateQueue.lastEffect

针对以上的描述, React 处理 Hooks 的一个完成流程图为:

image-20201112104512050

最后以一段代码为例

const App = () => {
  const [count, setCount] = useState(0);
  
  const computedVal = useMemo(() => count + 1, [count]);
  
  useLayoutEffect(() => {
    console.log('did mount');
  }, []);
  
  useEffect(() => {
    console.log(count);
  }, [count]);
  
  return <span>{ count }</span>
}

上述代码经过第一次挂载之后,完整的图示为

image-20201112104449994

三、总结

上述主要讲解了 Hooks 基本的运行机制, React 首先会判断当前组件是否是函数式组件,之后会通过 Dispatcher 调用 Hook,创建相应的 hook 链表,对于具有副作用的 Hooks,还会额外创建 effect, 而 Fiber 将承载这两个链表来保存函数式组件的状态。

对于 Hooks 的使用,本文也给出了几点建议,其中 useEffect 的刷新时机我们将在下篇文章中通过完整的 commit 阶段来给大家详细介绍,敬请期待。