在React Conf 2019上,我们宣布了一个支持并发模式和暂停的React实验性版本。在这篇文章中,我们将介绍我们在构建新的facebook.com过程中发现的使用它们的最佳实践。
这篇文章将与从事React数据获取库的人最相关。
它展示了如何将它们与并发模式和悬念进行最佳整合。这里介绍的模式是基于Relay--我们用GraphQL构建数据驱动UI的库。然而,这篇文章中的想法适用于其他GraphQL客户端,以及使用REST或其他方法的库。
这篇文章是针对库的作者。如果你主要是一个应用程序开发人员,你可能仍然会在这里找到一些有趣的想法,但不要觉得你必须完整地阅读它。
讲座视频
如果你喜欢看视频,这篇博文中的一些想法已经在React Conf 2019的几个演讲中被提及。
- 乔-萨沃纳(Joe Savona)的《在Relay中用悬念获取数据》(Data Fetching with Suspense)。
- 使用React和Relay构建新的Facebook,作者:Ashley Watkins
- React Conf KeynotebyYuzhi Zheng
这篇文章介绍了关于用Suspense实现数据获取库的深入研究。
将用户体验放在首位
长期以来,React团队和社区对开发者体验给予了应有的重视:确保React有良好的错误信息,关注组件作为本地推理应用行为的一种方式,精心设计可预测的API,并通过设计鼓励正确使用,等等。但是我们还没有提供足够的指导,说明在大型应用中实现良好用户体验的最佳方式。
例如,React团队一直专注于框架性能,并为开发人员提供工具来调试和调整应用程序的性能(例如React.memo )。但是,我们对高层次的模式还没有那么有主见,这些模式在快速、流畅的应用程序和缓慢、笨拙的应用程序之间起到了区别。我们总是想确保React对新用户来说是平易近人的,并且支持各种不同的使用情况--不是每个应用都要 "快得不得了 "的。但作为一个社区,我们可以也应该有更高的目标。我们应该让用户在世界各地不同的设备和网络上尽可能容易地建立起快速启动和保持快速的应用程序,即使它们的复杂性在增加。
并发模式和悬念是可以帮助开发者实现这一目标的实验性功能。我们在2018年的JSConf Iceland上首次介绍了它们,有意在很早的时候分享细节,以便让社区有时间消化新的概念,并为后续的变化做好准备。从那时起,我们已经完成了相关工作,如新的Context API和Hooks的引入,其部分目的是帮助开发人员自然地编写与并发模式更兼容的代码。但我们并不想在没有验证它们是否有效的情况下就实现这些功能并发布它们。因此,在过去的一年里,Facebook的React、Relay、网络基础设施和产品团队都紧密合作,建立了一个新版本的facebook.com,深度整合了并发模式和Suspense,创造了一个更加流畅和类似应用程序的体验。
由于这个项目,我们比以往任何时候都更有信心,并发模式和悬念可以使我们更容易提供伟大、快速的用户体验。但这样做需要重新思考我们如何为我们的应用程序加载代码和数据。实际上,新的facebook.com上的所有数据获取都是由Relay Hooks提供的--新的基于Hooks的Relay APIs,与Concurrent Mode和Suspense开箱即用。
中继钩子--以及GraphQL--并不适合每个人,这也没关系!通过我们在这些API上的工作,我们可以为每个人提供不同的服务。通过我们在这些API上的工作,我们已经确定了一套使用Suspense的更普遍的模式。即使Relay并不适合你,我们认为我们在Relay Hooks中介绍的关键模式也可以适用于其他框架。
悬念的最佳实践
只关注一个应用程序的总启动时间是很诱人的--但事实证明,用户对性能的感知是由绝对加载时间以外的因素决定的。例如,当比较两个绝对启动时间相同的应用程序时,我们的研究表明,用户通常会认为中间加载状态较少、布局变化较少的那个应用程序的加载速度更快。悬念是一个强大的工具,它可以通过一些定义明确的状态来精心安排一个优雅的加载序列,逐步展现内容。但是,提高感知性能只能到此为止--我们的应用程序仍然不应该永远地获取所有的代码、数据、图像和其他资产。
在React应用程序中加载数据的传统方法包括我们所说的"渲染时获取"。首先,我们用旋转器渲染一个组件,然后在装载时获取数据(componentDidMount 或useEffect),最后更新以渲染产生的数据。当然也可以用Suspense来使用这种模式:一个组件可以 "暂停"--向React表示它还没有准备好,而不是一开始就渲染一个占位符本身。这将告诉React找到最近的祖先<Suspense fallback={<Placeholder/>}> ,并渲染其回退。如果你看过早期的Suspense演示,这个例子可能会觉得很熟悉--这就是我们最初想象的使用Suspense来获取数据的方式。
事实证明,这种方法有一些局限性。考虑一个显示用户在社交媒体上发布的帖子,以及对该帖子的评论的页面。这可能会被结构化为一个<Post> 组件,同时渲染帖子正文和一个<CommentList> ,以显示评论。使用上面描述的fetch-on-render方法来实现这一点可能会导致连续的往返(有时被称为 "瀑布")。首先获取<Post> 组件的数据,然后获取<CommentList> 的数据,增加了显示整个页面的时间。
这种方法还有一个经常被忽视的缺点。如果<Post> 急切地需要(或导入)<CommentList> 组件,我们的应用程序将不得不在下载评论的代码时等待显示帖子正文。我们可以懒洋洋地加载<CommentList> ,但这将延迟获取评论数据,并增加显示整个页面的时间。我们如何在不影响用户体验的情况下解决这个问题?
边获取边渲染
今天的React应用广泛采用了 "获取-渲染"(fetch-on-render)的方法,当然也可以用来创建伟大的应用。但我们可以做得更好吗?让我们退一步考虑我们的目标。
在上述<Post> 的例子中,我们最好尽可能早地显示更重要的内容--帖子正文,而不对显示整个页面(包括评论)的时间产生负面影响。让我们考虑一下任何解决方案的关键制约因素,并看看我们如何实现它们。
- 尽早显示更重要的内容(文章主体)意味着我们需要逐步加载视图的代码和数据。例如,我们不希望在下载
<CommentList>的代码时阻止显示帖子主体。 - 同时,我们也不希望增加显示包括评论在内的整个页面的时间。因此,我们需要尽快开始加载代码和评论的数据,最好是与加载文章正文同时进行。
这听起来可能很难实现--但这些约束条件实际上是令人难以置信的帮助。它们排除了大量的方法,并为我们指出了一个解决方案。这给我们带来了我们在Relay Hooks中实现的关键模式,这些模式可以适应于其他数据获取库。我们将依次看一下每个模式,然后看看它们是如何加起来实现我们的目标--快速、愉快的加载体验。
- 平行数据和视图树
- 在事件处理程序中获取
- 渐进式加载数据
- 像对待数据一样对待代码
平行数据和视图树
读取-渲染模式最吸引人的地方是,它将一个组件需要的数据与如何渲染这些数据放在一起。这种集中管理非常好--这是一个例子,说明按关注点而不是按技术来分组代码是有意义的。我们在上面看到的所有问题都是由于我们在这种方法中获取数据的时间:渲染时。我们需要在渲染组件之前就能获取数据。实现这一目标的唯一方法是将数据依赖关系提取到并行数据和视图树中。
下面是在Relay Hooks中的工作方式。继续我们关于带有正文和评论的社交媒体帖子的例子,下面是我们如何用Relay Hooks来定义它。
// Post.js
function Post(props) {
// Given a reference to some post - `props.post` - *what* data
// do we need about that post?
const postData = useFragment(graphql`
fragment PostData on Post @refetchable(queryName: "PostQuery") {
author
title
# ... more fields ...
}
`, props.post);
// Now that we have the data, how do we render it?
return (
<div>
<h1>{postData.title}</h1>
<h2>by {postData.author}</h2>
{/* more fields */}
</div>
);
}
虽然GraphQL是在组件中编写的,但Relay有一个构建步骤(Relay Compiler),将这些数据依赖关系提取到单独的文件中,并将每个视图的GraphQL汇总到一个查询中。因此,我们得到了集中关注点的好处,同时在运行时拥有平行的数据和视图树。其他框架也可以达到类似的效果,允许开发者在一个同级文件中定义数据获取逻辑(也许是Post.data.js ),或者也许与一个捆绑器集成,允许用UI代码定义数据依赖并自动提取,类似于Relay Compiler。
关键是,无论我们使用什么技术来加载我们的数据--GraphQL、REST等--我们都可以将加载什么数据与如何以及何时实际加载数据分开。但是,一旦我们做到这一点,我们如何以及何时获取我们的数据?
在事件处理程序中获取
想象一下,我们要从一个用户的帖子列表中导航到一个特定帖子的页面。我们需要下载该页面的代码--Post.js --并获取其数据。
等到我们渲染组件的时候就会出现问题,正如我们在上面看到的。关键是要在触发显示该视图的同一事件处理程序中开始获取新视图的代码和数据。我们可以在路由器中获取数据--如果我们的路由器支持为路由预装数据--或者在触发导航的链接的点击事件中获取数据。事实证明,React Router的人已经在努力工作,建立API以支持路由的预加载数据。但其他路由框架也可以实现这个想法。
从概念上讲,我们希望每个路由定义包括两件事:渲染什么组件和预加载什么数据,作为路由/url参数的一个函数。下面是这样一个路由定义的样子。这个例子松散地受到React Router的路由定义的启发,主要是为了演示这个概念,而不是具体的API。
// PostRoute.js (GraphQL version)
// Relay generated query for loading Post data
import PostQuery from './__generated__/PostQuery.graphql';
const PostRoute = {
// a matching expression for which paths to handle
path: '/post/:id',
// what component to render for this route
component: React.lazy(() => import('./Post')),
// data to load for this route, as function of the route
// parameters
prepare: routeParams => {
// Relay extracts queries from components, allowing us to reference
// the data dependencies -- data tree -- from outside.
const postData = preloadQuery(PostQuery, {
postId: routeParams.id,
});
return { postData };
},
};
export default PostRoute;
给予这样一个定义,路由器可以。
- 将一个URL与一个路由定义相匹配。
- 调用
prepare()函数,开始加载该路由的数据。注意,prepare()是同步的--我们不等待数据准备好,因为我们想尽快开始渲染视图中更重要的部分(比如帖子正文)。 - 将预装的数据传递给组件。如果组件准备好了--
React.lazy动态导入已经完成--该组件将渲染并尝试访问其数据。如果不是,React.lazy将暂停,直到代码准备好。
这种方法可以被推广到其他的数据获取方案。一个使用REST的应用程序可以这样定义一个路由。
// PostRoute.js (REST version)
// Manually written logic for loading the data for the component
import PostData from './Post.data';
const PostRoute = {
// a matching expression for which paths to handle
path: '/post/:id',
// what component to render for this route
component: React.lazy(() => import('./Post')),
// data to load for this route, as function of the route
// parameters
prepare: routeParams => {
const postData = preloadRestEndpoint(
PostData.endpointUrl,
{
postId: routeParams.id,
},
);
return { postData };
},
};
export default PostRoute;
这种方法不仅可以用于路由,还可以用于其他我们懒散地显示内容或基于用户互动的地方。例如,一个标签组件可以急切地加载第一个标签的代码和数据,然后在标签变化事件处理程序中使用与上述相同的模式来加载其他标签的代码和数据。一个显示模态的组件可以在触发打开模态的点击处理程序中预先加载模态的代码和数据,以此类推。
一旦我们实现了独立开始加载视图的代码和数据的能力,我们就可以选择更进一步。考虑一个链接到路由的<Link to={path} /> 组件。如果用户将鼠标悬停在该链接上,他们有很大的可能会点击它。如果他们按下鼠标,他们完成点击的机会就更大了。如果我们可以在用户点击之后为视图加载代码和数据,我们也可以在他们点击之前开始工作,在准备视图方面取得先机。
最重要的是,我们可以将这些逻辑集中在几个关键的地方--路由器或核心UI组件--并在整个应用程序中自动获得任何性能上的好处。当然,预加载并不总是有益的。这是一个应用程序会根据用户的设备或网络速度进行调整,以避免吞噬用户的数据计划。但这里的模式使我们更容易集中实现预加载,并决定是否启用它。
渐进式加载数据
上述模式--并行的数据/视图树和事件处理程序中的获取--让我们更早地开始加载一个视图的所有数据。但我们仍然希望能够在不等待所有数据的情况下显示视图中更重要的部分。在Facebook,我们已经在GraphQL和Relay中以一些新的GraphQL指令(影响数据交付方式/时间的注释,但不是什么数据)的形式实现了对此的支持。这些新的指令被称为@defer 和@stream ,允许我们以递增的方式检索数据。例如,考虑我们上面的<Post> 组件。我们想在不等待评论准备好的情况下显示正文。我们可以通过@defer 和<Suspense> 来实现这个目标。
// Post.js
function Post(props) {
const postData = useFragment(graphql`
fragment PostData on Post {
author
title
# fetch data for the comments, but don't block on it being ready
...CommentList @defer
}
`, props.post);
return (
<div>
<h1>{postData.title}</h1>
<h2>by {postData.author}</h2>
{/* @defer pairs naturally with <Suspense> to make the UI non-blocking too */}
<Suspense fallback={<Spinner/>}>
<CommentList post={postData} />
</Suspense>
</div>
);
}
在这里,我们的GraphQL服务器将流回结果,首先返回author 和title 字段,然后在准备好时返回评论数据。我们把<CommentList> 包在一个<Suspense> 的边界中,这样我们就可以在<CommentList> 和它的数据准备好之前渲染帖子的主体。这种相同的模式也可以应用于其他框架。例如,调用REST API的应用程序可能会发出并行请求来获取帖子的正文和评论数据,以避免在所有数据准备好时出现阻塞。
像对待数据一样对待代码
但是还有一点没有做。我们已经展示了如何为一个路由预装数据--但代码呢?上面的例子有一点作弊,使用了React.lazy 。然而,React.lazy ,正如它的名字所暗示的,是懒惰的。它不会开始下载代码,直到懒惰的组件被实际渲染--它是 "在渲染时获取 "的代码
为了解决这个问题,React团队正在考虑提供API,允许对代码进行捆绑分割和急切预加载。这将允许用户将某种形式的懒惰组件传递给路由器,并让路由器尽可能早地触发与数据一起加载的代码。
把这一切结合起来
简而言之,实现良好的加载体验意味着我们需要尽早开始加载代码和数据,但不需要等待所有的代码和数据准备好。平行数据和视图树允许我们在加载视图(代码)本身的同时加载视图的数据。在事件处理程序中获取意味着我们可以尽早开始加载数据,甚至当我们有足够的信心认为用户会导航到一个视图时,可以乐观地预加载该视图。渐进式地加载数据使我们能够更早地加载重要的数据,而不耽误获取不太重要的数据。而将代码视为数据--并通过类似的API进行预加载--也可以让我们更早地加载代码。
使用这些模式
这些模式不仅仅是想法--我们已经在Relay Hooks中实现了它们,并在整个新的facebook.com(目前正在进行测试)的生产中使用它们。如果你有兴趣使用或学习更多关于这些模式,这里有一些资源。
-
React并发文档探讨了如何使用并发模式和Suspense,并对其中许多模式进行了更详细的介绍。这是一个很好的资源,可以了解更多关于他们支持的API和用例。
-
Relay Hooks的实验版本实现了这里描述的模式。
-
我们已经实现了两个类似的示例应用程序,以展示这些概念。
- Relay Hooks示例应用程序使用GitHub的公共GraphQL API来实现一个简单的问题跟踪器应用程序。它包括对代码和数据预加载的嵌套路由支持。该代码是完全注释过的--我们鼓励克隆该 repo,在本地运行该应用,并探索它是如何工作的。
- 我们还有一个非GraphQL版本的应用程序,演示了这些概念如何应用于其他数据获取库。
虽然围绕并发模式和暂停的API仍然是实验性的,但我们相信这篇文章中的想法已经被实践证明。然而,我们明白,Relay和GraphQL并不适合每个人。这也没关系!我们正在积极探索如何使Relay和GraphQL更适合每个人。**我们正在积极探索如何将这些模式推广到REST等方法上,**并且正在探索更通用(即非GraphQL)的API的想法,以组成数据依赖树。同时,我们很高兴看到会有哪些新的库出现,来实现这篇文章中所描述的模式,使我们更容易建立伟大、快速的用户体验。