React用过是吧,请说一下setState的大致流程...

这篇文章去年中旬的时候就想写了,无奈在读源码的过程中,遇到了很多卡点,直到今天,才有勇气去写这篇文章,同时也希望可以给正在读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; 
}

最后

上述过程只是一种情况对应的理想状态,其中还有很多细小的点没有在文章中说出来,如果在面试中遇到了这个问题,是要拼深度+广度的。

那么,我们下期再见啦,拜拜~~

相关推荐
奇怪的知识又增长了7 分钟前
Command SwiftCompile failed with a nonzero exit code Command SwiftGeneratePch em
前端
Maofu8 分钟前
从React项目 迁移到 Solid项目的踩坑记录
前端
薄荷味8 分钟前
ubuntu 服务器安装 docker
前端
Carlos_sam9 分钟前
OpenLayers:如何控制Overlay的层级?
前端·javascript
莫循瑾木12 分钟前
Vue3 Composition API 完全指南
前端·vue.js·前端工程化
初辰ge15 分钟前
后端说“基本增删改查都写好了,就差切图仔对接口了!”——我一怒之下撸了个代码生成器
前端·vue.js
原生高钙16 分钟前
源码级详解,React 如何利用 setState 进行数据管理的:
前端
Y.O.U..30 分钟前
今日八股——C++
开发语言·c++·面试
喝拿铁写前端40 分钟前
智能系统的冰山结构
前端
uhakadotcom1 小时前
Julia语言:高性能数值计算的新星
面试·架构·github