此文以数据结构为切面,一窥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包含了世界上最顶尖程序员的最精妙设计和思想。