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

React Router 6.4+ 中的路由懒加载

Matt Brophy
开发人员

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

这些链通常被称为“瀑布”,但我们试图重新思考这个术语,因为大多数人听到瀑布就会想到 尼亚加拉瀑布,那里所有的水都以一个巨大的瀑布向下倾泻。但“一次性”似乎是加载数据的好方法,那么为什么讨厌瀑布呢?也许我们应该追逐它们?

实际上,我们想要避免的“瀑布”更像是上面的标题图片,类似于楼梯。水下降一点,然后停止,然后下降一点,然后停止,依此类推。现在想象一下楼梯上的每一步都是一个加载动画。这不是我们希望提供给用户的 UI!因此,在这篇文章(以及希望将来)中,我们使用术语“链”来表示本质上按顺序排列的获取,并且每个获取都受到其之前获取的阻塞。

渲染 + 获取链

如果您还没有阅读 Remixing React Router 文章或观看 Ryan 在去年的 Reactathon 上的 何时获取 演讲,您可能需要在继续阅读本文的其余部分之前查看它们。它们涵盖了我们引入数据路由器概念的 **原因** 的大量背景信息。

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

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() 的数据路由器中利用代码分割的一些方法。但正如我们从文章中看到的,代码分割仍然有点冗长且难以手动完成。由于这种低于标准的开发体验,我们收到了来自 @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"),
  },
];

在路由上静态定义的任何字段始终优先于从懒加载中返回的任何字段。因此,虽然您不应该定义静态 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 功能、社区活动和教程。