作为框架的使用者,会首先关注 React 的 API ,但 React 团队作为框架的开发者,他们首先面对的是 React 的设计理念。所以,要学懂 React 的源码,我们需要将自己的角色,从框架的使用者转变为框架的开发者,从理念出发,自顶向下的学习。
调试源码
理念篇
设计理念
React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。
什么因素制约了快速响应:可能是由于我们正在进行大量的计算,或者是因为当前网络状况不佳,我们正在等待一个请求的返回。换句话说,就是 计算能力 和 网络延迟 这两个原因导致我们的应用不能快速响应。究其原因,就是 CPU 的瓶颈和 IO 的瓶颈。
解决 CPU 瓶颈:
主流浏览器的刷新频率为 60Hz,也就是说,每16.6ms会刷新一次。在这16.6ms中,浏览器会依次执行 JS脚本执行 - 样式布局 - 样式绘制 ,如果 JS 脚本执行时间超过了16.6ms,那么这一帧就没有时间继续执行后面的步骤了,浏览器就会掉帧,表现形式有:浏览器的滚动不流畅、在输入框输入的字符不能及时响应在页面上。
demo:输入框输入字符并更新页面
我们通常会使用防抖和节流这两种方案,限制触发更新的频率,来减少掉帧的可能性,但这是治标不治本的。随着我们输入的字符越来越多,我们每一次更新所需要的时间都超过了16.6ms,那么即使使用节流和防抖,也会造成浏览器掉帧。
为了解决这一难题,React 将同步更新变为异步可中断更新。简单来说,React 和浏览器做了一个约定,浏览器从16.6ms中抽出一部分给 React,React 利用这一部分时间完成自己的工作,如果某一个工作需要的时间超过了浏览器给的时间,React 会中断自己的工作,将控制权交给浏览器,等待下一帧留给 React 的那部分时间到来再继续执行之前被中断的工作。这样,浏览器在每一帧都会有时间执行样式布局和样式绘制。
解决 IO 瓶颈:比如一些需要等待数据请求结果才能做出进一步响应的场景下,用户如何才能感知到快速响应。
React 的答案是:
将人机交互研究的结果整合到真实的 UI 中。例如,研究表明,对 UI 交互来说,延迟显示中间的加载状态,能让用户感觉更流畅。
这话翻译一下就是,异步加载时,在结果出来前会有一段空白期,本来在用户看来这段空白期会显示 loading 状态,但如果我们稍微推后一点时间再显示这个 loading 状态,从用户的感知来说,其实就跟同步更新没什么区别。这是基于人的主观感受来实现的。
架构演进史
老的 React (React15)架构:
这个架构整体分为两部分:Reconciler(协调器)【决定渲染什么组件】和 Renderer(渲染器)【将组件渲染到视图中】。
Fiber 架构
Fiber (纤程)可以理解为协程的一种实现,在 js 中,协程已经有一种实现方式:Generator 。
那 React 为什么不使用 Generator ?
- 首先,Generator 和 async/await 一样也是具有传染性的。
- 其次,Fiber 架构需要达成两个目的:①更新可以中断并继续;②更新可以拥有不同的优先级,高优先级的更新可以打断低优先级的更新。
然而,使用 Generator 只能达到第一个目的,却不能达到第二个目的。
工作原理:
看一段程序:
当我们首次调用ReactDOM.render()
时,会创建整个应用的根节点FiberRootNode
【由于我们可以多次调用ReactDOM.render()
,将不同的应用挂载到不同的 DOM 节点下,所以每个应用都有它自己的根 Fiber 节点RootFiber
(在一个页面中可以有多个RootFiber
,但只能有一个FiberRootNode
来管理这些RootFiber
)】。
函数组件APP 会创建一个对应的 Fiber 节点,该节点的类型为function Component
;APP 的子节点p
会创建一个对应的 Fiber 节点,该节点的类型为host Component
,也就是原生 DOM 节点对应的 Fiber 节点;p 节点的第一个子节点 kasong 会创建一个对应的文本 Fiber 节点;kasong的兄弟节点 num 也会创建一个文本 Fiber 节点。
那这些 Fiber 节点是如何连接的呢:FiberRootNode.current = RootFiber, RootFiber.child = App, App.child = p, p.child = kasong, kasong.sibling = num, num.return = p
双缓存
定义:在内存中构建并直接替换的技术
架构篇
render 阶段
render 阶段
的主要工作是 构建 Fiber 树 和 生成 effectList 。我们知道,react 入口的两种模式会进入performSyncWorkOnRoot
或者performConcurrentWorkOnRoot
,这取决于本次更新是同步更新还是异步更新。而这两个方法分别会调用workLoopSync
或者workLoopConcurrent
方法。
// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// performConcurrentWorkOnRoot会调用该方法
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
当某一个 fiber 节点进入beginWork
时,它最终的目的是创建当前fiber
节点的第一个子fiber
节点。
beginWork的工作流程:
- 判断当前
fiber
节点的类型,以进入不同的update
逻辑; - 在
update
的逻辑中,会判断当前workInProgressFiber
是否存在对应的current fiber
,以决定是否标记effect Tag
; - 接着进入
reconcile
逻辑
commit 阶段
实现篇
Diff 算法
diff 操作本身是有性能损耗的,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n³),其中n是树中元素的数量。
为了降低算法复杂度,React 的 diff 设置了三个限制条件,以确保复杂度能降为O(n)
:
- 只对同级元素进行 diff 。如果一个
DOM 节点
在前后两次更新中跨越了层级,那么 React 不会复用该节点。 - 只复用同类型的 DOM 节点。如果元素由
div
变为p
,React 会销毁div
及其子孙节点,并新建p
及其子孙节点。 - 通过元素的
key
属性判断是否可以复用。
diff 口诀:
- diff 口诀有三条:
- 第一条,看元素,类型相同可复用,类型不同不复用
- 第二条,看 key ,key 同类型同,这才能复用
- 第三条,看同级,diff 更新不跨级
- 那么先看第一条
- 有 key 先看 key ,key 同看类型
- 类型相同可复用,类型不同要删除
- key 不同,不强求,不能复用就算了
- 没有 key ,看成 key = null ,接着就看第二条
- 类型相同可复用,类型不同不复用
React 源码:Diff 的入口函数 reconcileChildFibers
我们从 Diff 的入口函数reconcileChildFibers
看看该算法的实现过程,该函数会根据newChild
(即JSX对象)类型调用不同的处理函数。
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes
): Fiber | null {
const isUnkeyedTopLevelFragment: boolean =
typeof newChild === 'object' &&
newChild !== null &&
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null;
if (isUnkeyedTopLevelFragment) {
newChild = newChild.props.children;
}
// 判断 newChild 的类型
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// newChild是object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE 或 REACT_LAZY_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
case REACT_PORTAL_TYPE:
// xxx
case REACT_LAZY_TYPE:
// xxx
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
}
// 一些其他情况调用处理函数
// ...省略
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
对于单个节点,我们以类型object为例,会进入reconcileSingleElement
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 首先判断是否存在对应DOM节点
while (child !== null) {
// 上一次更新存在DOM节点,接下来判断是否可复用
// 首先比较key是否相同
if (child.key === key) {
// key相同,接下来比较type是否相同
switch (child.tag) {
// ...省略case
default: {
if (child.elementType === element.type) {
// type相同则表示可以复用
// 返回复用的fiber
return existing;
}
// type不同则跳出switch
break;
}
}
// 代码执行到这里代表:key相同但是type不同
// 将该fiber及其兄弟fiber标记为删除
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不同,将该fiber标记为删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 创建新Fiber,并返回 ...省略
}
状态更新
React 中一共有6
种优先级,从 官网 中可以看到:
export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;
几个关键的节点
- render 阶段的开始
render
阶段开始于performSyncWorkOnRoot
或performConcurrentWorkOnRoot
方法的调用。这取决于本次更新时同步更新还是异步更新。
- commit 阶段的开始
commit
阶段开始于commitRoot
方法的调用。其中rootFiber
会作为传参。
开始串联
我们知道,render阶段
完成后会进入commit阶段
。那么状态更新时,从触发状态更新
到render阶段
会经过什么步骤呢?
在 React 中,有如下方法可以触发状态更新:
ReactDOM.render
this.setState
this.forceUpdate
useState
useReducer
Q:这些方法调用的场景各不相同,他们是如何接入同一套状态更新机制呢?
A:每次状态更新都会创建一个保存更新状态相关内容的对象,我们叫它Update
。在render阶段
的beginWork
中会根据Update
计算新的state
。