全球数百万个部署的 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 树。可以将其视为应用程序的脚手架或外壳。
在客户端渲染的应用程序中,您将有一个 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/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 组件匹配,以便它可以附加事件监听器并在状态更改时执行更新。因此,如果 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.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 是一款很棒的工具集,可以帮助您从 命令行 生成这些组件,或者如果您更喜欢复制粘贴,则可以在 在线游乐场 中生成。
<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,您可以根据需要使用该 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
导出。您可以删除很多代码,甚至可能删除一个或两个依赖项!
Remix 内置支持 CSS 模块、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
不是合并路由层次结构中其他 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 时,我们认为您将能够大幅降低代码的复杂性,并改善应用程序的最终用户体验。这可能需要一些时间才能实现,但您可以一点一点地吃掉那只大象。
现在,去混合您的应用程序吧。我们认为您会喜欢您在途中构建的东西!💿