20. 🔥架构篇-数据更新流程设计

前面我们讲到了 React 位运算的三种场景,提到了 Lane 模型,更新上下文 Context,接下来我们还是以 React 数据更新为主线,看一下数据更新的架构设计。

一 React 更新前置设计

批量更新-减少更新次数

虽然 JS 执行是快速的,但是浏览器绘制的成本却是昂贵的,所以良好的性能保障是:

1 减少更新次数,从而减少浏览器的渲染绘制操作,比如重绘,回流等。 2 避免 JS 的执行,影响到浏览器的绘制。

我们都知道 React 也是采用数据驱动的,所以当每一次触发 setState 或者是 useState 更新 state 的时候,本质上都是数据变化-> DOM 元素变化 -> 浏览器绘制,那么正常情况下,如果一次用户交互事件,比如点击事件中,可能会触发多次更新,接下来就会多次更改 DOM 状态,进而占用浏览器大量的时间,所以为了避免这种情况发生,React 通过更新上下文的方式,来判断每一次更新是在什么上下文环境下,比如在 React 事件系统中,就有 EventContext。在这些上下文中的更新,都是 React 可控的,进而可以批量处理这些更新任务。

这种批量更新的方式,一定程度上减少了更新次数,但是这种控制手段也仅仅只能对同一上下文中的更新生效,打个比方,一些微任务中的更新,这种更新就不受 React 更新上下文的控制了,这样浏览器还是需要处理一个更新之后,马上执行下一个任务,如果有很多这样的任务,就会导致一直执行 JS 线程,从而阻塞了渲染线程的绘制。

更新调度-更新由浏览器控制

还好 React 中一个重要的模块去处理更新,那就是——Scheduler,在 React 中维护了一个更新队列中,去保存这些更新任务,当第一次产生更新的时候,会先把当前更新任务放入到更新队列中,然后先执行更新,接下来调度会向浏览器请求空闲时间,在此期间,如果有其他更新任务插入,比如上述的微任务,就会放入更新队列中,如果浏览器空闲了,就会判断更新队列中是否还有待更新的任务,如果有那么执行,接下来再向浏览器请求下一个空闲帧,一直到待更新队列中没有更新任务,这样就保证了更新任务导致浏览器卡住的情况发生,把更新的主动权交给了浏览器。

有了批量更新和更新调度,就解决了上面的两种性能保障问题,不过问题又来了,那就是更新任务,并不是相同的,而是有不同优先级的任务,就像一条业务线的产品,在给研发提需求的时候,本质上每一个需求的优先级是不同的,有一些需求是高优先级,有一些就不是那么重要,如果一视同仁的处理这些需求,就不是很合理。

这个时候就需要把这些任务做一些区别,那满足一些复杂的场景。

更新标识 Lane 和 ExpirationTime

为了区别更新任务,每一次更新都会创建一个 update,并把 update 赋予一个更新标识,在之前的老版本中用的是 ExpirationTime ,但是在新版本 React 中,用的是 Lane,至于有两者的区别呢。

老版本 ExpirationTime 代表的是过期时间,当每次执行的任务的时候,会通过 ExpirationTime 来计算当前任务是否过期,如果过期了说明需要马上优先执行,如果没有过期,那么就让更高优先级的任务先执行,这就好比如上产品会把每一个需求增加了一个 deadline (过期时间),来确保需求的迫切性。

如果说把每次事件中产生的任务都公平对待的话,ExpirationTime 就不会出现什么问题,但是 concurrent 模式下有一个并发场景,比如我们通过一个输入框,来进行搜索数据并展示列表,那么本质上是产生了两个更新任务,一个是表单内容的变化,另外一个列表的展示,表单变化是急迫的任务,但是列表的展示相比表单内容显得不是那么重要。这个时候如果两个更新任务继续合并,那么最终会导致因为表单输入是频繁的,但是需要列表更新才能返回更新的内容,列表的更新会影响到表单的输入,反映到用户眼中的就是,输入内容的延时。这个时候就需要把表单内容更新和列表的更新当成两个任务去处理。

这个时候一个 ExpirationTime 并不能描述出当前 fiber 上有两个不同优先级的任务。ExpirationTime 只能反映出更新的时间节点,无法处理任务交割的场景。

所以就采用了另外一个模式, 那就是 Lane 模型,Lane 采用位运算的方式,一个 Lane 上可以有多个任务合并,这样能够描述出一个 fiber 节点上,存在多个更新任务,那么就可以优先处理高优先级任务,我们还是列举上面产品需求例子,在 Lane 模式下,每个需求给设置 P0,P1 等不同的等级,这样就保证了需求进行的有序性。

进入更新

有了更新标识和 update 之后,就可以更新了吗,显然不能,因为众所周知,整个 React 应用中会有很多 fiber 节点,而函数组件和类组件本质上也是 fiber ,和其他 fiber 不同的是组件可以触发更新,这个更新标识描绘出 React 的更新时机和更新特点。

在前面的章节中,我们讲到了 React 每一个更新都是从根节点开始向下调和,在此期间,会把双缓冲树交替使用作为最新的渲染 fiber 树。那么在构建最新 fiber 树的时候,没有发生更新的地方是不需要处理的,那么直接跳过更新就可以了,这也是一种性能上的优化,那么 React 首先要做的事情就是根据更新标识找到发生更新的源头,但是在众多 fiber 中如何快速找到更新源呢?这还是在标记更新标识的时候,会通过当前 fiber 的 return 属性更新父级 fiber 链上的属性 childLanes,这样在从 root 开始向下调和的时候,就能够直接通过这个属性找到发生更新的组件对应的 fiber,接下来执行更新。

二 React 更新后置设计

上面说到了 React 在进入更新之前有哪些操作,比如控制更新频率,防止 JS 阻塞浏览器,已经通过 Lane 处理不同优先级的更新任务,解决更新的并发场景,接下来我们看一下进入到更新之后,React 会有哪些设计方式。

render 和 commit 阶段

React 在进入到更新流程之后,并不是马上更新数据,更新 DOM 元素,而是通过 render 和 commit 两大阶段来处理整个流程。

在 render 阶段中,核心思想就是 diff 对比,整个 render 都围绕着 diff 展开,首先就是 React 需要通过对比 childLanes 来找到更新的组件。找到对应的组件后,就会执行组件的 render 函数,然后会得到新的 element 对象,接下来就是新 element 和老 fiber 的 diff ,通过对比对单元素节点和多元素节点来复用老 fiber ,创建新 fiber 。

在此期间,会通过对比 props 或者 state 等手段判断组件是否更新。React 开发者控制渲染的手段基本上都是在 render 阶段执行的。

在 render 阶段 React 并不会实质性的执行更新,而是只会给 fiber 打上不同的 flag 标志,证明当前 fiber 发生了什么变化。

在 render 阶段中,会通过 fiber 上面的 child ,return 和 sibling 三个指针来遍历,找到需要更新的 fiber 并且执行更新。在此其中,会采用优先深度遍历的方式,遍历 child,当没有 child 之后会遍历 sibling 兄弟节点,最后到父元素节点。这种方式的好处,就是可以方便形成真实 DOM 树结构,在 fiber 初始化流程中,创建 DOM 元素是在 render 阶段完成的。

经历了 render 阶段之后,就进入到了 commit 阶段,commit 阶段会执行更新,然后就会执行一些生命周期和更新回调函数,所以 React 开发者就可以拿到更新后的 DOM 元素。

最后更新于