Remix 的设计目标是使你的应用程序默认具有高性能。我们最新的功能 战争迷雾1 (又名 "惰性路由发现")2,可以帮助你的应用程序保持高性能,无论它变得多么庞大。
Remix 主要是一个基于 React Router 的编译器和服务端运行时,旨在为你提供编写 React Router SSR 应用程序的惯用且高性能的方法。你可以在不使用 Remix 的情况下构建自己的 React Router SSR 应用程序吗?当然可以!但是,为了获得相同的性能优化,你很可能最终会编写自己的编译器和服务端运行时,模仿 Remix 内置的许多优化。
Remix 的大多数优化都旨在消除网络瀑布。
为了避免“渲染然后获取”的瀑布,Remix 将渲染与获取分离 (另见:Remixing React Router)。为此,Remix 需要提前知道你的路由树,以便在单击链接时可以并行启动数据获取和下载路由模块。这导致了反向且性能更高的“获取然后渲染”方法(如果你正在流式传输数据,则为“渲染时获取”)。
在“渲染然后获取”的世界中,你的应用程序会下载路由实现,然后在渲染组件时启动数据获取——导致瀑布
使用“获取然后渲染”,模块获取和数据获取可以并行化
你可以在 Remix 中通过 <Link prefetch>
更进一步,这允许你在用户单击链接之前预取路由数据和组件。这样,当单击链接时,导航可以瞬间完成
首先,为了清楚起见,让我们定义一些重要的术语
path
、index
、children
)loader
、Component
、ErrorBoundary
等)为了实现上一节中提到的优化,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 期间,然后当单击链接时,我们会向服务器发出请求以获取新路由并获取数据和路由模块。它看起来像这样
但是,如你所见,这种方法会导致网络瀑布——而我们讨厌这些!这也意味着我们不能再实现 <Link prefetch>
了,因为我们甚至没有要匹配的路由,更不用说用于获取数据和模块的元数据了。
因此,对于 Remix 1.0,完整的清单被发送出去,以消除瀑布并允许链接预取。“部分清单”优化被留到了以后——这一天终于在 Remix v2.10 中到来,同时发布了 future.unstable_fogOfWar
标志(在 v2.11 中重命名为 future.unstable_lazyRouteDiscovery
)2。
在 Remix 中实现这一点而不引入网络瀑布并且不牺牲诸如 <Link prefetch>
之类的优化,其关键讽刺的是 <Link prefetch>
本身的方法。就像我们可以在实际单击链接之前执行目标路由数据和模块的主动获取一样,我们也可以在单击链接之前执行目标路由的主动发现。
考虑上面的图表,主动完成发现方面
与其等待单击链接来发现路由,不如根据渲染的链接主动执行此操作,因为链接代表用户接下来可能访问的潜在路径。Remix 将所有渲染的链接批量处理,并向 Remix 服务器发出单个 fetch
调用,以获取该组链接所需的路由。如果我们一旦渲染这些链接就执行此操作,那么很可能这些路由将在用户有时间查找并单击他们选择的链接之前被发现并添加到路由树中。如果我们在单击链接之前修补这些路由,那么 Remix 的行为根本不会改变——即使我们在初始加载时仅发送匹配的路由。
如果我们将这种主动发现与上面的 <Link prefetch>
优化相结合,我们仍然可以实现瞬间导航!
还值得注意的是,因为这仅仅是一个优化,所以应用程序在没有它的情况下也可以正常工作——只是由于网络瀑布而速度稍慢。因此,如果用户在修补清单所需的时间内确实单击了该链接,则该链接导航将遇到瀑布。这类似于 <Link prefetch>
,如果预取没有及时完成,则会在单击时发生获取,并且用户会在导航期间看到微调器。还值得注意的是,一个路由每个会话只需要被发现一次。随后导航到同一路由将不需要发现步骤。
让我们退一步,从更直观的“路由树”的角度来看一下。让我们看一下 Remix 当前的状态,其中完整的清单在初始加载时发送。
在下面的路由树中,红点代表主动渲染的路由,白色区域是路由清单,其中包含所有可能的路由
现在,如果我们启用战争迷雾,我们将在初始加载时仅发送清单中的活动路由
当我们水合(渲染)客户端 UI 时,我们会遇到一些指向清单当前未知路由的链接
Remix 将通过对 Remix 服务器的 fetch
调用来发现这些路由,并将它们修补到清单中
如你所见 - 这种类型的“发现”允许路由清单从一开始就很小,并随着用户在应用程序中的路径而增长,从而允许你的应用程序扩展到任意数量的路由,而不会对应用程序的初始加载产生性能影响。
如前所述,我们一直在 https://shopify.com 上进行“自己吃狗粮”测试,并且我们很喜欢结果。在战争迷雾之前,他们的路由清单包含 1300 个路由,未压缩时重量超过 10MB。启用战争迷雾后,他们初始主页清单下降到只有 3 个路由和 1.9Kb 未压缩。
我们也已将其部署到 https://remix.org.cn,将初始清单的未压缩大小从 ~20Kb 降至 ~4Kb。
与 Remix 中的大多数路由功能一样,它底层都是 React Router。雾里看花 (Fog of War) 功能的实现得益于一个新的 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 在初始发布期间与我们合作,共同创建了一个使用此新 API 在联合的 rsbuild
React Router 应用程序中的出色示例。如果您有兴趣在 React Router 应用程序中使用模块联邦,请务必查看一下!