1

使用React的同学们都应该要知道React Fiber,因为这玩意快要正式发布了。

React Fiber这个大改变Facebook已经折腾两年多了,刚刚结束不久的React Conf 2017会议上,Facebook终于确认,React Fiber会搭上React下一个大版本v16的顺风车发布。

Is Fiber Ready Yet? 这个网站上可以看到当前React Fiber的单元测试通过率,我刚刚(北京时间2017年3月28日下午1:41)看到的是93.6%,据说Facebook在自己的网站已经将React Fiber投入实战,所以,React Fiber大势所趋,是时候了解一下React Fiber了。

React Fiber是个什么东西呢?官方的一句话解释是“React Fiber是对核心算法的一次重新实现”。这么说似乎太虚无缥缈,所以还是要详细说一下。

首先,不用太紧张,不要以为React Fiber的到来是一场大革命,实际上,对我们只是把React当做工具的开发者来说,很可能感觉不到有什么功能变化。等到React v16发布的时候,我们修改package.json中的react版本号,重新npm install,一切就搞定了,然后我们就感觉到网页性能更高了一些,如此而已。

当然,上面说的是“理想情况”,React Fiber对算法的修改,还是会带来一些功能逻辑的变化,后面会说。

为什么Facebook要搞React Fiber呢?我们先要了解现在React(也就是直到目前最新的v15版本)的局限。

同步更新过程的局限

在现有React中,更新过程是同步的,这可能会导致性能问题。

当React决定要加载或者更新组件树时,会做很多事,比如调用各个组件的生命周期函数,计算和比对Virtual DOM,最后更新DOM树,这整个过程是同步进行的,也就是说只要一个加载或者更新过程开始,那React就以不破楼兰终不还的气概,一鼓作气运行到底,中途绝不停歇。

表面上看,这样的设计也是挺合理的,因为更新过程不会有任何I/O操作嘛,完全是CPU计算,所以无需异步操作,的确只要一路狂奔就行了,但是,当组件树比较庞大的时候,问题就来了。

假如更新一个组件需要1毫秒,如果有200个组件要更新,那就需要200毫秒,在这200毫秒的更新过程中,浏览器那个唯一的主线程都在专心运行更新操作,无暇去做任何其他的事情。想象一下,在这200毫秒内,用户往一个input元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被React占着呢,抽不出空,最后的结果就是用户敲了按键看不到反应,等React更新过程结束之后,咔咔咔那些按键一下子出现在input元素里了。

这就是所谓的界面卡顿,很不好的用户体验。

现有的React版本,当组件树很大的时候就会出现这种问题,因为更新过程是同步地一层组件套一层组件,逐渐深入的过程,在更新完所有组件之前不停止,函数的调用栈就像下图这样,调用得很深,而且很长时间不会返回。

image.png

因为JavaScript单线程的特点,每个同步任务不能耗时太长,不然就会让程序不会对其他输入作出相应,React的更新过程就是犯了这个禁忌,而React Fiber就是要改变现状。

React Fiber的方式

破解JavaScript中同步操作时间过长的方法其实很简单——分片。

把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。

React Fiber把更新过程碎片化,执行过程如下面的图所示,每执行完一段更新过程,就把控制权交还给React负责任务协调的模块,看看有没有其他紧急任务要做,如果没有就继续去更新,如果有紧急任务,那就去做紧急任务。

维护每一个分片的数据结构,就是Fiber。

有了分片之后,更新过程的调用栈如下图所示,中间每一个波谷代表深入某个分片的执行过程,每个波峰就是一个分片执行结束交还控制权的时机。

image.png

道理很简单,但是React实现这一点却不容易,要不然怎么折腾了两年多!

对具体数据结构原理感兴趣的同学可以去看Lin Clark在React Conf 2017上的演讲 (要fanqiang的),本文中的介绍图片也出自这个演讲。

Fiber 如何做到异步渲染

在做显示方面的工作时,经常会听到一个目标叫 60 帧,这表示的是画面的更新频率,也就是画面每秒钟更新 60 次。这是因为在 60 帧的更新频率下,页面在人眼中显得流畅,无明显卡顿。每秒钟更新 60 次也就是每 16ms 需要更新一次页面,如果更新页面消耗的时间不到 16ms,那么在下一次更新时机来到之前会剩下一点时间执行其他的任务,只要保证及时在 16ms 的间隔下更新界面就完全不会影响到页面的流畅程度。fiber 的核心正是利用了 60 帧原则,实现了一个基于优先级和 requestIdleCallback 的循环任务调度算法。

image.png

requestIdleCallback 是浏览器提供的一个 api,可以让浏览器在空闲的时候执行回调,在回调参数中可以获取到当前帧剩余的时间,fiber 利用了这个参数,判断当前剩下的时间是否足够继续执行任务,如果足够则继续执行,否则暂停任务,并调用 requestIdleCallback 通知浏览器空闲的时候继续执行当前的任务。

function fiber(剩余时间) {
 if (剩余时间 > 任务所需时间) {
 做任务;
 } else {
 requestIdleCallback(fiber);
 }
}

fiber 还会为不同的任务设置不同的优先级,高优先级任务是需要马上展示到页面上的,比如你正在输入框中输入文字,你肯定希望你的手指在键盘上敲下每一个按键时,输入框能立马做出反馈,这样你才能知道你的输入是否正确,是否有效。低优先级的任务则是像从服务器传来了一些数据,这个时候需要更新页面,比如这篇文章喜欢的人数+1 或是评论+1,这并不是那么紧急的更新,延迟 100-200ms 并不会有多大差别,完全可以在后面进行处理。fiber 会根据任务优先级来动态调整任务调度,优先完成高优先级的任务。

{ 
 Synchronous: 1, // 同步任务,优先级最高
 Task: 2, // 当前调度正执行的任务
 Animation 3, // 动画
 High: 4, // 高优先级
 Low: 5, // 低优先级
 Offscreen: 6, // 当前屏幕外的更新,优先级最低
}

在 fiber 架构中,有一种数据结构,它的名字就叫做 fiber,这也是为什么新的 reconciler 叫做 fiber 的原因。fiber 其实就是一个 js 对象,这个对象的属性中比较重要的有 stateNode、tag、return、child、sibling 和 alternate。

Fiber = {
 tag // 标记任务的进度
 return // 父节点
 child // 子节点
 sibling // 兄弟节点
 alternate // 变化记录
 .....
};

我们可以看出 fiber 基于链表结构,拥有一个个指针,指向它的父节点子节点和兄弟节点,在 diff 的过程中,依照节点连接的关系进行遍历。

为什么叫Fiber呢?

大家应该都清楚进程(Process)和线程(Thread)的概念,在计算机科学中还有一个概念叫做Fiber,英文含义就是“纤维”,意指比Thread更细的线,也就是比线程(Thread)控制得更精密的并发处理机制。

上面说的Fiber和React Fiber不是相同的概念,但是,我相信,React团队把这个功能命名为Fiber,含义也是更加紧密的处理机制,比Thread更细。

说个题外话,很多年前,我听一个前辈讲课,他说到Fiber这么一回事,我就问他:怎么让我的程序进入可以操控Fiber的状态?前辈的回答是:你的程序真的需要用到Fiber吗?如果现在的方法就能满足需求,根本就不需要知道Fiber是什么东西。

呵呵,前辈说的当时我还不大理解,后来越来越觉得前辈说得有道理,如果根本没有必要用上一样东西,那就不用也罢,不要因为那个东西酷就去用,不然很可能就是自找苦吃。

扯远了,我想说的是,其实对大部分React使用者来说,也不用深究React Fiber是如何实现的,除非实现方式真的对我们的使用方式有影响,我们也不用要学会包子怎么做的才吃包子对不对?

但是,React Fiber的实现改变还真的让我们的代码方式要做一些调整。

React Fiber对现有代码的影响

理想情况下,React Fiber应该完全不影响现有代码,但可惜并完全是这样,要吃这个包子还真要知道一点这个包子怎么做的,你如果不喜欢吃甜的就不要吃糖包子,对不对?

在React Fiber中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来

因为一个更新过程可能被打断,所以React Fiber一个更新过程被分为两个阶段(Phase):第一个阶段Reconciliation Phase和第二阶段Commit Phase。

在第一阶段Reconciliation Phase,React Fiber会找出需要更新哪些DOM,这个阶段是可以被打断的;但是到了第二阶段Commit Phase,那就一鼓作气把DOM更新完,绝不会被打断。

这两个阶段大部分工作都是React Fiber做,和我们相关的也就是生命周期函数。

以render函数为界,第一阶段可能会调用下面这些生命周期函数,说是“可能会调用”是因为不同生命周期调用的函数不同。

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

下面这些生命周期函数则会在第二阶段调用。

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

image.png

因为第一阶段的过程会被打断而且“重头再来”,就会造成意想不到的情况。

比如说,一个低优先级的任务A正在执行,已经调用了某个组件的componentWillUpdate函数,接下来发现自己的时间分片已经用完了,于是冒出水面,看看有没有紧急任务,哎呀,真的有一个紧急任务B,接下来React Fiber就会去执行这个紧急任务B,任务A虽然进行了一半,但是没办法,只能完全放弃,等到任务B全搞定之后,任务A重头来一遍,注意,是重头来一遍,不是从刚才中段的部分开始,也就是说,componentWillUpdate函数会被再调用一次。

在现有的React中,每个生命周期函数在一个加载或者更新过程中绝对只会被调用一次;在React Fiber中,不再是这样了,第一阶段中的生命周期函数在一次加载和更新过程中可能会被多次调用!

image.png

使用React Fiber之后,一定要检查一下第一阶段相关的这些生命周期函数,看看有没有逻辑是假设在一个更新过程中只调用一次,有的话就要改了。

我们挨个看一看这些可能被重复调用的函数。

  • componentWillReceiveProps,即使当前组件不更新,只要父组件更新也会引发这个函数被调用,所以多调用几次没啥,通过!
  • shouldComponentUpdate,这函数的作用就是返回一个true或者false,不应该有任何副作用,多调用几次也无妨,通过!
  • render,应该是纯函数,多调用几次无妨,通过!

只剩下componentWillMount和componentWillUpdate这两个函数往往包含副作用,所以当使用React Fiber的时候一定要重点看这两个函数的实现。

异步渲染的组件设计

由于 reconciliation 的阶段会被打断,可能会导致 commit 前的这些生命周期函数多次执行。react 官方目前已经把 componentWillMount、componentWillReceiveProps 和 componetWillUpdate 标记为 unsafe,并使用新的生命周期函数 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 进行替换。

static getDerivedStateFromProps

该函数在一个组件被实例化或者接受到新的 props 的时候被触发。它返回一个对象,该对象可用来更新 state。该函数与 componentDidMount 一起使用可以取代 componentWillReceiveProps的所有功能。

getSnapshotBeforeUpdate

该函数在任何变化(例如 DOM 更新)发生之前被触发。返回值被传给 componentDidUpdate。该函数与 componentDidUpdate 一起使用可以取代 componentWillUpdate 的所有功能

下面让我们通过具体的例子来看一看异步渲染中组件的优化设计。

获取外部数据

image.png

前一段代码使用 componentWillMount 获取外部数据,这种方式在异步渲染的过程中会出现请求被多次发起的情况,建议将获取数据的步骤放在 componentDidMount 中。

添加事件监听

image.png

这段代码是一个组件挂载时订阅外部事件分发器的例子,它可能会造成内存泄漏,从而导致异步渲染被打断,异步渲染的中断会导致 componentWillUnmount 不会被执行。

在 React 的设计中,componentWillMount 被执行并不能保证 componentWillUnmount 被执行,但是,componentDidMount 被执行是能保证 componentWillUnmount 被执行的。因此,建议大家在添加事件监听的时候使用 componentDidMount 函数。

根据 props 更新 state

image.png

这两段代码都是根据新的 props 去更新 state 的例子。因为经常发生滥用 componentWillReceiveProps 从而导致错误发生的情况,React 团队将会在未来废弃 componentWillReceiveProps 函数,建议使用新的生命周期函数 getDerivedStateFromProps 。

调用外部函数

image.png

这是一个组件内部 state 变化时调用外部函数的例子。当我们使用 componetWillUpdate 进行级联更新(如先渲染 DOM 元素,再根据 DOM 元素的大小添加提示工具)的时候,一个状态的更新会导致回调函数多次被调用。建议使用 componentDidUpdate,因为它能确保每次状态更新时回调函数仅被调用一次。

更新前获取 DOM 属性

image.png

这是一个通过获取 DOM 的属性维持滚动条位置的例子。在实现异步渲染的时,渲染的过程中会出现渲染阶段(componentWillUpdate 和 render)和提交阶段(componentDidUpdate)的时间差,在这段时间里,如果用户改变了窗口大小,那么 componentWillUpdate 中获取的 scrollHeight 其实就已经过期了。

针对这个问题,React 团队推荐使用新的生命周期函数 getSnapshotBeforeUpdate,该函数能够在变化发生前(DOM 更新前)被立刻调用,传递新的参数给 componentDidUpdate,在变化发生后 componentDidUpdate 被重新调用。


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。