全球部署的数百万个 React 应用程序都由 React Router 提供支持。你可能已经发布了一些!因为 Remix 是在 React Router 的基础上构建的,我们努力使迁移成为一个简单的过程,你可以通过迭代来完成,以避免巨大的重构。
如果你还没有使用 React Router,我们认为有几个令人信服的理由让你重新考虑!历史记录管理、动态路径匹配、嵌套路由等等。看看 React Router 文档,看看我们提供了什么。
如果你正在使用较旧版本的 React Router,第一步是升级到 v6。查看 从 v5 到 v6 的迁移指南 和我们的 向后兼容包,以便快速且迭代地将你的应用升级到 v6。
首先,你需要一些我们的包来在 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
)app
目录中。如果你的现有应用程序使用同名的目录,请将其重命名为 src
或 old-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/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
称为捕获所有或“splat”路由,它对于让你的旧应用程序处理你尚未移动到 routes
目录中的路由非常有用。
在你的 _index.tsx
和 $.tsx
文件中,我们所需要做的就是从旧的根 App
中导出代码
export { default } from "~/old-app/app";
export { default } from "~/old-app/app";
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.lazy
和 React.Suspense
如果你使用 React.lazy
和 React.Suspense
延迟加载组件,则根据你使用的 React 版本,你可能会遇到问题。在 React 18 之前,这在服务器上不起作用,因为 React.Suspense
最初是作为仅限浏览器的功能实现的。
如果你使用的是 React 17,则有以下几个选择
React.lazy
和 React.Suspense
请记住,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.json
或 tsconfig.json
如果你使用的是 TypeScript,则你可能已经在你的项目中有 tsconfig.json
。jsconfig.json
是可选的,但为许多编辑器提供了有用的上下文。这些是我们建议你在你的语言配置中包含的最小设置。
路径别名,以便轻松地从根目录导入模块,无论你的文件位于项目的何处。如果你更改了 /_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
的更多信息,请参阅我们的文档。
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>
);
}
Create React App 和许多其他构建工具支持以各种方式在您的组件中导入 CSS。Remix 支持导入常规 CSS 文件以及下面描述的几种流行的 CSS 打包解决方案。
links
导出在 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
导出。您可以删除大量代码,甚至可能删除一两个依赖项!
Remix 内置支持 CSS Modules,Vanilla Extract 和 CSS 副作用导入。为了利用这些功能,您需要在应用程序中设置 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 }]
: []),
// ...
];
};
注意: 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
函数的值。 这是因为
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 您的应用程序吧。我们认为您会喜欢您在这个过程中构建的东西!💿