首发于公众号 前端从进阶到入院,欢迎关注。
Hi,我是 ssh,今天看到 Remix 团队带来了一篇关于如何解决 React 疑难杂症:Render + Fetch 渲染瀑布流的问题以及一些更深入的优化思考:Lazy Loading Routes in React Router 6.4+。
这个瀑布流问题简单来说,就是有些嵌套层级的路由的 Fetch 会有无效排队的问题,比如你的应用里有着 a -> b -> c
这样的组件依赖关系,先渲染了 a 以后,才会渲染 b,然后才会渲染 c,但是 a,b,c 这三个组件里的 fetch 请求其实没有依赖关系,如果在全部渲染这些组件之前,就先行并行发出请求,就可以节省很多时间。
接下来,我来和大家一起分享学习:
正文
React Router 6.4引入了一个名为“Data Router”的概念,其主要目标是将 data fetch 与渲染分离,以消除 Render+Fetch 链和相关的 Loading 动画。
let router = createBrowserRouter([
{
path: "/",
loader: () => ({ message: "Hello Data Router!" }),
Component() {
let data = useLoaderData() as { message: string };
return <h1>{data.message}</h1>;
},
},
]);
这些链通常被称为“瀑布流”,但我们正在重新思考这个术语,因为大多数人听到瀑布流就会想到尼亚加拉瀑布,那儿的水全部一起喷涌而下形成一个漂亮的瀑布。但“全部一次性”加载数据似乎蛮好的,那为什么对瀑布流有意见呢?
实际上,我们要避免的“瀑布流”看起来更像上面的图片,类似一个楼梯。水一点点下落,然后停下来,再下落一点点,然后停下来,依此类推。现在想象一下,这个楼梯中的每一步都是一个 Loading 动画。用户会骂死你!因此,在本文中,我们使用术语“链”来表示那些本质上是按顺序排列的 Fetch,每个 Fetch 都被前一个 Fetch 阻塞。
Render+Fetch 链
如果你还没有阅读过Remixing React Router文章或者去年在 Reactathon 上观看了 Ryan 的When to Fetch演讲,你可能希望在继续阅读本文之前先了解一下它们。它们涵盖了我们引入 Data Router 的原因背后的很多背景知识。
简而言之,当你的 Router 不知道有什么数据需要请求时,情况就会变成顺序的请求链,随着渲染子组件,才能发现后续的数据请求。
但是引入 Data Router 可以使你能够并行 fetch 数据并一次性渲染所有内容:
为了实现这一点,Data Router 将你的 Route 定义从渲染周期中提取出来,以便我们的 Router 可以提前识别嵌套的数据需求。
// app.jsx
import Layout, { getUser } from `./layout`;
import Home from `./home`;
import Projects, { getProjects } from `./projects`;
import Project, { getProject } from `./project`;
const routes = [{
path: '/',
loader: () => getUser(),
element: <Layout />,
children: [{
index: true,
element: <Home />,
}, {
path: 'projects',
loader: () => getProjects(),
element: <Projects />,
children: [{
path: ':projectId',
loader: ({ params }) => getProject(params.projectId),
element: <Project />,
}],
}],
}]
但这也有一个缺点。到目前为止,我们已经讨论了如何优化 data fetches,但我们还必须考虑如何优化 JS Bundle 的 fetches!在上面的 route 定义中,虽然我们可以并行 fetch 所有数据,但我们却通过下载包含所有 loader 和 Component 的 JavaScript Bundle 来阻塞 data fetch 的开始。
考虑一个用户在“/”根路径进入你的网站:
即使这个用户用不到projects
和:projectId
的 route 的 loader 和 Component,还是会下载!最坏的情况下,如果用户不进入这些 route,就永远用不到它们。这对我们的用户体验来说并不理想。
React.lazy 解救之道
React.lazy 提供了一个一等公民的原语,用于将组件树的部分进行分块,但它一样会遇到我们用 Data Router 想要消除的 fetch 和渲染的紧密耦合问题。这是因为当你使用 React.lazy()
时,会为组件创建一个异步 Chunk,但是直到渲染懒加载组件时,React 才会实际开始 fetch 这个 Chunk。
// app.jsx
const LazyComponent = React.lazy(() => import("./component"));
function App() {
return (
<React.Suspense fallback={<p>Loading lazy chunk...</p>}>
<LazyComponent />
</React.Suspense>
);
}
因此,虽然我们可以在 Data Router 中利用 React.lazy()
,但最终会导致一个在 data fetch 之后继续下载组件的链路。Ruben Casas 在一篇好文中介绍了如何使用 React.lazy()
在 Data Router 中利用代码分割的一些方法。但是从该文章可以看出,手动进行代码分割仍然有些冗长和繁琐。由于这种次优的开发体验,我们收到了来自 @rossipedia
的提案(和一个初步的POC 实现)。该提案很好地概述了当前的挑战,并让我们思考在 RouterProvider
中引入代码分割支持的最佳方式。我们要向这两位开发者(以及我们其他棒棒哒的社区成员)表示隆重的感谢,因为他们积极推动了 React Router 的进化 ?。
引入 route.lazy
如果我们希望延迟加载与 Data Router 很好的协作,我们需要能够在渲染周期之外引入延迟加载。就像我们将 data fetch 从渲染周期中提取出来一样,我们还希望将route Fetch也从渲染周期中提取出来。
如果你回过头来看一个 route 定义,它可以分为三个部分:
- 路径匹配字段,例如
path
、index
和children
- 数据加载/提交字段,如
loader
和action
- 渲染字段,如
element
和errorElement
对于 Data Router 而言,真正需要的只是路径匹配字段,因为它需要能够识别与给定 URL 匹配的所有路由。匹配后,我们就有了正在进行的异步导航,因此我们也没有理由在该导航过程中 fetch route 信息。然后,在完成 data fetch 之前,我们不需要渲染字段相关的内容,因为在 data fetch 完成之前,我们是不会渲染目标 route 的。是的,这可能会引入一个”链”的概念(加载路由,然后加载数据),但这是一个可选的开关,你可以根据需要开启,这就看你自己的权衡了。
以下是使用上面的 Router 结构和新的 lazy()
方法(在 React Router v6.9.0 中可用)的示例:
// app.jsx
import Layout, { getUser } from `./layout`;
import Home from `./home`;
const routes = [{
path: '/',
loader: () => getUser(),
element: <Layout />,
children: [{
index: true,
element: <Home />,
}, {
path: 'projects',
lazy: () => import("./projects"), // ? 延迟加载!
children: [{
path: ':projectId',
lazy: () => import("./project"), // ? 延迟加载!
}],
}],
}]
// projects.jsx
export function loader = () => { ... }; // 原先名为 getProjects
export function Component() { ... } // 原先名为 Projects
// project.jsx
export function loader = () => { ... }; // 原先名为 getProject
export function Component() { ... } // 原先名为 Project
你可能会疑惑 export function Component
是什么呢?从这个延迟加载模块导出的属性会直接添加到 route 的定义中。因为导出一个 element
看起来有些奇怪,所以我们支持在 route 对象中使用 Component
来定义,而不是 element
(不过element
也仍然有效!)。
在这种情况下,我们选择将布局和主页 route 保留在首屏 bundle 中,因为这是用户最常访问的入口。但是,我们将 projects
和 :projectId
route 的导入移到了自己的动态 import 中,只有在导航到这些 route 时才会加载。
初始加载时的网络图示如下:
现在我们的关键路径 bundle 中只包括我们认为在初始进入网站时关键的那些 Route。然后,当用户点击链接 /projects/123
时,我们通过 lazy()
方法并行 fetch 这些 Route,并执行它们返回的 loader
方法:
这使我们在某种程度上得到了最佳的解决方案,因为我们能够将关键路径 bundle 裁剪为相关的主页 Router。然后在导航时,我们可以匹配路径并 fetch 所需的新的 route 定义。
高级用法和优化
一些敏锐的读者可能会感觉到这里有些不对劲。这是最优解吗?其实不是!但是考虑到我们只需编写很少的代码就能实现它,它还是相当不错的 ?。
在上面的示例中,我们的 route 模块包括我们的 loader
和 Component
,这意味着在开始执行 loader
fetch 之前,我们需要下载两者的内容。实际上,你的 React Router SPA Loader 通常非常小,而且主要访问的是包含大部分业务逻辑的外部 API。而 Component 则定义了整个用户界面,包括与之相关的所有用户交互,它们可能会非常庞大。
下载巨型的 Component
树时阻塞了很小的 loader
(很可能是对某个 API 进行 fetch()
调用)似乎有些不合理?如果我们转变到下面的情况,会怎么样?
好消息是,你只需很小的代码改动即可实现!如果在 route 上单独直接定义loader
/action
,它就会和 lazy()
字段定义的文件并行下载。这使我们可以通过将 loader 和 Component 分别放入单独的文件中来解耦 loader data fetch 和组件 Chunk 下载:
const routes = [
{
path: "projects",
async loader({ request, params }) {
let { loader } = await import("./projects-loader");
return loader({ request, params });
},
lazy: () => import("./projects-component"),
},
];
route 上直接定义的任何字段始终优先于 lazy 返回的任何内容。因此不要在定义静态 loader
的同时又从 lazy
里返回一个 loader
,否则将忽略 lazy 里的版本,还会收到控制台警告。
这种静态定义的 loader 概念还为直接内联代码提供了一些有趣的可能。例如,也许你有一个单独的 API 域名,根据请求 URL 才知道如何 fetch route 数据。你可以以最小的 bundle 成本内联所有 loader,并在 data fetch 和 Component(或 route 模块)chunk 下载之间实现完全并行化。
const routes = [
{
path: "projects",
loader: ({ request }) => fetchDataForUrl(request.url),
lazy: () => import("./projects-component"),
},
];
更多信息
欲了解更多信息,请查看决策文档或 GitHub 中的示例。祝你懒加载愉快!
首发于公众号 前端从进阶到入院,分享 Vue 源码 / React / TS / 浏览器 / 工程化等各个前端领域,我的文章帮助了很多小伙伴进入大厂,欢迎关注。