什么?react的更新机制居然是“需求排期”?

1,648 阅读13分钟

前言

React版本:16.8以上

首先有请大家看看下面的案例:

import React, { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = React.useState(0);
  console.log('render', count);
  return (
    <div className="container">
      <button onClick={() => setCount(6)}>click me</button>
    </div>
  );
}

提问:第一次点击打印什么?

答:render 6

提问:第二次点击打印什么?

答:render 6

提问:第三次点击打印什么?

答:不打印。

第二次点击明明改变前后的state是一样的。为什么依然引发了组件render?接下来就让我们一起探索其中~

调试地址 https://stackblitz.com/edit/react-ts-culwyv?file=App.tsx

eagerState和bailout

我们还是用一个栗子来看:

import React, { useEffect, useState } from "react";

export default function App() {
  const [a, setA] = useState(1);
  return (
    <div className="App">
      <h1
        onClick={() => {
          setA(a + 1);
        }}
      >
        Hello CodeSandbox
      </h1>
    </div>
  );
}

我们在进行一次交互行动点的时候,通常会经历以下三个步骤:

  1. 点击按钮,状态更新
  2. 组件进行render
  3. 视图渲染

大家可以设想一下,如果你作为框架开发者,能在这几步中有什么优化空间吗?

答案是步骤一和步骤二。

步骤一】:如果改变前后状态一样,剩下两步便没有意义,可以取消。react把这个优化策略叫做eagerState(期望的state)。

【步骤二】: 如果节点没有依赖的状态改变,就可以跳过节点的render。react把这个优化策略叫做bailout(跳过更新操作)

比较前后状态似乎听起来很简单,但其实是非常复杂的,下面我抛出两个个问题大家思考一下:

【问题一】改变后的状态,也就是eagerState,是怎么能够精准地计算出来的?

【问题二】什么情况下节点能够进行bailout呢?

接下来让我们一起探索吧~

eagerState是什么?

eagerState的背景

首先我们要明白,为什么叫eagerState

eager翻译为期望的、渴望的。 eagerState为期望的state

我们通常在什么时候能够拿到最新state

答案是组件render的时候。组件在render前会处理掉当前fiber的更新,以至于让组件render时候能拿到最新状态。例如:在下面的点击事件中执行了三次state的更新。

const [count, setCount] = useState(0)
const onClick = () => {
  setCount(1);
  setCount(count => count + 1);
  setCount(count => count + 2);
}

随着代码的执行,3个更新任务被放入更新队列,随着react执行更新任务,count会被记录在创建的useState-hook节点中,并形成链表连接起来。

【1 -> 1 + 1-> 2 + 2】 = 【1 -> 2 -> 4】

最后,在新一轮的render中,count对应的useState-hook会返回4。

但情况真的有我们想象中那么简单吗?

现在,让我们继续站在开发者的视角:

我们不妨设想一下,你的老板给你安排了100个任务需要完成,你会怎么去安排时间?

大家很快都能做出判断,先完成重要的任务

没错,任务也分重要性,我们可以根据重要性得出不同任务的优先级,所以react进行更新任务的处理也会有一个优先级

所以我们在render前,根本没有办法确定哪些更新任务会参与状态的计算!

所以组件必须进行render,才能得到最新状态。也就意味着我们在 【步骤一】 中没有办法去比较前后状态是否一致

但是在某种情况,我们能够在render前拿到最新状态。这也就是eagerState的由来。

那什么情况下我们能够在render前拿到最新状态呢?

答案是:当前更新队列为空时。可以理解为更新任务为当前更新队列第一个任务时。

下面让我们进入正题⬇️

对应的源码知识

在看源码之前,我们再次站在开发者视角了解下react的更新机制的本质-需求排期

当我们拿到100个任务后,并不会马上去做,我们应该先划分好各个任务的优先级。当然,优先级往往也不是你自己能够决定的,你通常需要跟决策者确认,再开始做。

所以一个组件准备更新时,所做的【第一步】是:

【第一步】向react去请求更新当前的组件,并根据当前组件更新队列中任务的优先级,划分到不同的更新队列中等待更新。

100个任务,我们一定能够一个迭代做完吗?这恐怕不一定,除非你是百年难得一见的代码奇才。

亦或者说,我们在做任务的同时,又出现了其他情况,一个任务又触发了另一个新任务

所以这些遗留任务任务变更我们该怎么办呢?

留着下次的更新再处理,所以【第二步】是:

【第二步】将上一次的遗留任务和任务变更重新与新一轮的更新进行优先级划分,继续执行任务,直到完成所有任务。

所以我们在什么情况下能够得到eagerState从而取消更新呢?

下面是一段执行setState方法后,react会调用的函数。

看前小知识点:

  1. react通常用lane这个字段来给出更新的优先级,若lane === Nolanes时,便代表不需要更新。
function dispatchSetState(fiber, queue, action) {
	// ...省略无关代码
  // 向react请求更新当前fiber,返回一个更新任务优先级
  var lane = requestUpdateLane(fiber);
  var update = {
    // 如果lane === NoLanes,代表不需要更新
    lane: lane,
    action: action,
    hasEagerState: false,
    eagerState: null,
    next: null
  };
  // 如果处于render过程中,将更新任务放入当前fiber的更新队列中
  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    var alternate = fiber.alternate;
    // lanes === Nolanes代表当前fiber的更新队列为空
    if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      // 翻译⬇️
      // 当前更新queue为空,意味着我们能够在进入下次render前拿到eagerly,如果新老state是相同的,我们便能够进
      // 行完全bailout
      var lastRenderedReducer = queue.lastRenderedReducer;

    	// ...省略无关代码
      if (objectIs(eagerState, currentState)) {
        // eagerState等于当前state,当前的fiber便能够完全进行bailout
        enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update, lane);
        return;
      }
    }
    // ...省略无关代码
  }


  markUpdateInDevTools(fiber, lane);
}

细心的同学可能会发现,如果只需要当前更新队列为空, 也就是fiber.lanes === Nolanes,我们就能得到eagerState

但如果仅仅只是这样,为什么判断条件中还有一条alternate === null || alternate.lanes === NoLanes

alternate是什么?为什么它也需要处于空闲状态?难道更新队列为空还有另一层含义?

下面让我们一起来了解 - fiber的双缓存机制

fiber的双缓存机制

我们再次回到需求定理,当你要开发一个需求时,通常会经历哪些步骤?

我想我们通常会经历以下几个步骤:

  1. 需求排期,通知我开始做需求。

  2. 查看需求文档,然后脑子里开始构想两个场景。

    a. 场景一:这次的需求涉及的项目原有逻辑影响范围

    b. 场景二:这次的需求涉及的项目改造完后的逻辑

  1. 根据两个场景的比较,得出改动点,进行真正的需求开发

同样,一个组件的更新也是同样的步骤。

  1. react通知我需要进行组件更新

  2. 接收更新任务,然后开始构建出两个虚拟fiber

    a. current fiber:保存组件的旧状态,并存储着更新任务涉及到的父子组件

    b. wip(work in progress) fiber:正在进行更新操作的节点,会根据不同优先级批次的更新任务进行增量更新

  1. 比较两个虚拟fiber的差异,得出更新点,构建出真正的fiber

让我们再次思考,如果你作为开发者,这几步有什么优化空间吗:

答案是【步骤二】和【步骤三】:

【步骤二】:以空间换时间。变量的是构建要消耗时间的,如果我们能不回收这两个fiber的内存,那么不就能够节省出创建时间了吗?react把这种优化策略叫做fiber的双缓存机制

【步骤三】:比较两个fiber的差异点应该从头到尾的遍历吗?我们能否更快捷地比较出差异点,只更新局部呢?react把这种优化策略叫做diff算法(本次不讨论)。

那么fiber中的双缓存机制是怎样的执行流程呢?

看前小知识点:

  1. current fiber:保存着当前fiber的相关信息,存储着更新任务等。
  2. wip fiber将要变化的视图,会随着更新任务的执行进行增量更新
  3. fiber.alternatecurrent fiber指向wip fiber指针,携带着更新信息去wip fiber。

image.png

组件空闲时,毫无疑问,current fiberwip fiber没有被打上更新标记

只有组件明确地被react允许更新时,两个fiber才会被打上更新标记。

什么叫做被react允许更新?难不成还有不允许的情况吗?事实确实是这样:

当组件有更新任务,并需要进行更新时。会向react进行更新请求

但是只要请求了更新,react就一定会允许更新吗?你认为很重要的事,你的老板一定也会认为很重要吗?

不是的,你的更新任务可能在react看来,可能并不需要这一次更新批次执行,甚至都可能没有必要去执行

只有获得批准的更新任务,才能够被执行。这也就意味着,不是每次进行更新请求,都需要把current fiber打上更新标记

当获得批准执行更新任务后,会先把任务更新到current fiber更新队列中,然后由current fiberalternate指针指向wip fiber,并把更新信息带给wip fiber,并把wip fiber也打上更新标记,随着更新任务执行,wip fiber便进行增量更新。

当更新流程结束,组件在进行render中,便会把wip fiber更新标记取消,然后成为新的current fiber,而原来的current fiber已经完成了它的使命,便会成为新的wip fiber

回归正题

还记得我们最开始的案例吗?

明明setState相同的值,App组件依然进行了更新,现在让我们尝试用上文学到的知识进行解答。

首先,组件对应的current fiberwip fiber都没有被标记更新,才代表组件不需要更新。体现在以下源码中,感兴趣的同学可以自行调试。

function performUnitOfWork(unitOfWork) {
  // ... 省略无关的代码
  // unitOfWork入参为workInProgress,wip fiber和current fiber都没有更新标记的话,便
  // 新fiber便直接等于原有fiber
  if ( (unitOfWork.mode & ProfileMode) !== NoMode) {
    // ...省略无关的代码
  } else {
    next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
  }
	// ...省略无关的代码
}

在案例中,第一次点击触发了setCount(6),组件进行了render,打印了render 6

第二次点击触发了setCount(6),组件再次进行了render,打印了render 6

下面让我们分析它的更新流程

【第一次点击】 :

第一次点击触发setCount(6),App组件向react请求更新,react返回一个附带了优先级的任务。

此时需要将更新任务放入current fiber,所以要把对应的current fiber标记更新,并记录影响到的相关组件

current fiber中的alternate指针携带着更新信息指向wip fiber,并将wip fiber标记更新

随着不同优先级批次的任务开始执行,wip fiber会进行对应的增量更新,直到本轮更新结束。

随着更新任务执行完成,不满足「current fiberwip fiber都为空闲状态」的条件,App组件无法得到eagerState,所以App组件开始render,将wip fiber更新标记清除,并替换wip fibercurrent fiber

此时,新的current fiber没有被标记更新,被替换过去的新wip fiber依然被标记更新

在这一次点击触发的更新中,current fiberwip fiber都被标记更新,所以并没有办法计算出eagerState,无法进行bailout

image.png

第二次点击】:

第二次点击触发setCount(6),App组件向react请求更新,react返回一个附带了优先级的任务。但返回的任务优先级为NoLanes(代表不需要更新)

此时代表更新任务是不需要被执行的,所以并不需要放入current fiber更新队列,所以并不需要进行把current fiber标记更新。

然后本轮没有更新信息通过alternate指针指向wip fiber,所以wip fiber也并不会进行增量更新

随着更新任务执行完成,不满足「current fiberwip fiber都为空闲状态」的条件,App组件无法得到eagerState,所以App组件开始render,将wip fiber更新标记清除,并替换wip fibercurrent fiber

此时,current fiberwip fiber没有被标记更新

image.png

【第三次点击】

第三次点击触发setCount(6),App组件向react请求更新,react返回一个附带了优先级的任务。但返回的任务优先级为NoLanes(代表不需要更新)

此时代表更新任务是不需要被执行的,所以并不需要放入current fiber更新队列,所以并不需要进行把current fiber标记更新。且此时的wip fiber也没有被标记更新

满足「current fiberwip fiber都为空闲状态」的条件,App组件此时得到了eagerState = 6新老状态对比相等,进行bailout

image.png

此时你可能会问,为什么更新结束只把wip fiber更新标记清除

大家想一想,我们的开发场景中,是触发action后状态发生改变的场景多,还是状态不改变的场景多?

设想一下,如果我们每次将两个fiber的更新标记的清除掉,那么每次更新,就需要去给两个fiber都标记更新

这便是一种取舍。

如果两个fiber都取消,那么每次状态更新,都需要去对两个fiber进行标记更新

如果只取消wip fiber,那么每次状态改变,只需要把current fiber进行标记更新,但代价就是如果没有状态改变,组件也会进行一次无意义的render

明显触发action后状态改变的场景是远远大于状态不改变的。所以便选择了第一个方案。

所以eagerState的优化并没有达到极致

你可能会问:那么多造成的这次无意义render,也会造成性能消耗啊?这恐怕并不比标记一次更新的消耗小。

但大家回想一下,我们最开始提到的优化点是有 【步骤一】【步骤二】 的,eagerState只是步骤一的优化,剩下的未完成的优化任务便交给 【步骤二】- bailout

下期预告

我把开头的案例改造了一下,让我们接着闯关:

import React, { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = React.useState(0);
  console.log('render', count);
  return (
    <div className="container">
      <button onClick={() => setCount(6)}>click me</button>
      <Child/>
    </div>
  );
}
function Child() {
  console.log('child render');
  return <div />;
}

提问:第一次点击打印什么?

答:render 6child render

提问:第二次点击打印什么?

答:render 6

提问:第三次点击打印什么?

答:不打印。

提问:为什么第二次点击Child组件没有进行render?

敬请期待下期 - “setState的假更新之bailout”