React Router v7 已發布。 檢視文件
從 React Router 遷移
本頁內容

如果您想要一個 TL;DR 版本,以及一個概述簡化遷移的儲存庫,請查看我們的 React Router 到 Remix 升級路徑範例儲存庫

將您的 React Router 應用程式遷移到 Remix

本指南目前假設您使用的是傳統 Remix 編譯器,而不是 Remix Vite

全球數百萬個已部署的 React 應用程式都是由 React Router 所驅動。您很有可能已經發布過其中幾個!由於 Remix 是建立在 React Router 之上,我們致力於讓遷移成為一個簡單的過程,您可以透過反覆運算來避免大量的重構。

如果您還沒有使用 React Router,我們認為有幾個令人信服的理由可以重新考慮!歷史記錄管理、動態路徑匹配、巢狀路由等等。請查看 React Router 文件,了解我們所提供的一切。

確保您的應用程式使用 React Router v6

如果您使用的是舊版本的 React Router,第一步是升級到 v6。請查看從 v5 到 v6 的遷移指南和我們的 向後相容性套件,以快速且反覆地將您的應用程式升級到 v6。

安裝 Remix

首先,您需要一些我們的套件才能在 Remix 上建置。請依照以下指示,從您的專案根目錄執行所有命令。

npm install @remix-run/react @remix-run/node @remix-run/serve
npm install -D @remix-run/dev

建立伺服器和瀏覽器進入點

大多數 React Router 應用程式主要在瀏覽器中執行。伺服器的唯一工作是發送單一靜態 HTML 頁面,而 React Router 會管理用戶端基於路由的視圖。這些應用程式通常會有一個瀏覽器進入點檔案,例如根目錄 index.js,其內容如下所示

import { render } from "react-dom";

import App from "./App";

render(<App />, document.getElementById("app"));

伺服器渲染的 React 應用程式則有些不同。瀏覽器腳本並不是渲染您的應用程式,而是「水合作用」伺服器提供的 DOM。水合作用是將 DOM 中的元素對應到其 React 元件對應項,並設定事件監聽器的過程,以便您的應用程式具有互動性。

讓我們從建立兩個新檔案開始

  • app/entry.server.tsx (或 entry.server.jsx)
  • app/entry.client.tsx (或 entry.client.jsx)

按照慣例,您在 Remix 中的所有應用程式碼都會放在 app 目錄中。如果您的現有應用程式使用相同名稱的目錄,請將其重新命名為 srcold-app 之類的名稱,以便在我們遷移到 Remix 時區分。

import { PassThrough } from "node:stream";

import type {
  AppLoadContext,
  EntryContext,
} from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5_000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  loadContext: AppLoadContext
) {
  return isbot(request.headers.get("user-agent") || "")
    ? handleBotRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      )
    : handleBrowserRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      );
}

function handleBotRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer
        context={remixContext}
        url={request.url}
        abortDelay={ABORT_DELAY}
      />,
      {
        onAllReady() {
          const body = new PassThrough();

          responseHeaders.set("Content-Type", "text/html");

          resolve(
            new Response(
              createReadableStreamFromReadable(body),
              {
                headers: responseHeaders,
                status: responseStatusCode,
              }
            )
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          console.error(error);
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer
        context={remixContext}
        url={request.url}
        abortDelay={ABORT_DELAY}
      />,
      {
        onShellReady() {
          const body = new PassThrough();

          responseHeaders.set("Content-Type", "text/html");

          resolve(
            new Response(
              createReadableStreamFromReadable(body),
              {
                headers: responseHeaders,
                status: responseStatusCode,
              }
            )
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          console.error(error);
          responseStatusCode = 500;
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}

您的客戶端進入點會看起來像這樣

import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <RemixBrowser />
    </StrictMode>
  );
});

建立 root 路由

我們提到 Remix 是建立在 React Router 之上的。您的應用程式很可能會使用 JSX Route 元件呈現 BrowserRouter 以及您定義的路由。我們不需要在 Remix 中執行此操作,但稍後會詳細介紹。目前,我們需要提供 Remix 應用程式運作所需的最低層級路由。

根路由(如果您是 Wes Bos,則稱為「根根」)負責提供應用程式的結構。其預設導出是一個元件,該元件會呈現每個其他路由載入和依賴的完整 HTML 樹狀結構。將其視為您應用程式的支架或外殼。

在客戶端呈現的應用程式中,您將會有一個索引 HTML 檔案,其中包含用於掛載 React 應用程式的 DOM 節點。根路由將呈現反映此檔案結構的標記。

在您的 app 目錄中建立一個名為 root.tsx (或 root.jsx) 的新檔案。該檔案的內容會有所不同,但假設您的 index.html 看起來像這樣

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1"
    />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="My beautiful React app"
    />
    <link rel="apple-touch-icon" href="/logo192.png" />
    <link rel="manifest" href="/manifest.json" />
    <title>My React App</title>
  </head>
  <body>
    <noscript
      >You need to enable JavaScript to run this
      app.</noscript
    >
    <div id="root"></div>
  </body>
</html>

在您的 root.tsx 中,匯出一個反映其結構的元件

import { Outlet } from "@remix-run/react";

export default function Root() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <link rel="icon" href="/favicon.ico" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <meta name="theme-color" content="#000000" />
        <meta
          name="description"
          content="My beautiful React app"
        />
        <link rel="apple-touch-icon" href="/logo192.png" />
        <link rel="manifest" href="/manifest.json" />
        <title>My React App</title>
      </head>
      <body>
        <div id="root">
          <Outlet />
        </div>
      </body>
    </html>
  );
}

請注意這裡的一些事項

  • 我們移除了 noscript 標籤。我們現在正在進行伺服器端渲染,這表示禁用 JavaScript 的使用者仍然可以看到我們的應用程式(並且隨著時間的推移,當您進行一些調整以改善漸進式增強時,您的應用程式的許多部分仍然應該可以運作)。
  • 在根元素內部,我們從 @remix-run/react 呈現 Outlet 元件。這是在 React Router 應用程式中通常用來呈現匹配路由的相同元件;它在這裡的功能相同,但它已針對 Remix 中的路由器進行了調整。

重要:請務必在建立根路由後,從 public 目錄中刪除 index.html。保留該檔案可能會導致您的伺服器在存取 / 路由時,傳送該 HTML 而不是您的 Remix 應用程式。

調整您現有的應用程式碼

首先,將您現有 React 程式碼的根目錄移至 app 目錄中。因此,如果您的根應用程式碼位於專案根目錄中的 src 目錄中,現在應該位於 app/src 中。

我們也建議重新命名此目錄,以清楚表明這是您的舊程式碼,以便最終在遷移其所有內容後可以將其刪除。這種方法的好處在於,您不必一次性完成所有操作,您的應用程式才能像往常一樣執行。在我們的示範專案中,我們將此目錄命名為 old-app

最後,在您的根 App 元件(本來會掛載到 root 元素的元件)中,從 React Router 中移除 <BrowserRouter>。Remix 會為您處理此問題,而無需直接呈現提供者。

建立索引和萬用路由

Remix 除了根路由之外,還需要路由才能知道在 <Outlet /> 中呈現什麼。幸運的是,您已經在您的應用程式中呈現 <Route> 元件,並且當您遷移以使用我們的路由慣例時,Remix 可以使用這些元件。

首先,在 app 中建立一個名為 routes 的新目錄。在該目錄中,建立兩個名為 _index.tsx$.tsx 的檔案。$.tsx 稱為萬用或「星號」路由,它將有助於讓您的舊應用程式處理您尚未移至 routes 目錄中的路由。

在您的 _index.tsx$.tsx 檔案中,我們只需要匯出舊根 App 中的程式碼即可

export { default } from "~/old-app/app";
export { default } from "~/old-app/app";

使用 Remix 取代綁定器

Remix 提供其自身的綁定器和 CLI 工具來開發和建置您的應用程式。您的應用程式很可能使用 Create React App 之類的工具來啟動,或者您可能使用 Webpack 設定自訂建置。

在您的 package.json 檔案中,更新您的指令碼以使用 remix 命令,而不是您目前的建置和開發指令碼。

{
  "scripts": {
    "build": "remix build",
    "dev": "remix dev",
    "start": "remix-serve build/index.js",
    "typecheck": "tsc"
  }
}

然後就完成了!您的應用程式現在已在伺服器端渲染,並且您的建置時間從 90 秒變為 0.5 秒 ⚡

建立您的路由

隨著時間的推移,您會想要將 React Router 的 <Route> 元件所呈現的路由遷移到它們自己的路由檔案中。我們的路由慣例中概述的檔案名稱和目錄結構將會指導此遷移。

您的路由檔案中的預設匯出是在 <Outlet /> 中呈現的元件。因此,如果您的 App 中有一個看起來像這樣的路由

function About() {
  return (
    <main>
      <h1>About us</h1>
      <PageContent />
    </main>
  );
}

function App() {
  return (
    <Routes>
      <Route path="/about" element={<About />} />
    </Routes>
  );
}

您的路由檔案應該看起來像這樣

export default function About() {
  return (
    <main>
      <h1>About us</h1>
      <PageContent />
    </main>
  );
}

建立此檔案後,您可以從 App 中刪除 <Route> 元件。在遷移所有路由後,您可以刪除 <Routes>,並最終刪除 old-app 中的所有程式碼。

注意事項和後續步驟

此時,您可能可以說您已完成初始遷移。恭喜!但是,Remix 的運作方式與典型的 React 應用程式略有不同。如果不是這樣,我們為什麼要費心建置它呢? 😅

不安全的瀏覽器參考

將客戶端呈現的程式碼庫遷移到伺服器端呈現的程式碼庫時,一個常見的痛點是,您可能在伺服器上執行的程式碼中參考了瀏覽器 API。在初始化狀態中的值時,可以找到一個常見的範例

function Count() {
  const [count, setCount] = React.useState(
    () => localStorage.getItem("count") || 0
  );

  React.useEffect(() => {
    localStorage.setItem("count", count);
  }, [count]);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

在此範例中,localStorage 用作全域儲存區,以在頁面重新載入時保存一些資料。我們使用 useEffect 中的 count 的目前值更新 localStorage,這完全安全,因為 useEffect 只會在瀏覽器中呼叫!但是,根據 localStorage 初始化狀態是一個問題,因為此回呼會在伺服器和瀏覽器中執行。

您首選的解決方案可能是檢查 window 物件,並且僅在瀏覽器中執行回呼。但是,這可能會導致另一個問題,即可怕的水合不匹配。React 依賴伺服器呈現的標記與客戶端水合期間呈現的標記相同。這確保 react-dom 知道如何將 DOM 元素與其對應的 React 元件進行匹配,以便它可以附加事件監聽器並在狀態變更時執行更新。因此,如果本機儲存區給我們的值與我們在伺服器上啟動的值不同,我們就會遇到一個新的問題要處理。

僅限客戶端的元件

這裡的一個潛在解決方案是使用不同的快取機制,該機制可以在伺服器上使用,並透過從路由的載入器資料傳遞的道具傳遞給元件。但是,如果對於您的應用程式來說,在伺服器上呈現元件並不重要,那麼一個更簡單的解決方案可能是在伺服器上完全跳過呈現,並等到水合完成後再在瀏覽器中呈現它。

// We can safely track hydration in memory state
// outside of the component because it is only
// updated once after the version instance of
// `SomeComponent` has been hydrated. From there,
// the browser takes over rendering duties across
// route changes and we no longer need to worry
// about hydration mismatches until the page is
// reloaded and `isHydrating` is reset to true.
let isHydrating = true;

function SomeComponent() {
  const [isHydrated, setIsHydrated] = React.useState(
    !isHydrating
  );

  React.useEffect(() => {
    isHydrating = false;
    setIsHydrated(true);
  }, []);

  if (isHydrated) {
    return <Count />;
  } else {
    return <SomeFallbackComponent />;
  }
}

為了簡化此解決方案,我們建議使用ClientOnly 元件,它位於 remix-utils 社群套件中。在其範例儲存庫中可以找到其用法的範例。

React.lazyReact.Suspense

如果您使用 React.lazyReact.Suspense 延遲載入元件,您可能會遇到問題,具體取決於您使用的 React 版本。在 React 18 之前,這在伺服器上無法運作,因為 React.Suspense 最初是作為僅限瀏覽器的功能實作的。

如果您使用 React 17,您有幾個選項

請記住,Remix 會自動處理它管理的所有路由的程式碼分割,因此,當您將內容移至 routes 目錄時,您應該很少(如果有的話)需要手動使用 React.lazy

設定

其他設定是可選的,但以下設定可能有助於最佳化您的開發工作流程。

remix.config.js

每個 Remix 應用程式都接受專案根目錄中的 remix.config.js 檔案。雖然其設定是可選的,但我們建議您加入其中一些設定,以使其清晰明瞭。請參閱有關設定的文件,以取得有關所有可用選項的更多資訊。

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  appDirectory: "app",
  ignoredRouteFiles: ["**/*.css"],
  assetsBuildDirectory: "public/build",
};

jsconfig.jsontsconfig.json

如果您使用 TypeScript,您的專案中可能已經有一個 tsconfig.jsonjsconfig.json 是可選的,但會為許多編輯器提供有用的內容。這些是我們建議包含在您的語言設定中的最低設定。

Remix 使用 /_ 路徑別名,無論您的檔案位於專案中的哪個位置,都可以輕鬆地從根目錄匯入模組。如果您變更 remix.config.js 中的 appDirectory,您也需要更新 /_ 的路徑別名。

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "resolveJsonModule": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./app/*"]
    }
  }
}
{
  "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "isolatedModules": true,
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "resolveJsonModule": true,
    "moduleResolution": "Bundler",
    "baseUrl": ".",
    "noEmit": true,
    "paths": {
      "~/*": ["./app/*"]
    }
  }
}

如果您使用 TypeScript,您還需要在專案根目錄中建立 remix.env.d.ts 檔案,其中包含適當的全域類型參考。

/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

關於非標準匯入的注意事項

此時,您可能可以在不做任何變更的情況下執行您的應用程式。如果您使用 Create React App 或高度設定的綁定器設定,您很可能會使用 import 來包含非 JavaScript 模組,例如樣式表和影像。

Remix 不支援大多數非標準匯入,而且我們認為這是有原因的。以下是您在 Remix 中會遇到的一些差異的非詳盡清單,以及您在遷移時如何重構。

資源匯入

許多綁定器使用外掛程式來允許匯入各種資源,例如影像和字型。這些通常會以字串的形式進入您的元件,表示資源的檔案路徑。

import logo from "./logo.png";

export function Logo() {
  return <img src={logo} alt="My logo" />;
}

在 Remix 中,這基本上以相同的方式運作。對於由 <link> 元素載入的字型之類的資源,您通常會在路由模組中匯入這些資源,並將檔案名稱包含在 links 函數傳回的物件中。請參閱我們有關路由 links 的文件以取得更多資訊。

SVG 匯入

Create React App 和其他一些建置工具允許您將 SVG 檔案匯入為 React 元件。這是 SVG 檔案的常見使用案例,但 Remix 預設不支援。

// This will not work in Remix!
import MyLogo from "./logo.svg";

export function Logo() {
  return <MyLogo />;
}

如果您想將 SVG 檔案作為 React 組件使用,您需要先建立組件並直接匯入它們。 React SVGR 是一個很棒的工具集,可以幫助您從命令列或在線上遊樂場(如果您偏好複製貼上)產生這些組件。

<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 20 20" fill="currentColor">
  <path fill-rule="evenodd" clip-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z" />
</svg>
export default function Icon() {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      className="icon"
      viewBox="0 0 20 20"
      fill="currentColor"
    >
      <path
        fillRule="evenodd"
        clipRule="evenodd"
        d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z"
      />
    </svg>
  );
}

CSS 導入

Create React App 和許多其他建置工具都支援以各種方式在您的組件中匯入 CSS。Remix 支援匯入常規 CSS 檔案,以及以下描述的幾種流行的 CSS 打包解決方案。

在 Remix 中,常規樣式表可以從路由組件檔案載入。匯入它們不會對您的樣式產生任何神奇的效果,而是會回傳一個 URL,可用於載入您認為合適的樣式表。您可以直接在您的組件中呈現樣式表,或使用我們的 links 導出

讓我們將應用程式的樣式表和其他一些資源移至根路由中的 links 函式。

import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno
import { Links } from "@remix-run/react";

import App from "./app";
import stylesheetUrl from "./styles.css";

export const links: LinksFunction = () => {
  // `links` returns an array of objects whose
  // properties map to the `<link />` component props
  return [
    { rel: "icon", href: "/favicon.ico" },
    { rel: "apple-touch-icon", href: "/logo192.png" },
    { rel: "manifest", href: "/manifest.json" },
    { rel: "stylesheet", href: stylesheetUrl },
  ];
};

export default function Root() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <meta name="theme-color" content="#000000" />
        <meta
          name="description"
          content="Web site created using create-react-app"
        />
        <Links />
        <title>React App</title>
      </head>
      <body>
        <App />
      </body>
    </html>
  );
}

您會注意到在第 32 行,我們呈現了一個 <Links /> 組件,它取代了我們所有的單獨 <link /> 組件。如果我們只在根路由中使用連結,這並不重要,但是所有子路由都可以導出它們自己的連結,這些連結也會在這裡呈現。links 函式也可以回傳一個 PageLinkDescriptor 物件,讓您可以預先載入使用者可能導航到的頁面資源。

如果您目前在現有的路由組件中,直接或透過像 react-helmet 這樣的抽象概念將 <link /> 標籤注入到您的頁面客戶端,您可以停止這樣做,改為使用 links 導出。您可以刪除大量程式碼,甚至可能刪除一兩個依賴項!

CSS 打包

Remix 內建支援 CSS ModulesVanilla ExtractCSS 副作用導入。為了使用這些功能,您需要在您的應用程式中設定 CSS 打包。

首先,為了存取產生的 CSS 包,請安裝 @remix-run/css-bundle 套件。

npm install @remix-run/css-bundle

然後,匯入 cssBundleHref 並將其新增到連結描述符中——最有可能在 root.tsx 中,以便它應用於您的整個應用程式。

import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction } from "@remix-run/node"; // or cloudflare/deno

export const links: LinksFunction = () => {
  return [
    ...(cssBundleHref
      ? [{ rel: "stylesheet", href: cssBundleHref }]
      : []),
    // ...
  ];
};

有關更多資訊,請參閱我們關於 CSS 打包的文件。

注意: Remix 目前不直接支援 Sass/Less 處理,但您仍然可以將它們作為單獨的程序運行,以產生 CSS 檔案,然後可以將這些檔案匯入到您的 Remix 應用程式中。

<head> 中呈現組件

就像 <link> 在您的路由組件中呈現,並最終在您的根 <Links /> 組件中呈現一樣,您的應用程式可能會使用一些注入技巧,在文件 <head> 中呈現其他組件。通常這樣做是為了更改文件的 <title><meta> 標籤。

links 類似,每個路由還可以導出一個 meta 函式,該函式回傳負責呈現該路由的 <meta> 標籤的值(以及一些其他與元數據相關的標籤,例如 <title><link rel="canonical"><script type="application/ld+json">)。

meta 的行為與 links 略有不同。不是合併路由階層中其他 meta 函式的值,每個葉路由都負責呈現自己的標籤。這是因為

  • 您通常希望對元數據進行更精細的控制,以實現最佳的 SEO
  • 對於某些遵循 Open Graph 協議的標籤,某些標籤的順序會影響爬蟲和社交媒體網站如何解釋它們,而且 Remix 很難預測複雜的元數據應如何合併
  • 某些標籤允許多個值,而其他標籤則不允許,Remix 不應假設您希望如何處理所有這些情況

更新導入

Remix 重新導出您從 react-router-dom 獲得的所有內容,我們建議您更新您的導入,以從 @remix-run/react 獲得這些模組。在許多情況下,這些組件都包裝了額外的功能和特性,這些功能和特性是專門針對 Remix 優化的。

之前

import { Link, Outlet } from "react-router-dom";

之後

import { Link, Outlet } from "@remix-run/react";

最後的想法

雖然我們已盡力提供全面的遷移指南,但重要的是要注意,我們從頭開始建構 Remix,其中包含一些與目前許多 React 應用程式的建構方式明顯不同的關鍵原則。雖然您的應用程式在此時可能會執行,但當您深入研究我們的文件並探索我們的 API 時,我們認為您將能夠大幅降低程式碼的複雜性並改善應用程式的終端用戶體驗。可能需要一些時間才能達到目標,但您可以一口一口地吃掉那隻大象。

現在,開始去混音您的應用程式吧。我們認為您會喜歡您在過程中建立的東西!💿

延伸閱讀

文件和範例以授權 MIT