这篇文章旨在帮助您了解 Suspense 的工作原理、它能做什么,以及它如何集成到真实的 Web 应用中。 我们将了解如何将 *路由* 和 *数据加载* 与 React 中的 Suspense 集成。 对于路由,我将使用原生 JavaScript,并将使用我自己的 micro-graphql-react GraphQL 库来获取数据。
如果您想知道 React Router,它似乎很棒,但我从未有机会使用它。 我自己的副项目有一个非常简单的路由故事,我一直都是手动完成的。 此外,使用原生 JavaScript 将让我们更好地了解 Suspense 的工作原理。
一些背景
让我们谈谈 Suspense 本身。 Kingsley Silas 提供了一个 关于它的详细概述,但首先要注意的是它仍然是一个实验性 API。 这意味着(React 的文档也说一样)现在不要把它用于生产环境。 它总是有可能在完全完成之前发生变化,所以请牢记这一点。
也就是说,Suspense 的重点是在异步依赖项(例如延迟加载的 React 组件、GraphQL 数据等)面前保持一致的 UI。 Suspense 提供了低级 API,允许您在应用管理这些事物时轻松维护 UI。
但“一致”在这种情况下的意思是? **它意味着*不*渲染部分完成的 UI。** 也就是说,如果页面上存在三个数据源,其中一个已完成,我们*不*希望渲染该更新的状态,并在旁边显示一个旋转器,旁边是现在过时的其他两个状态。

我们*要*做的是向用户表明数据正在加载,同时继续显示旧的 UI 或一个指示我们正在等待数据的替代 UI;Suspense 支持两者,我会详细介绍。

Suspense 究竟做了什么
这并不像看起来那么复杂。 传统上,在 React 中,您会设置状态,您的 UI 会更新。 生活很简单。 但这也导致了上面描述的那种不一致。 Suspense 添加的功能是让组件在渲染时通知 React 它正在等待异步数据;这被称为挂起,它可以在组件树的任何地方发生,只要需要就可以,直到树准备就绪。 当组件挂起时,React 将拒绝渲染挂起的状态更新,直到所有挂起的依赖项都得到满足。
那么当组件挂起时会发生什么呢? React 会向上查找树,找到第一个 <Suspense>
组件,并渲染它的回退。 我将提供很多示例,但现在请注意,您可以提供这个
<Suspense fallback={<Loading />}>
…如果 <Suspense>
的任何子组件挂起,<Loading />
组件将被渲染。
但如果我们已经拥有一个有效的、一致的 UI,而用户加载了新数据,导致组件挂起会怎样? 这将导致整个现有 UI 取消渲染,并显示回退。 这仍然是一致的,但几乎不是一个好的 UX。 我们更希望旧的 UI 留在屏幕上,同时加载新数据。
为了支持这一点,React 提供了第二个 API,useTransition,它实际上在 **内存中** 进行状态更改。 换句话说,它允许您在内存中设置状态,同时保持现有 UI 在屏幕上;React 会在内存中实际保留组件树的第二个副本,并在*该*树上设置状态。 组件可能会挂起,但仅在内存中,因此您的现有 UI 将继续显示在屏幕上。 当状态更改完成且所有挂起都已解决时,内存中的状态更改将渲染到屏幕上。 显然,您希望在发生这种情况时向用户提供反馈,因此 useTransition
提供了一个 pending
布尔值,您可以使用它来显示某种内联“加载”通知,同时在内存中解决挂起。
想想看,您可能不希望您的现有 UI 在加载挂起时无限期地显示。 如果用户尝试执行某些操作,并且在完成之前经过了很长时间,您可能应该考虑现有 UI 已过时且无效。 在这一点上,您可能**确实**希望您的组件树挂起,并显示您的 <Suspense>
回退。
为了实现这一点,useTransition
接受一个 timeoutMs
值。 这表示您愿意让内存中状态更改运行的时间量,然后再挂起。
const Component = props => {
const [startTransition, isPending] = useTransition({ timeoutMs: 3000 });
// .....
};
在这里,startTransition
是一个函数。 当您想在“内存中”运行状态更改时,您调用 startTransition
并传递一个执行状态更改的 lambda 表达式。
startTransition(() => {
dispatch({ type: LOAD_DATA_OR_SOMETHING, value: 42 });
})
您可以在任何地方调用 startTransition
。 您可以将其传递给子组件等。 当您调用它时,您执行的任何状态更改都将在内存中发生。 如果发生挂起,isPending
将变为 true,您可以使用它来显示某种内联加载指示器。
就是这样。 这就是 Suspense 的作用。
这篇文章的其余部分将深入探讨一些实际代码,以利用这些功能。
示例:导航
为了将导航绑定到 Suspense,您会很高兴知道 React 提供了一个原始方法来执行此操作:React.lazy
。 它是一个函数,它接受一个 lambda 表达式,该表达式返回一个 Promise,该 Promise 解析为一个 React 组件。 该函数调用的结果成为您延迟加载的组件。 听起来很复杂,但它看起来像这样
const SettingsComponent = lazy(() => import("./modules/settings/settings"));
SettingsComponent
现在是一个 React 组件,当渲染时(但不是之前),它将调用我们传入的函数,该函数将调用 import()
并加载位于 ./modules/settings/settings
的 JavaScript 模块。
关键点在于:在该 import()
正在执行时,渲染 SettingsComponent
的组件将挂起。 似乎我们已经拥有了所有必要的部分,所以让我们将它们放在一起,构建一些基于 Suspense 的导航。
导航帮助程序
但首先,为了便于理解,我将简要介绍一下此应用中如何管理导航状态,这样 Suspense 代码会更有意义。
我将使用我的 booklist 应用。 它只是我的一个副项目,我主要用来测试最前沿的 Web 技术。 它由我独自编写,所以希望它的一些部分有点粗糙(尤其是设计)。
该应用很小,用户可以浏览大约八个不同的模块,而无需更深入的导航。 任何模块可能使用的搜索状态都存储在 URL 的查询字符串中。 考虑到这一点,有一些方法可以从 URL 中提取当前模块名称和搜索状态。 此代码使用来自 npm 的 query-string
和 history
包,看起来有点像这样(为了简单起见,已删除了一些细节,例如身份验证)。
import createHistory from "history/createBrowserHistory";
import queryString from "query-string";
export const history = createHistory();
export function getCurrentUrlState() {
let location = history.location;
let parsed = queryString.parse(location.search);
return {
pathname: location.pathname,
searchState: parsed
};
}
export function getCurrentModuleFromUrl() {
let location = history.location;
return location.pathname.replace(/\//g, "").toLowerCase();
}
我有一个 appSettings
减少器,它保存应用的当前模块和 searchState
值,并使用这些方法在需要时与 URL 同步。
基于 Suspense 的导航的各个部分
让我们开始进行一些 Suspense 工作。 首先,让我们为我们的模块创建延迟加载的组件。
const ActivateComponent = lazy(() => import("./modules/activate/activate"));
const AuthenticateComponent = lazy(() =>
import("./modules/authenticate/authenticate")
);
const BooksComponent = lazy(() => import("./modules/books/books"));
const HomeComponent = lazy(() => import("./modules/home/home"));
const ScanComponent = lazy(() => import("./modules/scan/scan"));
const SubjectsComponent = lazy(() => import("./modules/subjects/subjects"));
const SettingsComponent = lazy(() => import("./modules/settings/settings"));
const AdminComponent = lazy(() => import("./modules/admin/admin"));
现在我们需要一个方法根据当前模块选择正确的组件。 如果我们使用 React Router,我们将有一些不错的 <Route />
组件。 由于我们正在手动滚动,switch
就足够了。
export const getModuleComponent = moduleToLoad => {
if (moduleToLoad == null) {
return null;
}
switch (moduleToLoad.toLowerCase()) {
case "activate":
return ActivateComponent;
case "authenticate":
return AuthenticateComponent;
case "books":
return BooksComponent;
case "home":
return HomeComponent;
case "scan":
return ScanComponent;
case "subjects":
return SubjectsComponent;
case "settings":
return SettingsComponent;
case "admin":
return AdminComponent;
}
return HomeComponent;
};
将所有内容放在一起
所有无聊的设置都已完成,让我们看看整个应用根节点是什么样子。 这里有很多代码,但我保证,其中只有相对较少的几行与 Suspense 相关,我会介绍所有内容。
const App = () => {
const [startTransitionNewModule, isNewModulePending] = useTransition({
timeoutMs: 3000
});
const [startTransitionModuleUpdate, moduleUpdatePending] = useTransition({
timeoutMs: 3000
});
let appStatePacket = useAppState();
let [appState, _, dispatch] = appStatePacket;
let Component = getModuleComponent(appState.module);
useEffect(() => {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
}, []);
useEffect(() => {
return history.listen(location => {
if (appState.module != getCurrentModuleFromUrl()) {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
} else {
startTransitionModuleUpdate(() => {
dispatch({ type: URL_SYNC });
});
}
});
}, [appState.module]);
return (
<AppContext.Provider value={appStatePacket}>
<ModuleUpdateContext.Provider value={moduleUpdatePending}>
<div>
<MainNavigationBar />
{isNewModulePending ? <Loading /> : null}
<Suspense fallback={<LongLoading />}>
<div id="main-content" style={{ flex: 1, overflowY: "auto" }}>
{Component ? <Component updating={moduleUpdatePending} /> : null}
</div>
</Suspense>
</div>
</ModuleUpdateContext.Provider>
</AppContext.Provider>
);
};
首先,我们对 useTransition
有两个不同的调用。 我们将使用一个进行路由到新模块,另一个用于更新当前模块的搜索状态。 为什么有区别? 好吧,当模块的搜索状态正在更新时,该模块可能希望显示一个内联加载指示器。 正在更新的状态由 moduleUpdatePending
变量保存,您会看到我将其放在上下文中,以便活动模块获取并按需使用。
<div>
<MainNavigationBar />
{isNewModulePending ? <Loading /> : null}
<Suspense fallback={<LongLoading />}>
<div id="main-content" style={{ flex: 1, overflowY: "auto" }}>
{Component ? <Component updating={moduleUpdatePending} /> : null} // highlight
</div>
</Suspense>
</div>
appStatePacket
是上面我提到的应用程序状态 reducer(但没有展示)的结果。它包含各种应用程序状态,这些状态很少更改(颜色主题、离线状态、当前模块等)。
let appStatePacket = useAppState();
稍后,我会根据当前模块名称获取恰好处于活动状态的组件。最初这将为 null。
let Component = getModuleComponent(appState.module);
对 useEffect
的第一次调用将告诉我们的 appSettings
reducer 在启动时与 URL 同步。
useEffect(() => {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
}, []);
由于这是 Web 应用程序导航到的初始模块,因此我将其包装在 startTransitionNewModule
中以指示正在加载新模块。虽然将初始模块名称作为其初始状态的 appSettings
reducer 可能很诱人,但这样做会阻止我们调用 startTransitionNewModule
回调,这意味着我们的 Suspense 边界将立即呈现回退,而不是在超时之后。
对 useEffect
的下一次调用将设置历史记录订阅。无论如何,当 url 发生更改时,我们都会告诉我们的应用程序设置与 URL 同步。唯一的区别是该调用包装在哪个 startTransition
中。
useEffect(() => {
return history.listen(location => {
if (appState.module != getCurrentModuleFromUrl()) {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
} else {
startTransitionModuleUpdate(() => {
dispatch({ type: URL_SYNC });
});
}
});
}, [appState.module]);
如果我们正在浏览到一个新模块,我们将调用 startTransitionNewModule
。如果我们正在加载尚未加载的组件,React.lazy
将暂停,并且仅对应用程序的根可见的挂起指示器将设置,这将在应用程序顶部显示加载微调器,同时获取和加载延迟组件。由于 useTransition
的工作方式,当前屏幕将继续显示三秒钟。如果该时间到期并且组件仍然未准备就绪,我们的 UI 将暂停,并且将呈现回退,这将显示 <LongLoading />
组件
{isNewModulePending ? <Loading /> : null}
<Suspense fallback={<LongLoading />}>
<div id="main-content" style={{ flex: 1, overflowY: "auto" }}>
{Component ? <Component updating={moduleUpdatePending} /> : null}
</div>
</Suspense>
如果我们没有更改模块,我们将调用 startTransitionModuleUpdate
startTransitionModuleUpdate(() => {
dispatch({ type: URL_SYNC });
});
如果更新导致暂停,我们将放置在上下文中的挂起指示器将被触发。活动组件可以检测到这一点并显示它想要的任何内联加载指示器。与之前一样,如果暂停时间超过三秒钟,将触发之前相同的 Suspense 边界……除非,正如我们将在后面看到的那样,树中存在更低的 Suspense 边界。
需要注意的重要一点是,这三个秒钟的超时不仅适用于组件加载,还适用于组件准备显示。如果组件在两秒钟内加载,并且在内存中渲染(因为我们在 startTransition
调用内)时暂停,useTransition
将继续等待最多一秒钟,然后再暂停。
在撰写这篇博文时,我使用 Chrome 的慢速网络模式来帮助强制加载速度变慢,以测试我的 Suspense 边界。设置位于 Chrome 开发工具的网络选项卡中。

让我们将我们的应用程序打开到设置模块。这将被称为
dispatch({ type: URL_SYNC });
我们的 appSettings
reducer 将与 URL 同步,然后将模块设置为“settings”。这将发生在 startTransitionNewModule
内部,因此当延迟加载的组件尝试渲染时,它将暂停。由于我们在 startTransitionNewModule
内部,isNewModulePending
将切换到 true
,并且将呈现 <Loading />
组件。


<LongLoading />
组件。
那么,当我们浏览到其他地方时会发生什么呢?基本上与之前相同,除了这个调用
dispatch({ type: URL_SYNC });
…将来自 useEffect
的第二个实例。让我们浏览到书籍模块,看看会发生什么。首先,内联微调器按预期显示



搜索和更新
让我们留在书籍模块中,并更新 URL 搜索字符串以启动新的搜索。回想一下,我们之前在第二个 useEffect
调用中检测到同一个模块,并为此使用了一个专用的 useTransition
调用。从那里,我们将挂起指示器放在上下文中,以便我们抓取并使用它。
让我们看一些代码来实际使用它。这里没有真正与 Suspense 相关的代码。我从上下文中获取值,如果为 true,则在我的现有结果之上渲染内联微调器。回想一下,当 useTransition
调用开始时,应用程序会暂停在内存中时,就会发生这种情况。同时,我们会继续显示现有的 UI,但带有此加载指示器。
const BookResults: SFC<{ books: any; uiView: any }> = ({ books, uiView }) => {
const isUpdating = useContext(ModuleUpdateContext);
return (
<>
{!books.length ? (
<div
className="alert alert-warning"
style={{ marginTop: "20px", marginRight: "5px" }}
>
No books found
</div>
) : null}
{isUpdating ? <Loading /> : null}
{uiView.isGridView ? (
<GridView books={books} />
) : uiView.isBasicList ? (
<BasicListView books={books} />
) : uiView.isCoversList ? (
<CoversView books={books} />
) : null}
</>
);
};
让我们设置一个搜索词并看看会发生什么。首先,内联微调器会显示。

然后,如果 useTransition
超时时间到期,我们将获得 Suspense 边界的回退。书籍模块定义了自己的 Suspense 边界,以便提供更细化的加载指示器,它看起来像这样

这是一个关键点。在制作 Suspense 边界回退时,请尝试不要抛出任何类型的微调器和“正在加载”消息。这对于我们的顶级导航是有意义的,因为没有太多其他事情可以做。但是,当你在应用程序的特定部分时,请尝试让你的回退重用许多相同的组件,并在数据所在的位置放置一些加载指示器,但禁用所有其他内容。
这些是我的书籍模块的相关组件看起来的样子
const RenderModule: SFC<{}> = ({}) => {
const uiView = useBookSearchUiView();
const [lastBookResults, setLastBookResults] = useState({
totalPages: 0,
resultsCount: 0
});
return (
<div className="standard-module-container margin-bottom-lg">
<Suspense fallback={<Fallback uiView={uiView} {...lastBookResults} />}>
<MainContent uiView={uiView} setLastBookResults={setLastBookResults} />
</Suspense>
</div>
);
};
const Fallback: SFC<{
uiView: BookSearchUiView;
totalPages: number;
resultsCount: number;
}> = ({ uiView, totalPages, resultsCount }) => {
return (
<>
<BooksMenuBarDisabled
totalPages={totalPages}
resultsCount={resultsCount}
/>
{uiView.isGridView ? (
<GridViewShell />
) : (
<h1>
Books are loading <i className="fas fa-cog fa-spin"></i>
</h1>
)}
</>
);
};
关于一致性的简短说明
在我们继续之前,我想指出之前屏幕截图中的一件事。看看搜索挂起时显示的内联微调器,然后看看该搜索暂停时的屏幕,接下来是完成的结果

注意搜索窗格右侧的“C++”标签,是否有一个从搜索查询中删除它的选项?或者更确切地说,注意该标签是否仅在后两个屏幕截图中?URL 更新的那一刻,控制该标签的应用程序状态确实更新了;但是,该状态最初没有显示。最初,状态更新会在内存中暂停(因为我们使用了 useTransition),并且之前的 UI 会继续显示。
然后回退会呈现。回退会呈现该相同搜索栏的禁用版本,它确实显示了当前搜索状态(根据选择)。我们现在已删除了之前的 UI(因为现在它已经相当旧且过时了),并且正在等待禁用菜单栏中显示的搜索。
这就是 Suspense 免费为你提供的这种一致性。
你可以花时间创建漂亮的应用程序状态,React 会完成推断事物是否已准备就绪的繁重工作,而无需你处理承诺。
嵌套的 Suspense 边界
假设我们的顶级导航需要一段时间才能将我们的书籍组件加载到我们的“仍然加载,抱歉”微调器从 Suspense 边界呈现的程度。从那里,书籍组件加载,书籍组件内部的新 Suspense 边界呈现。但是,然后,当渲染继续时,我们的书籍搜索查询触发并暂停。会发生什么?顶级 Suspense 边界会继续显示,直到一切都准备就绪,还是书籍中更低的 Suspense 边界会接管?
答案是后者。当新的 Suspense 边界在树中更低的位置呈现时,它们的回退将替换任何已经显示的先前的 Suspense 回退的回退。目前有一个不稳定的 API 可以覆盖它,但是如果你正在努力制作回退,这可能就是你想要的行为。你不会想要“仍然加载,抱歉”只是继续显示。相反,只要书籍组件准备就绪,你绝对想要显示带有更针对性等待消息的 shell。
现在,如果我们的书籍模块加载并开始呈现,同时 startTransition
微调器仍在显示,然后暂停,会发生什么?换句话说,想象一下我们的 startTransition
超时时间为三秒钟,书籍组件呈现,嵌套的 Suspense 边界在一秒钟后出现在组件树中,并且搜索查询暂停。在该新的嵌套 Suspense 边界呈现回退之前,剩余的两秒钟会过去,还是回退会立即显示?答案,也许令人惊讶的是,新的 Suspense 回退将默认立即显示。这是因为最好尽快显示新的有效 UI,以便用户可以看到事情正在发生和进展。
数据如何融入其中
导航很好,但是数据加载如何融入所有这些内容?
它完全透明地融入其中。数据加载会触发暂停,就像使用 React.lazy
进行导航一样,并且它会挂接到所有相同的 useTransition
和 Suspense 边界。这就是 Suspense 如此惊人的地方:所有异步依赖项都在同一个系统中无缝地工作。在 Suspense 之前,手动管理这些各种异步请求以确保一致性是一场噩梦,这正是没有人这样做的原因。Web 应用程序以级联微调器而闻名,这些微调器在不可预测的时间停止,产生不一致的 UI,这些 UI 只是部分完成。
好的,但是我们实际上如何将数据加载绑定到其中?Suspense 中的数据加载具有讽刺意味地既更复杂,也更简单。
我会解释。
如果你正在等待数据,你将在读取(或尝试读取)数据的组件中抛出一个承诺。该承诺应基于数据请求始终保持一致。因此,对相同“C++”搜索查询的四次重复请求应抛出相同且完全相同的承诺。这意味着某种形式的缓存层来管理所有这一切。你可能不会自己编写它。相反,你只需要希望并等待你使用的库更新自己以支持 Suspense。
这已经在我的 micro-graphql-react 库中完成。你将使用 useSuspenseQuery
钩子而不是 useQuery
钩子,它具有相同的 API,但在等待数据时会抛出一致的承诺。
等等,预加载是怎么回事?!
你在 Suspense 上阅读其他关于瀑布流、渲染时获取、预加载等内容时,是不是觉得脑子已经变成浆糊了?别担心,这里解释一下这些内容的意思。
假设你延迟加载 books 组件,该组件会渲染,然后请求一些数据,这会导致新的 Suspense。组件的网络请求和数据的网络请求将依次发生,以瀑布流的方式。
但关键在于:当开始加载组件时,导致组件加载时执行的初始查询的应用程序状态已经可用(在本例中是 URL)。那么,为什么不在知道需要该查询后立即“启动”该查询呢?在你浏览到 /books
时,为什么不立即触发当前的搜索查询,这样它在组件加载时就已经在执行了?
micro-graphql-react 模块确实有一个 preload
方法,我强烈建议你使用它。预加载数据是一种不错的性能优化,但它与 Suspense 无关。传统的 React 应用程序可以在(也应该)在知道需要数据时立即预加载数据。Vue 应用程序应该在知道需要数据时立即预加载数据。Svelte 应用程序应该......你明白了吧。
预加载数据与 Suspense 是正交的,你可以使用任何框架来完成它。这也是我们一直都应该做的事情,尽管其他人没有这样做。
说真的,怎么预加载呢?
这取决于你。至少,运行当前搜索的逻辑需要完全分离到一个独立的模块中。你应该确保这个预加载函数在单独的文件中。不要依赖 webpack 来进行树形摇动;下次审核你的捆绑包时,你可能会面临极度悲伤。
你有一个 preload()
方法,它在自己的捆绑包中,所以调用它。当你确定即将导航到该模块时,就调用它。我假设 React Router 有某种 API 可以在导航更改时运行代码。对于上面的普通路由代码,我在之前的路由切换中调用了该方法。为了简洁,我省略了它,但 books 条目实际上是这样的
switch (moduleToLoad.toLowerCase()) {
case "activate":
return ActivateComponent;
case "authenticate":
return AuthenticateComponent;
case "books":
// preload!!!
booksPreload();
return BooksComponent;
就是这样。这里有一个可以用来测试的实时演示。
要修改 Suspense 超时值(默认值为 3000 毫秒),请导航到“设置”,然后查看“杂项”选项卡。修改后请务必刷新页面。
总结
在 web 开发生态系统中,我很少像对 Suspense 一样感到兴奋。它是一个雄心勃勃的系统,用于管理 web 开发中最棘手的问题之一:异步。
只有当我看到演示时,我才明白。很棒的文章!
useTransition
钩子适合生产应用吗?有没有办法在不使用useTransition
钩子的情况下实现这篇文章中描述的行为?Suspense 本身是稳定的。用于数据获取的 Suspense 是实验性的。用于仅显示加载指示器的 Suspense 已经在生产环境中使用。阅读这里的文档 -> https://reactjs.ac.cn/docs/react-api.html#reactsuspense
你能提供 Github 仓库吗?我想要导航栏的逻辑,想知道你是怎么禁用它的