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 路由的載入器和元件!在最壞的情況下,如果他們不導覽到這些路由,使用者永遠不需要它們。這對於我們的 UX 來說並不是理想的。

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(這很可能會呼叫 fetch() 呼叫某些 API)似乎很愚蠢?如果我們能把這 👆 變成這 👇 呢?

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 儲存庫中的 範例。祝您使用 Lazy Loading 愉快!


取得有關 Remix 最新消息的更新

搶先了解 Remix 的新功能、社群活動和教學課程。