sunilwang

V1

2022/08/10阅读:20主题:全栈蓝

React异步并发渲染,是魔法还是鸡肋?

React 异步并发渲染

react 设计理念

在官方文档的React 哲学[1]章节(如下方截图所示),已经将它的设计理念传达的非常清晰了。可以说,React 就是为了构建快速响应的大型 web 应用而设计的。抓重点,“快速响应”。

React哲学
React哲学

制约快速响应的瓶颈有哪些?

背景知识

浏览器的每个标签页都可以看作是一个个相互独立的进程。每一个进程中,除了包含 JS 执行线程,还有页面渲染、事件处理、网络 IO 线程等等这些重要的参与者。虽然是多线程的模型,但 JS 脚本依然是单线程执行。比如,在触发一个事件回调 A 的同时,收到网络响应回调 B,但只有一座独木桥,A、B 谁先过,总要有个类似于先来后到的排队规则,浏览器为此专门设计一个处理并发多任务的调度机制,称为事件循环,它的基本特性如下:

  • GUI 渲染线程与 JS 引擎线程互斥 由于 JS 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即 JS 线程和渲染线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。因此为了防止渲染出现不可预期的结果,GUI 渲染线程总是等待当前 JS 线程的任务清空后,将统一收集到的 DOM 操作提交给渲染线程,进行一次有效的屏幕更新。

  • 事件及网络响应。

如果 JS 线程长时间占用,用户在屏幕上进行的交互事件回调就会迟迟得不到执行。网络请求的回调执行也是一样。

小结:在 JS 线程负载低的情况下,一切都是井然有序的,没有任何问题,但假如 JS 线程长时间被大量计算占用,情况就急转直下了。

页面渲染瓶颈

就拿最直观的页面渲染举例,主流浏览器刷新频率为 60Hz,即每(1000ms / 60Hz)16.6ms 浏览器刷新一次。在这 16.6ms 中,需要完成 JS 执行=>布局 => 样式绘制,这有点像小时候操场上排队跳大绳的活动。

跳大绳
跳大绳

是不是依然能回味到当时那种满满的紧迫感!

先说最流畅与理想的情况,就是一圈一人的过。假如出现有人抢圈,甚至一圈挤多人的情况,就很容易卡住了。又或者因犹豫时间过久导致没有在合适的时机进圈,也很容易卡住。

如果把两边摇绳子的同学类比为浏览器渲染,那留给 JS 执行的时机就相当于在每摇一圈的过程中成功率最高的那一小段时间。超时,就会来不及收集与提交这一帧中发生的 DOM 操作,也就错过了这一次的渲染时机。假如一次 JS 执行占用了横跨好几帧的时间,用户此时正在滚动浏览网页,那么渲染的流畅度就明显跟不上了。JS 线程被大量计算占用过久,还会导致事件绑定及回调等得不到及时的执行与响应,而事件任务的回调里常常伴随着又一次的更新 UI,导致界面响应相对用户交互预期产生延迟,这使得状况变得更加糟糕。

这个问题,在 React 中尤其需要引起重视,因为这是由从它的工作原理决定的。

React 基本工作原理

React 的基本工作原理其实非常简单,简单到可以用一句公式概括。

在需要更新状态时,对整个应用进行更新,就像一个纯函数调用一样。换句话说,React 并不关心这次更新是哪个组件的哪个状态发生了变化,而是在整个应用更新的前提下,通过虚拟 DOM 的 Diff,找到前后渲染的最小差异。用相对低成本的 JS 计算,减少 DOM 操作的昂贵成本,获得一个大多数场景下都可接受的渲染性能,详见我的另一篇文章最近风靡一时的 “No DomDiff”潮流是怎么回事?Virtual Dom 不香了?[2]

React 老架构的问题

这里的老架构指的是 v15 及之前版本的架构。

React同步架构
React同步架构

整体分为两部分

  • Reconciler(协调器)— 负责找出变化的组件
  • Renderer(渲染器)— 负责将变化的组件渲染到页面上

整体的工作流程可为分四个步骤:

  1. 当调用this.setStatethis.forceUpdateReactDOM.render等 API 触发更新时:
  2. Reconciler(协调器)调用函数组件或 class 组件的render方法,将返回的 JSX 转化为虚拟 DOM。
  3. 接着就 DOM-Diff,将虚拟 DOM 和上次更新时的虚拟 DOM 对比,找出本次更新中变化的虚拟 DOM
  4. 通知 Renderer 将变化的虚拟 DOM 渲染到页面上

同步递归更新

React 老架构的特点:

  • 采用的是同步递归的更新方式。
  • Reconciler 和 Renderer 是交替工作的,整个更新过程不可中断。也就是说一旦开始一次 React 更新,就无法停止,谁也拦不住。

还记得上边制约快速响应的那些瓶颈吗?一旦页面的元素过多,需要 Diff 的范围及深度也就随之变大,就算大幅优化过的 DOM-Diff 算法也可能占用 JS 线程时间过久,导致渲染、事件响应不及时等问题。

那么,React 团队就想,假如一次更新的 Reconciler 工作时长超过了这一帧中留给 JS 的执行时间,那就先把这次的更新暂停一下,下一帧接着干。这样虽然还是会造成渲染不及时,毕竟还是跨帧延迟渲染了,但这样做有一个明显的好处: 至少能确保用户的交互事件能得到及时的响应,整体体验会好很多。这项技术被称为时间切片。这就要求 React 必须拥有有异步可中断更新的能力,老架构的同步递归很显然无法满足,所以架构层面的重构势在必行。

只是,React 这一重构就是整整 4 年,跨越 16、17 两个版本,到最新的 v18 版本才终于成为了默认模式。堪称 React 历史上最大的一次更新。那为什么大家的关注度不高呢?也许是相比函数组件支持 hook 这种使用 API 层面的升级,架构层面的优化更多的是为了解决 React 自身的问题。而且还处在不停的调整中,缺乏讨论层面的一个稳定且标准的事实基础。设想一下,假如 React 在 API 层面频繁调整,那就事大了!

React 新架构

同步更新 vs 异步更新的 Demo[3]

为了实现异步可中断更新,React 将新的架构划为为三部分:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入 Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

可以看到 Scheduler 是一个新事物,它就是用来负责判断当前的更新任务需不需要暂停,如果暂停了在什么时机继续更新的。既然我们以浏览器是否有剩余时间作为任务中断的依据,那么我们需要一种机制,当浏览器有剩余时间时通知我们。其实 chrome 浏览器已经实现了这个 API,这就是requestIdleCallback[4]。但由于兼容性,及 React 团队有着更加复杂的需求,React放弃了对它的使用,转而,自己动手实现了一个更高级的Scheduler, 不仅支持空闲时调度,还提供了多种调度优先级供任务自由设置,后边会详细介绍。

核心代码:

function workLoopConcurrent({
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}

在遍历完每个 VDOM 节点后,进行shouldYield()判断,决定当前更新任务是否需要暂停。

Reconciler(协调器)是升级的重点。老架构递归更新,上下文只保存在函数调用栈中,一旦中止,上下文信息就会丢失,下次就不能原地继续。React 的做法是从基础的数据结构及工作流程上做出改变。

  • 递归改链表。链表的节点可以保存更多的信息,并且可追溯,也就能够实现从上次中断处继续更新。这种涉及基础数据结构层面的改变是巨大的,大到 React 团队认为有必要给这种新的虚拟 DOM 技术,升级个新名字“Fiber”,下边是它的数据结构,可以看出,相比之前老架构的 VDOM 节点,多出了好几个纬度的信息。
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
{
// 作为静态数据结构的属性
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;

// 用于连接其他Fiber节点形成Fiber树
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;

this.ref = null;

// 作为动态的工作单元的属性
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;

this.mode = mode;

this.effectTag = NoEffect;
this.nextEffect = null;

this.firstEffect = null;
this.lastEffect = null;

// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;

// 指向该fiber在另一次更新时对应的fiber
this.alternate = null;
}
  • 打标记。新的 Reconciler 与 Renderer 不再是交替工作,而是在 render 阶段(scheduler 与 Reconciler 的工作统称为 render 阶段)给每个 Fiber 用一种打标记的方式记录需要在 commit 阶段(由 Renderer 负责)进行的 dom 操作,具体是赋值给 FiberNode 中的 effectTag 属性。最后统一在 commit 阶段,将所有 effectTag 记录的操作由 renderer(渲染器)一次性渲染到页面上。下边是具体的 DOM 操作对应的标记类型。
// DOM需要插入到页面中
export const Placement = /* */ 0b00000000000010;
// DOM需要更新
export const Update = /* */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /* */ 0b00000000000110;
// DOM需要删除
export const Deletion = /* */ 0b00000000001000;

单个 Fiber 节点偏微观的介绍就到这里,下边介绍下 React 中由一个个 Fiber 节点链成的整个 Fiber 树偏宏观层面的设计。

Fiber 架构

双缓存

在 React 中最多会同时存在两棵 Fiber 树。当前屏幕上显示内容对应的 Fiber 树称为current Fiber树,正在内存中构建的 Fiber 树称为workInProgress Fiber树

每次状态更新都会产生新的 workInProgress Fiber 树,通过 current 与 workInProgress 的替换,完成最终的渲染。

应用首次渲染也就是 mount 时,因为不存在current Fiber树,所以直接生成一颗的workInProgress Fiber树移交给 current 即可,并且也不用考虑前后 Fiber 节点复用的情况,比较简单,下边介绍下更新时的情况。

更新

这里存在一个效率问题:当某个 Fiber 节点上触发了一次更新后,在 render 阶段已经全量遍历了一次整个 Fiber 树,相应的 effectTag 也已经打好了,等到 commit 阶段需要拿到所有的 effectTag,那是不是需要再次遍历整个 Fiber 树找对应 Fiber 节点的 effectTag 呢?这显然是很低效的。

React 的做法是在首次遍历 Fiber 时,也就是 render 阶段,将存在 effectTag 的 Fiber 节点另外保存在一条被称为 effectList 的单向链表中。effectList 中第一个 Fiber 节点保存在 fiber.firstEffect,最后一个元素保存在 fiber.lastEffect。最终形成一条以 rootFiber.firstEffect 为起点的单向链表。

nextEffect nextEffect
rootFiber.firstEffect -----------> fiber -----------> fiber

这样,在commit阶段只需要遍历相比整个 Fiber 树链表节点少的多的effectList就可以了。

React团队成员Dan Abramov说过:effectList相较于Fiber树,就像圣诞树上挂的那一串彩灯。

任务优先级

React 在实现异步可中断的基础上,更近一步,实现了多任务的优先级调度。这也是并发渲染模式(concurrent-mode)名字的由来。

初版的 Schduler,只是实现了浏览器空闲时间的判断,也就是时间分片的功能,固定预留 5ms 的时间给到协调器工作。但升级后的 Schduler,将这套判断升级为了功能更加强大的 lane 模型,目前的调度优先级规则如下:

  • 生命周期方法:同步执行。
  • 受控的用户输入:比如输入框内输入文字,同步执行。
  • 交互事件:比如动画,高优先级执行。
  • 其他:比如数据请求,低优先级执行。

大致的优先级倾向是,生命周期方法 > 用户输入 > 交互事件 > 数据请求。这个优先级排序也侧面给出了 React 团队关于实现“快速响应”的方案:那就是以用户交互输入为最高优先级,先确保用户输入是流畅的,其他的等有空再说。

任务中断挂起:

如果一次更新需要的时间,超出了预留时间,那么这时候就会先将任务挂起,等浏览器空闲了继续执行,这里是通过一个相对全局的变量记住当前任务中断的节点,当需要恢复执行时,通过这个全局变量,找到它的中断节点恢复执行。

被更高优先级打断:

总结图中内容,当低优先级任务被后触发的高优先级任务打断后,会在后者 commit 阶段完成后,紧接着重新调度执行一次前者的更新。也就是说,React 并不确保任务的执行顺序与用户交互顺序一致,但是会确保最终的渲染结果一致。

最后

回到文章的标题,是魔法还是鸡肋?想看热闹,可以点这里[5]

但笔者认为,首先相对于 vue、Svelte 这类的数据响应式框架而言,它们的工作原理完全可以使它们做到节点级的精确更新。并且它们的 DSL 都基于模版,还可以像 vue3 那样做极致的编译时优化,框架层面性能优化的手段是比较多样的。但 React+JSX 这样的组合从工作原理上完全依赖运行时 Diff,除了丢给开发者手动优化,要想从框架层面做运行时优化,走并发渲染 concurrent 这条路,也许没有对错之分,因为这很可能是唯一的选择。而且随着 concurrent-mode,还带来了 Suspence 等新功能,作为框架的使用者,当然喜闻乐见。至于最终的性能收益到底如何,这点笔者倒比较看淡。毕竟大家喜欢 React 的原因有很多,但应该很少有人是冲着性能快这一项去的。。。

React: 我只跟自个比,至于其他框架如何,我并不关心!

以上就是内容得全部,感谢大家阅读、点赞、分享、批评。

参考文献:

  1. React 技术揭秘[6]

  2. React Fiber 是如何实现更新过程可控的[7]

作者简介

郑瑜栋:在团队拥有一个奇怪的外号“=”哥。

Reference

[1]

React 哲学: https://react.docschina.org/docs/thinking-in-react.html

[2]

最近风靡一时的 “No DomDiff”潮流是怎么回事?Virtual Dom不香了?: https://juejin.cn/post/7009575427731636232

[3]

同步更新vs异步更新的Demo: https://codesandbox.io/s/concurrent-3h48s?file=/src/index.js

[4]

requestIdleCallback: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback

[5]

这里: https://www.zhihu.com/question/434791954/answer/1717536954

[6]

React技术揭秘: https://react.iamkasong.com/#%E5%AF%BC%E5%AD%A6%E8%A7%86%E9%A2%91

[7]

React Fiber 是如何实现更新过程可控的: https://segmentfault.com/a/1190000038729757

分类:

前端

标签:

React.js

作者介绍

sunilwang
V1