-
Notifications
You must be signed in to change notification settings - Fork 128
Description
Suspense 是什么
Suspense 是React今年推出的一个新特性,在 16.6 已经可以部分使用,不过完整的特性支持要等到2019年中。简单的说,Suspense 可以以更简单的方式来实现异步数据获取。
让我们通过一个示例来理解,假设我们有一个组件,可以加载并显示一个电影的名字和简介。为了演示,我们不会获取一个列表,而是每一个电影单独获取自己的数据。这样就会有两个组件:
List组件会是电影列表,其中每一个电影信息都是一个Movie组件Movie组件会负责加载并显示指定id的电影信息
假设我们不借助任何 Redux 之类的数据层框架,我们一般会这样实现:
movie.js
import React, { Component } from 'react';
import Spinner from '../Spinner.js';
import { fetchMovie } from '../api.js';
class Movie extends Component {
constructor(props) {
super(props)
this.state = {
loading: true,
data: undefined
}
}
componentDidMount() {
fetchMovie(this.props.id).then((data) => {
this.setState({
loading: false,
data: data
})
})
}
render() {
if (this.state.loading) return <Spinner />;
return (
<div className="movie">
<h4>{this.state.data.title}</h4>
<p>{this.state.data.info}</p>
</div>
)
}
}
export default Movielist.js
import React, { Component } from 'react';
import Movie from './movie.js';
class List extends Component {
render() {
return (
<div>
<h2>热门电影</h2>
<Movie id={0} />
<Movie id={1} />
<Movie id={2} />
<Movie id={3} />
</div>
);
}
}
export default List;这是一段很常见的代码,基本逻辑如下:
- 组件加载的时候显示一个加载中的状态,同时去异步加载数据
- 数据加载完成后显示电影信息
那么这么做存在哪些问题呢?
- 额外的
loading标记 - 组件渲染的生命周期和数据加载周期混在一起
- 每次加载Movie组件都会重新请求数据
- 使用多个
Movie的时候会出现多个Spinner
那么Suspense是如何解决这些问题的?
suspense 如何解决
首先我们看用 Suspense 是如何写上述组件的:
import React from 'react';
import { fetchMovie } from '../api.js';
import { unstable_createResource as createResource } from "react-cache";
/**
* 使用Suspense实现
*/
const MovieFetcher = createResource(fetchMovie);
export default (props) => {
const data = MovieFetcher.read(props.id)
return (
<div className="movie">
<h4>{data.title}</h4>
<p>{data.info}</p>
</div>
)
}有人可能立马发现了,不是说好了用 Suspense 吗,在哪呢?
其实是因为 Suspense 会被放在父组件中,别急,我们先看 Movie 组件。
这里有一个非常反常的地方,就是在 render 中竟然加载了数据,而且就直接用了。显然正常情况下这样是错的。
- 第一,render中不应该加载数据,因为render可能会多次被执行导致多次加载数据
- 第二,就算非要这么写也没用,因为数据加载是异步的
然而事实是这确实是正确的写法,当执行到 return 的时候我们的数据已经获取到了。这正是 Suspense 的神奇之处,他可以在获取异步数据的时候 暂停 渲染,当数据获取到的时候继续渲染。为了搞清楚他是如何做到的,让我们继续看下父组件是怎么样的。
比如我们有一个 list 组件会显示多个 Movie,那么 List 组件如下
import React, { Suspense, Component } from 'react';
import Movie from './movie.js';
import Spinner from '../Spinner.js';
class List extends Component {
render() {
return (
<div>
<h2>热门电影</h2>
<Suspense maxDuration={1500} fallback={<Spinner />}>
<Movie id={0} />
<Movie id={1} />
<Movie id={2} />
<Movie id={3} />
</Suspense>
</div>
);
}
}
export default List;可以看到 Movie 会被一个 Suspense 包裹起来。当Movie在加载数据的时候,Suspense 会降级显示 fallback 中的内容,加载完成后会显示真实的数据。
原理是这样的:
createResource加载数据的时候会进行缓存,如果有缓存就直接用,当没有缓存的时候会抛出一个异常并异步加载数据,这个异常是一个Promise,加载完成后会缓存数据并resolve- 由于
Movie加载数据的时候会抛出异常,因此执行到const data = MovieFetcher.read(props.id)会由于抛出异常而停止执行 Suspense会捕获这个异常,然后显示 fallback 中的内容- 当异常都被
resolve之后,Suspense才会render children。也就是movie的render函数会被重新调用,因为已经有数据了,就可以正常渲染了。
所以 Suspense 能 暂停 渲染的魔法就是通过异常来实现的。不过这里需要注意, render 被暂停之后,恢复执行的时候是重新执行了 render ,而不是从被暂停的地方继续执行,JS应该无法实现这种自由的暂停和恢复执行。
模拟实现方式
为了搞清楚Suspense的原理,让我们自己动手实现一个简陋的版本。要实现Suspense的功能,我们只需要实现两个关键点即可:
createResource函数,他会在数据没有加载的时候返回一个Promise,并且在加载到数据之后resolveSuspense高阶组件,如果发现子组件抛出了一个Promise错误,则显示fallback,当resolve之后就显示孩子。
因为完整的实现会稍微麻烦一些,这里为了演示原理,只做一个简单的实现。我们先实现createResource,只支持一个并发请求(因为我们用了一个全局 result 变量做缓存)。实现原理是这样的:
用一个result缓存请求结果,当调用 read 函数的时候先去判断有没有缓存数据,有缓存就返回,没有缓存就抛出一个 promise 异常,并且在resolve的时候缓存数据。
// 简单mock,只支持一个请求
// 重要提示:dev模式下会把错误显示出来,所以请在build模式下运行
let result = undefined;
export const createResource = (p) => {
return {
read (a) {
if (result) return result;
const _p = p(a).then((r) => {
result = r;
});
throw { p: _p }; // 不要直接抛出 _p,否则react会当做内置的Suspense逻辑处理
}
}
}细心的读者可能会发现,为什么不直接抛出 _p 而是用一个对象包装一下呢?这是因为 React 内部有对 Suspense 特性的支持,如果他发现有一个组件抛出了一个 Promise,就必须要求外面有一个 Suspense组件进行处理,而我们自己写的 Suspense 显然 react是不会认可的,因此会显示错误。所以这里用一个对象进行了一次包装。
那么 Suspense 应该怎么写呢?
Suspense 显然是一个高阶组件,他会包装我们的组件,在未加载的时候显示 fallback ,加载完成的时候显示我们的组件。通过 componentDidCatch 可以捕获 createResource 抛出的异常,并且能在 resolve 的时候读取数据。
import React, { Component } from 'react';
export default class extends Component {
state = { loading: false };
componentDidCatch(error, info) {
this.setState({
loading: true
});
error.p.then(() => {
this.setState({
loading: false
});
});
}
render() {
return this.state.loading ? this.props.fallback : this.props.children
}
}这样我们就自己实现了一个 Suspense,虽然很简陋,不过可以揭示Suspense的原理。
Suspense 的其他特性
Suspense还有一些其他特性,这里不一一展开。
- 可以设置一个阈值时间,在这个时间内不用显示loading,这样在较快的网速下就不会闪
- 支持 code splitting
- 等
suspense的优缺点
Suspense是一个争议很大的特性,因为他其实颠覆了React的一些哲学:
- React只负责View层,不会管数据
- render函数不能有数据获取操作
- 纯函数组件应该是无状态的
实际使用的时候还发现,Suspense 会等待所有的孩子都加载完成之后才能显示,这样如果有一个加载很慢就会导致页面空白很久。
虽然有这些问题,但是这个特性依然有一些好处:
- 一些有简单数据逻辑的组件将变得更加简洁易懂
- 视图和数据状态的解耦
- 可以替代部分redux的功能
有人可能会问是不是以后就不需要 Redux 之类的数据层框架了?我的看法的是,Suspense 能替代Redux 中获取数据,缓存数据的能力,但是无法替代跨层级的数据通信能力。因此对于需要经常进行跨层级组件通信的复杂应用来说,Suspense 依然无法满足需要。
资源
这篇博客里面提到的全部代码都在这个仓库中可以找到,包括三种不同实现方式的代码:https://github.com/lihongxun945/suspense-demo
参考
https://medium.com/@Charles_Stover/react-suspense-with-the-fetch-api-a1b7369b0469