你不知道的 React v18 的任务调度机制

React的任务调度机制是我早就想要探究但直到现在才基本捋清楚的一个议题。因为想要弄清楚React中任务的调度,需要的前置知识还是蛮多的,首先你要对jsx->vNode->fiber树和hooks的实现有源码级的了解,其次要对scheduler这个独立的包有源码级的了解,然后你才有可能探究React的任务调度。所以从我想了解到基本了解,已经有大概两年多的时间了。

期间我读过一些社区关于任务调度的文章,但感觉都比较抽象,缺乏结合实例相关的讲解,导致我虽然理解了一部分,但无法把源码中的逻辑和日常开发中的代码对应上,进而无法深入。

因此我试图写一篇更具体易懂的关于React调度的文章。我的叙事逻辑是先说明一些最重要的概念具体指什么,然后在分析一些日常代码的具体调度逻辑时,点出React调度的关键行为,我觉得这样是更好的说明方式。

文章以最新的React v18@beta版本为背景,不过整体逻辑应该与v17版本的差别并不大。

任务、调度和更新的具体含义

任务

在React语境下谈调度任务时,任务是指指遍历整个fiber树,找到需要更新的fiber并执行更新的过程。

调度

调度的核心逻辑就是利用浏览器提供的queueMicrotask或者message channel api,把遍历fiber树这个任务压入浏览器微任务队列等待执行。

调度由useState生成的setState方法引发。

更新

这里我要讲的更新不是宏观的更新动作,而是一个更新实体,虽然初听有些怪异,但其实想一下就能明白,一次宏观更新动作有一个对象来记录本次更新相关信息是很自然的。

在React内部更新实体是这样的:

var update = {
    // 此时lane为1,代表同步更新的优先级(直接调用setCounter是同步的更新)
    lane: lane,
    // counter状态本次动作要被修改为的值,也就是1
    action: action,
    // 马上会被置为true
    hasEagerState: false,
    // 最新状态,也就是1
    eagerState: null,
    // 下一个任务,可能在下轮渲染前产生了多个更新,这时next就指向同一个状态的后一个update对象
    next: null
};

update对象上其实就是存储了更新的优先级以及要更新为的最新值。

update对象在setState时产生,产生后会挂在到组件fiber对应的hook上。

在最终重新执行组件函数,重新执行useState等hook时会消费update,把更新结果赋值给组件函数内的状态。

优先级

无论是任务、调度还是更新实体本身,都和优先级这个概念密切相关。我们只探究两种有代表性的优先级:最高优的同步优先级和较低优的transition优先级。

其中同步优先级是直接调用setState产生的,而transition任务是通过v18版本的新api,startTransition函数包裹setState产生的:

const [counter, setCounter] = useState(0);
const [isPending, startTransition] = useTransition();

startTransition(() => {
    // 这次更新产生的任务是transition优先级的
    setCounter(counter + 1)
})

React把同步任务的lane设为1,而transition任务的lane为64。

两种优先级任务的区别

同步任务和transition任务的区别在于遍历fiber树的过程是否可中断。

同步任务是会一次性遍历整个fiber树的,无论花费了多长时间。

但要注意同步优先级任务从宏观上并不是同步的。

同步任务这个说法很容易让人误解成整个更新过程是同步的,但实际上同步优先级的任务在整个更新过程中并不是同步的。

很容易验证这一点:

const Demo = () => {
    console.log('rerender');
    const [counter, setCounter] = useState(0);

    function handleClick() {
        setCounter(counter + 1)
        console.log('紧随setCounter后的代码');
    }

    return (
        <>
            <div>{counter}</div>
            <div onClick={handleClick}>点我发起同步优先级的更新任务</div>
        </>
    )
}

点击按钮,你会发现先log “紧随setCounter后的代码”后log “rerender”。这说明从生成update对象到重新执行组件函数并在commit phase更新DOM节点并不是一气呵成的。

实际上,任何优先级的任务都要被调度,而调度的核心逻辑就是加入浏览器的微任务队列,所以任何任务的执行从整体上来看都是异步的。

之所以叫同步优先级,是指任务执行时是同步执行完毕不可中断,而不是从发起到执行完毕全过程是同步的。

而transition优先级的任务的执行是可以中断的,执行任务也就是遍历fiber树的过程中,每次完毕完一个fiber都会检查执行时间是否超过5ms,如果超过了,那么就把剩余任务压入浏览器微任务队列,让出线程响应其他事件。

这种5ms一次的让步会一直持续到fiber树遍历完毕或者任务超时,任务超时则会被不再让步而是一次性遍历完剩余的fiber树并完成渲染DOM等所有具体更新。

两种优先级调度的区别

如果只论调度的核心逻辑,那么区别不大,都是用浏览器api加入浏览器微任务队列,只不过同步优先级的任务是用queueMicrotask这个api加入的,而transition优先级的任务是用message channel这个api加入的。

两种优先级更新的区别

同步优先级update的lane为1,hasEagerState为true,eagerState会是最新值。

transition优先级update的lane为64,hasEagerState为false,eagerState为null。

在整个更新阶段的最后,重新执行组件函数,重新执行useState等hook时会消费update。

这里需要说明下消费update的逻辑,会从fiber对应hook的queue上取到所有的update,实际上一次完整的更新流程中可能一个状态是可能有不同优先级的update的,这些update以环形链表的形式存储在hook的queue对象上。

每次更新流程都有一个renderLanes表示本次更新的赛道,所有update只要是这些赛道的子集就可以把update上的最新值返回给函数组件,也就意味着在本轮渲染更新。这个renderLanes是哪来的呢?通常来说renderLanes就是每次渲染时取所有update中最高优的那个。

比如本轮有同步更新,那renderLanes就是1,所有同步优先级的update都可以把自己上面维护的状态返回给组件函数并最终渲染到DOM上。

而本轮所有transition优先级的update就需要在queue上继续排队等着,无法在本轮更新到DOM上。

lane在更新过程中的作用

上面说了各个环节中优先级的作用,但是整个流程还没串起来,下面以lane为线索讲解下整个流程。

先说在同步更新的过程中,lane到底起了什么作用呢?首先,lane最重要的作用是表示优先级,同步任务的优先级用1表示。所以我们也就生产了的update上lane属性的值就是1。

我们知道一个组件对应一个fiber,更新任务是属于组件的,所以组件fiber上的lanes属性被设置为1,这从语义上是说组件有且仅有一个同步的更新待消费。

而组件函数上面可能还有父组件,一定还有根组件。而子组件的任务一定也是父组件的任务,所以这个lane会被不断往上合并,所有的父级fiber上的childLanes属性都会被设置为1。这意味着父级的fiber上有所有子树上未完成任务的优先级之和。

比如同步发起了一个同步任务,一个transition任务,那么Root Fiber上的childLanes属性就会是65。

在同步任务的消费任务被执行时,会从Root Fiber开始遍历。遍历每个节点时会检查两个属性:

  • 当前fiber的lanes属性,也就是表示当前fiber上的更新任务的优先级

  • 整个子树上最高优任务的优先级

这里其实就能领悟到lane的真正含义,也就是赛道。每个任务根据优先级会划分不同的赛道,比如同步优先级的用lane为1这个赛道,而transition优先级用lane为64的赛道。不同赛道是二进制的不同位。React中一共规划了31位表示31条赛道,位数越小的赛道优先级越高。

通常来说,不同优先级是位于不同的赛道的。但是一个赛道上可以有多个任务,比如同时有两个同步任务时,两个任务都是在lane为1的赛道上,都表示同步优先级。

了解了赛道的设计,我们可以从Root Fiber的childLanes 65就很容易解读出当前子树上有两个赛道,64赛道和1赛道。在遍历到某个fiber时,我们检查当前fiber的任务是不是属于最高优赛道。

从Root Fiber到发生setState的所有fiber都没有更新任务,所以会bail out,简单理解就是什么也不做。

到了发生setState的组件fiber,发现上面的lane和子树最高优lane一致,所以就会消费update。

低优让步于同步执行的高优update

本章我们探究一个问题:同步发起一个同步任务,一个transition任务,React是如何做到保证同步update先于transition update消费并渲染到DOM上的?

当然两个任务谁先发起是会导致流程区别的,两个任务是否更新同一个状态也是有区别的。所以本章会分四个小节来谈论。

先同步任务后transition任务的调度逻辑

我们举例来说明这个过程

const Demo = () => {
    console.log('rerender');
    const [counter, setCounter] = useState(0);
    const [isPending, startTransition] = useTransition();

    function handleClick() {
        startTransition(() => {
            // 这次更新产生的任务是transition优先级的
            setCounter(counter + 1)
        })
    }

    return (
        <>
            <div>{counter}</div>
            <div onClick={handleClick}>点我发起transition优先级的更新任务</div>
        </>
    )
}

这个过程看似仅有一个transition优先级的任务,实则是先发起同步任务后发起transtion任务。

因为startTransition这个方法实际上会先把isPending状态置以同步优先级更新为true,表示进入transition状态。

而后会产生把isPending置为false和更新counter两个transition优先级的任务。

简单理解,startTransition方法内部:

// 极简的startTransition
function startTransition(callback) {
    // 这个被赋予同步优先级
    setPending(true);
    // 这个被赋予transition优先级
    setPending(false);
    // 开发者传入的更新也被赋予transition优先级
    callback();
}

第一个同步优先级的任务生成后会被压入微任务队列,等待执行。压入队列,等待执行就叫作被schedule完毕。

走到setPending(false)这个transition任务,这里React会判断当前最高优的任务和当前被schedule的任务是否是相同的优先级。是的话就不会schedule这个任务,而是直接return掉。

return掉仅仅代表任务没有执行计划,但是update本身还是存储在组件fiber上,我们看看isPending这个状态在setPending(false)后fiber上是如何存储相关的更新信息的。

首先一个状态一定是对应一个hook的。比如counter这个状态对应组件函数中的第一个useState hook,而hook是key为memoizedState的对象,它以链表的形式存储在组件fiber上:

// 组件fiber
{
    // 第一个hook,也就是useState hook相关状态信息
    memoizedState: {
        memoizedState: 0,
        // counter的queue先不关注,重点关注第二个状态也就是isPending.
        queue: unshown,
        // 第二个hook,也就是useTransition hook对应的状态信息
        next: {
            // 当前状态(更新发生前)为false
            memoizedState: false,
            // 这里存储即将发生的更新队列
            queue: {
                // pending.next存储当前要进行的更新
                pending: {
                    "lane": 64,
                    "action": false,
                    "hasEagerState": false,
                    "eagerState": null,
                    // next上的才是即将要发生的更新
                    "next": {
                        "lane": 1,
                        "action": true,
                        "hasEagerState": true,
                        "eagerState": true,
                        // 这里是首尾循环引用
                        "next": ...
                    }
                }
            }
        }
    }
}

其实决定同步更新的关键的是第二个hook上的queue.pending.next,它代表着本次要进行的更新,这里是同步的所以本次更新会把hook.memoizedState状态置为true并赋值给isPending。

同步的更新完毕后会再次schedule遍历任务,遍历到存在与树上最高优任务同等优先级的fiber时会停止遍历并再次来到更新,这次更新就是消费两个transition update了。

第一个transition update也就是counter置为1的更新,它的关键是baseQueue.next也就是:

// 本次消费的update
{
    action: 1
    eagerState: null
    hasEagerState: false
    lane: 64
}

这里会根据action和reducer计算出newState,当然这里调用时没有用reducer,所以newState就是action,所以hook.memoizedState会被修改为1并返回。

第二个transition update被消费的逻辑是一样的。

调度指的就是把遍历整个fiber树加入到微任务队列。有两个关键点:

  • 同步任务的调度动作是用queueMicrotask这个api加入的,并且遍历树的动作不可中断。而transition任务的调度是用message channel这个api加入的,并且遍历树的过程可以中断。

  • 同步任务schedule而未消费前的状态叫作pending,存在pending时,不会再schedule新的任务。

所以先发起同步任务后发起transition任务时的调度逻辑一句话就是:同步任务会阻塞其他任何任务的调度,直到同步更新被消费完毕后才会调度低优任务。

先transition任务后同步任务的调度逻辑

第一节只是预热,这一节我们真正探索下高优打断的机制,怎么保证高优任务先于低优任务渲染。

这个例子就是高优打断低优的实例:

const Demo = () => {
    console.log('rerender');
    const [counter, setCounter] = useState(0);
    const [counter1, setCounter1] = useState(0);
    const [isPending, startTransition] = useTransition();

    function handleClick() {
        startTransition(() => {
            setCounter(counter + 1)
        })
        // 后发起的高优更新,React是如何实现抢占的呢?
        setCounter1(counter1 + 2);
    }

    return (
        <>
            <div>{counter}</div>
            <div onClick={handleClick}>点我发起低优抢占高优的更新</div>
        </>
    )
}

上一节我们已经讲过了一个transition任务实际上是由一个同步任务加两个transition任务组成的。

同步任务会先被调度并pending掉后续的所有调度,注意是所有,不止前面说过的两个transition任务,甚至后面setCounter1这个同步任务的调度也会被阻塞掉。

也就是说即使先transition任务后同步任务,也是会被transition任务内部的同步任务阻塞掉调度。

但是阻塞掉调度和阻塞不代表阻塞掉更新,同步任务被消费的那轮渲染,React会判断当前hook上update的lane,只要是和本次渲染的赛道内的update,都会消费掉,所以isPending会更新为true,而counter1会更新为2。

注意,这里我理解React每次渲染都对应一个renderLanes表示本次更新的赛道,只要next update的lane是renderLanes的子集,那么这个update就会被消费。

所以其实这里高优抢占低优并没有什么特别的逻辑。实际上,后发起的高优任务counter1最终会先于counter更新,根本不是自己抢占的,而是搭了transition任务内部把isPending置为true这个同步任务的顺风车。

同一个状态,先发起transition任务后发起同步任务

上节讲了先transition任务后同步任务的抢占逻辑,不过是搭transition任务内部的同步任务的顺风车。

那么如果说同步任务和transition任务更新同一个状态,transition更新会覆盖同步更新吗?

const Demo = () => {
    console.log('rerender');
    const [counter, setCounter] = useState(0);
    const [isPending, startTransition] = useTransition();

    function handleClick() {
        startTransition(() => {
            setCounter(counter + 1)
        })
        // 后发起的高优更新,React会让transition更新覆盖先完成的同步更新吗?
        setCounter(counter1 + 2);
    }

    return (
        <>
            <div>{counter}</div>
            <div onClick={handleClick}>点我发起</div>
        </>
    )
}

我们尝试了下,实际上,transition update虽然是后消费的,但并不会覆盖高优任务,点击后counter的结果是2。

那么内部的逻辑是怎样的呢?

第一轮更新是,counter状态对应的hook上,queue.pending.next是lane为64,action为1的更新,queue.pending.next.next才是lane为1,action为2的更新。

首先React会遍历整个pendingQueue,找到在renderLanes赛道中的,同步的更新,也就是把counter置为2。

而在第二轮rerender时,我们发现已经渲染到DOM上的,把counter置为2的update并没有清除,仅仅是lane变为了0,而0是任何lanes的子集,所以本次遍历queue时还是能通过isSubsetOfRenderLanes的判定,于是会采用了这个update的action作为newState,所以第二轮更新counter还是2。

但为什么已经完成更新的update仍会在queue上不清除,仅仅是lane从1变为0呢?

可以在updateReducer方法中看到这段代码:

// This update does have sufficient priority.
        if (newBaseQueueLast !== null) {
          var _clone = {
            // This update is going to be committed so we never want uncommit
            // it. Using NoLane works because 0 is a subset of all bitmasks, so
            // this will never be skipped by the check above.
            lane: NoLane,
            action: update.action,
            hasEagerState: update.hasEagerState,
            eagerState: update.eagerState,
            next: null
          };
          newBaseQueueLast = newBaseQueueLast.next = _clone;
        } // Process this update.

这个clone就是原来的同步update lane变为0的复制,而newBaseQueueLast则是本轮没有被消费的transition update,这里会把复制体放到其next上。

然而回顾下第一个例子中,isPending置为true之后,hook的queue上就没有hasEagerState的update了,这里没有看到queue上有clone,所以再回头单步调试下第一个例子。

首先没有走到这个clone,因为newBaseQueueLast为null,在第一行就进入不了这个分支。

而newBaseQueueLast这个变量是在遍历queue的时候初始化的,它会在每次遍历到需要跳过的update时更新自身为这个update的copy。

这里就是关键了,第一个例子中,queue的第一项是同步update,第二项才是transition update。这是因为pending置为true这个同步任务是先来的,置为false这个transition任务是后来的。

这就导致了第一个update就不能跳过,所以newBaseQueueLast仍为初始值null。所以就进不到我们本节开始贴的那段逻辑,同步update消费后就不在queue链表上了。

而我们最新的例子中,是transition update先来的,同步update是第二项。所以第一项跳过后,newBaseQueueLast会被置为transition update,所以轮到第二项同步任务时就会生成一个lane为0的copy,继续存在于链表上。

这也就导致了在第二次渲染时,还是采用的同步update的状态。

同一个状态,先发起同步任务后发起transition任务

const Demo = () => {
    console.log('rerender');
    const [counter, setCounter] = useState(0);
    const [isPending, startTransition] = useTransition();

    function handleClick() {
        setCounter(counter1 + 2);
        startTransition(() => {
            setCounter(counter + 1)
        })        
    }

    return (
        <>
            <div>{counter}</div>
            <div onClick={handleClick}>点我发起</div>
        </>
    )
}

这样做的话counter会是几呢?

实际上如果理解了上一节,这个例子想一下就知道了,最终counter会是1。

因为hook.queue上第一项是同步update,第二项是transition update。

第一次renderLanes为1的渲染,由于newBaseQueueLast为null,同步update消费完毕后不会存在于queue上。

第二次renderLanes为64的渲染,会消费掉transition update。

所以counter会先变为2,后变为1。

当然从页面上观察,这个过程很快,你是看不到的,偶尔能看到闪烁,但是单步调试的话,你能看到的确是这样的。

通过以上两节,我们看出,如果不同优先级的更新同步更新同一个状态,最终的状态是是由最后面的setState决定的。

低优任务让步于异步高优任务

在上一章所说的高优先于低优执行,本质上有两点:

  • 高优pending低优,也就是说因为被pending导致低优任务不能schedule

  • 存在高优任务时,当次渲染的renderLanes就是最高优的,所以不能消费低优update 但是这都是给同步高优让步的逻辑,那么在低优任务已经schedule后,执行中让步的时机发起高优任务,低优任务是否会让步呢?如果是,是按照什么逻辑让步的呢?

首先再说一遍低优任务执行中的让步机制。低优任务在执行中是可以打断的,任务的执行就是遍历fiber,而低优任务在遍历fiber时会检查执行时间,只要时间超过了5ms就会让出线程。这就是低优任务的执行中让步。

那么低优任务中断后,如有同步任务会怎么样?低优任务会像同步任务阻塞它那样阻塞回去吗?

构建这种例子我们是不能像上一章那样先写个低优后写个同步的,因为那样两个任务还是同步发起的,后面的同步任务是会搭transition任务内部自己的同步渲染的顺风车实现提前更新的,而我们这里要探究的是等低优任务已经在执行中,再发起高优任务,看看是否能抢占。

为了尽量快速打断,我的设计是低优任务用键入Input框来触发,而同步任务用点击按钮触发,只需要左手键入后立即右手点击按钮,这样就是低优执行后发起的高优任务了。

从实际结果来看,即使执行中的低优任务也会被高优任务抢占,具体表现为input框内容是后于counter的更新的。

然而我们不能仅凭表象下结论,还需要结合源码印证一下。之前之所以低优任务不会被schedule是在ensureRootIsScheduled这个方法中判断当前待完成任务的lane和本次新任务的最高lane,如果相等就会被return掉。上一章的所有例子中无论谁先谁后,当前待flush任务的优先级都是1,而RootFiber上lane是65,最快赛道也是1,所以后面的所有调度请求都会被return掉。

而这次我们当前待完成任务的优先级来看,pending同步赛道已经被消费掉,当前待完成任务的赛道变为了64。而新任务是1,所以当然不会被return而是会被schedule。

然后就通过scheduleMicrotask方法把flush任务加入了微任务队列。加入微任务队列后,等到transition任务执行再次让步时就轮到同步任务的flush了。

而同步任务的flush是同步的,一旦获得线程就会执行完毕并渲染到DOM上。

这就是低优任务被异步高优任务的抢占。

低优任务的中断恢复机制

既然低优任务是可以中断的,那么是如何恢复的呢?

我们知道任务是遍历fiber,中断一定是遍历到某个fiber后检查执行时间发现超时后中断。

而听到恢复这个说法,很自然想到应该是从中断的fiber处恢复。那么实际来看是否是这样呢?我们需要验证一下。

我们还是以上一章的例子的基础上,在setCounter时打点,在低优任务被打断后打点,在低优任务恢复执行后打点。

然而实际上看到的确是每次打断恢复都是从根部从头开始遍历的。而不是脑补的从打断处继续。

饥饿问题

什么是饥饿问题?其实我们已经看到,先发起的低优任务会对高优任务做出很多让步:

  • 同步的高优update会和transition任务内部的pending update一起被消费并更新到DOM。

  • 异步的高优任务会在低优任务执行中让出线程的机会中抢过线程,完成更新。

这还仅仅是让步高优更新,其实不只是更新状态,任何任务都有可能通过响应事件的方式在低优任务让出线程的窗口期抢过线程。

而如果这个任务是耗时的,低优更新可能会延迟了很长时间才最终渲染到页面上并执行相应的effect,如果这个时间超出可以接受的阈值,那么这种情况就被称为饿死。

为了避免饿死,React做了哪些操作呢?

实际上React执行任务有两种模式,一种是renderRootConcurrent方法对应的可中断执行模式,一种是renderRootSync对应的不可中断模式。

React通过让低优任务切换到不可中断的执行模式来避免饿死。那么是如何切换的呢?实际上React有三个开关可以让任务的执行切换到不可中断模式,这里我讲解其中两种。

用expiredLanes避免首次执行即超时

lane的过期时间就是创建update后设定的。

更精确来说,第一次transition任务在schedule时会因为已经schedule了同步的isPending任务而被直接取消掉,但就在取消前,lane为64的所有update的过期时间已经设置好了。

也就是说,React会在第一次企图schedule的时候就给这个要被schedule的lane设置好过期时间。transition优先级的过期时间是currentTime+5000。

而第一次轮到transition任务执行的时候,会在performConcurrentWorkOnRoot中检查当前要flush的lane是不是属于expireLanes,是的话则直接进入不可中断模式。

这段逻辑很好证实,只需要在发起任务同时来一段耗时任务就可以了。就像这样:

const Demo = () => {
    console.log('rerender');
    const [counter, setCounter] = useState(0);
    const [isPending, startTransition] = useTransition();

    function handleClick() {
        startTransition(() => {
            setCounter(counter + 1)
        });
        let result = 0,
            len = 10000000000;
        while (len--) {
            result += len;
        }
    }

    return (
        <>
            <div>{counter}</div>
            <div onClick={handleClick}>构造一个过期的transition lane</div>
        </>
    )
}

点击后的流程:

  • 同步的执行时机内就设置好了transition lane的过期时间,也就是当前时间+5000。

  • while循环这块的耗时任务。

  • 第一轮渲染,同步的update也就是isPending置为true,被消费。

  • 第二轮渲染,由于第二步的耗时任务,所以transition lane已经过期,root fiber上的expireLanes会|=这个64赛道。

  • performConcurrentWorkOnRoot中,检测到当前要flush的64赛道是expireLanes的子集,所以进入不可中断模式,同步完成更新。

流程梳理完毕,大家思考下这种设计可以避免哪些情况的饿死呢?

实际上就是避免第一次轮到执行就已经超时的情况,这种情况通常也就发生于和另一个同步任务同步发起,但是那个同步任务耗时超过5s。

用expirationTime避免执行中让步导致的超时

这个情况就更熟悉了,具体场景就如<低优任务让步与异步高优任务>这一章节中所讲。

如果让步给的高优任务是耗时的,那么就会导致饿死。

所以React会给task一个expirationTime属性表明task的过期时间。

这里的task是scheduler这个包中的概念。一个task是一个对象:

// scheduler.development.js
var newTask = {
    id: taskIdCounter++,
    callback: callback,
    priorityLevel: priorityLevel,
    startTime: startTime,
    expirationTime: expirationTime,
    sortIndex: -1
};

对象上最重要是callback,在这里callback就是performConcurrentWorkOnRoot方法。这个方法之前说了就是切换两种遍历模式的。

在首次轮到transition任务开始执行后,task就会设置好过期时间。并且让步后重新执行scheduler这个包中workLoop方法时,都会检查任务是否已经过期了。

如果已经过期,调用performConcurrentWorkOnRoot时传入的参数就会改变,performConcurrentWorkOnRoot方法也就知道任务过期,就会切换到不可中断模式。

好了,到这里我提个问题,你明白为什么有两处需要防止超时的设计吗?它们不会重复吗?

答案就在两小节的标题中,expiredLanes解决的是执行前已经超时的情况,而expirationTime解决的执行中让步导致超时情况。两者是缺一不可的。

尾言

关于贴源码和调用栈

本文的讲解逻辑就像开篇说的那样,是以例子以及重要概念为核心的,并没有贴出完整的源码流程。从我个人来看,那种逐个讲解源码方法的文章非常难懂,不利于理解问题的本质。

然而如果读者大致理解之后,想自己打debugger,完整走完某个例子的流程来进行验证文章内容的话,逐个贴源码方法这种形式则又有用了,所以我在另一篇文章会贴出标准流程的完整调用栈和简单讲解。(先挖个坑)

原文: https://zhuanlan.zhihu.com/p/454432618

内容总结

本文讲了:

  • React的任务、更新和调度的概念。

  • 两种最常见的优先级更新整个流程的内部逻辑

  • 高优优先、高优抢占、什么情况会出现低优饿死以及如何避免低优饿死

没讲:

  • React最基本的东西如:fiber树、hook实现等,这些东西没法在讲任务的时候讲,因为要讲的话内容比较多,只能默认读者有了解,后面有机会会梳理成文章。

  • scheduler这个包实现的让步机制(其实就是中断while循环后把剩余压入微任务队列)

感谢阅读这篇万字长文,写完才发现文章竟然写的这么长,但是这些内容确实是一个个实实在在的具体问题,在完成文章前我自己也想知道,并且单步debug得出的。希望对大家也有帮助。文章如有错漏之处,或者有要补充的内容欢迎在评论区留言。

最后, 送人玫瑰,手留余香,觉得有收获的朋友可以点赞,关注一波 ,我们组建了高级前端交流群,如果您热爱技术,想一起讨论技术,交流进步,不管是面试题,工作中的问题,难点热点都可以在交流群交流,为了拿到大Offer,邀请您进群,入群就送前端精选100本电子书以及 阿里面试前端精选资料 添加 下方小助手二维码或者扫描二维码 就可以进群。让我们一起学习进步.

310e0b4e6b066768117d3806d59b20e7.png 29a8849b4e0ad6d3b8ec927242ae7edb.png

f1fdafc3345b27a3e5169b2134f645d6.png


推荐阅读

(点击标题可跳转阅读)

[极客前沿]-你不知道的 React 18 新特性

[极客前沿]-写给前端的 K8s 上手指南

[极客前沿]-写给前端的Docker上手指南

[面试必问]-你不知道的 React Hooks 那些糟心事

[面试必问]-一文彻底搞懂 React 调度机制原理

[面试必问]-一文彻底搞懂 React 合成事件原理

[面试必问]-全网最简单的React Hooks源码解析

[面试必问]-一文掌握 Webpack 编译流程

[面试必问]-一文深度剖析 Axios 源码

[面试必问]-一文掌握JavaScript函数式编程重点

[面试必问]-阿里,网易,滴滴,头条等20家面试真题

[面试必问]-全网最全 React16.0-16.8 特性总结

[架构分享]- 微前端qiankun+docker+nginx自动化部署

[架构分享]-石墨文档 Websocket 百万长连接技术实践

[自我提升]-Javascript条件逻辑设计重构

[自我提升]-送给React开发者十九条性能优化建议

[自我提升]-页面可视化工具的前世今生

[大前端之路]-连前端都看得懂的《Nginx 入门指南》

[软实力提升]-金三银四,如何写一份面试官心中的简历

觉得本文对你有帮助?请分享给更多人

关注「React中文社区」加星标,每天进步

f2562d6e4facc545b168d85bec3b236f.png   

点个赞👍🏻,顺便点个 在看 支持下我吧

React 核心理解 react16之前更新 React Fiber是16版本之后的一种更新机制,使用链表取代了树,是一种fiber数据结构,其有三个指针,分别指向了父节点、子节点、兄弟节点,当中断的时候会记录下当前的节点,然后继续更新,而15版本中的DOM stack能有中断操作,它把组件渲染的工作分片,到时会主动让出渲染主线程;提炼fiber的关键词,大概给出如下几点: fiber是一种数据结构。 fiber使用父子关系以及next的妙用,以链表形式模拟了传统调用栈。 fiber是一种调度让出机制,只在有剩余时间的情况下运行。 fiber实现了增量渲染,在浏览器允许的情况下一点点拼凑出最终渲染效果。 fiber实现了并发,为任务赋予同优先级,保证了一有时间总是做最高优先级的事,而是先来先占位死板的去执行。 fiber有协调与提交两个阶段,协调包含了fiber创建与diff更新,此过程可暂停。而提交必须同步执行,保证渲染卡顿。 react17更新 1、新的JSX转换,需要手动引入react React 16: babel-loader会预编译JSX为 React.createElement(...) React 17: React 17中的 JSX 转换会将 JSX 转换为 React.createElement,而是自动从 React 的 package 中引入react并调用。 另外此次升级会改变 JSX 语法,旧的 JSX 转换也将继续工作。 2、事件代理更改 在React 17中,将再在后台的文档级别附加事件处理程序,在document对象上绑定事件,改为绑定于每个react应用的rootNode节点,因为各个应用的rootNode肯定同,所以这样可以使多个版本的react应用同时安全的存在于页面中,会因为事件绑定系统起冲突。react应用之间也可以安全的进行嵌套。 3、事件池(event pooling)的改变 React 17去除了事件池(event pooling),在需要e.persist(),现在可以直接在异步事件中(回掉或timeout等)拿到事件对象,操作更加直观,会令人迷惑。e.persist()仍然可用,但是会有任何效果。 4、异步执行 React 17将副作用清理函数(useEffect)改为异步执行,即在浏览器渲染完毕后执行。 5、forwardRef 和 memo组件的行为 React 17中forwardRef 和 memo组件的行为会与常规函数组件和class组件保持一致。它们在返回undefined时会报错。 react18更新 并发模式 v18的新特性是使用现代浏览器的特性构建的,彻底放弃对 IE 的支持。 v17 和 v18 的区别就是:从同步可中断更新变成了异步可中断更新,v17可以通过一些试验性的API开启并发模式,而v18则全面开启并发模式。 并发模式可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整,该模式通过使渲染可中断来修复阻塞渲染限制。在 Concurrent 模式中,React 可以同时更新多个状态。 这里参考下文区分几个概念: 并发模式是实现并发更新的基本前提 v18 中,以是否使用并发特性作为是否开启并发更新的依据。 并发特性指开启并发模式后才能使用的特性,比如:useDeferredValue/useTransition 更新 render API v18 使用 ReactDOM.createRoot() 创建一个新的根元素进行渲染,使用该 API,会自动启用并发模式。如果你升级到v18,但没有使用ReactDOM.createRoot()代替ReactDOM.render()时,控制台会打印错误日志要提醒你使用React,该警告也意味此项变更没有造成breaking change,而可以并存,当然尽量是建议。 自动批处理 批处理是指 React 将多个状态更新,聚合到一次 render 中执行,以提升性能 在v17的批处理只会在事件处理函数中实现,而在Promise链、异步代码、原生事件处理函数中失效。而v18则所有的更新都会自动进行批处理。 Suspense 支持 SSR SSR 一次页面渲染的流程: 服务器获取页面所需数据 将组件渲染成 HTML 形式作为响应返回 客户端加载资源 (hydrate)执行 JS,并生成页面最终内容 上述流程是串行执行的,v18前的 SSR 有一个问题就是它允许组件"等待数据",必须收集好所有的数据,才能开始向客户端发送HTML。如果其中有一步比较慢,都会影响整体的渲染速度。 v18 中使用并发渲染特性扩展了Suspense的功能,使其支持流式 SSR,将 React 组件分解成更小的块,允许服务端一点一点返回页面,尽早发送 HTML和选择性的 hydrate, 从而可以使SSR更快的加载页面: startTransition Transitions 是 React 18 引入的一个全新的并发特性。它允许你将标记更新作为一个 transitions(过渡),这会告诉 React 它们可以被中断执行,并避免回到已经可见内容的 Suspense 降级方案。本质上是用于一些是很急迫的更新上,用来进行并发控制 在v18之前,所有的更新任务都被视为急迫的任务,而Concurrent Mode 模式能将渲染中断,可以让高优先级的任务先更新渲染。 React 的状态更新可以分为两类: 紧急更新:比如点击按钮、搜索框打字是需要立即响应的行为,如果没有立即响应给用户的体验就是感觉卡顿延迟 过渡/非紧急更新:将 UI 从一个视图过渡到另一个视图。一些延迟可以接受的更新操作,需要立即响应 startTransition API 允许将更新标记为非紧急事件处理,被startTransition包裹的会延迟更新的state,期间可能被其他紧急渲染所抢占。因为 React 会在高优先级更新渲染完成之后,才会渲染低优先级任务的更新 React 无法自动识别哪些更新是优先级更高的。比如用户的键盘输入操作后,setInputValue会立即更新用户的输入到界面上,是紧急更新。而setSearchQuery是根据用户输入,查询相应的内容,是非紧急的。 React无法自动识别,所以它提供了 startTransition让我们手动指定哪些更新是紧急的,哪些是非紧急的,从而让我们改善用户交互体验。 useTransition 当有过渡任务(非紧急更新)时,我们可能需要告诉用户什么时候当前处于 pending(过渡) 状态,因此v18提供了一个带有isPending标志的 Hook useTransition来跟踪 transition 状态,用于过渡期。 useTransition 执行返回一个数组。数组有两个状态值: isPending: 指处于过渡状态,正在加载中 startTransition: 通过回调函数将状态更新包装起来告诉 React这是一个过渡任务,是一个低优先级的更新 直观感觉这有点像 setTimeout,而防抖节流其实本质也是setTimeout,区别是防抖节流是控制了执行频率,让渲染次数减少了,而 v18的 transition 则没有减少渲染的次数。 useDeferredValue useDeferredValue 和 useTransition 一样,都是标记了一次非紧急更新。useTransition是处理一段逻辑,而useDeferredValue是产生一个新状态,它是延时状态,这个新的状态则叫 DeferredValue。所以使用useDeferredValue可以推迟状态的渲染 useDeferredValue 接受一个值,并返回该值的新副本,该副本将推迟到紧急更新之后。如果当前渲染是一个紧急更新的结果,比如用户输入,React 将返回之前的值,然后在紧急渲染完成后渲染新的值。 JavaScript 运行代码 复制代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function Typeahead() { const query = useSearchQuery(''); const deferredQuery = useDeferredValue(query); // Memoizing 告诉 React 仅当 deferredQuery 改变, // 而是 query 改变的时候才重新渲染 const suggestions = useMemo(() => <SearchSuggestions query={deferredQuery} />, [deferredQuery] ); return ( <> <SearchInput query={query} /> <Suspense fallback="Loading results..."> {suggestions} </Suspense> </> ); } 这样一看,useDeferredValue直观就是延迟显示状态,那用防抖节流有什么区别呢? 如果使用防抖节流,比如延迟300ms显示则意味着所有用户都要延时,在渲染内容较少、用户CPU性能较好的情况下也是会延迟300ms,而且你要根据实际情况来调整延迟的合适值;但是useDeferredValue是否延迟取决于计算机的性能。 useId useId支持同一个组件在客户端和服务端生成相同的唯一的 ID,避免 hydration 的匹配,原理就是每个 id 代表该组件在组件树中的层级结构: JavaScript 运行代码 复制代码 1 2 3 4 5 6 7 8 9 function Checkbox() { const id = useId() return ( <> <label htmlFor={id}>Do you like React?</label> <input id={id} type="checkbox" name="react" /> </> ) } React.memo 和性能优化。当某个组件状态更新时,它的所有子组件树将会重新渲染。 React.memo 和记忆化数据 React.memo 和 React.useMemo 优化性能 React.memo 和 React.useCallback 优化性能 React useEffect cleanup。在这段代码中,示例演示 cleanup 的时机 React 中可以以数组的 index 作为 key 吗?。在这段代码中,使用 index 作为 key,其中夹杂了 input,引发 bug React 中以数组的 index 作为 key。在这段代码中,使用 index 作为 key,其中夹杂了随机数,引发了 bug React 兄弟组件通信。兄弟组件在 React 中如何通信 React 中合成事件。React 中事件为合成事件,你可以通过 e.nativeEvent 获取到原生事件,观察 e.nativeEvent.currentTarget 你将会发现 React 将所有事件都绑定在了 #app(React 应用挂载的根组件) React 中 input.onChange 的原生事件是什么?。观察 e.nativeEvent.type 可知 React hooks 如何实现一个计数器 Counter React FiberNode 数据结构。贯彻 element._owner 可知 FiberNode 数据结构 React 点击按钮时自增三次。此时需使用回调函数,否则会报错 React 可变数据的必要性。 React 可变数据的必要性之函数组件。当在 React hooks 中 setState 两次为相同数据时,会重新渲染 React 状态批量更新之事件处理。事件处理中的状态会批量更新,减少渲染次数 React 状态批量更新之异步请求。异步请求中的状态会批量更新,将会造成多次渲染 React18 状态批量更新。在 React 18 中所有状态将会批量更新 React capture value React 核心理解 react16之前更新 React Fiber是16版本之后的一种更新机制,使用链表取代了树,是一种fiber数据结构,其有三个指针,分别指向了父节点、子节点、兄弟节点,当中断的时候会记录下当前的节点,然后继续更新,而15版本中的DOM stack能有中断操作,它把组件渲染的工作分片,到时会主动让出渲染主线程;提炼fiber的关键词,大概给出如下几点: fiber是一种数据结构。 fiber使用父子关系以及next的妙用,以链表形式模拟了传统调用栈。 fiber是一种调度让出机制,只在有剩余时间的情况下运行。 fiber实现了增量渲染,在浏览器允许的情况下一点点拼凑出最终渲染效果。 fiber实现了并发,为任务赋予同优先级,保证了一有时间总是做最高优先级的事,而是先来先占位死板的去执行。 fiber有协调与提交两个阶段,协调包含了fiber创建与diff更新,此过程可暂停。而提交必须同步执行,保证渲染卡顿。 react17更新 1、新的JSX转换,需要手动引入react React 16: babel-loader会预编译JSX为 React.createElement(...) React 17: React 17中的 JSX 转换会将 JSX 转换为 React.createElement,而是自动从 React 的 package 中引入react并调用。 另外此次升级会改变 JSX 语法,旧的 JSX 转换也将继续工作。 2、事件代理更改 在React 17中,将再在后台的文档级别附加事件处理程序,在document对象上绑定事件,改为绑定于每个react应用的rootNode节点,因为各个应用的rootNode肯定同,所以这样可以使多个版本的react应用同时安全的存在于页面中,会因为事件绑定系统起冲突。react应用之间也可以安全的进行嵌套。 3、事件池(event pooling)的改变 React 17去除了事件池(event pooling),在需要e.persist(),现在可以直接在异步事件中(回掉或timeout等)拿到事件对象,操作更加直观,会令人迷惑。e.persist()仍然可用,但是会有任何效果。 4、异步执行 React 17将副作用清理函数(useEffect)改为异步执行,即在浏览器渲染完毕后执行。 5、forwardRef 和 memo组件的行为 React 17中forwardRef 和 memo组件的行为会与常规函数组件和class组件保持一致。它们在返回undefined时会报错。 react18更新 并发模式 v18的新特性是使用现代浏览器的特性构建的,彻底放弃对 IE 的支持。 v17 和 v18 的区别就是:从同步可中断更新变成了异步可中断更新,v17可以通过一些试验性的API开启并发模式,而v18则全面开启并发模式。 并发模式可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整,该模式通过使渲染可中断来修复阻塞渲染限制。在 Concurrent 模式中,React 可以同时更新多个状态。 这里参考下文区分几个概念: 并发模式是实现并发更新的基本前提 v18 中,以是否使用并发特性作为是否开启并发更新的依据。 并发特性指开启并发模式后才能使用的特性,比如:useDeferredValue/useTransition 更新 render API v18 使用 ReactDOM.createRoot() 创建一个新的根元素进行渲染,使用该 API,会自动启用并发模式。如果你升级到v18,但没有使用ReactDOM.createRoot()代替ReactDOM.render()时,控制台会打印错误日志要提醒你使用React,该警告也意味此项变更没有造成breaking change,而可以并存,当然尽量是建议。 自动批处理 批处理是指 React 将多个状态更新,聚合到一次 render 中执行,以提升性能 在v17的批处理只会在事件处理函数中实现,而在Promise链、异步代码、原生事件处理函数中失效。而v18则所有的更新都会自动进行批处理。 Suspense 支持 SSR SSR 一次页面渲染的流程: 服务器获取页面所需数据 将组件渲染成 HTML 形式作为响应返回 客户端加载资源 (hydrate)执行 JS,并生成页面最终内容 上述流程是串行执行的,v18前的 SSR 有一个问题就是它允许组件"等待数据",必须收集好所有的数据,才能开始向客户端发送HTML。如果其中有一步比较慢,都会影响整体的渲染速度。 v18 中使用并发渲染特性扩展了Suspense的功能,使其支持流式 SSR,将 React 组件分解成更小的块,允许服务端一点一点返回页面,尽早发送 HTML和选择性的 hydrate, 从而可以使SSR更快的加载页面: startTransition Transitions 是 React 18 引入的一个全新的并发特性。它允许你将标记更新作为一个 transitions(过渡),这会告诉 React 它们可以被中断执行,并避免回到已经可见内容的 Suspense 降级方案。本质上是用于一些是很急迫的更新上,用来进行并发控制 在v18之前,所有的更新任务都被视为急迫的任务,而Concurrent Mode 模式能将渲染中断,可以让高优先级的任务先更新渲染。 React 的状态更新可以分为两类: 紧急更新:比如点击按钮、搜索框打字是需要立即响应的行为,如果没有立即响应给用户的体验就是感觉卡顿延迟 过渡/非紧急更新:将 UI 从一个视图过渡到另一个视图。一些延迟可以接受的更新操作,需要立即响应 startTransition API 允许将更新标记为非紧急事件处理,被startTransition包裹的会延迟更新的state,期间可能被其他紧急渲染所抢占。因为 React 会在高优先级更新渲染完成之后,才会渲染低优先级任务的更新 React 无法自动识别哪些更新是优先级更高的。比如用户的键盘输入操作后,setInputValue会立即更新用户的输入到界面上,是紧急更新。而setSearchQuery是根据用户输入,查询相应的内容,是非紧急的。 React无法自动识别,所以它提供了 startTransition让我们手动指定哪些更新是紧急的,哪些是非紧急的,从而让我们改善用户交互体验。 useTransition 当有过渡任务(非紧急更新)时,我们可能需要告诉用户什么时候当前处于 pending(过渡) 状态,因此v18提供了一个带有isPending标志的 Hook useTransition来跟踪 transition 状态,用于过渡期。 useTransition 执行返回一个数组。数组有两个状态值: isPending: 指处于过渡状态,正在加载中 startTransition: 通过回调函数将状态更新包装起来告诉 React这是一个过渡任务,是一个低优先级的更新 直观感觉这有点像 setTimeout,而防抖节流其实本质也是setTimeout,区别是防抖节流是控制了执行频率,让渲染次数减少了,而 v18的 transition 则没有减少渲染的次数。 useDeferredValue useDeferredValue 和 useTransition 一样,都是标记了一次非紧急更新。useTransition是处理一段逻辑,而useDeferredValue是产生一个新状态,它是延时状态,这个新的状态则叫 DeferredValue。所以使用useDeferredValue可以推迟状态的渲染 useDeferredValue 接受一个值,并返回该值的新副本,该副本将推迟到紧急更新之后。如果当前渲染是一个紧急更新的结果,比如用户输入,React 将返回之前的值,然后在紧急渲染完成后渲染新的值。 JavaScript 运行代码 复制代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function Typeahead() { const query = useSearchQuery(''); const deferredQuery = useDeferredValue(query); // Memoizing 告诉 React 仅当 deferredQuery 改变, // 而是 query 改变的时候才重新渲染 const suggestions = useMemo(() => <SearchSuggestions query={deferredQuery} />, [deferredQuery] ); return ( <> <SearchInput query={query} /> <Suspense fallback="Loading results..."> {suggestions} </Suspense> </> ); } 这样一看,useDeferredValue直观就是延迟显示状态,那用防抖节流有什么区别呢? 如果使用防抖节流,比如延迟300ms显示则意味着所有用户都要延时,在渲染内容较少、用户CPU性能较好的情况下也是会延迟300ms,而且你要根据实际情况来调整延迟的合适值;但是useDeferredValue是否延迟取决于计算机的性能。 useId useId支持同一个组件在客户端和服务端生成相同的唯一的 ID,避免 hydration 的匹配,原理就是每个 id 代表该组件在组件树中的层级结构: JavaScript 运行代码 复制代码 1 2 3 4 5 6 7 8 9 function Checkbox() { const id = useId() return ( <> <label htmlFor={id}>Do you like React?</label> <input id={id} type="checkbox" name="react" /> </> ) } React.memo 和性能优化。当某个组件状态更新时,它的所有子组件树将会重新渲染。 React.memo 和记忆化数据 React.memo 和 React.useMemo 优化性能 React.memo 和 React.useCallback 优化性能 React useEffect cleanup。在这段代码中,示例演示 cleanup 的时机 React 中可以以数组的 index 作为 key 吗?。在这段代码中,使用 index 作为 key,其中夹杂了 input,引发 bug React 中以数组的 index 作为 key。在这段代码中,使用 index 作为 key,其中夹杂了随机数,引发了 bug React 兄弟组件通信。兄弟组件在 React 中如何通信 React 中合成事件。React 中事件为合成事件,你可以通过 e.nativeEvent 获取到原生事件,观察 e.nativeEvent.currentTarget 你将会发现 React 将所有事件都绑定在了 #app(React 应用挂载的根组件) React 中 input.onChange 的原生事件是什么?。观察 e.nativeEvent.type 可知 React hooks 如何实现一个计数器 Counter React FiberNode 数据结构。贯彻 element._owner 可知 FiberNode 数据结构 React 点击按钮时自增三次。此时需使用回调函数,否则会报错 React 可变数据的必要性。 React 可变数据的必要性之函数组件。当在 React hooks 中 setState 两次为相同数据时,会重新渲染 React 状态批量更新之事件处理。事件处理中的状态会批量更新,减少渲染次数 React 状态批量更新之异步请求。异步请求中的状态会批量更新,将会造成多次渲染 React18 状态批量更新。在 React 18 中所有状态将会批量更新 React capture value 整理成markerdown代码风格文章写出来 版面可以改但能改原义 整理成markerdown代码风格写出来 我copy
08-23
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值