前言

浏览器中的渲染引擎是单线程的,几乎所有的操作都是在这个单线程中执行——解析渲染 DOM Tree 和 CSS Tree,解析执行 JavaScript ——除了网络操作。这个线程就是浏览器的主线程。单线程意味着,一段时间只做一件事,所以浏览器在同一时间内,其主线程只能关注于一个任务。

稍有经验的前端工程师会知道,页面的 DOM 改变,就会导致页面重新计算 DOM,进行重绘或者重排,DOM 结构复杂或者频繁操作 DOM 通常是性能瓶颈产生的原因。而网站从最开始比较简单,开始变的越来越复杂,用户交互也会越来越多,怎么去减轻 DOM 操作带来的性能损耗就变得重要起来。

React 第一次提出了 Virtual DOM 的概念

Virtual DOM 是一个 JavaScript 对象。每次,我们只需要告诉 React 下一个状态是什么,React 会自己构建一个新的 Virtual DOM,然后根据新旧 Virtual DOM 快速计算其差异,找出需要重绘或重排的元素,告诉浏览器。浏览器根据相关的更新,重新计算 DOM Tree,重绘页面。

看一个例子:

  1. const NUMBER_OF_BLOCK = 2;
  2. class ReactExample extends Component {
  3. constructor() {
  4. super();
  5. this.state = { timesOfButtonClicked: 0 };
  6. }
  7. _addTimesOfButtonClicked() {
  8. const {timesOfButtonClicked} = this.state;
  9. this.setState({timesOfButtonClicked: timesOfButtonClicked + 1});
  10. }
  11. render() {
  12. const {timesOfButtonClicked} = this.state;
  13. return (
  14. <div >
  15. <input type="text" />
  16. <button onClick={this._addTimesOfButtonClicked.bind(this)}>
  17. Click Me
  18. </button>
  19. <BlockList
  20. displayNumber ={timesOfButtonClicked}
  21. numberOfBlock={NUMBER_OF_BLOCK} />
  22. </div>
  23. )
  24. }
  25. };
  26. // 创建块元素列表组件,输入块元素的数量 NUMBER_OF_BLOCK_LIST,渲染出对应数量的块元素
  27. reactDOM.render(<ReactExample />, document.getElementById('app-container'));

这个例子会在页面中创建一个输入框,一个按钮,一个 BlockList 组件。BlockList 组件会根据NUMBER_OF_BLOCK 的数值渲染出对应数量的数字显示框,数字显示框显示点击按钮的次数。我们最开始设置 据 NUMBER_OF_BLOCK 为 2 ,只渲染 2 个数字显示框。

首次渲染出页面之后,我们点击按钮,页面中的数字显示框的值由 0 变为 1。如下图所示:

image.png

当点击按钮的时候,按钮点击次数从 0 变为 1,我们需要告诉 React 下面你要显示 1 了,于是,通过 setState 操作,我们告诉 React: 下一个你需要显示的数据是 1。然后,React 开始更新组件。对应的 Virtual DOM Tree 变化如下图所示。黄色表示状态被更新。

image.png
image.png

我们点击按钮,触发 setState 之后,React 就会创建一个新的 Virtual DOM,然后将新旧 Virtual DOM 进行 diff 操作,判断哪些元素需要更新,将需要更新的元素放到更新列表中,最后遍历更新列表更新所有的元素,这所有的过程都是 React 帮我们完成的。对浏览器而言,这个过程仅仅是编译执行了一段 JavaScript 代码而已,我们把从 setState 开始,到页面渲染结束的浏览器主线程工作流程画出来,如下图所示。蓝色粗线表示浏览器主线程。

image.png

从获得最新的数据,到将数据在页面中呈现出来,可以分为两个阶段。

第一个 调度阶段。这个阶段 React 用新数据生成新的 Virtual DOM ,遍历 Virtual DOM ,然后通过 Diff 算法,快速找出需要更新的元素,放到更新队列中去。

第二个 渲染阶段。这个阶段 React 根据所在的渲染环境,遍历更新队列,将对应元素更新。在浏览器中,就是更新对应的 DOM 元素。除浏览器外,渲染环境还可以是 Native,硬件,VR 等。

新问题

之前,React 在官网中写道:

We built React to solve one problem: building large applications with data that changes over time.

现在更新为:

React is a declarative, efficient, and flexible JavaScript library for building user interfaces.

所以我们看出,React 新的定位在于灵活高效的数据。但是在实际的使用中,尤其是遇到页面结构复杂,数据更新频繁的应用的时候,React 的表现不尽如人意。在上一个例子中,我们可以设置 NUMBER_OF_BLOCK 的值为 100000(实际情况下,可能没有那么多),将其变为一个“复杂”的网页。

点击按钮,触发 setState,页面开始更新。

点击输入框,输入一些字符串,比如 “hireact”。我们可以看到,页面此时没有任何的响应。

等待 7 s,输入框中突然出现了,之前输入的 “hireact”,同时, BlockList 组件也更新了。

在这等待 7 s 中,页面不会给我任何的响应,我会以为网站崩溃了,或者电脑死机了。如果没有让我等待几秒,只是等待了 0.5 秒,多等待几个 0.5 秒之后我会在心里默想:这是什么破网站!

显而易见,这样的用户体验并不好。

将浏览器主线程在这 7 s 的 performance 如下图所示:

image.png

黄色部分是 JavaScript 执行时间,也是 React 占用主线程时间,紫色部分是浏览器重新计算 DOM Tree 的时间,绿色部分是浏览器绘制页面的时间。

三种任务,占用浏览器主线程 7 s,此时间内浏览器无法与用户交互。但是DOM 改变之后,浏览器重新计算 DOM Tree,重绘页面是一个必不可少的阶段(紫色绿色阶段),浏览器一直都是这样执行的。主要是黄色部分执行时间较长, 占用了 6 s,即 React 较长时间占用主线程,导致主线程无法响应用户输入。

新调度策略

可以确定的是复杂度为常数的 diff 算法还是很优秀的,主要问题出现在React 的调度策略 – Stack Reconcile。这个策略像函数调用栈一样,会深度优先遍历所有的 Virtual DOM 节点,进行Diff。它一定要等整棵 Virtual DOM 计算完成之后,才将任务出栈释放主线程。所以,在浏览器主线程被 React 更新状态任务占据的时候,用户与浏览器进行任何的交互都不能得到反馈,只有等到任务结束,才能突然得到浏览器的响应。

React 这样的调度策略对动画的支持也不好。如果 React 更新一次状态,占用浏览器主线程的时间超过 16.6 ms,就会被人眼发现前后两帧不连续,给用户呈现出动画卡顿的效果。

React 核心团队很早之前就预知这样的风险的存在,并且持续探索可解决的方式。基于浏览器对 requestIdleCallback requestAnimationFrame 这两个 API 的支持,以及其他团队对者两个 API 的实现,如 React Native 团队。React 团队实现新的调度策略 – Fiber Reconcile

Fiber 是一种轻量的执行线程,同线程一样共享定址空间,线程靠系统调度,并且是抢占式多任务处理,Fiber 则是自调用,协作式多任务处理。

Fiber 其实指的是一种数据结构,它可以用一个纯 JS 对象来表示:

  1. const fiber = {
  2. stateNode, // 节点实例
  3. child, // 子节点
  4. sibling, // 兄弟节点
  5. return, // 父节点
  6. }

Fiber Reconcile 与 Stack Reconcile 主要有两方面的不同。

首先,使用协作式多任务处理任务。将原来的整个 Virtual DOM 的更新任务拆分成一个个小的任务。每次做完一个小任务之后,放弃一下自己的执行将主线程空闲出来,看看有没有其他的任务。如果有的话,就暂停本次任务,执行其他的任务,如果没有的话,就继续下一个任务。

整个页面更新并重渲染过程分为两个阶段。

  1. Reconcile 阶段。此阶段中,依序遍历组件,通过 diff 算法,判断组件是否需要更新,给需要更新的组件加上 tag。遍历完之后,将所有带有 tag 的组件加到一个数组中。这个阶段的任务可以被打断。
  2. Commit 阶段。根据在 Reconcile 阶段生成的数组,遍历更新 DOM,这个阶段需要一次性执行完。如果是在其他的渲染环境 – Native、硬件,就会更新对应的元素。

image.png

所以之前浏览器主线程执行更新任务的执行流程就变成了这样。

image.png

其次,对任务进行优先级划分。不是每来一个新任务,就要放弃现执行任务,转而执行新任务。与我们做事情一样,将任务划分优先级,只有当比现任务优先级高的任务来了,才需要放弃现任务的执行。比如说,屏幕外元素的渲染和更新任务的优先级应该小于响应用户输入任务。若现在进行屏幕外组件状态更新,用户又在输入,浏览器就应该先执行响应用户输入任务。浏览器主线程任务执行流程如下图所示。

任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

image.png

我们重写一个组件,跟之前的一样。一个输入框,一个按钮,一个 BlockList 组件。BlockList 组件会根据NUMBER_OF_BLOCK 的数值渲染出对应数量的数字显示框,数字显示框显示点击按钮的次数。将 NUMBER_OF_BLOCK 设置为 100000,模拟一个复杂的页面。不同的是,使用 Fiber reconcile 调度策略,设置任务优先级,让浏览器先响应用户输入再执行组件更新。

  1. const NUMBER_OF_BLOCK = 100000;
  2. class FiberExample extends Component {
  3. constructor() {
  4. super();
  5. this.state = { timesOfButtonClicked: 0 };
  6. }
  7. _addTimesOfButtonClicked() {
  8. const {timesOfButtonClicked} = this.state;
  9. ReactDOMFiber.unstable_deferredUpdates(() =>
  10. this.setState(state =>
  11. {timesOfButtonClicked: timesOfButtonClicked + 1})
  12. );
  13. }
  14. render() {
  15. const {timesOfButtonClicked} = this.state;
  16. return [
  17. <input type="text" style={inputStyle}/>,
  18. <button style={buttonStyle}
  19. onClick={this._addTimesOfButtonClicked.bind(this)}>
  20. Click Me
  21. </button>,
  22. <BlockList displayNumber={timesOfButtonClicked}
  23. numberOfDotList={NUMBER_OF_BLOCK}/>
  24. ]
  25. }
  26. };
  27. ReactDOMFiber.render(<FiberExample />, document.getElementById('app-container'));

在对比代码差异之前,我们先执行同样的操作,对比一下浏览器的行为。

点击 button,触发 setState,页面开始更新。

点击输入框,输入一些字符串,比如 “hireact”。我们可以看到,页面能够响应我们的输入了。

浏览器主线程的 performance 如下图所示:
image.png

可以看到,在黄色 JavaScript 执行过程中,也就是 React 占用浏览器主线程期间,浏览器在也在重新计算 DOM Tree,并且进行重绘,截图显示,浏览器渲染的就是用户新输入的内容。简单说,在 React 占用浏览器主线程期间,浏览器也在与用户交互。这个才是我们在网站上面期望获得的体验,浏览器总是对我的输入有反馈。

那我们的代码改变了哪些呢?从下往上看:

首先,从 reactDOM.render() 变成了 ReactDOMFiber.render()。我们使用了 ReactFiber 去渲染整个页面,ReactFiber 会将整个更新任务分成若干个小的更新任务,然后设置一些任务默认的优先级。每执行完一个小任务之后,会释放主线程。

其次,render 方法中返回的不再是一个被 div 元素包一层的组件列表,而是直接返回一个组件列表,这是 React 在新版中提供的新的写法。除此之外,可以直接返回字符串和数字。像下面:

  1. render() {
  2. return 'Hi, ReactFiber!'
  3. }
  1. render() {
  2. return 123
  3. }

再次,我们传给 setState 的不是最新状态,而是一个 callback,这个 callback 返回最新状态。同上,这个也是 React 新版中提供的新的写法,同时也是推荐的写法。

最后,我们没有直接调用 setState,而是将其作为 callback 传给了 unstable_deferredUpdates 这个 API。从名字就可以看出,deferredUpdates 是将更新推迟,unstable 表明现在还不稳定,在开发阶段。从源代码上看,unstable_deferredUpdates 做了一件事情,就是将传给它的更新任务的优先级设置为 lowpriority。所以我们将 seState 作为 callback 传给了 unstable_deferredUpdates,就是告诉 React,这个 setState 任务,是一个 lowpriority 的任务。(需要注意的是,并不确定 React 团队是否将 unstable_deferredUpdates 或者 deferredUpdates 作为一个开放的接口,现在这个版本可以通过这个 API 去设置优先级。同时,从源代码可以看到,React 团队想要实现给任务设置优先级的功能,目前只看到一个 performWithPriority 的接口,也还没有实现。)

我们点击按钮之后,unstable_deferredUpdates 将这个更新任务设置为 low priority。此时是没有其他任务存在的,React 就开始进行状态更新了。更新任务进入了 Reconcile 阶段,我们点击输入框,此时,用户交互任务来了,此任务优先级高于更新任务,所以浏览器主线程将焦点放在了输入框……。之后更新任务进入了 Commit 阶段,不能将浏览器主线程放弃,到了最后浏览器渲染完成之后,将用户在更新任务 Commit 阶段的输入以及最新的状态显示出来。

对比 Stack Reconcile 和 Fiber Reconfile 的实现,我们可以看到 React 新的调度策略让开发者对 React 应用有了更细节的控制。开发者,可以通过优先级,控制不同类型任务的优先级,提高用户体验,以及整个应用程序的灵活性。

采用新的调度算法之后,会将动画的渲染任务优先级提高,对动画的支持会比较友好。

后记

总结一下:旧版 React 通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback这一 API。

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

当然,React Fiber 还是有一些问题是需要考虑的。

比如说,task 按照优先级之后,可能低优先级的任务永远不会执行,称之为 starvation;

比如说,task 有可能被打断,需要重新执行,那么某些依赖生命周期实现的业务逻辑可能会受到影响。

……

React Fiber 也是带来了很多的好处的。

比如说,增强了某些领域的支持,比如动画、布局和手势;

比如说,在复杂页面,对用户的反馈会更及时,应用的用户体验会变好,简单页面看不到明显的差异;

比如说,api 基本上没有变化,对现有项目很友好。