コンテンツにスキップ

将你的 React Router 应用迁移到 Remix

このコンテンツはまだ日本語訳がありません。

如果您想要一个 TL;DR 版本以及概述简化迁移的 repo,请查看我们的示例 React Router-to-Remix repo

本指南目前假设您使用的是 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 上构建。按照以下说明,从项目根目录运行所有命令。

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

创建服务器和浏览器入口点

大多数 React Router 应用主要在浏览器中运行。服务器的唯一工作是发送单个静态 HTML 页面,而 React Router 则管理基于路由的客户端视图。这些应用通常有一个浏览器入口点文件,如根目录 index.js,如下所示:

index.tsx
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 时进行区分。

app/entry.server.tsx
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);
});
}

您的客户端入口点将如下所示:

app/entry.client.tsx
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,则为 root root)负责提供应用程序的结构。其默认导出是一个组件,用于呈现其他所有路由加载并依赖的完整 HTML 树。可以将其视为应用程序的脚手架或外壳。

在客户端呈现的应用中,您将拥有一个索引 HTML 文件,其中包含用于挂载 React 应用的 DOM 节点。根路由将呈现与此文件结构相符的标记。

app 目录中创建一个名为 root.tsx(或 root.jsx)的新文件。该文件的内容会有所不同,但我们假设您的 index.html 如下所示:

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 中,导出一个镜像其结构的组件:

app/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 称为catch-all 或splat路由,它对于让您的旧应用处理尚未移入 routes 目录的路由非常有用。

在你的 _index.tsx$.tsx 文件中,我们需要做的就是从旧的根 App 中导出代码:

app/routes/_index.tsx
export { default } from "~/old-app/app";
// app/routes/$.tsx
export { default } from "~/old-app/app";

使用 Remix 替换打包工具

Remix 提供自己的打包器和 CLI 工具,用于开发和构建您的应用。您的应用可能使用类似 Create React App 的工具进行引导,或者您可能使用 Webpack 设置了自定义构建。

在您的 package.json 文件中,更新您的脚本以使用 remix 命令而不是当前的构建和开发脚本。

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

然后噗!你的应用现在已由服务器渲染,构建时间从 90 秒缩短至 0.5 秒⚡

创建你的路由

随着时间的推移,您将需要将 React Router 的 <Route> 组件呈现的路由迁移到它们自己的路由文件中。我们的 路由约定 中概述的文件名和目录结构将指导此迁移。

路由文件中的默认导出是在 <Outlet /> 中渲染的组件。因此,如果你的 App 中有一个如下所示的路由:

app/old-app/app.tsx
function About() {
return (
<main>
<h1>About us</h1>
<PageContent />
</main>
);
}
function App() {
return (
<Routes>
<Route path="/about" element={<About />} />
</Routes>
);
}

你的路由文件看起来应该像这样:

app/routes/about.tsx
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 对象并仅在浏览器中运行回调。然而,这可能会导致另一个问题,即可怕的hydration mismatch。React 依赖于服务器呈现的标记与客户端 hydration 期间呈现的标记相同。这确保了 react-dom 知道如何将 DOM 元素与其相应的 React 组件匹配,以便它可以附加事件侦听器并在状态更改时执行更新。因此,如果本地存储给我们的值与我们在服务器上启动的值不同,我们将面临一个新的问题需要处理。

仅限客户端的组件

这里的一个潜在解决方案是使用不同的缓存机制,该机制可以在服务器上使用,并通过从路由的 loader data 传递的 props 传递给组件。但是,如果你的应用不需要在服务器上渲染组件,那么更简单的解决方案可能是完全跳过服务器上的渲染,等到 hydration 完成后再在浏览器中渲染。

// 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 />;
}
}

为了简化此解决方案,我们建议使用 remix-utils 社区包中的 ClientOnly 组件。其使用示例可在 examples 存储库 中找到。

React.lazyReact.Suspense

如果您使用 React.lazyReact.Suspense 延迟加载组件,您可能会遇到问题,具体取决于您使用的 React 版本。在 React 18 之前,这在服务器上不起作用,因为 React.Suspense 最初是作为仅限浏览器的功能实现的。

如果你使用的是 React 17,则有以下几个选择:

  • 升级到 React 18
  • 使用上面概述的 仅客户端方法
  • 使用替代的延迟加载解决方案,例如 可加载组件
  • 完全删除 React.lazyReact.Suspense

请记住,Remix 会自动处理它管理的所有路由的代码拆分,因此当您将内容移动到 routes 目录时,您很少(如果有的话)需要手动使用 React.lazy

配置

进一步的配置是可选的,但以下内容可能有助于优化您的开发工作流程。

remix.config.js

每个 Remix 应用都会在项目根目录中接受一个 remix.config.js 文件。虽然其设置是可选的,但为了清晰起见,我们建议您添加一些设置。有关所有可用选项的更多信息,请参阅 配置文档

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,您还需要更新 /_ 的路径别名。

jsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
}
}
}
tsconfig.json
{
"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 文件。

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

// icon.svg
<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>
icon.tsx
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 函数:

app/root.tsx
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 包。

Terminal window
npm install @remix-run/css-bundle

然后,导入 cssBundleHref 并将其添加到链接描述符 - 最有可能在 root.tsx 中,以便它适用于整个应用程序。

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 时,我们认为您将能够大幅降低代码的复杂性并改善应用的最终用户体验。这可能需要一些时间,但您可以一次一口地解决这头大象。

现在,开始_重新组合你的应用_吧。我们认为你会喜欢你在此过程中构建的东西!💿

进一步阅读