React源码中的数据结构

67 阅读6分钟

此文以数据结构为切面,一窥React源码。

当我们谈论hooks,我们在谈论什么

从一个警告谈起

import React, { useState } from 'react';

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

  if (count > 0) {
    const [flag, setFlag] = useState(false); 
  }

  const [name, setName] = useState('Alice')


  return (
    <div>
      <p>Count: {age}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

上面是一段的简单的React样例代码。

点击一次按钮后,会遇到过如下报错。我相信,同样的错误也曾出现于我们的日常开发中。

Error: Rendered more hooks than during the previous render.

报错原因是:React不允许在条件语句、循环和嵌套函数里使用hooks。官方文档见此处:zh-hans.react.dev/reference/r…

单链表

React的官方文档虽然给出了hooks的使用规范,但没有解释规范存在的原因。而答案其实在React hooks的源码里。

export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

我们可以看到hooks是以链表的形式存在的。当前hook的next属性存储了下一个hook的引用。

在一个函数组件的mount阶段,我们得到包括一个函数组件所有hooks的单链表。如下图所示,一个hook的next属性指向下一个hook。

我们知道,一个函数组件的update,就是函数代码的再次执行。所以,在函数组件的update阶段,hooks会再一次地被顺序执行。

在update阶段时,函数组件这次较mount阶段,多了一个hooks。

我们会发现,之后mount阶段创建的hooks链表的节点不够用了,React源码就会抛出错误——就像之前样例代码一样。

好的。至此为止。我们借一个常见的源码报错,揭示了函数组件的hooks的本质:它其实是一张单链表。

从Fiber到任务优先度

除了单链表,树也是React源码中不可或缺的数据结构。

React Element

我们知道Babel将JSX语法转换成——调用React.createElement()的JS代码

React.createElement(li,{ key: index }, item); 
// JSX code 
<li key={index}>{item}</li>

ReactElement的结构如下。

element = {
  // This tag allows us to uniquely identify this as a React Element
  $$typeof: REACT_ELEMENT_TYPE,

  // Built-in properties that belong on the element
  type,
  key,
  ref,

  props,
};

我们会很自然地想到:基于JSX的层级结构,React维护一个ReactElement的树结构,以渲染内容。

Fiber Node

虽然ReactElement的props中有children属性,ReactElement树结构也的确是存在的,但React不止步于此。

React会将ReactElement转换一个名为FiberNode的对象。

function FiberNode(
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null; // parent FiberNode
  this.child = null;  // child FiberNode
  this.sibling = null; // next sibling FiberNode
  this.index = 0;
  ...
}

值得注意的是:一个FiberNode上存有父FiberNode,子FiberNode, 下一个兄弟FiberNode的引用。

所以,基于样例代码,内存中会存在如下图所示的Fiber树

我们都知道,只需要子节点的引用,我们就能遍历一整颗树。

那么你或许会问:为什么需要父FiberNode和下一个兄弟FiberNode,为什么React需要大费周章在React16设计Fiber树结构?

这其实是为了支持React的可中断渲染和恢复。

可中断渲染和恢复

我们知道:包括React在内的前端框架,相对于原生DOM,一大进步在于引入了虚拟DOM。React通过比较更新前后的虚拟DOM树,以找出两者之前的差异,再将改动集中应用到真实DOM树,以提升前端性能。

React把比较虚拟DOM的过程称为:reconcilation。在React16以前,reconcilation是一次性完成的。基于浏览器的单线程机制,如果reconcilation一直占用浏览器的线程,渲染引擎将无法及时绘制出页面,用户事件也无法得到及时响应,造成页面卡顿现象。

因为人肉眼能够识别的帧率是60帧1秒,大约1帧16ms,所以只需要浏览器能够在16ms内能及时对用户事件做出响应,以及绘制出部分界面,用户就不会觉得页面卡顿。

对于不能及时完成reconcilation的情况,React会中断reconcilation,让浏览器进行其他操作。

function shouldYieldToHost(): boolean {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) { 
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  }
  // Yield now.
  return true;
}

frameInterval的默认值是5ms。

在React16以前,React是通过递归的深度优先算法来做reconcilation的任务。这造成React一旦中断reconcilation,就无法恢复之前的状态。即使暂存了当前节点,我们无法找到其父节点和兄弟节点。

而有了Fiber树,我们只需要暂存当前Fiber,在中断reconcilation后,我们后续还能继续reconcilation的任务。

任务优先度

在有了Fiber树和中断后可恢复这么精妙的设计后,React还更进一次,支持了基于任务优先度的调度机制。这其实是非常自然的设计。

不同的任务被给予不同的任务优先度,比如click事件被给予最高的任务优先度。任务优先度决定了过期时间。

switch (priorityLevel) {
  case ImmediatePriority:
    // Times out immediately
    timeout = -1;
    break;
  case UserBlockingPriority:
    // Eventually times out
    timeout = userBlockingPriorityTimeout;
    break;
  case IdlePriority:
    // Never times out
    timeout = maxSigned31BitInt;
    break;
  case LowPriority:
    // Eventually times out
    timeout = lowPriorityTimeout;
    break;
  case NormalPriority:
  default:
    // Eventually times out
    timeout = normalPriorityTimeout;
    break;
}

基于过期时间,React将不同的任务分别塞入taskQueue和timerQueue。前者是已经到期的任务,后者还没到期。前者会比后者先执行。而这两个队列实则是最小堆。

// Tasks are stored on a min heap
var taskQueue: Array<Task> = [];
var timerQueue: Array<Task> = [];

一个实践场景

React有了基于任务优先度的调度机制后,我们很自然想到:给较为不重要的任务设置低优先度,避免阻塞较为重要的任务;反之亦然。

在MicroStrategy产品中大量应用的ChatBot有一个“建议列表”组件。

从产品的角度看,用户输入的文本本身显然比建议列表更符合用户需要。所以,响应用户在ChatBot输入框里输入的内容,这是优先度较高的任务;随着输入内容的变化,建议列表的更新则应当被视为优先度较低的非紧急更新。

React18也适时推出了新hook, useTransition,可以主动任务设置为较低优先度。这适用于非紧急更新。

const [isPending, startTransition] = useTransition()
const onType = () => {
  startTransition(() => {
    // 更新建议列表
  })
}

在以上代码中,我们主动为更新建议列表的任务设置了较低优先度。即使不做防抖动设计,用户快速输入时,我们也能提供流畅的用户体验。

总结

此文以数据结构这一切片,涵盖了单链表、树、最小堆,分享了React源码的冰山一角。我们为什么要学习React源码呢?我想,有如下好处:

  • 源码是被广泛应用的,由世界上最优秀的一批程序员编写的。其中的设计模式、思想值得学习和借鉴。这点不仅限于React。
  • 借此,我们可以熟悉React背后的原理。Not only make it work, but also know how does it work. 这能帮助我们编写高性能应用代码,提升应用性能,打磨出提供更流畅用户体验的产品。
  • 这也能帮助我们更快定位问题根源,提升解决问题的能力。

React包含了世界上最顶尖程序员的最精妙设计和思想。