A waterfall down a series of rocks shaped like a stairway
2023年3月10日

React Router 6.4+ 中的路由懒加载

Matt Brophy
高级开发工程师

React Router 6.4 引入了“数据路由”的概念,其主要目的是将数据获取与渲染分离,以消除渲染 + 获取链以及随之而来的加载指示器。

这些链更常被称为“瀑布”,但我们试图重新思考这个术语,因为大多数人听到瀑布时会想到尼亚加拉瀑布,所有的水都像一个巨大的、漂亮的瀑布一样倾泻而下。但“一次性”加载数据似乎是一种很棒的方式,那么为什么会讨厌瀑布呢?也许我们应该去追逐它们?

实际上,我们想要避免的“瀑布”更像是上面的标题图片,类似于一个楼梯。水先落下一小段,然后停止,然后再落下一小段,然后停止,如此反复。现在想象一下,楼梯的每一步都是一个加载指示器。这不是我们想给用户提供的用户界面!因此,在本文(以及希望以后)中,我们使用术语“链”来表示本质上按顺序排列的获取操作,并且每个获取操作都被之前的获取操作阻塞。

渲染 + 获取链

如果您还没有阅读Remixing React Router这篇文章或观看Ryan在去年Reactathon上的When to Fetch演讲,您可能需要在深入阅读本文的其余部分之前查看它们。它们涵盖了我们引入数据路由概念的原因背后的许多背景知识。

简而言之,当您的路由器不了解您的数据需求时,您最终会得到链式请求,并且在渲染子组件时会“发现”后续的数据需求。

network diagram showing sequential network requests
将数据获取与组件耦合会导致渲染+获取链

但是引入数据路由允许您并行化获取并一次性渲染所有内容

network diagram showing parallel network requests
路由获取并行化请求,消除缓慢的渲染+获取链

为了实现这一点,数据路由器将您的路由定义从渲染周期中提取出来,以便我们的路由器可以提前识别嵌套的数据需求。

// 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 />,
    }],
  }],
}]

但这也有缺点。到目前为止,我们已经讨论了如何优化数据获取,但我们也必须考虑如何优化我们的 JS 包获取!使用上面的路由定义,虽然我们可以并行获取所有数据,但我们通过下载包含所有加载器和组件的 Javascript 包来阻塞数据获取的开始。

假设用户通过 / 路由进入您的网站

network diagram showing an application JS bundle blocking data fetches
单个 JS 包阻塞了数据获取

即使他们不需要,此用户仍然必须下载 projects:projectId 路由的加载器和组件!在最坏的情况下,如果他们不导航到这些路由,用户将永远不需要它们。这对于我们的用户体验来说并不理想。

React.lazy 来救援?

React.lazy 提供了一个一流的原语来分离组件树的部分,但它也遭受了与数据路由试图消除的获取和渲染的紧密耦合 😕。这是因为当您使用 React.lazy() 时,您会为组件创建一个异步块,但 React 实际上不会开始获取该块,直到它渲染懒加载组件。

// app.jsx
const LazyComponent = React.lazy(() => import("./component"));

function App() {
  return (
    <React.Suspense fallback={<p>Loading lazy chunk...</p>}>
      <LazyComponent />
    </React.Suspense>
  );
}
network diagram showing a React.lazy() render + fetch chain
React.lazy() 调用会产生类似的渲染 + 获取链

因此,虽然我们可以将 React.lazy() 与数据路由器一起使用,但我们最终会引入一个链来在数据获取之后下载组件。Ruben Casas 写了一篇很棒的文章,介绍了在数据路由器中使用 React.lazy() 进行代码拆分的一些方法。但正如我们从文章中看到的那样,代码拆分仍然有点冗长且手动操作很繁琐。由于这种不佳的 DX,我们收到了来自 @rossipedia提案(和一个初始的POC 实现)。该提案很好地概述了当前的挑战,并促使我们思考在 RouterProvider 中引入一流的代码拆分支持的最佳方式。我们想对这两位人士(以及我们其他出色的社区成员)大声称赞,感谢他们如此积极地参与 React Router 的演变 🙌。

介绍 Route.lazy

如果我们想让懒加载与数据路由器很好地配合,我们需要能够在渲染周期之外引入懒惰。就像我们从渲染周期中提取数据获取一样,我们也希望从渲染周期中提取路由获取

如果您退后一步查看路由定义,它可以分为 3 个部分

  • 路径匹配字段,例如 pathindexchildren
  • 数据加载/提交字段,例如 loaderaction
  • 渲染字段,例如 elementerrorElement

数据路由器在关键路径上真正需要的只是路径匹配字段,因为它需要能够识别给定 URL 匹配的所有路由。匹配之后,我们已经有一个异步导航正在进行中,因此我们没有理由不能在导航过程中也获取路由信息。然后,在完成数据获取之前,我们不需要渲染方面,因为我们不会渲染目标路由,直到数据获取完成。是的,这会引入“链”的概念(加载路由,然后加载数据),但这是一个可选择的杠杆,您可以根据需要来平衡初始加载速度和后续导航速度之间的权衡。

以下是使用我们上面的路由结构,并在路由定义上使用新的 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"), // 💤 Lazy load!
    children: [{
      path: ':projectId',
      lazy: () => import("./project"), // 💤 Lazy load!
    }],
  }],
}]

// projects.jsx
export function loader = () => { ... }; // formerly named getProjects

export function Component() { ... } // formerly named Projects

// project.jsx
export function loader = () => { ... }; // formerly named getProject

export function Component() { ... } // formerly named Project

您会问什么是 export function Component 吗?从此懒加载模块导出的属性将逐字添加到路由定义中。因为导出 element 很奇怪,所以我们添加了在路由对象上定义 Component 而不是 element 的支持(但不用担心,element 仍然有效!)。

在这种情况下,我们选择将布局和主页路由保留在主包中,因为这是用户最常见的入口点。但是,我们已将 projects:projectId 路由的导入移到它们自己的动态导入中,除非我们导航到这些路由,否则不会加载这些导入。

初始加载时生成的网络图如下所示

network diagram showing a initial load using route.lazy()
lazy() 方法允许我们减少关键路径包的大小

现在我们的关键路径包包含我们认为对于网站初始进入至关重要的那些路由。然后,当用户单击链接到 /projects/123 时,我们会通过 lazy() 方法并行获取这些路由并执行它们返回的 loader 方法

network diagram showing a link click using route.lazy()
我们在导航时并行懒加载路由

这给了我们两全其美的效果,我们可以将关键路径包修剪为相关的首页路由。然后在导航时,我们可以匹配路径并获取我们需要的新路由定义。

高级用法和优化

一些精明的读者可能会感觉到这里潜藏着一些隐藏的链接。这是最佳的网络图吗?事实证明,并非如此!但是,对于我们为了获得它而编写的代码量来说,它已经很不错了😉。

在上面的示例中,我们的路由模块包括我们的 loader 以及我们的 Component,这意味着我们需要下载两者的内容才能启动我们的 loader 获取。在实践中,您的 React Router SPA 加载器通常很小,并且会访问外部 API,其中包含您的大部分业务逻辑。另一方面,组件定义了您的整个用户界面,包括随之而来的所有用户交互,它们可能会变得非常大。

network diagram showing a loader + component chunk blocking a data fetch
单个路由文件会阻止组件下载背后的数据获取

通过大型 Component 树的 JS 下载来阻止 loader (可能正在对某些 API 进行 fetch() 调用)似乎很愚蠢?如果我们能把这个 👆 变成这个 👇 呢?

network diagram showing separate loader and component files unblocking the data fetch
我们可以通过将组件提取到其自己的文件中来解除对数据获取的阻止

好消息是您可以通过最少的代码更改来实现!如果 loader/action 在路由上静态定义,则它将与 lazy() 并行执行。这允许我们通过将加载器和组件分离到单独的文件中来将加载器数据获取与组件块下载分离

const routes = [
  {
    path: "projects",
    async loader({ request, params }) {
      let { loader } = await import("./projects-loader");
      return loader({ request, params });
    },
    lazy: () => import("./projects-component"),
  },
];

静态定义在路由上的任何字段都将始终优先于从 lazy 返回的任何内容。因此,虽然您不应该定义静态 loader 并且还lazy 返回 loader,但会忽略懒加载版本,并且如果您这样做,您将收到控制台警告。

这种静态定义的加载器概念还为直接内联代码开辟了一些有趣的可能性。例如,您可能有一个 API 端点,该端点知道如何根据请求 URL 获取给定路由的数据。您可以以最小的包成本内联所有加载器,并在数据获取和组件(或路由模块)块下载之间实现完全并行化。

const routes = [
  {
    path: "projects",
    loader: ({ request }) => fetchDataForUrl(request.url),
    lazy: () => import("./projects-component"),
  },
];
network diagram showing total parallelization between the data fetch and the component download
看嘛,没有加载器块!

事实上,这正是 Remix 解决此问题的方式,因为路由加载器是它们自己的 API 端点 🔥。

更多信息

有关更多信息,请查看决策文档或 GitHub 存储库中的示例。祝您懒加载愉快!


获取有关最新 Remix 新闻的更新

率先了解 Remix 的新功能、社区活动和教程。