从React到React Fiber

3,367 阅读12分钟

本文首发于公众号:前端之巅

前言

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

在Web开发中,很多人觉得,不就是写HTML和CSS将数据显示出来么,So Easy!抱着这样想法开发出来的网站,如果比较简单的话,还不能看出差别,但是一旦页面复杂,用户交互变多,弊端就会爆发:卡顿,没反应,容易崩溃……

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

(图片来自:http://t.cn/R0wwkPo)

React来了

React是近几年非常火的一个前端框架,它第一次提出了Virtual DOM的概念。

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

我们下面看一个例子:

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

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

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

我们点击按钮,触发setState之后,React就会创建一个新的Virtual DOM,然后将新旧Virtual DOM进行diff操作,判断哪些元素需要更新,将需要更新的元素放到更新列表中,最后遍历更新列表更新所有的元素,这所有的过程都是React帮我们完成的。

对浏览器而言,这个过程仅仅是编译执行了一段JavaScript代码而已,我们把从setState开始,到页面渲染结束的浏览器主线程工作流程画出来,如下图所示。蓝色粗线表示浏览器主线程。

所以从拿到最新的数据,到将数据在页面中渲染出来,可以分为两个阶段。

  1. 调度阶段。这个阶段React用新数据生成新的Virtual DOM,遍历Virtual DOM,然后通过Diff算法,快速找出需要更新的元素,放到更新队列中去。
  2. 渲染阶段。这个阶段 React 根据所在的渲染环境,遍历更新队列,将对应元素更新。在浏览器中,就是跟新对应的DOM元素。除浏览器外,渲染环境还可以是 Native,硬件,VR 等。

想知道怎么React 怎么进行Diff的,可以看这里

新问题

之前,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”。我们可以看到,页面此时没有任何的响应。

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

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

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

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

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

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

新技能Get

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

再次,我们传给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作为一个开放的接口,现在这个版本[2]可以通过这个API去设置优先级。同时,从源代码可以看到,React团队想要实现“给任务设置优先级”的功能,目前只看到一个performWithPriority的接口,也还没有实现。)

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

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

采用新的调度算法之后,会将动画的渲染任务优先级提高,对动画的支持会比较友好,具体例子可以看Lin Clark在React Conf 2017的演讲。

后记

看起来React Fiber很厉害的样子,如果要用的话,还是有一些问题是需要考虑的。

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

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

……

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

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

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

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

……

现在,react-fiber已经通过了所有的测试,在网站上面Is Fiber Ready Yet?http://isfiberreadyyet.com/,已经通过了所有的测试,还有4个warning需要fix。它会随着React 16发布,到底效果怎么样,只有用过才知道了。

参考资料:

备注:

[1]: 只有动画或者视频达到 60 fps,人眼看起来才是流畅的,即平均 16.6 ms 就要完成整个页面的重渲染,否则就会让用户觉得卡顿。这里所指的动画,不是在页面播放一个视频或者动画,而是用 React 写动画。

[2]: 本文中 React 的版本是 React@16.0.0-alpha.3