likes
comments
collection
share

谈谈我对React异步并发渲染的理解

作者站长头像
站长
· 阅读数 42

React异步并发渲染

react设计理念

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

谈谈我对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执行=>布局 => 样式绘制,这有点像小时候操场上排队跳大绳的活动。

谈谈我对React异步并发渲染的理解

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

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

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

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

React基本工作原理

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

谈谈我对React异步并发渲染的理解

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

React老架构的问题

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

谈谈我对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

谈谈我对React异步并发渲染的理解

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

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

可以看到Scheduler是一个新事物,它就是用来负责判断当前的更新任务需不需要暂停,如果暂停了在什么时机继续更新的。既然我们以浏览器是否有剩余时间作为任务中断的依据,那么我们需要一种机制,当浏览器有剩余时间时通知我们。其实chrome浏览器已经实现了这个API,这就是requestIdleCallback。但由于兼容性,及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的替换,完成最终的渲染。

谈谈我对React异步并发渲染的理解

应用首次渲染也就是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异步并发渲染的理解

任务优先级

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

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

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

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

任务中断挂起:

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

被更高优先级打断:

谈谈我对React异步并发渲染的理解

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

最后

React异步并发渲染,是魔法还是鸡肋?想看热闹,可以点这里

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

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

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

参考文献:

  1. React技术揭秘

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

转载自:https://juejin.cn/post/7116757398361997320
评论
请登录