Fog of War
2024年7月10日

战争迷雾

Matt Brophy
开发人员

Remix 旨在默认使您的应用程序具有高性能。我们最新的功能,战争迷雾1(又名“延迟路由发现”)2,有助于您的应用程序无论规模多大都能保持高性能。

Remix 如何处理 Fetch 请求

Remix 主要是一个构建在 React Router 之上的编译器和服务器运行时,旨在为您提供编写 React Router SSR 应用程序的惯用且高效的方式。您可以在不使用 Remix 的情况下构建自己的 React Router SSR 应用程序吗?当然可以!但是,要获得相同类型的性能优化,您很可能需要编写自己的编译器和服务器运行时,模仿 Remix 内置的许多优化。

Remix 的大多数优化都具有一个共同的目标:消除网络瀑布。

为了避免“渲染后再获取”的瀑布现象,Remix 将渲染与获取解耦(另请参阅:Remixing React Router)。为此,Remix 需要预先了解您的路由树,以便它可以在每次点击链接时并行启动数据获取和下载路由模块。这导致了“先获取后渲染”(或者如果您正在流式传输数据,则为“获取时渲染”)的反向且更有效率的方法。

在“渲染后再获取”的世界中,您的应用程序会下载路由实现,然后在渲染组件的同时启动数据获取——导致瀑布现象

Render then Fetch network diagram

使用“先获取后渲染”,模块获取和数据获取可以并行化

Fetch then Render network diagram

您可以通过 Remix 中的 <Link prefetch> 将此进一步扩展,它允许您在用户甚至点击链接之前预取路由数据和组件。这样,当点击链接时,导航就可以立即完成

Prefetching network diagram

路由清单

首先,让我们定义一些重要的术语以确保清晰度

  • 路由树:一个路由树,它通过父子关系定义了您的应用程序可以匹配的 URL
  • 路由定义:用于匹配 URL 的路由部分(pathindexchildren
  • 路由实现:用于加载数据和渲染 UI 的路由部分(loaderComponentErrorBoundary 等)

为了实现上一节中提到的优化,Remix 需要知道客户端上所有路由的定义,以便它可以仅根据 <Link to> 来匹配路由。一旦点击链接并匹配路由,Remix 就可以并行获取数据并下载路由的实现

为此,Remix 向客户端发送一个路由清单,其中包含所有路由定义以及少量元数据。此清单允许 Remix 在不包含底层路由实现的情况下构建客户端路由树。这些实现是在导航到路由时通过 route.lazy 加载的。

这是一个 https://remix.org.cn 上根路由的路由清单示例条目

"root": {
  "id": "root",
  "path": "",
  "hasAction": false,
  "hasLoader": true,
  "hasClientAction": false,
  "hasClientLoader": false,
  "hasErrorBoundary": true,
  "module": "/assets/root-x1zXK6d6.js",
  "imports": [
    "/assets/entry.client-uo5Ucqv5.js",
    "/assets/utils-c6MmN8mv.js",
    "/assets/color-scheme-y8FjcTs4.js",
    "/assets/icons-6-7TeyZS.js"
  ],
  "css": [
    "/assets/root-Wq_jPy7B.css"
  ]
}

随着时间的推移,当用户在页面之间导航时——越来越多的路由实现通过 route.lazy 下载,并且路由树变得越来越完整。

这种方法适用于大多数应用程序——因为清单非常轻量级,并且压缩效果很好,因为它是一个重复的键/值 JSON 结构。例如,https://remix.org.cn/ 的清单包含 50 个路由,未压缩时重 19.6Kb,但在压缩后仅发送 2.6Kb。

但是,Remix 不仅希望为中小型应用程序提供良好的性能——我们还希望大型超大型应用程序也能默认快速!因为我们喜欢在 Shopify 内部和面向公众的无数应用程序上使用 React Router 和 Remix,所以我们知道我们目前的策略不适用于那些大型应用程序。

当我们开始将 Remix 推出到 https://www.shopify.com 时,我们意识到该网站有多大。当您考虑所有路由及其国际化 URL(例如,/pricing/en/pricing/es/precios 等等)时——该应用程序拥有超过 1300 个路由!并且由于 Remix(目前)没有针对 URL 别名的良好解决方案,因此许多路由条目都是指向相同路由模块的重复条目,从而重复了模块信息(其路径、其其他模块 imports 等)。这导致清单压缩后约为 85Kb,未压缩时约为 10Mb。在较慢的设备上,这可能会对页面加载时间产生明显的影响,因为设备需要解压缩、解析、编译和执行 JS 模块。

战争迷雾

在 Remix,我们非常喜欢我们年轻时代复古的氛围:从使用 HTML <form> 元素和 HTTP POST 请求的旧式 Web 开发,到 90 年代的音乐,再到带有不断扩展的地图的复古视频游戏。这些扩展的游戏地图至少为我们解决不断增长的路由清单问题的方案提供了灵感。

Remix 路由树与视频游戏中的地图并没有太大区别。在游戏中,地图可能很大,但玩家一开始无法看到整个地图。他们只从地图的初始部分开始。当他们四处移动时,越来越多的地图加载进来。

为什么 Remix 清单不能以这种方式工作?为什么我们不能只在 SSR 上加载匹配的初始路由并在用户四处导航时填充它们?答案很简单:可以,而且已经实现了。有点。

在 v1.0 之前,Remix 实际上就是这样工作的!在 SSR 期间仅包含初始路由,然后当点击链接时,我们将向服务器发出请求以获取新路由并获取数据和路由模块。它看起来像这样

Remix v0 network diagram

但是,如您所见,这种方法会导致网络瀑布——我们讨厌它们!这也意味着我们无法再实现 <Link prefetch>,因为我们甚至没有要匹配的路由,更不用说用于获取数据和模块的元数据了。

因此,对于 Remix 1.0,发送了完整的清单以消除瀑布现象并允许链接预取。将“部分清单”优化留待以后——这一天最终在 Remix v2.10 中实现,并发布了 future.unstable_fogOfWar 标志(在 v2.11 中重命名为 future.unstable_lazyRouteDiscovery2

积极路由发现

在 Remix 中实现这一点的关键在于,无需引入网络瀑布,也无需牺牲诸如 <Link prefetch> 之类的优化,具有讽刺意味的是,正是 <Link prefetch> 方法本身。就像我们可以在实际点击链接之前执行目标路由数据和模块的积极获取一样,我们也可以在点击链接之前积极发现目标路由。

考虑上面的图表,其中发现方面是积极完成的

Fog of War network diagram with eager discovery

我们不必等到点击链接来发现路由,而是可以根据渲染的链接积极地做到这一点,因为链接代表了用户接下来可能转到的潜在路径。Remix 将所有渲染的链接批量处理,并向 Remix 服务器发出单个 fetch 调用以获取这些链接所需的路。如果我们在渲染这些链接后立即执行此操作,那么这些路由很可能在用户有时间找到并点击他们选择的链接之前就被发现并添加到路由树中。如果我们在点击链接之前将这些内容修补好,那么 Remix 的行为将完全不会改变——即使我们在初始加载时仅发送匹配的路由。

如果我们将这种积极的发现与上面的 <Link prefetch> 优化相结合,我们仍然可以实现即时导航!

Fog of War network diagram with eager discovery

还值得注意的是,因为这仅仅是一种优化,所以应用程序即使没有它也能正常工作——只是因为网络瀑布而速度稍慢。因此,如果用户确实在修补清单所需的一小段时间内点击了该链接,则该链接导航将遇到瀑布现象。这就像 <Link prefetch>,如果预取没有及时完成,则在点击时会发生获取,并且用户在导航期间会看到一个加载指示器。还值得注意的是,每个路由只需要发现一次即可。后续导航到同一路由不需要发现步骤。

视觉解释

让我们退一步,从更直观的“路由树”角度来看一下。让我们看看 Remix 当今的状态,其中完整的清单在初始加载时发送。

在下图的路由树中,红点代表正在积极渲染的路由,白色区域是路由清单,其中包含所有可能的路由

Route tree showing the entire manifest without Fog of War enabled

现在,如果我们启用战争迷雾,我们将在初始加载时仅发送清单中的活动路由

Route tree showing the initial manifest with Fog of War enabled

当我们在客户端对 UI 进行水化(渲染)时,我们会遇到一些指向当前清单未知的其他路由的链接

Route tree showing destination links rendered on the current page

Remix 将通过对 Remix 服务器的 fetch 调用发现这些路由并将它们修补到清单中

Route tree with expanded manifest including destination links

如您所见——这种“发现”类型允许路由清单从小开始,并随着用户在应用程序中的路径增长,从而使您的应用程序能够扩展到任意数量的路由,而不会对应用程序的初始加载造成性能影响。

如前所述,我们一直在 https://shopify.com 上使用它,并且我们非常喜欢结果。在战争迷雾之前,他们的路由清单包含1300 个路由,未压缩时重超过 10MB。启用战争迷雾后,他们的初始首页清单减少到仅3 个路由,未压缩时为1.9Kb

我们还将其部署到了 https://remix.org.cn,将初始清单的未压缩大小从约 20Kb 降至约 4Kb

React Router 实现

与 Remix 中大多数路由功能一样,它都在底层使用 React Router。战争迷雾功能得益于一个新的 unstable_patchRoutesOnMiss API。此 API 允许您提供一个实现,以便在 React Router 无法在当前路由树中匹配路径时,随时向路由树添加新的路由。您可以在此方法中实现任何所需的异步逻辑,以发现合适的路由并将其修补到您需要的任何位置的当前树中。

const router = createBrowserRouter(
  [
    {
      id: "root",
      path: "/",
      Component: RootComponent,
    },
  ],
  {
    async unstable_patchRoutesOnMiss({ path, patch }) {
      if (path === "/a") {
        let route = await getARoute(); // { path: 'a', Component: A }
        // Patch the `a` route into the tree as a child of the `root` route
        patch("root", [route]);
      }
    },
  },
);

您可以扩展此异步逻辑并转向类似清单的方法,这与 Remix 使用的方法不太一样,但没有服务器方面。

// Manifest mapping route prefixes to sub-app implementations
let manifest = {
  "/account": () => import("./account"),
  "/dashboard": () => import("./dashboard"),
};

let router = createBrowserRouter(
  [
    {
      path: "/",
      Component: Home,
    },
  ],
  {
    async unstable_patchRoutesOnMiss({ path, patch }) {
      let prefix = Object.keys(manifest).find((p) => path.startsWith(p));
      if (prefix) {
        let children = await manifest[prefix]();
        patch(null, children);
      }
    },
  },
);

这种实现异步逻辑的能力也非常适合 React Router 中的微前端和模块联邦架构,因为您现在有了异步插入点来加载应用程序的子部分。

我们还要感谢 Shane Walker 在初始发布期间与我们合作,为基于联邦 rsbuild 的 React Router 应用程序提供了一个 很棒的示例,展示了如何使用这个新的 API。如果您有兴趣在您的 React Router 应用程序中使用模块联邦,请务必查看一下!

脚注

  1. 战争迷雾功能在 Remix v2.10 版本中以 unstable 标志发布,用于早期测试版 - 我们希望在即将发布的版本中将其稳定

  2. 此功能在 v2.10 版本中以 future.unstable_fogOFWar 标志发布,但在 v2.11 版本中重命名为 future.unstable_lazyRouteDiscovery。我们发现,如果没有我们在本文中讨论的“战争迷雾”背后的上下文,标志命名可能会有点令人困惑 🙂。 2


获取 Remix 最新新闻的更新

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