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.dev.org.tw 上根路由的範例路由資訊清單條目
"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.dev.org.tw/ 的資訊清單包含 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.dev.org.tw,將初始資訊清單的未壓縮大小從 ~20Kb 減少到 ~4Kb。
與 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 應用程式中使用 Module Federation,請務必參考一下!