探索 React Hooks底层设计

1,568 阅读11分钟

本篇文章主要是从源码层面探索React Hook底层是如何设计的,类似的文章其实也是有很多的,但是还是想多写一些,若有不对,欢迎下方评论,从这篇文章你可以了解到

  • React是如何区分函数式组件和Class组件的
  • Hook底层结构
  • React如何检查Hook嵌套使用以及函数式组件外部使用
  • 如何刷新Effect
  • 为什么少些一些deps就拿不到最新的值了
  • useLayoutEffect 与 useEffect 的区别
  • useEffect 真的是异步的吗
  • React组件的commit阶段大致流程

Hooks 是个啥,为啥需要Hooks

在React16之前,大家写的都是ClassComponent,所有的函数式组件都是纯函数,并没有自己的一份状态,只可以通过 props ,在React16后,React官方推出了 Fiber 架构以及 Hooks,Hooks增强了函数式组件,再也不是纯函数了,组件可以管理一份自己的状态。引用官网的一句话:

Hooks are a new addition in React 16.8. They let you use state and other React features without writing a class.

那么Hooks解决了什么问题呢,还是引用官网

  • 在组件之间复用状态逻辑很难

    在 Hooks 之前,我们一般是使用 renderProps 和 高阶组件 来复用一些状态逻辑,这个时候打开 React Devtools 你会发现我们有一堆的Providers,consumers,高阶组件,renderProps等其他抽象层的组件形成一个嵌套地狱

    使用 Hooks,我们可以使用自定义Hook,来抽取通用的状态逻辑,这样不会修改我们的组件结构,这是一个很关键的点,我们的组件是正常的嵌套,没有任何抽象成的组件来干扰我们的视线。官网给的「获取好友状态」是一个很nice的🌰

  • 复杂组件变的难以理解

    • 过重的 DidMount

    这个点主要在于生命周期中,我们往往会在 DidMount 中获取数据或者设置一些监听、setTimeout等定时器,然后再 WillUnMount 中清除我们的定时器、清除监听,但是事实上,你会发现一个 DidMount 做了太多的事,我们无法细粒度的拆解这些初始化的操作,我们唯一能做的优化是写两个函数,然后再 DidMount 中调用这两个函数,以达到 DidMount 尽可能清晰的目的,类似这样

    // 不要care格式,大致是这个意思
    const loadTableData = () => { ... }
    
    const setListener = () => { ... }
    
    componentDidMount() {
      loadTableData(); // 加载数据
      setListener(); // 设置监听
    }
    

    这是我们唯一能做的事,如果将所有的初始化代码都堆到 DidMount 的话,你会发现你的初始化逻辑很乱,给后续的维护增加了很多的成本,因此我们应该尽可能的拆分工作单元,更加的细粒度,一个 DidMount 做一件事,这是我们期望的结果,如果使用 Hooks 的话可以达到这一点,就像这样:

    useEffect(() => {
      // 加载数据
    }, []);
    
    useEffect(() => {
      // 设置监听
    }, []);
    
    • 关联逻辑分离

    在开发中,我们常常会用到一些定时器或者窗口监听等一些需要在组件卸载时清除掉的副作用,那么设置监听和清楚监听本身就是相关联的逻辑,但是在 ClassComponent 中我们将一对相互恩爱的情侣拆散了,这是何等的罪恶!我们会在 DidMount 中设置监听,在 WillUnMount 中清除监听。但是 Hooks 不会强制要求开发者按照生命周期拆分,它可以将 设置监听和清除监听放在一起,就像这样

    useEffect(() => {
      const id = setInterval(() => {}, 1000);
      
      return () => {
        window.clearInterval(id);
      }
    }, [])
    
  • 难以理解的 Class

    这是我感到最难受的一个点,我很不喜欢写 Class,我更喜欢写 Function,从根本上讲,Class 也是被编译成一个 Function,引用 React 官网的一句话:

    从概念上讲,React 组件一直更像是函数。而 Hooks 则拥抱了函数,同时也没有牺牲 React 的精神原则。Hook 提供了问题的解决方案,无需学习复杂的函数式或响应式编程技术。

如何区分ClassComponent 和 FunctionComponent

之所以有这个问题是因为 ClassComponent 被编译后其实也是一个 Function,而 FunctionComponent 它本身就是一个Function,React是如何从这两个 Function 中区分组件的类型呢。其实这个问题很简单,React 会调用一次编译后的 Function,根据得到的的返回值来判断。

// ...
const value = Component(props, secondArgs);
if (
  typeof value === 'object' &&
  value !== null &&
  typeof value.render === 'function' &&
  value.$$typeof === undefined
) {
  // ClassComponent
} else {
  // FunctionComponent
}
// ...

当编译 ClassComponent 时,得到的是一个构造函数,执行构造函数返回的是Class组件的实例,实例上会有 render 属性,而执行 FunctionComponent 得到的大多数情况是一个 ReactElement ,React就是通过这种方式来明确组件类型。

如何处理 useXXX

接下来就是本文重心的开始了,当我们执行函数式组件时,遇到 useXXX 时是如何处理的呢,开始之前我将 Hook 分为两类,一类是有副作用的,例如useEffect useLayoutEffect 等,另外一类是没有副作用的例如useState useMemo useCallback 等。

Dispatcher 是什么

剩下的就不先写了,走这边可以看到每当我们调用 useXXX 时,都会去执行 resolveDispatcher,这个函数就是拿到当前的 Dispatcher,可以看出来这个 Dispatcher 是干什么用的,它保存的是 React 内所有的 useXXX ,所以我们实质都是调用的 dispatcher.useXXX。这样做的意义在哪呢?将所有的调用统一收口到一个变量中,这种操作也是我们常用的操作了,这样我们可以根据不同的环境直接对变量设置就可以了,而不是在每个 useXXX 内部去做判断该调用哪一个环境下的 useXXX 。这边的环境说的是 React 当前的一个执行阶段,比如说挂载、更新等,那么 dispatcher 在生产环境下主要分为四种

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

    function throwInvalidHookError() {
      invariant(
        false,
        '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://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.',
      );
    }
    
  • HooksDispatcherOnMount:用于挂载阶段

  • HookDipatcherOnUpdate:用于更新阶段

  • HooksDispatcherOnRerender:用于 rerender 阶段

另外在开发环境下,对于挂载、更新、rerender 有三个相对应的 dispatcher,以挂载为例,InvalidNestedHooksDispatcherOnMountInDEV ,用来检测是否在 useXXX 内部嵌套使用了 useXXX

hook 是什么即如何创建 hook

React 会维护两个链表,一个是 CurrentHook,一个是 WorkInProgressHook,每一个节点类型都是 Hook,每当我们调用 useXXX 时,React 都会创建一个 hook 对象,并挂载到链表的尾部,函数式组件之所以可以做一些Class才能做的事,就是因为 hook 对象,函数式组件的状态、计算值、缓存的函数都是交由 hook 进行管理的,而单单有 hook 是没有用的,它还需要和当前的调用它的组件关联起来,如何关联就是通过 Fiber.memoizedState ,组件对应的 Fiber 会维护一个 memoizedState 属性,它永远指向 Hook链表的头部。

每一个 hook 的基本结构如下

const hook: Hook = {
  memoizedState: null,

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

  next: null,
};

Tips:为什么不可以改变 Hook 的调用顺序

在更新阶段,每次从链表里拿 hook 的时候,是有一个指针指向了当前处理的hook 的,如果当前 hook 为空的话会创建一个新的 hook ,因为是按照链表的顺序一个一个往下取 hook,所以我们不可以去改变已经挂载的 hook 的顺序。比如说这样

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

这边的 workInprogressHook 是一个游标,指向的是当前拿到的 hook ,那么游标本来指向的是 effect1 但是在更新阶段,我们直接执行了第二个 useEffect 就会导致调用的 useXXX 和对应的 hook 不一致的问题,不过好在 React 可以检测出这种条件判断,直接给我们报错。

effect 是什么即如何创建 effect

当我们调用的 useXXX 是具有副作用的话,React 会额外生成一个 effect 对象,需要注意的是这也是一个链表,会关联到 Fiber.updateQueue.lastEffect 上,需要注意的是,effect 链表是一个单向循环链表,Fiber.updateQueue.lastEffect 永远指向的是最后一个 effecteffect 对象的基本结构如下:

const effect: Effect = {
  tag,
  create,
  destroy,
  deps,
  // Circular
  next: (null: any),
};

Tips: 如何判断 Effect 是需要执行的

每一个 effecttag 属性很关键,它标记着当前 effect 的类型,也是这个属性决定了这个 effect 会不会被执行,对于这个变量的取值 React 使用的是二进制,通过各种二进制运算进行计算,设计的很巧妙,基础值如下:

export const NoEffect = /*  */ 0b000;

// Represents whether effect should fire.
export const HasEffect = /* */ 0b001; // 1

// Represents the phase in which the effect (not the clean-up) fires.
export const Layout = /*    */ 0b010; // 2
export const Passive = /*   */ 0b100; // 4

这边以一个 layoutEffect 为🌰

需要执行 effect 的话effect.tag = HasEffect | Layout ,如果不需要执行,那么 effect.tag = Layout

如何刷新 Effect 链表

React 将刷新 Effect 这件事放在了 commit 阶段,在 commit 阶段前我们会拿到一个 effectList 存储的是所有需要处理的 Fiber 链表,React 会遍历这个链表,为每一个 Fiber 刷新 effect

从大流程中我们可以了解到以下几点

  • 会使用 postMessage ,将 flushPassiveEffects 加到微任务队列里,PassiveEffects 在微任务队列里异步执行
  • effect 每次执行 create 前都会先执行 destory
  • layoutEffect 是同步的,不用怀疑,如果在 layoutEffect 里面做了耗时较长的任务,会阻碍视觉更新
  • layoutEffect 是真正的 componentDidMount ,但是官方建议我们先使用 useEffect 等它出问题了再去使用 useLayoutEffect,因此下面看一段出问题的代码
class Parent extend React.Component {
  // ...
  constructor() {
    this.formIns = null;
  }
  componentDidMount() {
    this.formIns.updateModel({...})
  }
  render() {
		return <Child getFormIns={(ins) => { this.formIns = ins }} />   
  }
}
  
const Child = ({ getFormIns }) => {
  const formIns = Form.useForm();
  
  useEffect(() => {
    if (getFormIns) {
      getFormIns(formIns);
    }
  }, []);
  
  return "Child";
}

useEffect 真的是异步的吗

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

  useLayoutEffect(() => {
    setCount(1);
  }, [])

  useEffect(() => {
    setCount(2);
    setCount(3);
  }, []);

  console.log('render');

  return count;
}

按照我们之前的理解,第一次初始化的时候打印一次,然后再 commit 阶段刷新 layoutEffect 时,因为做了 setState ,所以在刷新完之后,又会去render 一次,然后等同步任务都执行完之后,开始执行微任务,执行 flushPassiveEffects 刷新所有的 passiveEffects ,里面调用了两次 setState 但是会被批量处理,所以又是一次 render ,一共打印三次,答案对吗?不对的,事实上只会打印两次。why?按照我们的理解 useEffect 是异步的,上面的流程也证实了这一点。所以我们 回过头来再看一遍大流程,我们忽视了一个方法,也是我没有加注释的一个方法 flushSyncCallbackQueue

这里面有两个比较关键的变量,immediateQueueCallbackNodesyncQueue,上面介绍大流程我们已经知道 React 会维护一个 taskQueue 里面存放的是异步队列,那从变量名上了解到这是一个同步的,这个同步队列一般只有一个,就是 performSyncWorkOnRoot,那么是什么时候添加进去的呢,最终在经过几轮重新 debug 后我发现,我漏一个很重要的函数,setState 在函数式里应该叫 dispatchAction

所以这边,我们在 useLayoutEffect 中调用了 setState ,但是与此同时我们还有其他的 passiveEffects ,React 会保证所有的 passiveEffects 会在下一轮的 render 前都执行完成,这就是 React 保证的方法。它会维护一个 syncQueue ,每次在 commit 阶段的末尾都会去刷新这个同步队列,来判断我们有没有调用 setState ,因为 setState 实质是同步的,只不过是做了一个回调延迟执行,也正因为是同步的,我们在同步任务执行的末尾,就应该处理新的 state 了,但是我们又需要确保每次 render 前,上一轮的 passiveEffects 都执行完,所以在调度的入口函数 performSyncWorkOnRoot 里,首先执行了 flushPassiveEffects ,刷新了上一轮的 passiveEffects,而在上一轮的副作用中,我们调用了两次的 setState 这两次 setState 会和当前这一轮的 render 合并。

所以我们发现,useEffect 并不真的是异步的,它可能会被早早的执行了。下面引用 React 官网的话,对于 useEffect 的执行时机,React 早已说明,官方文档还是得好好看,还得尽量看英文的....

Does useEffect run after every render? Yes! By default, it runs both after the first render and after every update. (We will later talk about how to customize this.) Instead of thinking in terms of “mounting” and “updating”, you might find it easier to think that effects happen “after render”. React guarantees the DOM has been updated by the time it runs the effects.

以上,就是我想分享的内容,如果有什么问题或者疑问,欢迎下方评论!

======================================

打一个广告,有同学对字节跳动北京商业化部门感兴趣吗,或者想试一试的,欢迎内推,求简历,求内推!简历可以投递 shenweizheng.weini@bytedance.com

======================================