React Router v7 已发布。 查看文档
从 React Router 迁移
本页内容

如果你想要一个 TL;DR 版本,以及一个概述简化迁移的仓库,请查看我们的 示例 React Router 到 Remix 的仓库

将你的 React Router 应用迁移到 Remix

本指南目前假设你使用的是 Classic Remix Compiler,而不是 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 文件,其中包含用于挂载 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 组件匹配,以便它可以附加事件侦听器并在状态更改时执行更新。因此,如果本地存储给我们提供的值与我们在服务器上初始化的值不同,我们将面临一个新的问题。

仅客户端组件

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

// 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 是一个很棒的工具集,可以帮助您从命令行或在在线 playground 中生成这些组件,如果您喜欢复制和粘贴。

<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 时,我们认为您将能够大大降低代码的复杂性并改善应用程序的最终用户体验。这可能需要一些时间才能到达那里,但您可以一口一口地吃掉那头大象。

现在,去remix 您的应用程序吧。我们认为您会喜欢您在这个过程中构建的东西!💿

延伸阅读

文档和示例在以下许可下授权 MIT