这篇文章去年中旬的时候就想写了,无奈在读源码的过程中,遇到了很多卡点,直到今天,才有勇气去写这篇文章,同时也希望可以给正在读React源码的小伙伴一个借鉴。
本文针对的是React18版本的代码,最新版本是React19,是否要追真正的新,大家自行决定。
请看下面一段代码:
javascript
import { useState } from 'react'
function Clock(){
const [count, setCount] = useState(50);
const [flag, setFlag] = useState(true);
const update1 = function(prevCount) {
return prevCount + 1;
}
const clickDiv = () => {
setCount(
update1
)
}
return <div className='father-fiber'>
<div className = 'className-1' onClick={clickDiv}>
{ count }
</div>
</div>
}
什么是Fiber?
它是一个object对象,这个对象可以描述组件的一切信息,object之间通过某些key关联,可以描述页面的整体结构。
上述代码对应的Fiber大致如下:
javascript
{
elementType: f Clock(props),
memoizedState: {
memoizedState: 50,
baseState: 50,
queue: {
dispatch: ƒ (),
lastRenderedState: 50,
pending: null
},
next: {
memoizedState: true,
baseState: true,
next: null,
queue: {
dispatch: ƒ (),
lastRenderedState: true,
pending: null
}
}
},
child: {
elementType: 'div',
memoizedState: null,
props: {
className: 'father-fiber'
},
child: {
elementType: 'div',
memoizedState: null,
props: {
className: 'className-1',
onClick: ƒ clickDiv()
}
}
}
}
从上面的代码我们可以看到,一个组件可以是一个Fiber,组件里render的dom元素也会分别对应一个Fiber,在组件对应的Fiber里,会有那么一个地方,存放着组件内部维护的state。
触发更新
流程大致如下:
产生更新 -》调度更新 -》协调(Diff)-》提交DOM更新
useState伪代码
javascript
function useState(initState){
// 创建hook
let hook = {
baseState: initState,
memoizedState: initState,
next: null,
queue: {
dispatch: null,
lastRenderedState: initState,
pending: null
}
}
const dispatch = hook.queue.dispatch = f().bind(
null,
curFiberInfo,
hook.queue
)
return [
hook.memoizedState,
dispatch
]
}
上面是伪代码,主要讲逻辑,在这篇文章讲解的代码里,setCount、setState就是dispatch。调用一次useState,就会创建一个hook信息并为其分配一个执行更新的dispatch函数。
产生更新
当我们调用一次setState,就会产生一个update对象,连续调用同一个setState,会产生N个update对象,这个时候update对象之间通过环形链表的形式来维护。
javascript
// 这里的action就是我们调用setCount时传入的参数
function dispatch(fiber, queue, action){
// 产生更新
const update = {
lane: requestUpdateLane(),
action: action,
eagerReducer: null,
eagerState: null,
next: null
}
let pending = queue.pending
if (pending === null) {
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update
}
注意,这里有个"Lane"。从这里就要开始区分版本了,如果你是通过 "ReactDOM.render" 来启动的应用,这个阶段,所有的更新都一样,都需要立即执行,不存在优先级的概念。否则并发模式下,这里需要根据触发事件的类型,人为的分配优先级,为接下来的优化手段
做准备。
在并发模式下,React给所有的事件分配了优先级,如何做的呢?就是初始化的时候,将所有的事件进行分类,分类相同的,都包裹在一个wrap函数里,这样在触发合成事件时,根据代码逻辑与事件委托,自然能知道当前的合成事件在整个机制里处于什么样的优先级。以下就是合成事件与优先级关联的大致伪代码:
javascript
// 初始化全局变量
var DiscreteEventPriority = SyncLane;
var ContinuousEventPriority = InputContinuousLane;
var DefaultEventPriority = DefaultLane;
var IdleEventPriority = IdleLane;
var currentUpdatePriority = NoLane;
// 初始化事件机制
function createEventListenerWrapperWithPriority(domEventName){
var eventPriority = getEventPriority(domEventName);
var listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
// 其余的优先级的绑定逻辑...
}
return listenerWrapper.bind(null, domEventName);
}
function setCurrentUpdatePriority(newPriority) {
currentUpdatePriority = newPriority;
}
function dispatchDiscreteEvent(domEventName){
setCurrentUpdatePriority(DiscreteEventPriority);
dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
}
在这个阶段,对于Hook来,我们还需要注意2个点,如下:
- 当前组件产生的state信息维护在哪里了?
- 某个state产生的update信息维护在哪了?
在本篇代码中,state信息维护在了(组件对应的Fiber).memoizedState
,多个state信息则是通过next
属性进行关联,形成了以memoizedState
为首的单链表。
这个过程中,如果某个state产生了update,这个update会维护在(state对应的memoizedState).queue
里。
开始调度
调度,调度,负责指挥交通的,定义调度任务以怎样的顺序去执行。
这里需要关注以下几点:
- 这个过程中,会将"更新任务"通过lane转换为"调度任务"。
- 并不是所有的调度任务都要入堆,并不是所有的更新都要立即执行。
- 入堆入的是调度任务,而不是update信息。
如果当前的更新占用较长时间(超过5ms),或者存在多个不同优先级的更新任务时,React就会将这些任务封装成调度任务并放入堆中。堆会根据任务的优先级(通过 Lane
模型表示)对任务进行排序,高优先级的任务会优先执行。
我们来试一下就知道了:
javascript
// 合成事件1
const update1 = function(){
setCount(1)
}
// 合成事件2
const update2 = async function() {
await new Promise(
(resolve, reject) => {
setTimeout(
() => resolve('1')
,3000
)
}
)
await setCount(2)
}
将这两个事件分别绑定到不同的dom上,通过click触发一下事件,然后在heap.push
处打个断点,会发现1没进,2进了,证明刚才的结论成立。
同时,在进断点前,我们会发现,在上面又创建了一个Task,最后是将Task推入heap里的。
接下来我们看一看,"调度器"是如何将"更新信息"、"调度任务"这2者关联起来的。
这块的开始,我们还得回到创建update的位置,根据源码,我们可以得到下面的伪代码:
javascript
const update = {
// 一些信息...
}
let pending = queue.pending
if (pending === null) {
update.next = update
} else {
update.next = pending.next
pending.next = update
}
queue.pending = update
const root = markUpdateLaneFromFiberToRoot(fiber, update.lane)
if (root !== null) {
var eventTime = requestEventTime()
scheduleUpdateOnFiber(root, fiber, update.lane, eventTime)
}
function scheduleUpdateOnFiber(root, fiber, lane, eventTime){
ensureRootIsScheduled(root, eventTime);
}
其中,这个markUpdateLaneFromFiberToRoot
函数是比较重要的一环,从发起更新的Fiber节点开始,向上遍历直到根节点,一路标记发起更新的Fiber的update.lane
。
在读源码的过程中,大家会遇到 mergeLanes
函数,用于将当前 Fiber
节点已有的 lanes
和新的 lane
进行合并,如何合并的呢?通过按位"|",即a | b
。
我们都知道,lane是通过进制的形式表现的,根据代码逻辑,所有的更新,都会向上遍历至根节点,最后根节点会汇总所有的lane,所以问题是通过按位"|"的形式,得到的汇总lane,汇总lane为什么能表示是否存在更新,为什么能表示有哪些更新存在?举个例子大家就知道了,如下:
javascript
// 按位或嘛,全是0,才是0,否则都是1
const noLane = 0b00000000; // 没有更新
const lane1 = 0b00000001; // 优先级1
const lane2 = 0b00000010; // 优先级2
const allLane = lane1 | lane2
// allLane的结果: 0b00000011
// 是否存在优先级
const isHaveLane = (allLane | noLane) === noLane
// 是否存在优先级1
const isHaveLane1 = (allLane | noLane) === lane1
所以,React团队通过"按位或"的形式去实现汇总更新的功能,确实巧妙,非常值得学习!
ensureRootIsScheduled
负责根节点的调度任务
能否被正确安排,同时根据根节点的状态和当前更新信息的 lane
,决定是否需要创建新的调度任务。伪代码如下:
javascript
function ensureRootIsScheduled(root, eventTime){
// 获取根节点当前正在调度的回调任务
var existingCallbackNode = root.callbackNode;
// 根据根节点的状态和当前渲染情况,确定下一个要处理的更新任务集合
const nextLanes = getNextLanes( root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes);
// 根据上一步得到的更新任务集合,确定最新的调度优先级
var schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediatePriority;
break;
// 其余的逻辑判断...
}
// 创建调度任务
var newCallbackNode = scheduleCallback$1(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
}
function scheduleCallback$1(priorityLevel, callback) {
// 省略其余逻辑...
var newTask = {
id: taskIdCounter++,
callback: callback,
priorityLevel: priorityLevel,
startTime: startTime,
expirationTime: expirationTime,
sortIndex: -1
};
// 省略其余逻辑...
push(taskQueue, newTask);
}
调度至此就差不多讲完了,就说了个大概,中间很多细小且重要的逻辑也没说到(比如更新优先级与合成事件之间的双向转换、哪些更新会立即执行、入堆的任务是如何触发执行的等等),以后的文章会慢慢的讲。
Diff算法
Diff是页面实现局部更新的根本,通过Diff算法可以找到真实DOM里需要做出更新的部分,最后只更新真实DOM中对应的节点。
在具体的实现中,至少分为了2类,一类是单节点的Diff,一类是多节点的Diff。分别如下:
理论上,对比2棵树,单节点、多节点,只要存在一个算法就可以实现2棵树的对比,React为什么要同时存在多个对比算法?2个算法真的就比一个算法性能更好吗?
对于我这个问题,有大神的话,可以评论区里沟通一下,嘿嘿。
提交DOM更新
这一块主要是渲染差异,根据网上的理论,这一阶段就是常说的commit阶段。
javascript
function commitRootImpl(root, renderPriorityLevel) {
commitBeforeMutationEffects();
commitMutationEffects(root, renderPriorityLevel);
commitLayoutEffects(root, renderPriorityLevel);
// 副作用阶段
if (rootDoesHavePassiveEffects) {
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
在mutation
阶段,主要执行一些具体的DOM操作,如下:
javascript
function commitPlacement(finishedWork) {
const parentFiber = getParentFiber(finishedWork);
const parentStateNode = parentFiber.stateNode;
const newNode = finishedWork.stateNode; // 将新节点插入到父节点中 parentStateNode.appendChild(newNode);
}
随后的Passive
阶段,会执行useEffect等副作用,如下:
javascript
function flushPassiveEffects() {
if (!rootWithPendingPassiveEffects) {
return false;
}
const root = rootWithPendingPassiveEffects;
rootWithPendingPassiveEffects = null; // 执行副作用清理函数
commitPassiveUnmountEffects(root.current); // 执行副作用回调函数
commitPassiveMountEffects(root, root.current);
return true;
}
最后
上述过程只是一种情况对应的理想状态,其中还有很多细小的点没有在文章中说出来,如果在面试中遇到了这个问题,是要拼深度+广度的。
那么,我们下期再见啦,拜拜~~