<Suspense> 允许你在其子组件加载完成之前显示一个回退界面。
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>参考
<Suspense>
属性
children:你打算渲染的实际 UI。如果children在渲染时挂起,Suspense 边界将切换为渲染fallback。fallback:如果实际 UI 还未完成加载,用来替代渲染的备用 UI。任何有效的 React 节点都可以接受,不过在实践中,回退界面通常是一个轻量级占位视图,例如加载中的转圈器或骨架屏。当children挂起时,Suspense 会自动切换到fallback;当数据准备好后,再切换回children。如果fallback在渲染时挂起,它会激活最近的父级 Suspense 边界。
注意事项
- 对于那些在第一次挂载之前就挂起的渲染,React 不会保留任何状态。当组件加载完成后,React 会从头重新尝试渲染挂起的树。
- 如果 Suspense 曾经显示过该树的内容,但随后它再次挂起,则会再次显示
fallback,除非导致它挂起的更新是由startTransition或useDeferredValue引起的。 - 如果 React 需要隐藏已经可见的内容,因为它再次挂起了,那么它会清理内容树中的 布局 Effect。当内容再次准备好显示时,React 会再次触发布局 Effect。这可以确保测量 DOM 布局的 Effect 不会在内容被隐藏时尝试这么做。
- React 在底层包含了一些优化,例如与 Suspense 集成的 流式服务端渲染 和 选择性 Hydration。阅读 架构概览 并观看 技术演讲 了解更多。
用法
在内容加载时显示回退界面
你可以用一个 Suspense 边界包裹应用的任意部分:
<Suspense fallback={<Loading />}>
<Albums />
</Suspense>React 会显示你的 加载回退界面,直到 子组件 所需的所有代码和数据都已加载完成。
在下面的示例中,Albums 组件在获取专辑列表时会挂起。在它准备好渲染之前,React 会把上方最近的 Suspense 边界切换为显示回退界面——你的 Loading 组件。然后,当数据加载完成后,React 会隐藏 Loading 回退界面,并带着数据渲染 Albums 组件。
import { Suspense } from 'react'; import Albums from './Albums.js'; export default function ArtistPage({ artist }) { return ( <> <h1>{artist.name}</h1> <Suspense fallback={<Loading />}> <Albums artistId={artist.id} /> </Suspense> </> ); } function Loading() { return <h2>🌀 Loading...</h2>; }
一次性一起显示内容
默认情况下,Suspense 内部的整个树会被视为一个整体。例如,即使这些组件中只有一个在挂起等待某些数据,所有组件也会一起被加载指示器替换:
<Suspense fallback={<Loading />}>
<Biography />
<Panel>
<Albums />
</Panel>
</Suspense>然后,当它们全部准备好显示时,它们会一起同时出现。
在下面的示例中,Biography 和 Albums 都会获取一些数据。不过,因为它们被归在同一个 Suspense 边界下,这些组件总是会在同一时间一起“出现”。
import { Suspense } from 'react'; import Albums from './Albums.js'; import Biography from './Biography.js'; import Panel from './Panel.js'; export default function ArtistPage({ artist }) { return ( <> <h1>{artist.name}</h1> <Suspense fallback={<Loading />}> <Biography artistId={artist.id} /> <Panel> <Albums artistId={artist.id} /> </Panel> </Suspense> </> ); } function Loading() { return <h2>🌀 Loading...</h2>; }
加载数据的组件不必是 Suspense 边界的直接子组件。例如,你可以把 Biography 和 Albums 移到一个新的 Details 组件中。这不会改变行为。Biography 和 Albums 共享同一个最近的父级 Suspense 边界,所以它们的显示是协调一致的。
<Suspense fallback={<Loading />}>
<Details artistId={artist.id} />
</Suspense>
function Details({ artistId }) {
return (
<>
<Biography artistId={artistId} />
<Panel>
<Albums artistId={artistId} />
</Panel>
</>
);
}随着内容加载逐步显示嵌套内容
当组件挂起时,最近的父级 Suspense 组件会显示回退界面。这使你能够嵌套多个 Suspense 组件来创建加载顺序。每个 Suspense 边界的回退界面都会在下一层内容可用时填充出来。例如,你可以给专辑列表提供自己的回退界面:
<Suspense fallback={<BigSpinner />}>
<Biography />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums />
</Panel>
</Suspense>
</Suspense>通过这个改动,显示 Biography 时就不需要“等待” Albums 加载完成。
这个顺序会是:
- 如果
Biography还没加载完成,则用BigSpinner替换整个内容区域显示。 - 一旦
Biography加载完成,BigSpinner会被内容替换。 - 如果
Albums还没加载完成,则AlbumsGlimmer会替换Albums及其父级Panel显示。 - 最后,一旦
Albums加载完成,它会替换AlbumsGlimmer。
import { Suspense } from 'react'; import Albums from './Albums.js'; import Biography from './Biography.js'; import Panel from './Panel.js'; export default function ArtistPage({ artist }) { return ( <> <h1>{artist.name}</h1> <Suspense fallback={<BigSpinner />}> <Biography artistId={artist.id} /> <Suspense fallback={<AlbumsGlimmer />}> <Panel> <Albums artistId={artist.id} /> </Panel> </Suspense> </Suspense> </> ); } function BigSpinner() { return <h2>🌀 Loading...</h2>; } function AlbumsGlimmer() { return ( <div className="glimmer-panel"> <div className="glimmer-line" /> <div className="glimmer-line" /> <div className="glimmer-line" /> </div> ); }
Suspense 边界让你可以协调 UI 中哪些部分应该总是同时“出现”,以及哪些部分应该在一系列加载状态中逐步显现。你可以在树中的任何位置添加、移动或删除 Suspense 边界,而不会影响应用其余部分的行为。
不要为每个组件都包一层 Suspense 边界。Suspense 边界的粒度不应比你希望用户感受到的加载顺序更细。如果你和设计师一起工作,问问他们加载状态应该放在哪里——很可能他们已经在设计线框图里包含了这些内容。
在新内容加载时显示旧内容
在这个示例中,SearchResults 组件在获取搜索结果时会挂起。输入 "a",等待结果出现,然后将其编辑为 "ab"。"a" 的结果会被加载回退界面替换。
import { Suspense, useState } from 'react'; import SearchResults from './SearchResults.js'; export default function App() { const [query, setQuery] = useState(''); return ( <> <label> 搜索专辑: <input value={query} onChange={e => setQuery(e.target.value)} /> </label> <Suspense fallback={<h2>Loading...</h2>}> <SearchResults query={query} /> </Suspense> </> ); }
一种常见的替代 UI 模式是延迟更新列表,并在新结果准备好之前继续显示之前的结果。useDeferredValue Hook 允许你向下传递查询的延迟版本:
export default function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<label>
搜索专辑:
<input value={query} onChange={e => setQuery(e.target.value)} />
</label>
<Suspense fallback={<h2>Loading...</h2>}>
<SearchResults query={deferredQuery} />
</Suspense>
</>
);
}query 会立即更新,所以输入框会显示新值。不过,deferredQuery 会在数据加载完成之前保持上一个值,因此 SearchResults 会在一段时间内显示旧结果。
为了让用户更容易察觉,你可以在显示旧结果列表时添加一个视觉提示:
<div style={{
opacity: query !== deferredQuery ? 0.5 : 1
}}>
<SearchResults query={deferredQuery} />
</div>在下面的示例中输入 "a",等待结果加载完成,然后把输入框编辑为 "ab"。请注意,现在你看到的不是 Suspense 回退界面,而是在新结果加载完成前显示的变暗旧结果列表:
import { Suspense, useState, useDeferredValue } from 'react'; import SearchResults from './SearchResults.js'; export default function App() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <> <label> 搜索专辑: <input value={query} onChange={e => setQuery(e.target.value)} /> </label> <Suspense fallback={<h2>Loading...</h2>}> <div style={{ opacity: isStale ? 0.5 : 1 }}> <SearchResults query={deferredQuery} /> </div> </Suspense> </> ); }
import { Suspense, useState } from 'react'; import IndexPage from './IndexPage.js'; import ArtistPage from './ArtistPage.js'; import Layout from './Layout.js'; export default function App() { return ( <Suspense fallback={<BigSpinner />}> <Router /> </Suspense> ); } function Router() { const [page, setPage] = useState('/'); function navigate(url) { setPage(url); } let content; if (page === '/') { content = ( <IndexPage navigate={navigate} /> ); } else if (page === '/the-beatles') { content = ( <ArtistPage artist={{ id: 'the-beatles', name: 'The Beatles', }} /> ); } return ( <Layout> {content} </Layout> ); } function BigSpinner() { return <h2>🌀 Loading...</h2>; }
当你点击按钮时,Router 组件渲染了 ArtistPage,而不是 IndexPage。ArtistPage 内部的某个组件挂起了,因此最近的 Suspense 边界开始显示回退界面。最近的 Suspense 边界靠近根节点,所以整个网站布局都被 BigSpinner 替换了。
为避免这种情况,你可以使用 startTransition 将导航状态更新标记为过渡:
function Router() {
const [page, setPage] = useState('/');
function navigate(url) {
startTransition(() => {
setPage(url);
});
}
// ...这会告诉 React,这个状态转换不是紧急的,最好继续显示上一页,而不要隐藏任何已经显示出来的内容。现在点击按钮会“等待” Biography 加载完成:
import { Suspense, startTransition, useState } from 'react'; import IndexPage from './IndexPage.js'; import ArtistPage from './ArtistPage.js'; import Layout from './Layout.js'; export default function App() { return ( <Suspense fallback={<BigSpinner />}> <Router /> </Suspense> ); } function Router() { const [page, setPage] = useState('/'); function navigate(url) { startTransition(() => { setPage(url); }); } let content; if (page === '/') { content = ( <IndexPage navigate={navigate} /> ); } else if (page === '/the-beatles') { content = ( <ArtistPage artist={{ id: 'the-beatles', name: 'The Beatles', }} /> ); } return ( <Layout> {content} </Layout> ); } function BigSpinner() { return <h2>🌀 Loading...</h2>; }
过渡不会等待所有内容都加载完成。它只会等待足够长的时间,以避免隐藏已经显示出来的内容。例如,网站的 Layout 已经显示出来了,因此把它隐藏在加载转圈器后面是不合适的。不过,包裹 Albums 的嵌套 Suspense 边界是新的,所以过渡不会等待它。
指示正在发生过渡
在上面的示例中,一旦你点击按钮,就没有视觉提示表明导航正在进行。要添加指示器,你可以用 useTransition 替换 startTransition,它会提供一个布尔值 isPending。在下面的示例中,它用于在过渡进行时更改网站头部的样式:
import { Suspense, useState, useTransition } from 'react'; import IndexPage from './IndexPage.js'; import ArtistPage from './ArtistPage.js'; import Layout from './Layout.js'; export default function App() { return ( <Suspense fallback={<BigSpinner />}> <Router /> </Suspense> ); } function Router() { const [page, setPage] = useState('/'); const [isPending, startTransition] = useTransition(); function navigate(url) { startTransition(() => { setPage(url); }); } let content; if (page === '/') { content = ( <IndexPage navigate={navigate} /> ); } else if (page === '/the-beatles') { content = ( <ArtistPage artist={{ id: 'the-beatles', name: 'The Beatles', }} /> ); } return ( <Layout isPending={isPending}> {content} </Layout> ); } function BigSpinner() { return <h2>🌀 Loading...</h2>; }
在导航时重置 Suspense 边界
在过渡期间,React 会避免隐藏已经显示出来的内容。不过,如果你导航到一个带有不同参数的路由,你可能希望告诉 React 这是不同的内容。你可以用 key 来表达这一点:
<ProfilePage key={queryParams.id} />假设你正在用户资料页内切换,而且某些内容挂起了。如果该更新被包裹在过渡中,它不会为已经可见的内容触发回退界面。这是预期行为。
不过,现在再想象你在两个不同的用户资料之间切换。在这种情况下,显示回退界面是合理的。例如,一个用户的时间线与另一个用户的时间线是不同内容。通过指定 key,你可以确保 React 将不同用户的资料视为不同组件,并在导航期间重置 Suspense 边界。集成了 Suspense 的路由器应该会自动这样做。
为服务器错误和仅客户端内容提供回退界面
如果你使用的是 流式服务端渲染 API 之一(或依赖它们的框架),React 也会使用你的 <Suspense> 边界来处理服务器上的错误。如果某个组件在服务器上抛出错误,React 不会中止服务器渲染。相反,它会找到它上方最近的 <Suspense> 组件,并把它的回退界面(例如一个转圈器)包含进生成的服务器 HTML 中。用户一开始会看到一个转圈器。
在客户端,React 会再次尝试渲染同一个组件。如果它在客户端也出错了,React 会抛出该错误并显示最近的 错误边界。 但是,如果它在客户端没有出错,React 就不会向用户显示该错误,因为内容最终已经成功显示了。
你可以利用这一点,让某些组件不在服务器上渲染。为此,在服务器环境中抛出一个错误,然后用 <Suspense> 边界将它们包裹起来,用回退界面替换它们的 HTML:
<Suspense fallback={<Loading />}>
<Chat />
</Suspense>
function Chat() {
if (typeof window === 'undefined') {
throw Error('Chat should only render on the client.');
}
// ...
}服务器 HTML 会包含加载指示器。到客户端后,它会被 Chat 组件替换。
故障排除
如何防止在更新期间 UI 被回退内容替换?
用回退内容替换可见 UI 会造成突兀的用户体验。当一次更新导致某个组件挂起,并且最近的 Suspense 边界已经在向用户显示内容时,就可能发生这种情况。
为了防止这种情况发生,使用 startTransition 将更新标记为非紧急。在一次 Transition 期间,React 会等待,直到有足够的数据加载完成,从而避免出现不必要的回退内容:
function handleNextPageClick() {
// 如果此更新发生挂起,不要隐藏已经显示的内容
startTransition(() => {
setCurrentPage(currentPage + 1);
});
}这样可以避免隐藏已有内容。不过,任何新渲染的 Suspense 边界仍会立即显示回退内容,以避免阻塞 UI,并让用户在内容可用时立即看到它。
React 只会在非紧急更新期间防止不必要的回退内容。如果一次渲染是由紧急更新导致的,它不会延迟渲染。你必须通过诸如 startTransition 或 useDeferredValue 这样的 API 明确启用这一行为。
如果你的路由器与 Suspense 集成,它应该自动将其更新包装进 startTransition 中。