背景
React Fiber
React Fiber 是在 v16 的时候引入的一个全新架构, 旨在解决异步渲染的问题。新的架构使得使得 React 用异步渲染成为可能,但要注意,这个改变只是让异步渲染成为可能。但是 React 却并没有在 v16 发布的时候立刻开启,也就是说,React 在 v16 发布之后依然使用的是同步渲染
不过,虽然异步渲染没有立刻采用,Fiber 架构还是打开了通向新世界的大门,React v16 一系列新功能几乎都是基于 Fiber 架构。说到这, 也要说一下同步渲染和异步渲染
同步渲染
我们都知道 React 是facebook 推出的,他们内部也在大量使用这个框架,然后就发现了很多问题,比较突出的就是渲染问题
他们的应用是比较复杂的,组件树也是非常庞大,假设有一千个组件要渲染,每个耗费1ms,一千个就是1000ms,由于 javascript 是单线程的,这 1000ms 里 CPU 都在努力的干活,一旦开始,中间就不会停。如果这时候用户去操作,比如输入、点击按钮,此时页面是没有响应的。等更新完了,你之前的那些输入就会啪啪啪一下子出来了。这就是我们说的页面卡顿,用起来很不爽, 体验不好
这个问题和设备性能没有多大关系, 归根结底还是同步渲染机制的问题。目前的 React 版本(v16.7), 当组件树很大的时候,也会出现这个问题,逐层渲染,逐渐深入,不更新完就不会停
函数调用栈如图所示:
因为 JavaScript 单线程的特点,每个同步任务不能耗时太长,不然就会让程序不会对其他输入作出相应,React 的更新过程就是犯了这个禁忌,而 React Fiber 就是要改变现状
异步渲染
Fiber 的做法是:分片
把一个很耗时的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。 而维护每一个分片的数据结构,就是 Fiber
用一张图来展示 Fiber 的碎片化更新过程:
中间每一个波谷代表深入某个分片的执行过程,每个波峰就是一个分片执行结束交还控制权的时机
更详细的信息可以看: Lin Clark - A Cartoon Intro to Fiber - React Conf 2017
在 React Fiber 中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级的更新任务所做的工作则会完全作废,然后等待机会从头再来
因为一个更新过程可能被打断,所以 React Fiber 一个更新过程被分为两个阶段:render phase and commit phase
两个重要概念:render phase and commit phase
有了 Fiber 之后,react 的渲染过程不再是一旦开始就不能终止的模式了,而是划分成为了两个过程:第一阶段和第二阶段,也就是官网所谓的 render phase and commit phase
在 Render phase 中,React Fiber 会找出需要更新哪些DOM,这个阶段是可以被打断的,而到了第二阶段 commit phase,就一鼓作气把 DOM 更新完,绝不会被打断
两个阶段的分界点
这两个阶段, 分界点是什么呢?
其实是 render 函数,而且,render 函数也是属于第一阶段 render phase 的
那这两个 phase 包含的的生命周期函数有哪些呢?
render phase:
- componentWillMount
- componentWillReceiveProps
- shouldComponentUpdate
- componentWillUpdate
commit phase:
- componentDidMount
- componentDidUpdate
- componentWillUnmount
因为第一阶段的过程会被打断而且重头再来,就会造成意想不到的情况
比如说,一个低优先级的任务A正在执行,已经调用了某个组件的 componentWillUpdate 函数,接下来发现自己的时间分片已经用完了,于是冒出水面,看看有没有紧急任务,发现真的有一个紧急任务B,接下来 React Fiber 就会去执行这个紧急任务B,任务A虽然进行了一半,但是没办法,只能完全放弃,等到任务B全搞定之后,任务A重头来一遍,注意,是重头来一遍,不是从刚才中断的部分开始,也就是说,componentWillUpdate 函数会被再调用一次
在现有的 React 中,每个生命周期函数在一个加载或者更新过程中绝对只会被调用一次。在 React Fiber 中,不再是这样了,第一阶段中的生命周期函数在一次加载和更新过程中可能会被多次调用
新的静态方法
为了减少一些开发者的骚操作,React v16.3,干脆引入了一个新的生命周期函数 getDerivedStateFromProps,这个函数是一个 static 函数,也是一个纯函数,里面不能通过 this 访问到当前组件(强制避免一些有副作用的操作),输入只能通过参数,对组件渲染的影响只能通过返回值,目的大概也是让开发者逐步去适应异步渲染
我们再看一下 React v16.3 之前的的生命周期函数示意图:
再看看16.3的示意图:
上图中并包没有含全部 React 生命周期函数,另外在 React v16发布时,还增加了一个 componentDidCatch,当异常发生时,一个可以捕捉到异常的 componentDidCatch 就派上用场了。不过,很快 React 觉着这还不够,在v16.6.0 又推出了一个新的捕捉异常的生命周期函数 getDerivedStateFromError
如果异常发生在 render 阶段,React 就会调用 getDerivedStateFromError,如果异常发生在 commit 阶段,React会调用 componentDidCatch。 这个异常可以是任何类型的异常, 捕捉到这个异常之后可以做一些补救之类的事情
componentDidCatch 和 getDerivedStateFromError 的区别
componentDidCatch 和 getDerivedStateFromError 都是能捕捉异常的,那他们有什么区别呢?
我们之前说了两个阶段, render phase 和 commit phase。render phase 里产生异常的时候,会调用 getDerivedStateFromError,在 commit phase 里产生异常的时候, 会调用 componentDidCatch
严格来说, 其实还有一点区别:
componentDidCatch 是不会在服务器端渲染的时候被调用的,而 getDerivedStateFromError 会
小结
渲染的两个阶段:render phase 和 commit phase:
- render phase 可以被打断,不要在此阶段做一些有副作用的操作,可以放心在 commit phase 里做
- 然后就是生命周期的调整,react 把有可能在 render phase 里做的有副作用的函数都改成了 static 函数,强迫开发者做一些纯函数的操作
suspense
Suspense要解决的两个问题:
- 代码分片
- 异步获取数据
刚开始的时候,React 觉得自己只是管视图的,代码打包的事不归我管,怎么拿数据也不归我管。代码都打包到一起,比如十几M,下载就要半天,体验显然不会好到哪里去
可是后来这两个事情越来越重要, React 又觉得还是要掺和一下,是时候站出来展现真正的技术了。所以 Suspense 在 v16.6 的时候已经解决了代码分片的问题,异步获取数据还没有正式发布
先看一个简单的例子:
import React from "react";
import moment from "moment";
const Clock = () => <h1>{moment().format("MMMM Do YYYY, h:mm:ss a")}</h1>;
export default Clock;
假设我们有一个组件,是看当前时间的,它用了一个很大的第三方插件,而我只想在用的时候再加载资源,不打在总包里
再看一段代码:
// Usage of Clock
const Clock = React.lazy(() => {
console.log("start importing Clock");
return import("./Clock");
});
这里我们使用了 React.lazy,这样就能实现代码的懒加载。React.lazy 的参数是一个 function,返回的是一个promise。这里返回的是一个 import 函数,webpack build 的时候,看到这个东西,就知道这是个分界点。import 里面的东西可以打包到另外一个包里
真正要用的话, 代码大概是这个样子的:
<Suspense fallback={<Loading />}>
{ showClock ? <Clock/> : null}
</Suspense>
showClock 为 true,就尝试 render clock,这时候就触发另一个事件:去加载 clock.js 和它里面的 lib momment
看到这你可能觉得奇怪,怎么还需要用个
前面我们说到,目前 react 的渲染模式还是同步的,一口气走到黑,那我现在画到 clock 这里,但是这 clock 在另外一个文件里,服务器就需要去下载,什么时候能下载完呢,不知道。假设你要花十分钟去下载,那这十分钟你让 react 去干啥,总不能一直等你吧。Suspens 就是来解决这个问题的,你要画clock,现在没有,那就会抛一个异常出来,我们之前说 componentDidCatch 和 getDerivedStateFromProps,这两个函数就是来抓子组件或者子子组件抛出的异常的
子组件有异常的时候就会往上抛,直到某个组件的 getDerivedStateFromProps 抓住这个异常,抓住之后先忍着。 下载资源的时候会抛出一个 promise,会有地方(这里是 suspense )捕捉这个 promise,suspense 实现了getDerivedStateFromProps,getDerivedStateFromProps 捕获到异常的时候,就等这个 promise resole,resolve 完成之后,它会尝试重新画一下子组件。这时候资源已经到本地了,也就能画成功了
以上大概就是 Suspense 的原理,其实也不是很复杂,就是利用了 componentDidCatch 和getDerivedStateFromError,其实刚开始在 v16 的时候, 是要用 componentDidCatch 的, 但它毕竟是 commit phase 里的东西,还是分出来吧,所以又加了个 getDerivedStateFromError 来实现 Suspense 的功能
这里需要注意的是 reRender 会渲染 suspense 下面的所有子组件
异步渲染什么时候开启呢,根据介绍说是在19年的第二个季度随着一个小版本的升级开启,让我们提前做好准备。
做些什么准备呢?
- render 函数之前的代码都检查一遍, 避免一些有副作用的操作
到这,我们说完了Suspense 的一半功能,还有另一半: 异步获取数据
目前这一部分功能还没正式发布,那我们获取数据还是只能在 commit phase 做,也就是在 componentDidMount 里或者 didUpdate 里做
就目前来说, 如果一个组件要自己获取数据,就必须实现为一个类组件,而且会画两次,第一次没有数据,是空的,你可以画个 loading,didMount 之后发请求,数据回来之后,把数据 setState 到组件里,这时候有数据了, 再画一次,就画出来了
虽然是一个很简单的功能,我就想请求个数据,还要写一堆东西,很麻烦,但在目前的正式版里不得不这么做
但以后这种情况会得到改善, 看一段示例:
import {unstable_createResource as createResource} from 'react-cache';
const resource = createResource(fetchDataApi);
const Foo = () => {
const result = resource.read();
return (
<div>{result}</div>
);
// ...
<Suspense>
<Foo />
</Suskpense>};
代码里我们看不到任何譬如 async await 之类的操作,看起来完全是同步的操作,这是什么原理呢?
上面的例子里, 有个 resource.read(),这里就会调api,返回一个promise,上面会有 suspense 抓住,等 resolve 的时候,再画一下,就达到目的了
可问题是 resource.read() 明显是一个有副作用的操作,而且 render 函数又属于 render phase,之前又说,不建议在 render phase 里做有副作用的操作,这么矛盾,不是自己打脸了吗?
这里也能看出来 React 团队现在还没完全想好,目前放出来测试 api 也是以 unstable_ 开头的,用意还是很明显的:让大家不要写 class 组件,Suspense 能很好的支持函数式组件