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

文摘   2024-05-14 10:48   浙江  

当我们谈论 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。

官网文档见 https://zh-hans.react.dev/reference/rules/rules-of-hooks


『其实是一张单链表』

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 会再一次地被顺序执行。在这个阶段,函数组件较 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 的过程称为:reconciliation。在 React16 以前,reconciliation 是一次性完成的。基于浏览器的单线程机制,如果 reconciliation 一直占用浏览器的线程,渲染引擎将无法及时绘制出页面,用户事件也无法得到及时响应,造成页面卡顿现象。


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


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


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 是通过递归的深度优先算法来做 reconciliation 的任务。这造成 React 一旦中断 reconciliation,就无法恢复之前的状态。即使暂存了当前节点,我们无法找到其父节点和兄弟节点。


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


『任务优先度』

在有了 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 heapvar 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 背后的原理。这能帮助我们编写高性能应用代码,提升应用性能,打磨出提供更流畅用户体验的产品。

  • 也能帮助我们更快定位问题根源,提升解决问题的能力。

React 包含了世界上最顶尖程序员的最精妙设计和思想。MicroStrategy 产品拥抱此开源的现代前端框架,致力于为用户提供高性能的产品界面和流畅的用户体验。


微策略 商业智能
微策略 MicroStrategy (Nasdaq: MSTR) 是企业级分析和移动应用软件行业的佼佼者。关注我们了解行业资讯、技术干货和程序员日常。
 最新文章