不折腾的前端,和咸鱼有什么区别!
当前版本v17.0.0-alpha
地址在这
目录 |
---|
一 React理念 [理论篇] |
1.1 React理念 |
1.2 老的React架构 |
1.3 新的React架构 |
1.4 Fiber架构的心智模型 |
1.5 Fiber架构的实现原理 |
1.6 Fiber架构的工作原理 |
1.7 总结 |
二 前置知识 [理论篇] |
2.1 源码的文件结构 |
2.2 调试源码 |
2.3 深入理解JSX |
三 render阶段 [架构篇] |
3.1 流程概览 |
3.2 beginWork |
3.3 completeWork |
四 commit阶段 [架构篇] |
4.1 流程概览 |
4.2 before mutation阶段 |
4.3 mutation阶段 |
4.4 layout阶段 |
五 Diff算法 [实现篇] |
5.1 概览 |
5.2 单节点Diff |
5.3 多节点Diff |
六 状态更新 [实现篇] |
6.1 流程概览 |
6.2 心智模型 |
6.3 Update |
6.4 深入理解优先级 |
6.5 ReactDOM.render |
6.6 this.setState |
七 Hooks [实现篇] |
7.1 Hooks理念 |
7.2 极简Hooks实现 |
7.3 Hooks数据结构 |
7.4 useState与useReducer |
7.5 useEffect |
7.6 useRef |
7.7 useMemo与useCallback |
八 Concurrent Mode [实现篇] |
8.1 概览 |
8.2 Scheduler的原理与实现 |
8.3 lane模型 |
8.4 异步可中断更新 |
8.5 高优任务打断机制 |
8.6 batchedUpdates |
8.7 Suspense |
来自官网的解释:
我们认为,React 是用 JavaScript 构建
快速响应
的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。
可见,关键是实现快速响应。那么制约快速响应的因素是什么呢?
我们日常使用App,浏览网页时,有两类场景会制约快速响应:
当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。
这两类场景可以概括为:
CPU的瓶颈
IO的瓶颈
React是如何解决这两个瓶颈的呢?
考虑如下Demo,我们向视图中渲染3000个li:
function App() {
const len = 3000;
return (
<ul>
{Array(len).fill(0).map((_, i) => <li>{i}</li>)}
</ul>
);
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl);
主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。
我们知道,JS可以操作DOM,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。
在每16.6ms时间内,需要完成如下工作:
JS脚本执行 ----- 样式布局 ----- 样式绘制
当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。
在Demo中,由于组件数量繁多(3000个),JS脚本执行时间过长,页面掉帧,造成卡顿。
可以从打印的执行堆栈图看到,JS执行时间为73.65ms,远远多于一帧的时间。
如何解决这个问题呢?
答案是:在浏览器每一帧的时间中,预留一些时间给JS线程,React利用这部分时间更新组件(可以看到,在源码 中,预留的初始时间是5ms)。
当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。
这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)
接下来我们开启Concurrent Mode
// 通过使用ReactDOM.unstable_createRoot开启Concurrent Mode
// ReactDOM.render( , rootEl);
ReactDOM.unstable_createRoot(rootEl).render(<App/>);
此时我们的长任务被拆分到每一帧不同的task中,JS脚本执行时间大体在5ms左右,这样浏览器就有剩余时间执行样式布局
和样式绘制
,减少掉帧的可能性。
所以,解决CPU瓶颈
的关键是实现时间切片
,而时间切片
的关键是:将同步的更新变为可中断的异步更新。
网络延迟
是前端开发者无法解决的。如何在网络延迟
客观存在的情况下,减少用户对网络延迟
的感知?
React
给出的答案是将人机交互研究的结果整合到真实的 UI 中。
为此,React
实现了Suspense 功能及配套的hook
——useDeferredValue 。
而在源码内部,为了支持这些特性,同样需要将同步的更新
变为可中断的异步更新
。
通过以上内容,我们可以看到,React为了践行“构建快速响应
的大型 Web 应用程序”理念做出的努力。
其中的关键是解决CPU的瓶颈与IO的瓶颈。而落实到实现上,则需要将同步的更新
变为可中断的异步更新
。
React从v15升级到v16后重构了整个架构。因为不能满足快速响应的理念,以至于被重构。
React15架构可以分为两层:
我们知道,在React
中可以通过this.setState
、this.forceUpdate
、ReactDOM.render
等API触发更新。
每当有更新发生时,Reconciler会做如下工作:
render
方法,将返回的JSX转化为虚拟DOM你可以在这里 看到
React
官方对Reconciler的解释
由于React支持跨平台,所以不同平台有不同的Renderer。我们前端最熟悉的是负责在浏览器环境渲染的Renderer —— ReactDOM 。
除此之外,还有:
在每次更新发生时,Renderer接到Reconciler通知,将变化的组件渲染在当前宿主环境。
你可以在这里 看到React官方对Renderer的解释
在Reconciler中,mount
的组件会调用mountComponent,update
的组件会调用updateComponent。这两个方法都会递归更新子组件。
由于递归执行,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。
那么React15的架构支持异步更新么?让我们看一个例子:
我们可以看到,Reconciler和Renderer是交替工作的,当第一个li在页面上已经变化后,第二个li
再进入Reconciler。
由于整个过程都是同步的,所以在用户看来所有DOM是同时更新的。
接下来,让我们模拟一下,如果中途中断更新会怎么样?
注意
以下是我们模拟中断的情况,实际上React15并不会中断进行中的更新
当第一个li
完成更新时中断更新,即步骤3完成后中断更新,此时后面的步骤都还未执行。
用户本来期望123
变为246
。实际却看见更新不完全的DOM!(即223
)
基于这个原因,React
决定重写整个架构。
React16是如何支持异步更新的
React16架构可以分为三层:
可以看到,相较于React15,React16中新增了Scheduler(调度器),让我们来了解下他。
既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,React放弃使用:
requestIdleCallback
触发的频率会变得很低基于以上原因,React
实现了功能更完备的requestIdleCallbackpolyfill
,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。
Scheduler是独立于
React
的库
我们知道,在React15中Reconciler是递归处理虚拟DOM的。让我们看看React16的Reconciler 。
我们可以看见,更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield判断当前是否有剩余时间。
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
那么React16是如何解决中断更新时DOM渲染不完全的问题呢?
在React16中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记,类似这样:
export const Placement = /* */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;
export const PlacementAndUpdate = /* */ 0b0000000000110;
export const Deletion = /* */ 0b0000000001000;
全部的标记在这里
整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。
你可以在这里 看到
React
官方对React16新Reconciler的解释
Renderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。
所以,对于我们在上一节使用过的Demo
在React16架构中整个更新流程为:
其中红框中的步骤随时可能由于以下原因被中断:
由于红框中的工作都在内存中进行,不会更新页面上的DOM,所以即使反复中断,用户也不会看见更新不完全的DOM(即上一节演示的情况)。
实际上,由于
Scheduler
和Reconciler
都是平台无关的,所以React为他们单独发了一个包react-Reconciler 。你可以用这个包自己实现一个ReactDOM,具体见参考资料-youtube视频
通过本节我们知道了React16
采用新的Reconciler
。
Reconciler
内部采用了Fiber
的架构。
Fiber
是什么?他和Reconciler
或者说和React
之间是什么关系?我们会在接下来三节解答。
React核心团队成员Sebastian Markbåge(
React Hooks
的发明者)曾说:我们在React
中做的就是践行代数效应
(Algebraic Effects)。
那么,代数效应
是什么呢?他和React
有什么关系呢。
代数效应
是函数式编程
中的一个概念,用于将副作用
从函数
调用中分离。
接下来我们用虚构的语法
来解释。
假设我们有一个函数getTotalPicNum
,传入2个用户名称后,分别查找该用户在平台保存的图片数量,最后将图片数量相加后返回。
function getTotalPicNum(user1, user2) {
const picNum1 = getPicNum(user1);
const picNum2 = getPicNum(user2);
return picNum1 + picNum2;
}
在getTotalPicNum
中,我们不关注getPicNum
的实现,只在乎“获取到两个数字后将他们相加的结果返回”这一过程。
接下来我们来实现getPicNum
。
"用户在平台保存的图片数量"是保存在服务器中的。所以,为了获取该值,我们需要发起异步请求。
为了尽量保持getTotalPicNum
的调用方式不变,我们首先想到了使用async await
:
async function getTotalPicNum(user1, user2) {
const picNum1 = await getPicNum(user1);
const picNum2 = await getPicNum(user2);
return picNum1 + picNum2;
}
但是,async await
是有传染性的 —— 当一个函数变为async
后,这意味着调用他的函数也需要是async
,这破坏了getTotalPicNum
的同步特性。
有没有什么办法能保持getTotalPicNum
保持现有调用方式不变的情况下实现异步请求呢?
没有。不过我们可以虚构
一个。
我们虚构一个类似try...catch
的语法 —— try...handle
与两个操作符perform
、resume
。
function getPicNum(name) {
const picNum = perform name;
return picNum;
}
try {
getTotalPicNum('kaSong', 'xiaoMing');
} handle (who) {
switch (who) {
case 'kaSong':
resume with 230;
case 'xiaoMing':
resume with 122;
default:
resume with 0;
}
}
当执行到getTotalPicNum
内部的getPicNum
方法时,会执行perform name
。
此时函数调用栈会从getPicNum
方法内跳出,被最近一个try...handle
捕获。类似throw Error
后被最近一个try...catch
捕获。
类似throw Error
后Error
会作为catch
的参数,perform name
后name
会作为handle
的参数。
与try...catch
最大的不同在于:当Error
被catch
捕获后,之前的调用栈就销毁了。而handle
执行resume
后会回到之前perform
的调用栈。
对于case 'kaSong'
,执行完resume with 230
;后调用栈会回到getPicNum
,此时picNum === 230
注意
再次申明,try...handle
的语法是虚构的,只是为了演示代数效应
的思想。
虚构一个类似try...catch
的语法 ——try...handle
与两个操作符perform
、resume
总结一下:代数效应
能够将副作用
(例子中为请求图片数量
)从函数逻辑中分离,使函数关注点保持纯粹。
并且,从例子中可以看出,perform resume
不需要区分同步异步。
那么代数效应
与React
有什么关系呢?最明显的例子就是Hooks
。
对于类似useState
、useReducer
、useRef
这样的Hook
,我们不需要关注FunctionComponent
的state
在Hook
中是如何保存的,React
会为我们处理。
我们只需要假设useState
返回的是我们想要的state
,并编写业务逻辑就行。
function App() {
const [num, updateNum] = useState(0);
return (
<button onClick={() => updateNum(num => num + 1)}>{num}</button>
)
}
如果这个例子还不够明显,可以看看官方的Suspense Demo
在Demo
中ProfileDetails
用于展示用户名称。而用户名称是异步请求的。
但是Demo
中完全是同步
的写法。
function ProfileDetails() {
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
从React15
到React16
,协调器(Reconciler
)重构的一大目的是:将老的同步更新
的架构变为异步可中断更新
。
异步可中断更新
可以理解为:更新
在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。
这就是代数效应
中try...handle
的作用。
其实,浏览器原生就支持类似的实现,这就是Generator
。
但是Generator
的一些缺陷使React
团队放弃了他:
类似async
,Generator
也是传染性的,使用了Generator
则上下文的其他函数也需要作出改变。这样心智负担比较重。
Generator
执行的中间状态是上下文关联的。
function* doWork(A, B, C) {
var x = doExpensiveWorkA(A);
yield;
var y = x + doExpensiveWorkB(B);
yield;
var z = y + doExpensiveWorkC(C);
return z;
}
每当浏览器有空闲时间都会依次执行其中一个doExpensiveWork
,当时间用尽则会中断,当再次恢复时会从中断位置继续执行。
只考虑“单一优先级任务的中断与继续”情况下Generator
可以很好的实现异步可中断更新
。
但是当我们考虑“高优先级任务插队”的情况,如果此时已经完成doExpensiveWorkA
与doExpensiveWorkB
计算出x与y。
此时B
组件接收到一个高优更新
,由于Generator
执行的中间状态是上下文关联
的,所以计算y
时无法复用之前已经计算出的x
,需要重新计算。
如果通过全局变量保存之前执行的中间状态,又会引入新的复杂度。
更详细的解释可以参考这个issue
基于这些原因,React没有采用Generator实现协调器。
Fiber
并不是计算机术语中的新名词,他的中文翻译叫做纤程
,与进程(Process)、线程(Thread)、协程(Coroutine)同为程序执行过程。
在很多文章中将纤程
理解为协程
的一种实现。在JS中,协程
的实现便是Generator
。
所以,我们可以将纤程
(Fiber)、协程
(Generator)理解为代数效应
思想在JS
中的体现。
React Fiber
可以理解为:
React
内部实现的一套状态更新机制。支持任务不同优先级
,可中断
与恢复
,并且恢复后
可以复用
之前的中间状态
。
其中每个任务更新单元为React Element
对应的Fiber
节点。
下面,我们具体讲解Fiber架构的实现。
Fiber因何而来?他的作用是什么?
最早的Fiber官方解释来源于2016年React团队成员Acdlite的一篇介绍。
在React15
及以前,Reconciler
采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。
为了解决这个问题,React16
将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber
架构应运而生。
作为架构来说,之前React15
的Reconciler
采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack Reconciler
。React16
的Reconciler
基于Fiber
节点实现,被称为Fiber Reconciler
。
作为静态的数据结构来说,每个Fiber
节点对应一个React element
,保存了该组件的类型(函数组件/类组件/原生组件…)、对应的DOM节点等信息。
作为动态的工作单元来说,每个Fiber节点
保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新…)。
你可以从这里看到Fiber节点的属性定义。虽然属性很多,但我们可以按三层含义将他们分类来看
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// 作为静态数据结构的属性
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// 用于连接其他Fiber节点形成Fiber树
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
// 作为动态的工作单元的属性
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 指向该fiber在另一次更新时对应的fiber
this.alternate = null;
}
每个Fiber节点有个对应的React element
,多个Fiber
节点是如何连接形成树呢?靠如下三个属性:
// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;
举个例子,如下的组件结构:
function App() {
return (
<div>
i am
<span>KaSong</span>
</div>
)
}
这里需要提一下,为什么父级指针叫做
return
而不是parent
或者father
呢?因为作为一个工作单元,return
指节点执行完completeWork(后面会介绍)后会返回的下一个节点。子Fiber
节点及其兄弟节点完成工作后会返回其父级节点,所以用return
指代父级节点。
作为一种静态的数据结构,保存了组件相关的信息:
// Fiber对应组件的类型 Function/Class/Host...
this.tag = tag;
// key属性
this.key = key;
// 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.type = null;
// Fiber对应的真实DOM节点
this.stateNode = null;
作为动态的工作单元,Fiber中如下参数保存了本次更新相关的信息,我们会在后续的更新流程中使用到具体属性时再详细介绍
// 保存本次更新造成的状态改变相关信息
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// 保存本次更新会造成的DOM操作
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
如下两个字段保存调度优先级相关的信息,会在讲解Scheduler
时介绍。
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
本节我们了解了Fiber的起源与架构,其中Fiber节点可以构成Fiber树。那么Fiber树和页面呈现的DOM树有什么关系,React又是如何更新DOM的呢?
我们了解了Fiber
是什么,知道Fiber节点
可以保存对应的DOM节点
。
相应的,Fiber节点
构成的Fiber树
就对应DOM树
。
那么如何更新DOM
呢?这需要用到被称为“双缓存”的技术。
当我们用canvas
绘制动画,每一帧绘制前都会调用ctx.clearRect
清除上一帧的画面。
如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。
为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。
这种在内存中构建并直接替换
的技术叫做双缓存。
React
使用“双缓存”来完成Fiber树
的构建与替换——对应着DOM树
的创建与更新。
在React
中最多会同时存在两棵Fiber
树。当前屏幕上显示内容对应的Fiber树
称为current Fiber树
,正在内存中构建的Fiber树
称为workInProgress Fiber`树。
current Fiber树
中的Fiber节点
被称为current fiber
,workInProgress Fiber树
中的Fiber节点
被称为workInProgress fiber
,他们通过alternate
属性连接。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React
应用的根节点通过使current指针
在不同Fiber树
的rootFiber
间切换来完成current Fiber树
指向的切换。
即当workInProgress Fiber树
构建完成交给Renderer
渲染在页面上后,应用根节点的current
指针指向workInProgress Fiber树
,此时workInProgress Fiber树
就变为current Fiber树
。
每次状态更新都会产生新的workInProgress Fiber树
,通过current
与workInProgress
的替换,完成DOM
更新。
接下来我们以具体例子讲解mount时
、update时
的构建/替换流程。
function App() {
const [num, add] = useState(0);
return (
<p onClick={() => add(num + 1)}>{num}</p>
)
}
ReactDOM.render(<App/>, document.getElementById('root'));
首次执行ReactDOM.render
会创建fiberRootNode
(源码中叫fiberRoot
)和rootFiber
。其中fiberRootNode
是整个应用的根节点,rootFiber
是
所在组件树的根节点。
之所以要区分fiberRootNode
与rootFiber
,是因为在应用中我们可以多次调用ReactDOM.render
渲染不同的组件树,他们会拥有不同的rootFiber
。但是整个应用的根节点只有一个,那就是fiberRootNode
。
fiberRootNode
的current
会指向当前页面上已渲染内容对应Fiber树
,即current Fiber树
。
fiberRootNode.current = rootFiber;
由于是首屏渲染,页面中还没有挂载任何DOM
,所以fiberRootNode.current
指向的rootFiber
没有任何子Fiber节点
(即current Fiber树
为空)。
接下来进入render阶段
,根据组件返回的JSX
在内存中依次创建Fiber节点
并连接在一起构建Fiber树
,被称为workInProgress Fiber树
。(下图中右侧为内存中构建的树,左侧为页面显示的树)
在构建workInProgress Fiber树
时会尝试复用current Fiber树
中已有的Fiber节点
内的属性,在首屏渲染
时只有rootFiber
存在对应的current fiber
(即rootFiber.alternate
)。
图中右侧已构建完的workInProgress Fiber
树在commit阶段
渲染到页面。
此时DOM
更新为右侧树对应的样子。fiberRootNode
的current
指针指向workInProgress Fiber树
使其变为current Fiber 树
。
接下来我们点击p节点
触发状态改变,这会开启一次新的render阶段
并构建一棵新的workInProgress Fiber 树
。
和mount
时一样,workInProgress fiber
的创建可以复用current Fiber树
对应的节点数据。
这个决定是否复用的过程就是Diff算法,后面章节会详细讲解
workInProgress Fiber 树
在render阶段
完成构建后进入commit阶段
渲染到页面上。渲染完毕后,workInProgress Fiber
树变为current Fiber 树
。
本文介绍了Fiber树
的构建与替换过程,这个过程伴随着DOM
的更新。
那么在构建过程中每个Fiber节点
具体是如何创建的呢?我们会在下文的render阶段讲解。
通过本章的学习,我们了解了React
的Scheduler-Reconciler-Renderer
架构体系,在结束本章前,介绍几个源码内的术语:
Reconciler
工作的阶段被称为render阶段
。因为在该阶段会调用组件的render方法
。Renderer
工作的阶段被称为commit阶段
。就像你完成一个需求的编码后执行git commit
提交代码。commit阶段
会把render
阶段提交的信息渲染在页面上。render
与commit阶段
统称为work
,即React
在工作中。相对应的,如果任务正在Scheduler
内调度,就不属于work
。除去配置文件和隐藏文件夹,根目录的文件夹包括三个:
根目录
├── fixtures # 包含一些给贡献者准备的小型 React 测试项目
├── packages # 包含元数据(比如 package.json)和 React 仓库中所有 package 的源码(子目录 src)
├── scripts # 各种工具链的脚本,比如git、jest、eslint等
这里我们关注packages目录
React的核心,包含所有全局 React API,如:
这些 API 是全平台通用的,它不包含ReactDOM
、ReactNative
等平台特定的代码。在 NPM 上作为单独的一个包发布。
Scheduler(调度器)的实现。
源码中其他模块公用的方法和全局变量,比如在shared/ReactSymbols.js中保存React
不同组件类型的定义。
// ...
export let REACT_ELEMENT_TYPE = 0xeac7;
export let REACT_PORTAL_TYPE = 0xeaca;
export let REACT_FRAGMENT_TYPE = 0xeacb;
// ...
如下几个文件夹为对应的Renderer
- react-art
- react-dom # 注意这同时是DOM和SSR(服务端渲染)的入口
- react-native-renderer
- react-noop-renderer # 用于debug fiber(后面会介绍fiber)
- react-test-renderer
React将自己流程中的一部分抽离出来,形成可以独立使用的包,由于他们是试验性质的,所以不被建议在生产环境使用。包括如下文件夹:
- react-server # 创建自定义SSR流
- react-client # 创建自定义的流
- react-fetch # 用于数据请求
- react-interactions # 用于测试交互相关的内部特性,比如React的事件模型
- react-reconciler # Reconciler的实现,你可以用他构建自己的Renderer
React
将一些辅助功能形成单独的包。包括如下文件夹:
- react-is # 用于测试组件是否是某类型
- react-client # 创建自定义的流
- react-fetch # 用于数据请求
- react-refresh # “热重载”的React官方实现
我们需要重点关注react-reconciler,在源码学习中 80%的代码量都来自这个包。
虽然他是一个实验性的包,内部的很多功能在正式版本中还未开放。但是他一边对接Scheduler,一边对接不同平台的Renderer,构成了整个 React16 的架构体系。
JSX
作为描述组件内容的数据结构,为JS赋予了更多视觉表现力。在React
中我们大量使用他。在深入源码之前,有些疑问我们需要先解决:
相信作为React
的使用者,你已经接触过JSX
。如果你还不了解他,可以看下官网对其的描述 。
JSX
在编译时会被Babel
编译为React.createElement
方法。
这也是为什么在每个使用JSX的JS文件中,你必须显式的声明
import React from 'react';
否则在运行时该模块内就会报未定义变量 React
的错误。
注意
在React17中,已经不需要显式导入React了。详见介绍全新的 JSX 转换
JSX
并不是只能被编译为React.createElement
方法,你可以通过@babel/plugin-transform-react-jsx插件显式告诉Babel
编译时需要将JSX
编译为什么函数的调用(默认为React.createElement
)。
比如在preact这个类React库中,JSX会被编译为一个名为h的函数调用。
// 编译前
<p>KaSong</p>
// 编译后
h("p", null, "KaSong");
既然JSX
会被编译为React.createElement
,让我们看看他做了什么:
export function createElement(type, config, children) {
let propName;
const props = {};
let key = null;
let ref = null;
let self = null;
let source = null;
if (config != null) {
// 将 config 处理后赋值给 props
// ...省略
}
const childrenLength = arguments.length - 2;
// 处理 children,会被赋值给props.children
// ...省略
// 处理 defaultProps
// ...省略
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}
const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
// 标记这是个 React Element
$$typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: ref,
props: props,
_owner: owner,
};
return element;
};
我们可以看到,React.createElement
最终会调用ReactElement
方法返回一个包含组件数据的对象,该对象有个参数$$typeof: REACT_ELEMENT_TYPE
标记了该对象是个React Element
。
所以调用React.createElement
返回的对象就是React Element
么?
React
提供了验证合法React Element
的全局API React.isValidElement,我们看下他的实现:
export function isValidElement(object) {
return (
typeof object === 'object' &&
object !== null &&
object.$$typeof === REACT_ELEMENT_TYPE
);
}
可以看到,$$typeof === REACT_ELEMENT_TYPE
的非null
对象就是一个合法的React Element
。换言之,在React
中,所有JSX
在运行时的返回结果(即React.createElement
()的返回值)都是React Element
。
那么JSX
和React Component
的关系呢?
在React
中,我们常使用ClassComponent
与FunctionComponent
构建组件。
class AppClass extends React.Component {
render() {
return <p>KaSong</p>
}
}
console.log('这是ClassComponent:', AppClass);
console.log('这是Element:', <AppClass/>);
function AppFunc() {
return <p>KaSong</p>;
}
console.log('这是FunctionComponent:', AppFunc);
console.log('这是Element:', <AppFunc/>);
我们可以从Demo控制台打印的对象看出,ClassComponent
对应的Element
的type
字段为AppClass
自身。
FunctionComponent
对应的Element
的type
字段为AppFunc
自身,如下所示:
{
$$typeof: Symbol(react.element),
key: null,
props: {},
ref: null,
type: ƒ AppFunc(),
_owner: null,
_store: {validated: false},
_self: null,
_source: null
}
值得注意的一点,由于
AppClass instanceof Function === true;
AppFunc instanceof Function === true;
所以无法通过引用类型区分ClassComponent和FunctionComponent。React通过ClassComponent实例原型上的isReactComponent变量判断是否是ClassComponent。
ClassComponent.prototype.isReactComponent = {};
从上面的内容我们可以发现,JSX
是一种描述当前组件内容的数据结构,他不包含组件schedule
、reconcile
、render
所需的相关信息。
比如如下信息就不包括在JSX
中:
组件在更新中的优先级
组件的state
组件被打上的用于Renderer的标记
这些内容都包含在Fiber节点
中。
所以,在组件mount
时,Reconciler
根据JSX
描述的组件内容生成组件对应的Fiber节点
。
在update
时,Reconciler
将JSX
与Fiber节点
保存的数据对比,生成组件对应的Fiber节点
,并根据对比结果为Fiber节点
打上标记
。
render
阶段开始于performSyncWorkOnRoot
或performConcurrentWorkOnRoot
方法的调用。这取决于本次更新是同步更新还是异步更新。
我们现在还不需要学习这两个方法,只需要知道在这两个方法中会调用如下两个方法:
// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// performConcurrentWorkOnRoot会调用该方法
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
可以看到,他们唯一的区别是是否调用shouldYield
。如果当前浏览器帧没有剩余时间,shouldYield
会中止循环,直到浏览器有空闲时间后再继续遍历。
workInProgress
代表当前已创建的workInProgress fiber
。
performUnitOfWork
方法会创建下一个Fiber节点
并赋值给workInProgress
,并将workInProgress
与已创建的Fiber节点
连接起来构成Fiber树
。
你可以从这里看到workLoopConcurrent的源码
我们知道Fiber Reconciler
是从Stack Reconciler
重构而来,通过遍历的方式实现可中断的递归,所以performUnitOfWork
的工作可以分为两部分:“递”和“归”。
首先从rootFiber
开始向下深度优先遍历。为遍历到的每个Fiber节点
调用beginWork。
该方法会根据传入的Fiber节点
创建子Fiber节点
,并将这两个Fiber节点
连接起来。
当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。
在“归”阶段会调用completeWork处理Fiber节点。
当某个Fiber节点
执行完completeWork
,如果其存在兄弟Fiber节点
(即fiber.sibling !== null),会进入其兄弟Fiber
的“递”阶段。
如果不存在兄弟Fiber
,会进入父级Fiber
的“归”阶段。
“递”和“归”阶段会交错执行直到“归”到rootFiber
。至此,render
阶段的工作就结束了。
function App() {
return (
<div>
i am
<span>KaSong</span>
</div>
)
}
ReactDOM.render(<App />, document.getElementById("root"));
对应的Fiber
树结构:Fiber
架构
render
阶段会依次执行:
1. rootFiber beginWork
2. App Fiber beginWork
3. div Fiber beginWork
4. "i am" Fiber beginWork
5. "i am" Fiber completeWork
6. span Fiber beginWork
7. span Fiber completeWork
8. div Fiber completeWork
9. App Fiber completeWork
10. rootFiber completeWork
注意
之所以没有“KaSong” Fiber
的beginWork/completeWork
,是因为作为一种性能优化手段,针对只有单一文本子节点的Fiber
,React
会特殊处理。
源码在这里
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...省略函数体
}
Fiber
节点,即workInProgress.alternate
Fiber
节点组件mount
时,由于是首次渲染,是不存在当前组件对应的Fiber节点
在上一次更新时的Fiber节点
,即mount
时current === null
。
组件update
时,由于之前已经mount
过,所以current !== null
。
所以我们可以通过current === null
?来区分组件是处于mount
还是update
。
基于此原因,beginWork
的工作可以分为两部分:
update时:如果current
存在,在满足一定条件时可以复用current节点
,这样就能克隆current.child
作为workInProgress.child
,而不需要新建workInProgress.child
。
mount时:除fiberRootNode
以外,current === null
。会根据fiber.tag
不同,创建不同类型的子Fiber节点
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
// update时:如果current存在可能存在优化路径,可以复用current(即上一次更新的Fiber节点)
if (current !== null) {
// ...省略
// 复用current
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);
} else {
didReceiveUpdate = false;
}
// mount时:根据tag不同,创建不同的子Fiber节点
switch (workInProgress.tag) {
case IndeterminateComponent:
// ...省略
case LazyComponent:
// ...省略
case FunctionComponent:
// ...省略
case ClassComponent:
// ...省略
case HostRoot:
// ...省略
case HostComponent:
// ...省略
case HostText:
// ...省略
// ...省略其他类型
}
}
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
(__DEV__ ? workInProgress.type !== current.type : false)
) {
didReceiveUpdate = true;
} else if (!includesSomeLane(renderLanes, updateLanes)) {
didReceiveUpdate = false;
switch (workInProgress.tag) {
// 省略处理
}
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);
} else {
didReceiveUpdate = false;
}
} else {
didReceiveUpdate = false;
}
我们可以看到,满足如下情况时didReceiveUpdate === false
(即可以直接复用前一次更新的子Fiber
,不需要新建子Fiber
)
oldProps === newProps && workInProgress.type === current.type
,即props
与fiber.type
不变!includesSomeLane(renderLanes, updateLanes)
,即当前Fiber节点
优先级不够当不满足优化路径时,我们就进入第二部分,新建子Fiber
。
我们可以看到,根据fiber.tag
不同,进入不同类型Fiber
的创建逻辑。
tag对应的组件类型
// mount时:根据tag不同,创建不同的Fiber节点
switch (workInProgress.tag) {
case IndeterminateComponent:
// ...省略
case LazyComponent:
// ...省略
case FunctionComponent:
// ...省略
case ClassComponent:
// ...省略
case HostRoot:
// ...省略
case HostComponent:
// ...省略
case HostText:
// ...省略
// ...省略其他类型
}
对于我们常见的组件类型,如(FunctionComponent
/ClassComponent
/HostComponent
),最终会进入reconcileChildren方法。
从该函数名就能看出这是Reconciler模块的核心部分。那么他究竟做了什么呢?
对于mount的组件,他会创建新的子Fiber节点
对于update的组件,他会将当前组件与该组件在上次更新时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes
) {
if (current === null) {
// 对于mount的组件
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// 对于update的组件
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
从代码可以看出,和beginWork
一样,他也是通过current === null
?区分mount
与update
。
不论走哪个逻辑,最终他会生成新的子Fiber节点
并赋值给workInProgress.child
,作为本次beginWork
返回值,并作为下次performUnitOfWork
执行时workInProgress
的传参。
注意
值得一提的是,mountChildFibers
与reconcileChildFibers
这两个方法的逻辑基本一致。唯一的区别是:reconcileChildFibers
会为生成的Fiber节点
带上effectTag
属性,而mountChildFibers
不会。
我们知道,render阶段
的工作是在内存中进行,当工作结束后会通知Renderer
需要执行的DOM
操作。要执行DOM
操作的具体类型就保存在fiber.effectTag
中。
你可以从这里看到
effectTag
对应的DOM
操作
// DOM需要插入到页面中
export const Placement = /* */ 0b00000000000010;
// DOM需要更新
export const Update = /* */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /* */ 0b00000000000110;
// DOM需要删除
export const Deletion = /* */ 0b00000000001000;
那么,如果要通知Renderer
将Fiber节点
对应的DOM节点
插入页面中,需要满足两个条件:
fiber.stateNode
存在,即·Fiber节点·中保存了对应的DOM节点
(fiber.effectTag & Placement) !== 0
,即Fiber节点
存在Placement effectTag
我们知道,moun
t时,fiber.stateNode === null
,且在reconcileChildren
中调用的mountChildFibers
不会为Fiber节点
赋值effectTag
。那么首屏渲染如何完成呢?
针对第一个问题,fiber.stateNode
会在completeWork
中创建。
第二个问题的答案十分巧妙:假设mountChildFibers
也会赋值effectTag
,那么可以预见mount
时整棵Fiber树
所有节点都会有Placement effectTag
。那么commit阶段
在执行DOM
操作时每个节点都会执行一次插入操作,这样大量的DOM
操作是极低效的。
为了解决这个问题,在mount
时只有rootFiber
会赋值Placement effectTag
,在commit阶段
只会执行一次插入操作。
类似beginWork,completeWork也是针对不同fiber.tag调用不同的处理逻辑。
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
const newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case IndeterminateComponent:
case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
return null;
case ClassComponent: {
// ...省略
return null;
}
case HostRoot: {
// ...省略
updateHostContainer(workInProgress);
return null;
}
case HostComponent: {
// ...省略
return null;
}
// ...省略
我们重点关注页面渲染所必须的HostComponent
(即原生DOM组件对应的Fiber节点),其他类型Fiber
的处理留在具体功能实现时讲解。
和beginWork
一样,我们根据current === null
?判断是mount
还是update
。
同时针对HostComponent
,判断update
时我们还需要考虑workInProgress.stateNode != null
?(即该Fiber节点
是否存在对应的DOM节点
)
case HostComponent: {
popHostContext(workInProgress);
const rootContainerInstance = getRootHostContainer();
const type = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) {
// update的情况
// ...省略
} else {
// mount的情况
// ...省略
}
return null;
}
当update
时,Fiber节点
已经存在对应DOM节点
,所以不需要生成DOM节点
。需要做的主要是处理props
,比如:
onClick
、onChange
等回调函数的注册style prop
DANGEROUSLY_SET_INNER_HTML prop
children prop
我们去掉一些当前不需要关注的功能(比如ref)。可以看到最主要的逻辑是调用updateHostComponent
方法。
你可以从这里看到updateHostComponent
方法定义。
在updateHostComponent
内部,被处理完的props
会被赋值给workInProgress.updateQueue
,并最终会在commit阶段
被渲染在页面上。
workInProgress.updateQueue = (updatePayload: any);
其中updatePayload
为数组形式,他的偶数索引的值为变化的prop key
,奇数索引的值为变化的prop value
。
mount
时的主要逻辑包括三个:
Fiber节点
生成对应的DOM节点
DOM节点
插入刚生成的DOM节点
中update
逻辑中的updateHostComponent
类似的处理props
的过程// mount的情况
const currentHostContext = getHostContext();
// 为fiber创建对应DOM节点
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
// 将子孙DOM节点插入刚生成的DOM节点中
appendAllChildren(instance, workInProgress, false, false);
// DOM节点赋值给fiber.stateNode
workInProgress.stateNode = instance;
// 与update逻辑中的updateHostComponent类似的处理props的过程
if (
finalizeInitialChildren(
instance,
type,
newProps,
rootContainerInstance,
currentHostContext,
)
) {
markUpdate(workInProgress);
}
commit阶段
是如何通过一次插入DOM操作
(对应一个Placement effectTag
)将整棵DOM树
插入页面的呢?
原因就在于completeWork
中的appendAllChildren
方法。
由于completeWork
属于“归”阶段调用的函数,每次调用appendAllChildren
时都会将已生成的子孙DOM节点
插入当前生成的DOM节点
下。那么当“归”到rootFiber
时,我们已经有一个构建好的离屏DOM树
。
至此render阶段的绝大部分工作就完成了。
还有一个问题:作为DOM操作
的依据,commit阶段
需要找到所有有effectTag
的Fiber节点
并依次执行effectTag
对应操作。难道需要在commit阶段
再遍历一次Fiber树
寻找effectTag !== null
的Fiber节点
么?
这显然是很低效的。
为了解决这个问题,在completeWork
的上层函数completeUnitOfWork
中,每个执行完completeWork
且存在effectTag
的Fiber节点
会被保存在一条被称为effectList
的单向链表中。
effectList
中第一个Fiber节点
保存在fiber.firstEffect
,最后一个元素保存在fiber.lastEffect
。
类似appendAllChildren
,在“归”阶段,所有有effectTag
的Fiber节点
都会被追加在effectList
中,最终形成一条以rootFiber.firstEffect
为起点的单向链表。
nextEffect nextEffect
rootFiber.firstEffect -----------> fiber -----------> fiber
这样,在commit阶段
只需要遍历effectList
就能执行所有effect
了。
你可以在这里看到这段代码逻辑。
借用React团队成员Dan Abramov的话:effectList
相较于Fiber树
,就像圣诞树上挂的那一串彩灯。
至此,render阶段
全部工作完成。在performSyncWorkOnRoot
函数中fiberRootNode
被传递给commitRoot
方法,开启commit阶段
工作流程。
commitRoot(root);
代码在这里
commitRoot
方法是commit阶段
工作的起点。fiberRootNode
会作为传参。
commitRoot(root);
你可以从这里看到commit阶段
的完整代码
在rootFiber.firstEffect
上保存了一条需要执行副作用的Fiber节点
的单向链表effectList
,这些Fiber节点
的updateQueue
中保存了变化的props
。
这些副作用对应的DOM操作
在commit阶段
执行。
除此之外,一些生命周期钩子(比如componentDidXXX
)、hook
(比如useEffect
)需要在commit阶段
执行。
commit阶段
的主要工作(即Renderer
的工作流程)分为三部分:
before mutation阶段(执行DOM操作前)
mutation阶段(执行DOM操作)
layout阶段(执行DOM操作后)
在before mutation阶段
之前和layout阶段
之后还有一些额外工作,涉及到比如useEffect
的触发、优先级
相关的重置、ref
的绑定/解绑。
commitRootImpl
方法中直到第一句if (firstEffect !== null)
之前属于before mutation
之前。
我们大体看下他做的工作:
do {
// 触发useEffect回调与其他同步任务。由于这些任务可能触发新的渲染,所以这里要一直遍历执行直到没有任务
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// root指 fiberRootNode
// root.finishedWork指当前应用的rootFiber
const finishedWork = root.finishedWork;
// 凡是变量名带lane的都是优先级相关
const lanes = root.finishedLanes;
if (finishedWork === null) {
return null;
}
root.finishedWork = null;
root.finishedLanes = NoLanes;
// 重置Scheduler绑定的回调函数
root.callbackNode = null;
root.callbackId = NoLanes;
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
// 重置优先级相关变量
markRootFinished(root, remainingLanes);
// 清除已完成的discrete updates,例如:用户鼠标点击触发的更新。
if (rootsWithPendingDiscreteUpdates !== null) {
if (
!hasDiscreteLanes(remainingLanes) &&
rootsWithPendingDiscreteUpdates.has(root)
) {
rootsWithPendingDiscreteUpdates.delete(root);
}
}
// 重置全局变量
if (root === workInProgressRoot) {
workInProgressRoot = null;
workInProgress = null;
workInProgressRootRenderLanes = NoLanes;
} else {
}
// 将effectList赋值给firstEffect
// 由于每个fiber的effectList只包含他的子孙节点
// 所以根节点如果有effectTag则不会被包含进来
// 所以这里将有effectTag的根节点插入到effectList尾部
// 这样才能保证有effect的fiber都在effectList中
let firstEffect;
if (finishedWork.effectTag > PerformedWork) {
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
// 根节点没有effectTag
firstEffect = finishedWork.firstEffect;
}
可以看到,before mutation
之前主要做一些变量赋值,状态重置的工作。
这一长串代码我们只需要关注最后赋值的firstEffect
,在commit
的三个子阶段都会用到他。
接下来让我们简单看下layout阶段执行完后的代码
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
// useEffect相关
if (rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsLanes = lanes;
pendingPassiveEffectsRenderPriority = renderPriorityLevel;
} else {}
// 性能优化相关
if (remainingLanes !== NoLanes) {
if (enableSchedulerTracing) {
// ...
}
} else {
// ...
}
// 性能优化相关
if (enableSchedulerTracing) {
if (!rootDidHavePassiveEffects) {
// ...
}
}
// ...检测无限循环的同步任务
if (remainingLanes === SyncLane) {
// ...
}
// 在离开commitRoot函数前调用,触发一次新的调度,确保任何附加的任务被调度
ensureRootIsScheduled(root, now());
// ...处理未捕获错误及老版本遗留的边界问题
// 执行同步任务,这样同步任务不需要等到下次事件循环再执行
// 比如在 componentDidMount 中执行 setState 创建的更新会在这里被同步执行
// 或useLayoutEffect
flushSyncCallbackQueue();
return null;
你可以在这里看到这段代码
主要包括三点内容:
useEffect
相关的处理。
我们会在讲解layout阶段时讲解。
性能追踪相关。
在commit阶段
会触发一些生命周期钩子(如 componentDidXXX
)和hook
(如useLayoutEffect
、useEffect
)。
在这些回调方法中可能触发新的更新,新的更新会开启新的render-commit
流程。
前面说到Renderer
工作的阶段被称为commit阶段
。commit阶段
可以分为三个子阶段:
before mutation阶段(执行DOM操作前)
mutation阶段(执行DOM操作)
layout阶段(执行DOM操作后)
before mutation
阶段的代码很短,整个过程就是遍历effectList
并调用commitBeforeMutationEffects
函数处理。
这部分源码在这里。
// 保存之前的优先级,以同步优先级执行,执行完毕后恢复之前优先级
const previousLanePriority = getCurrentUpdateLanePriority();
setCurrentUpdateLanePriority(SyncLanePriority);
// 将当前上下文标记为CommitContext,作为commit阶段的标志
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// 处理focus状态
focusedInstanceHandle = prepareForCommit(root.containerInfo);
shouldFireAfterActiveInstanceBlur = false;
// beforeMutation阶段的主函数
commitBeforeMutationEffects(finishedWork);
focusedInstanceHandle = null;
我们重点关注beforeMutation阶段的主函数commitBeforeMutationEffects做了什么。
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const current = nextEffect.alternate;
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
// ...focus blur相关
}
const effectTag = nextEffect.effectTag;
// 调用getSnapshotBeforeUpdate
if ((effectTag & Snapshot) !== NoEffect) {
commitBeforeMutationEffectOnFiber(current, nextEffect);
}
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
整体可以分为三部分:
处理DOM节点渲染/删除后的 autoFocus、blur 逻辑。
调用getSnapshotBeforeUpdate生命周期钩子。
调度useEffect。
commitBeforeMutationEffectOnFiber
是commitBeforeMutationLifeCycles
的别名。
在该方法内会调用getSnapshotBeforeUpdate
。
你可以在这里看到这段逻辑
从Reactv16
开始,componentWillXXX
钩子前增加了UNSAFE_前缀
。
究其原因,是因为Stack Reconciler
重构为Fiber Reconciler
后,render阶段
的任务可能中断/重新开始,对应的组件在render阶段
的生命周期钩子(即componentWillXXX
)可能触发多次。
这种行为和Reactv15
不一致,所以标记为UNSAFE_
。
为此,React提供了替代的生命周期钩子getSnapshotBeforeUpdate。
我们可以看见,getSnapshotBeforeUpdate
是在commit阶段
内的before mutation阶段
调用的,由于commit阶段
是同步的,所以不会遇到多次调用的问题。
在这几行代码内,scheduleCallback
方法由Scheduler
模块提供,用于以某个优先级异步调度一个回调函数。
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
// 触发useEffect
flushPassiveEffects();
return null;
});
}
}
在此处,被异步调度的回调函数就是触发useEffect
的方法flushPassiveEffects
。
在flushPassiveEffects
方法内部会从全局变量rootWithPendingPassiveEffects
获取effectList
。
在completeWork中我们讲到,effectList中保存了需要执行副作用的Fiber节点。其中副作用包括
除此外,当一个FunctionComponent
含有useEffect
或useLayoutEffect
,他对应的Fiber节点
也会被赋值effectTag
。
你可以从这里看到
hook
相关的effectTag
在flushPassiveEffects
方法内部会遍历rootWithPendingPassiveEffects
(即effectList
)执行effect回调函数
。
如果在此时直接执行,rootWithPendingPassiveEffects === null
。
那么rootWithPendingPassiveEffects
会在何时赋值呢?
在上面layout之后的代码片段中会根据rootDoesHavePassiveEffects === true
?决定是否赋值rootWithPendingPassiveEffects
。
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
if (rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsLanes = lanes;
pendingPassiveEffectsRenderPriority = renderPriorityLevel;
}
所以整个useEffect
异步调用分为三步:
before mutation阶段
在scheduleCallback
中调度flushPassiveEffects
layout阶段
之后将effectList
赋值给rootWithPendingPassiveEffects
scheduleCallback
触发flushPassiveEffects
,flushPassiveEffects
内部遍历rootWithPendingPassiveEffects
摘录自React
文档effect 的执行时机:
与
componentDidMount
、componentDidUpdate
不同的是,传给useEffect
的函数会在浏览器完成布局与绘制之后,在一个延迟事件中被调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因为绝大多数操作不应阻塞浏览器对屏幕的更新。
可见,useEffect
异步执行的原因主要是防止同步执行时阻塞浏览器渲染。
在before mutation阶段,会遍历effectList,依次执行:
处理DOM节点渲染/删除后的 autoFocus、blur逻辑
调用getSnapshotBeforeUpdate生命周期钩子
调度useEffect
mutation阶段:执行DOM
操作
类似before mutation阶段
,mutation阶段
也是遍历effectList
,执行函数。这里执行的是commitMutationEffects
。
源码地址在这
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
// 遍历effectList
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 根据 ContentReset effectTag重置文字节点
if (effectTag & ContentReset) {
commitResetTextContent(nextEffect);
}
// 更新ref
if (effectTag & Ref) {
const current = nextEffect.alternate;
if (current !== null) {
commitDetachRef(current);
}
}
// 根据 effectTag 分别处理
const primaryEffectTag =
effectTag & (Placement | Update | Deletion | Hydrating);
switch (primaryEffectTag) {
// 插入DOM
case Placement: {
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
break;
}
// 插入DOM 并 更新DOM
case PlacementAndUpdate: {
// 插入
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
// 更新
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// SSR
case Hydrating: {
nextEffect.effectTag &= ~Hydrating;
break;
}
// SSR
case HydratingAndUpdate: {
nextEffect.effectTag &= ~Hydrating;
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 更新DOM
case Update: {
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 删除DOM
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel);
break;
}
}
nextEffect = nextEffect.nextEffect;
}
}
commitMutationEffects
会遍历effectList
,对每个Fiber节点
执行如下三个操作:
effectTag
包括(Placement
| Update
| Deletion
| Hydrating
)我们关注步骤三中的Placement
| Update
| Deletion
。Hydrating作为服务端渲染相关,我们先不关注。
当Fiber节点
含有Placement effectTag
,意味着该Fiber节点
对应的DOM节点
需要插入到页面中。
调用的方法为commitPlacement
。
你可以在这里看到
commitPlacement源码
该方法所做的工作分为三步:
获取父级DOM节点
。其中finishedWork
为传入的Fiber节点
。
const parentFiber = getHostParentFiber(finishedWork);
// 父级DOM节点
const parentStateNode = parentFiber.stateNode;
获取Fiber节点
的DOM兄弟节点
const before = getHostSibling(finishedWork);
根据DOM
兄弟节点是否存在决定调用parentNode.insertBefore
或parentNode.appendChild
执行DOM插入操作。
// parentStateNode是否是rootFiber
if (isContainer) {
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
insertOrAppendPlacementNode(finishedWork, before, parent);
}
值得注意的是, getHostSibling
(获取兄弟 DOM节点
)的执行很耗时,当在同一个父 Fiber节点
下依次执行多个插入操作, getHostSibling
算法的复杂度为指数级。
这是由于Fiber节点
不只包括HostComponent
,所以Fiber树
和渲染的DOM树节点
并不是一一对应的。要从Fiber节点
找到DOM节点
很可能跨层级遍历。
当Fiber节点
含有Update effectTag
,意味着该Fiber节点
需要更新。调用的方法为commitWork
,他会根据Fiber.tag
分别处理。
你可以在这里看到commitWork源码
当Fiber节点
含有Deletion effectTag
,意味着该Fiber节点
对应的DOM节点
需要从页面中删除。调用的方法为commitDeletion
。
你可以在这里看到
commitDeletion
源码
该方法会执行如下操作:
Fiber节点
及其子孙Fiber节点
中fiber.tag
为ClassComponent
的componentWillUnmount生命周期钩子,从页面移除Fiber节点
对应DOM节点
useEffect
的销毁函数mutation阶段
会遍历effectList
,依次执行commitMutationEffects
。该方法的主要工作为“根据effectTag
调用不同的处理函数处理Fiber
。
该阶段之所以称为layout
,因为该阶段的代码都是在DOM
渲染完成(mutation阶段
完成)后执行的。
该阶段触发的生命周期钩子和hook
可以直接访问到已经改变后的DOM
,即该阶段是可以参与DOM layout
的阶段。
与前两个阶段类似,layout阶段
也是遍历effectList
,执行函数。
具体执行的函数是commitLayoutEffects
。
root.current = finishedWork;
nextEffect = firstEffect;
do {
try {
commitLayoutEffects(root, lanes);
} catch (error) {
invariant(nextEffect !== null, "Should be working on an effect.");
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
nextEffect = null;
commitLayoutEffects源码
function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 调用生命周期钩子和hook
if (effectTag & (Update | Callback)) {
const current = nextEffect.alternate;
commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
}
// 赋值ref
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
commitLayoutEffects
一共做了两件事:
commitLayoutEffectOnFiber(调用生命周期
钩子和hook
相关操作)
commitAttachRef(赋值 ref)
commitLayoutEffectOnFiber
方法会根据fiber.tag
对不同类型的节点分别处理。
你可以在这里看到
commitLayoutEffectOnFiber
源码(commitLayoutEffectOnFiber
为别名,方法原名为commitLifeCycles
)
对于ClassComponent
,他会通过current === null
?区分是mount
还是update
,调用componentDidMount或componentDidUpdate。
触发状态更新的this.setState
如果赋值了第二个参数回调函数,也会在此时调用。
this.setState({ xxx: 1 }, () => {
console.log("i am update~");
});
对于FunctionComponent
及相关类型,他会调用useLayoutEffect hook
的回调函数,调度useEffect
的销毁与回调函数
相关类型指特殊处理后的
FunctionComponent
,比如ForwardRef
、React.memo
包裹的FunctionComponent
,你可以从这里看到下面这段代码
对于HostRoot
,即rootFiber
,如果赋值了第三个参数回调函数,也会在此时调用。
ReactDOM.render(<App />, document.querySelector("#root"), function() {
console.log("i am mount~");
});
commitLayoutEffects
会做的第二件事是commitAttachRef。
代码逻辑很简单:获取DOM实例
,更新ref
。
至此,整个layout阶段就结束了。
在结束本节的学习前,我们关注下这行代码:
root.current = finishedWork;
在这里查看这行源码
workInProgress Fiber树
在commit阶段
完成渲染后会变为current Fiber树
。这行代码的作用就是切换fiberRootNode
指向的current Fiber树
。
那么这行代码为什么在这里呢?(在mutation阶段
结束后,layout阶段
开始前。)
我们知道componentWillUnmount
会在mutation阶段执行
。此时current Fiber树
还指向前一次更新的Fiber树
,在生命周期钩子内获取的DOM
还是更新前的。
componentDidMount
和componentDidUpdate
会在layout阶段
执行。此时current Fiber树
已经指向更新后的Fiber树
,在生命周期钩子内获取的DOM
就是更新后的。
从这节我们学到,layout阶段
会遍历effectList
,依次执行commitLayoutEffects
。该方法的主要工作为“根据effectTag
调用不同的处理函数处理Fiber
并更新ref
。
后续内容转移到下篇查看
前端进阶之React原理、源码解读-下篇