React Router Logo
2022 年 3 月 23 日

重新混搭 React Router

Ryan Florence
共同創辦人

實際上,React Router 的第一個版本有一個非同步的 hook 來協助資料載入,稱為 willTransitionTo。當時沒有人真正知道如何使用 React,我們也不例外。它並不是非常棒,但至少朝著正確的方向發展。

無論好壞,我們在 React Router v4 中全力投入於元件,並移除了這個 hook。隨著 willTransitionTo 的消失,以及元件成為我們的主要工具,現今幾乎每個 React Router 應用程式都是在元件內部提取資料。

我們了解到,在元件中提取資料是導致最慢使用者體驗的最快方式(更不用說隨之而來的所有內容佈局偏移)。

不僅使用者體驗受到影響。開發人員體驗也變得複雜,包括所有上下文管道、全域狀態管理解決方案(通常只是伺服器端狀態的客戶端快取),以及每個需要資料的元件都需要擁有自己的載入、錯誤和成功狀態。很少有順遂的途徑!

在我們建構 Remix 的過程中,我們獲得了許多實踐經驗,依靠 React Router 的巢狀路由抽象來一次解決所有這些問題。今天,我們很高興宣布我們已開始將這些資料 API 引入 React Router,但這次它是非常出色的。

tl;dr

幾乎所有關於 Remix 的資料和非同步 UI 管理的優點都將引入 React Router。

  • Remix 中的所有資料元件、hook 和細微的非同步資料管理都將引入 React Router。
    • 使用 <Route loader /> 載入資料
    • 使用 <Route action /><Form> 進行資料變更
    • 自動處理中斷、錯誤、重新驗證、競爭條件等。
    • 使用 useFetcher 進行非導航資料互動
  • 一個新的套件 @remix-run/router 將結合 History 中的所有相關功能、React Router 的匹配,以及 Remix 的資料管理,以一種與 vue 無關的方式——抱歉——一種視圖無關的方式。這只是一個內部依賴,您仍然會使用 npm install react-router-dom

元件提取和渲染提取鏈

當您在元件內部提取資料時,您會建立我們稱之為渲染+提取鏈的東西,它會透過依序而不是並行提取數個資料依賴,來人為地減慢您的頁面載入和轉換速度。

考慮這些路由

<Routes>
  <Route element={<Root />}>
    <Route path="projects" element={<ProjectsList />}>
      <Route path=":projectId" element={<ProjectPage />} />
    </Route>
  </Route>
</Routes>

現在考慮每個元件都提取自己的資料

function Root() {
  let data = useFetch("/api/user.json");

  if (!data) {
    return <BigSpinner />;
  }

  if (data.error) {
    return <ErrorPage />;
  }

  return (
    <div>
      <Header user={data.user} />
      <Outlet />
      <Footer />
    </div>
  );
}
function ProjectsList() {
  let data = useFetch("/api/projects.json");

  if (!data) {
    return <MediumSpinner />;
  }

  return (
    <div style={{ display: "flex" }}>
      <ProjectsSidebar project={data.projects}>
      <ProjectsContent>
        <Outlet />
      </ProjectContent>
    </div>
  );
}
function ProjectPage() {
  let params = useParams();
  let data = useFetch(`/api/projects/${params.projectId}.json`);

  if (!data) {
    return <div>Loading...</div>;
  }

  if (data.notFound) {
    return <NotFound />;
  }

  if (data.error) {
    return <ErrorPage />;
  }

  return (
    <div>
      <h3>{project.title}</h3>
      {/* ... */}
    </div>
  );
}

當使用者訪問 /projects/123 時會發生什麼?

  1. <Root> 提取 /api/user.json 並渲染 <BigSpinner/>
  2. 網路響應
  3. <ProjectsList> 提取 /api/projects.json 並渲染 <MediumSpinner/>
  4. 網路響應
  5. <ProjectPage> 提取 /api/projects/123.json 並渲染 <div>載入中...</div>
  6. 網路響應
  7. <ProjectPage> 最終渲染,頁面完成

像這樣元件提取會使您的應用程式比它應有的速度慢得可笑。元件在掛載時啟動提取,但父元件本身的待處理狀態會阻止子元件渲染,因此也阻止了提取!

這是一個渲染+提取鏈。我們範例應用程式中的所有三個提取邏輯上都可以並行進行,但它們不能,因為它們與 UI 階層結構耦合,並被父級載入狀態所阻擋。

如果每個提取都需要一秒鐘才能解析,那麼整個頁面至少需要三秒鐘才能渲染!這就是為什麼許多 React 應用程式載入緩慢且轉換緩慢的原因。

network diagram showing sequential network requests
將資料提取與元件耦合會導致渲染+提取鏈

解決方案是將啟動提取讀取結果分離。這正是 Remix API 現在所做的事情,也是 React Router 即將做的事情。透過在巢狀路由邊界啟動提取,請求瀑布鏈被扁平化,速度快了 3 倍。

network diagram showing parallel network requests
路由提取會平行處理請求,消除緩慢的渲染+提取鏈

這不僅僅關乎使用者體驗。新 API 一次解決的所有問題的數量對程式碼的簡潔性和您在編碼時的樂趣產生了巨大影響。

即將推出

我們仍在對一些東西的名稱進行推敲,但以下是您可以期待的

import * as React from "react";
import {
  BrowserRouter,
  Routes,
  Route,
  useLoaderData,
  Form,
} from "react-router-dom";

ReactDOM.render(
  <BrowserRouter>
    <Routes
      // if you're not server rendering, this manages the
      // initial loading state
      fallbackElement={<BigSpinner />}
      // any rendering or async loading and mutation errors will
      // automatically be caught and render here, no more error
      // state tracking or render branching
      exceptionElement={<GlobalErrorPage />}
    >
      <Route
        // Loaders provide data to route component and are initiated
        // when the URL changes
        loader={({ signal }) => {
          // React Router speaks the Web Fetch API, so you can return
          // a web fetch Response and it'll automatically be
          // deserialized with `res.json()`. No more useFetch hooks
          // and messing with their pending states in every component
          // that needs them.
          return fetch("/api/user.json", {
            // It also handles navigation interruptions and (as long as
            // you pass the signal) cancels the actual fetch.
            signal,
          });
        }}
      >
        <Route
          path="projects"
          element={<Projects />}
          // exceptions bubble, so you can handle them in context or
          // just let them bubble to the top, tons of happy paths!
          exceptionElement={<TasksErrorPage />}
          loader={async ({ signal }) => {
            // You can also unwrap the fetch yourself and write
            // simple `async/await` code (try that inside a useEffect 🥺).
            // You don't even have to `fetch`, you can get data from
            // anywhere (localStorage, indexedDB whatever)
            let res = await fetch("/api/tasks.json", { signal });

            // if at any point you can't render the route component
            // based on the data you're trying to load, just `throw` an
            // exception and the exceptionElement will render instead.
            // This keeps your happy path happy, and your exception path,
            // uh, exceptional!
            if (res.status === 404) {
              throw { notFound: true };
            }

            return res.json();
          }}
        >
          <Route
            path=":projectId"
            element={<Projects />}
            // a lot of your loading is gonna be this simple, React
            // Router will handle all the pending states and expose it
            // to you so you can build pending/optimistic UI
            loader={async ({ signal, params }) =>
              fetch(`/api/projects/${params.projectId}`, { signal })
            }
          />
        </Route>
        <Route index element={<Index />} />
      </Route>
    </Routes>
  </BrowserRouter>,
);
function Root() {
  // components access route data with this hook, data is guaranteed
  // to be here, error free, and no pending states to deal with in
  // every component that has a data dependency (also helps with
  // removing Content Layout Shift).
  let data = useLoaderData();

  // the transition tells you everything you need to build pending
  // indicators, busy spinners, optimistic UI, and side effects.
  let transition = useTransition();

  return (
    <div>
      {/* You can put global navigation indicators at the root and
          never worry about loading states in your components again,
          or you can get more granular around Outlets to build
          skeleton UI so the user gets immediate feedback when a link
          is clicked (we'll show how to do that another time) */}
      <GlobalNavSpinner show={transition.state === "loading"} />
      <Header user={data.user} />
      <Outlet />
      <Footer />
    </div>
  );
}

資料變更也來了!

我們不僅透過這些資料載入 API 加快您的應用程式速度,我們還弄清楚如何將資料變更 API 也引入進來!當您擁有包含讀取和寫入的路由和資料解決方案時,您可以一次解決許多問題。

考慮這個「新專案」表單。

function NewProjectForm() {
  return (
    <Form method="post" action="/projects">
      <label>
        New Project: <input name="title" />
      </label>
      <button type="submit">Create</button>
    </Form>
  );
}

一旦您有了 UI,您唯一需要的另一件事就是表單動作指向的路由上的動作

<Route
  path="projects"
  element={<Projects />}
  // this action will be called when the form submits because it
  // matches the form's action prop, routes can now handle all of
  // your data needs: reads AND writes.
  action={async ({ request, signal }) => {
    let values = Object.fromEntries(
      // React Router intercepted the normal browser POST request and
      // provides it to you here as a standard Web Fetch Request. The
      // formData as serialized by React Router and available to you
      // on the request. Standard HTML and DOM APIs, nothing new.
      await request.formData(),
    );

    // You already know the web fetch API because you've been using it
    // for years like this:
    let res = await fetch("/api/projects.json", {
      signal,
      method: "post",
      body: JSON.stringify(values),
      headers: { "Content-Type": "application/json; utf-8" },
    });

    let project = await res.json();

    // if there's a problem, just throw an exception and the
    // exception element will render, keeping the happy path happy.
    // (there are better things to throw than errors if you keep
    // reading)
    if (project.error) throw new Error(project.error);

    // now you can return from here to render this route or return a
    // redirect (which is really a Web Fetch Response, ofc) to go
    // somewhere else, like the new project!
    return redirect(`/projects/${project.id}`);
  }}
/>

就這樣。您只需要在一個簡單的 async 函式中編寫 UI 和實際的應用程式特定變更程式碼。

無需分派錯誤或成功狀態,無需擔心 useEffect 相依性,無需傳回清除函式,無需過期快取鍵。您只有一個問題:執行變更,如果發生錯誤,請拋出。非同步 UI、變更問題和異常渲染路徑已完全分離。

從那裡開始,React Router 將為您處理所有這些問題

  • 在表單提交時呼叫動作(不再需要事件處理程式、event.preventDefault() 和全域資料上下文管道)
  • 如果動作中拋出任何內容,則渲染異常邊界(不再需要在每個具有變更的元件中處理錯誤和異常狀態)
  • 透過呼叫頁面的載入器來重新驗證頁面上的資料(不再需要上下文管道、不再需要伺服器狀態的全域儲存、不再需要快取鍵過期,程式碼更少)
  • 如果使用者過於快速點擊,則處理中斷,避免 UI 不同步
  • 當多個變更和重新驗證同時進行時,處理重新驗證競爭條件

因為它為您處理所有這些事情,所以它可以透過一個簡單的 hook:useTransition 來公開它所知道的一切。這就是您向使用者提供回饋以使您的應用程式感覺穩如磐石的方式(也是我們首先將 React 放在頁面上的原因!)

function NewProjectForm() {
  let transition = useTransition();

  let busy = transition.state === "submitting";

  // This hook tells you everything--what state the transition is
  // in ("idle", "submitting", "loading"), what formData is being
  // submitted to the server for optimistic UI and more.

  // You can build the fanciest SPA UI your designers can dream up...
  return (
    <Form method="post" action="/projects">
      <label>
        New Project: <input name="title" />
      </label>
      {/* ... or just disable the button 😂 */}
      <button type="submit" disabled={busy}>
        Create
      </button>
    </Form>
  );
}

如果您的應用程式大部分都是處理提取和發佈到 API 路由,當這個功能發布時,請準備刪除大量程式碼。

為抽象而建構

許多開發人員可能會查看這個 API,並認為它在路由配置中佔用太多程式碼。Remix 能夠將載入器和動作與路由模組共同定位,並從檔案系統本身建立路由配置。我們預期人們會為他們的應用程式建立類似的模式。

以下是一個非常簡單的範例,說明如何在不費吹灰之力的情况下共同定位這些問題。建立一個「路由模組」,其中動態導入實際內容。這使您可以進行程式碼分割,並獲得更清晰的路由配置。

export async function loader(args) {
  let actualLoader = await import("./actualModule").loader;
  return actualLoader(args);
}

export async function action(args) {
  let actualAction = await import("./actualModule").action;
  return actualAction(args);
}

export const Component = React.lazy(() => import("./actualModule").default);
import * as Tasks from "./tasks.route";

// ...
<Route
  path="/tasks"
  element={<Tasks.Component />}
  loader={Tasks.loader}
  action={Tasks.action}
/>;

Suspense + React Router = ❤️

React Server Components、Suspense 和 Streaming 雖然尚未發布,但它們是在 React 中形成的令人興奮的功能。我們在 React Router 中進行這項工作時考慮了這些 API。

這些 React API 是為在渲染之前啟動資料載入的系統而設計的。它們不是用於定義您在何處啟動提取,而是用於定義您在何處存取結果。

  • Suspense 定義了您需要等待已啟動的提取、待處理的 UI 以及何時在串流時「刷新」HTML
  • React Server Components 將資料載入和渲染移至伺服器
  • 當資料可用時,Streaming 會渲染 React Server Components,並在 Suspense 邊界傳送 HTML 區塊以進行初始 SSR。

這些 API 都不是設計用於啟動載入,而是設計用於在資料可用時如何以及在何處渲染。如果您在 Suspense 邊界內部啟動提取,您仍然只是在元件中提取,這與現今 React Router 應用程式中存在的所有效能問題相同。

React Router 的新資料載入 API 正是 Suspense 所期望的!當 URL 變更時,React Router 會在渲染之前啟動每個匹配路由的提取。這為這些新的 React 功能提供了它們所需的一切來發光 ✨。

儲存庫合併

在我們開發這些功能的過程中,我們的工作橫跨三個儲存庫:History、React Router 和 Remix。當一切都如此相關時,這對我們來說是一個非常糟糕的開發人員體驗,需要跨所有這些儲存庫維護工具、問題和 PR。社群也很難提供貢獻。

我們一直認為 Remix 是「只是 React Router 的編譯器和伺服器」。現在是時候讓它們搬到一起了。

在物流上,這表示我們將

  • 將 Remix 合併到 React Router 儲存庫中,因為 React Router 是我們正在做的一切的主要依賴項。它在網路上也擁有最悠久的歷史,在過去 7 年中擁有問題、PR 和反向連結。Remix 只有幾個月的歷史。
  • 將 Remix 儲存庫從「remix」重新命名並封存為「remix-archive」
  • 將「react-router」儲存庫重新命名為「remix」,所有套件都放在一起
  • 繼續以與之前相同的名稱將所有內容發佈到 NPM。這只是原始碼/專案洗牌,您的 package.json 不會受到影響

有許多內部事務需要處理,因此當我們開始合併作業時,您可能會看到儲存庫上的 Issues/PRs 被移動、合併或關閉。我們會盡力維護每個儲存庫的 Git 歷史記錄,因為我們相信每位貢獻者都應該在提交記錄上留下自己的名字!

如果您對上述任何或所有事項有任何疑問或感到興奮,請在 DiscordTwitter 上與我們聯繫:)


獲取最新的 Remix 新聞

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