前言
目前Suspense尚处于实验阶段,大部分文档还没有被翻译。我基于目前的官方文档,对Suspense作一些介绍。
什么是 Suspense ?
⚠️⚠️⚠️本文是概念性的,主要介绍
Suspense解决了那些问题,而不是正确的使用方法。目前Facebook只在生产中,使用了Suspense与Relay的集成方案。如果你不使用Relay,可能需要等待一段时间才能在应用程序中真正的使用Suspense。(本文示例中的代码是"伪"代码,真实的实现可能要复杂的多,示例代码不要复制粘贴到你的项目中。)
Suspense是React16.6版本中新增的组件,允许我们等待一些代码的加载,并在等待时声明加载状态。
// 使用React.lazy以及Suspense进行代码分割的例子
const Foo = React.lazy(() => import('./Foo'))
function Component () {
return (
<Suspense fallback={<Spinner />}>
<Component />
</Suspense>
)
}
Suspense for Data是一个新的特性。允许您使用Suspense等待任何其他内容。包括Ajax请求异步返回的数据。(本文着重于介绍异步获取数据的例子)。Suspense可以让组件在渲染之前进行等待。Suspense是一种通信机制,告知组件数据尚未准备就绪,React会等待它准备好后更新UI。
Suspense for Data 的简单示例

const apiParent = () => {
return new Promise(resolve => {
setTimeout(() => resolve({ name: 'parent' }), 1000)
})
}
const apiChild = () => {
return new Promise(resolve => {
setTimeout(() => resolve({ name: 'child' }), 2000)
})
}
// pending 时,wrapPromise会抛出一个Promise
// resolve 时,wrapPromise会返回结果
const wrapPromise = (promise) => {
let status = 'pending'
let result = null
let suspender = promise.then(res => {
status = 'success'
result = res
}).catch(err => {
status = 'error'
result = err
})
return {
read() {
if (status === 'pending') {
throw suspender
} else if (status === 'error') {
throw result
} else if (status === 'success') {
return result
}
}
}
}
const http = () => {
const parentPromise = apiParent()
const childPromise = apiChild()
return {
parent: wrapPromise(parentPromise),
child: wrapPromise(childPromise)
}
}
const resource = http()
function Parent () {
const result = resource.parent.read()
return <div>Parent: { result.name }</div>
}
function Child () {
const result = resource.child.read()
return <div>Child: { result.name }</div>
}
function App() {
return (
<div className="App">
{/* 在Parent没有返回结果前,显示<h1>Loading Parent...</h1> */}
<React.Suspense fallback={<h1>Loading Parent...</h1>}>
<Parent/>
{/* 在Child没有返回结果前,显示<h1>Loading Child...</h1> */}
<React.Suspense fallback={<h1>Loading Child...</h1>}>
<Child/>
</React.Suspense>
</React.Suspense>
</div>
)
}
Suspense 与传统请求数据的方法
在实际开发一个应用时,应该根据需求混合使用不同的方法。这里区别看待,只是为了更好的权衡它们的取舍。
我们完全可以在不提及其他数据获取方法的情况下,介绍 Suspense。但是这样我们就难以知道,Suspense解决了那些问题,以及Suspense与现在的方案有那些不同。
Approach 1: 渲染时请求数据(例如: useEffect,componentDidMount)
渲染时请求数据,是React应用中常用的获取数据的方法。因为它直到组件在屏幕上进行渲染后,才开始请求数据。会导致所谓的“瀑布”问题。
function Foo () {
const [state, setState] = useState('')
useEffect(() => {
api().then(res => setState(res))
}, [])
if (!state) return <div>Loading Foo……</div>
return (
<div>Foo</div>
)
}
function Bar () {
const [state, setState] = useState('')
useEffect(() => {
api().then(res => setState(res))
}, [])
if (!state) return <div>Loading Bar……</div>
return (
<React.Fragment>
<div>Bar</div>
<Foo/>
</React.Fragment>
)
}
function App() {
return (
<div className="App">
<Bar/>
</div>
)
}
考虑上面的代码。代码的执行顺序将会是
- 开始获取Bar组件的数据
- 等待……
- 完成渲染Bar组件
- 开始获取Foo组件的数据
- 等待……
- 完成渲染Foo组件
如果获取Bar组件的数据,需要花费3秒。那么,我们只能在3秒后,开始获取Foo组件的数据。这就是“瀑布问题”, 应该被并行处理的请求序列。
Approach 2: 请求数据完成后渲染
我们可以使用Promise.all避免瀑布问题。
const api = (ms = 1000, type) => {
return new Promise(resolve => {
setTimeout(() => resolve('result'), ms)
})
}
const fakeHttp = () => {
return Promise.all([api(1000, 'bar'), api(3000, 'foo')])
}
const promise = fakeHttp()
function Foo (props) {
if (!props.state) return <div>Loading Foo……</div>
return (
<div>Foo</div>
)
}
function Bar () {
const [state, setState] = useState('')
useEffect(() => {
promise.then(res => setState(true))
}, [])
if (!state) return <div>Loading Bar……</div>
return (
<React.Fragment>
<div>Bar</div>
<Foo state={state}/>
</React.Fragment>
)
}
考虑上面的代码。代码的执行顺序将会是
- 开始获取Bar组件的数据
- 开始获取Foo组件的数据
- 等待……
- 完成渲染Bar组件
- 完成渲染Foo组件
我们解决了瀑布问题。但是却映入了另一个问题,我们必须等待所有数据返回后才开始渲染。
虽然我们可以把请求从Promise.all拆开,分别发起两个Promise,但是随着组件树越发的复杂,这显然不是一个好主意,维护起来将会相当的困难。
Approach 3: 按需渲染(例如:集成了Suspense的Relay)
在之前的方法中。我们的步骤都是
- 开始异步获取数据
- 异步请求完成
- 开始渲染
使用Suspense后,我们可以无需等待响应返回就开始渲染。
- 开始异步获取数据
- 开始渲染
- 异步请求完成
const resource = http()
function Foo () {
const result = resource.foo.read()
return <div>Foo: { result.name }</div>
}
function Bar () {
const result = resource.bar.read()
return <div>Bar: { result.name }</div>
}
function Page () {
return (
<React.Suspense fallback={<h1>Loading Foo...</h1>}>
<Foo/>
<React.Suspense fallback={<h1>Loading Bar...</h1>}>
<Bar/>
</React.Suspense>
</React.Suspense>
)
}
function App() {
return (
<div className="App">
<Page/>
</div>
)
}
- 渲染之前,http开始请求数据,它会返回一个特殊的资源,而不是Promise(这通常由实现了Suspense的请求库进行封装)。
- React尝试渲染Page组件,返回Foo,和Bar作为子组件。
- React尝试渲染Foo,
resource.foo.read()没有返回数据,组件被挂起。React跳过它,尝试渲染树中的其他组件。 - React尝试渲染Bar,
resource.bar.read()没有返回数据,组件被挂起,React跳过它。 - 暂时没有东西可以渲染了,React会渲染组件树最上方的
Suspense fallback。 - 随着数据的流入,React会尝试重新渲染,最终获取所有的数据后,页面上的
Suspense fallback将会消失。
当我们调用read()方法时,要么获取数据,要么将组件挂起
使用Suspense可以帮助我们消除if (statr) return loading这样的的模版代码。我们还可以根据需要,增删Suspense组件控制加载状态的粒度(比如,两个列表的情况下。我只想要一个加载态,可以在两个列表的外面,统一添加一层Suspense边界。如果需想要两个加载态,可以给各个列表各添加一个Suspense边界)而无需对组件代码进行侵入式的修改。
Suspense 与竞态问题
const api = (id) => {
const ms = getRandomTime()
return new Promise(resolve => {
setTimeout(() => resolve(id), ms)
})
}
function Bar (props) {
const { id, clickNumber } = props
if (!id) return <h1>Loading……</h1>
return (
<div>state: { id } clickNumber: { clickNumber }</div>
)
}
let id = 0
let clickNumber = 0
function Page () {
const [selfId, setSelfId] = useState(id)
return (
<>
<button onClick={() => {
id += 1
clickNumber += 1
api(id).then((id) => setSelfId(id))
}}>+</button>
<Bar id={selfId} clickNumber={clickNumber}/>
</>
)
}

在上面的代码,接口返回的结果可能存在“竞态”的问题。
因为每一次接口响应返回时间是不确定的,所以可能存在前一次的返回的结果,覆盖后一次的情况。而使用Suspense可以很好的解决竞态的问题。下面我们使用Suspense重写示例。
const api = (id) => {
const ms = getRandomTime()
return new Promise(resolve => {
setTimeout(() => resolve(id), ms)
})
}
const http = (id) => {
return wrapPromise(api(id))
}
function Bar (props) {
const { resource, clickNumber } = props
const id = resource.read()
return (
<div>state: { id } clickNumber: { clickNumber }</div>
)
}
let id = 0
let clickNumber = 0
const initResource = http(id)
function Page () {
const [resource, setResource] = useState(initResource)
return (
<>
<button onClick={() => {
id += 1
clickNumber += 1
setResource(http(id))
}}>+</button>
<React.Suspense fallback={<h1>Loading……</h1>}>
<Bar resource={resource} clickNumber={clickNumber}/>
</React.Suspense>
</>
)
}

在Suspense版本的例子中,我们不需要等待响应结束后设置组件的状态,这样很容易出错,因为我们需要考虑设置对应状态的时机。我们直接传给子组件资源对象resource,只要resource.read没有返回数据,组件将一直处于挂起的状态,当props.resource更新,重新请求,组件依然处于挂起的状态,只到resource.read返回数据,组件才会被重新渲染,我们就不需要考虑竞态的问题。
Suspense 处理错误
当异步请求发生了错误,Suspense可以借助“错误边界”捕获异步请求抛出的错误。
class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
render () {
if (this.state.hasError) {
return <h1>:( error</h1>
}
return this.props.children;
}
}
function Page () {
const [resource, setResource] = useState(initResource)
return (
<>
<button onClick={() => {
id += 1
clickNumber += 1
setResource(http(id))
}}>+</button>
{/* 使用错误边界捕获异步错误 */}
<ErrorBoundary>
<React.Suspense fallback={<h1>Loading……</h1>}>
<Bar resource={resource} clickNumber={clickNumber}/>
</React.Suspense>
</ErrorBoundary>
</>
)
}
结语
上面仅是作者自己的理解,如有错误请及时指出。