从 React Router 迁移
本页内容

如果您想获得 TL;DR 版本以及一个概述简化迁移的仓库,请查看我们的 示例 React Router-to-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 之上构建的。您的应用程序可能会使用 BrowserRouter 渲染您的路由,这些路由在 JSX Route 组件中定义。我们不需要在 Remix 中执行此操作,但稍后会详细说明。现在,我们需要为 Remix 应用程序提供最低级别的路由以使其能够工作。

根路由(或者如果您是 Wes Bos 的话,则是“根根”)负责提供应用程序的结构。其默认导出是渲染完整 HTML 树的组件,每个其他路由都加载并依赖于该 HTML 树。可以将其视为应用程序的脚手架或外壳。

在客户端渲染的应用程序中,您将有一个 index 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/reactOutlet 组件。这与您通常在 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 被称为 通配符或“splat”路由,它将有助于让您的旧应用程序处理尚未迁移到 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 组件匹配,以便它可以附加事件监听器并在状态更改时执行更新。因此,如果 localStorage 给出了与我们在服务器上初始化的值不同的值,那么我们将遇到新的问题。

仅限客户端的组件

这里一个可能的解决方案是使用不同的缓存机制,该机制可以在服务器上使用,并通过从路由的 加载程序数据 传递的道具传递给组件。但是,如果您的应用程序不需要在服务器上渲染组件,则一个更简单的解决方案可能是完全跳过服务器上的渲染,并等到水合完成,然后在浏览器中渲染它。

// 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 社区包中)。在 examples 存储库 中可以找到其用法的示例。

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,您可以根据需要使用该 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 对象,使您能够预取用户可能会导航到的页面的资源。

如果您目前在现有的路由组件中将 <link /> 标签注入到页面客户端中,无论是直接注入还是通过 react-helmet 之类的抽象注入,您都可以停止这样做,而是使用 links 导出。您可以删除很多代码,甚至可能删除一个或两个依赖项!

CSS 捆绑

Remix 内置支持 CSS 模块Vanilla 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 不是合并路由层次结构中其他 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