“
当我们谈论 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 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 背后的原理。这能帮助我们编写高性能应用代码,提升应用性能,打磨出提供更流畅用户体验的产品。
也能帮助我们更快定位问题根源,提升解决问题的能力。
React 包含了世界上最顶尖程序员的最精妙设计和思想。MicroStrategy 产品拥抱此开源的现代前端框架,致力于为用户提供高性能的产品界面和流畅的用户体验。