跳转到内容

升级到 v2

本文档提供了使用 Classic Remix 编译器 从 v1 迁移到 v2 的指南。有关迁移到 Vite 的更多指南,请参阅 Remix Vite 文档

所有 v2 API 和行为在 v1 中都可通过 Future Flags 获得。可以一次启用一个,以避免项目开发中断。启用所有标志后,升级到 v2 应该是一次不间断的升级。

如果您遇到问题,请参阅 Troubleshooting 部分。

要快速了解一些常见的升级问题,请查看 🎥 2 分钟升级到 v2

remix dev

有关配置选项,请参阅 remix dev docs

remix-serve

如果您使用的是 Remix App Server (remix-serve),请启用 v2_dev:

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
v2_dev: true,
},
};

就是这样!

自定义应用服务器

如果您使用自己的应用服务器(server.js), 请查看我们的 模板,了解如何与 v2_dev 集成的示例 或按照以下步骤操作:

  1. 启用 v2_dev
remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
v2_dev: true,
},
};
  1. 更新 package.json 中的 scripts
  • remix dev 替换所有 remix watch
  • 删除多余的 NODE_ENV=development
  • 使用 -c / --command 运行您的应用服务器

例如:

package.json
{
"scripts": {
"dev:remix": "cross-env NODE_ENV=development remix watch",
"dev:server": "cross-env NODE_ENV=development node ./server.js"
"dev": "remix dev -c 'node ./server.js'",
}
}
  1. 应用运行时,向 Remix 编译器发送 ready 消息
// server.js lines=[1-2,11]
import { broadcastDevReady } from "@remix-run/node";
// 从`@remix-run/cloudflare`导入 { logDevReady } // 如果使用 CloudFlare,请使用 `logDevReady`
const BUILD_DIR = path.join(process.cwd(), "build");
// ... 设置服务器的代码放在这里 ...
const port = 3000;
app.listen(port, async () => {
console.log(`👉 http://localhost:${port}`);
broadcastDevReady(await import(BUILD_DIR));
});
  1. (可选) --manual

如果您依赖于 require 缓存清除,则可以使用 --manual 标志继续执行此操作:

Terminal window
remix dev --manual -c 'node ./server.js'

查看 手动模式指南 了解更多详情。

从 v1 升级到 v2 后

在 v1 中启用 future.v2_dev 标志并使其正常工作后,您就可以升级到 v2 了。 如果您刚刚将 v2_dev 设置为 true,则可以将其删除,一切应该都可以正常工作。

如果您正在使用 v2_dev 配置,则需要将其移动到 dev 配置字段:

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
- future: {
- v2_dev: {
- port: 4004
- }
- }
+ dev: {
+ port: 4004
+ }
}

文件系统路由约定

升级而不更改文件

如果您现在不想进行更改(或者永远不想,这只是一个约定,您可以使用您喜欢的任何文件组织),那么即使升级到 v2 后,您也可以继续使用旧约定 @remix-run/v1-route-convention

Terminal window
npm i -D @remix-run/v1-route-convention
remix.config.js
const {
createRoutesFromFolders,
} = require("@remix-run/v1-route-convention");
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
// makes the warning go away in v1.15+
v2_routeConvention: true,
},
routes(defineRoutes) {
// uses the v1 convention, works in v1.15+ and v2
return createRoutesFromFolders(defineRoutes);
},
};

升级到新约定

  • 路由嵌套现在由文件名中的点(.)创建,而不是文件夹嵌套
  • 段中的 后缀_ 下划线选择退出嵌套,使用可能匹配的父路由,而不是点(.)。
  • 段中的 _前缀 下划线创建没有路径的布局路由,而不是 __double 下划线前缀。
  • _index.tsx 文件创建索引路由,而不是 index.tsx

在 v1 中,路由文件夹如下所示:

app/
├── routes/
│ ├── __auth/
│ │ ├── login.tsx
│ │ ├── logout.tsx
│ │ └── signup.tsx
│ ├── __public/
│ │ ├── about-us.tsx
│ │ ├── contact.tsx
│ │ └── index.tsx
│ ├── dashboard/
│ │ ├── calendar/
│ │ │ ├── $day.tsx
│ │ │ └── index.tsx
│ │ ├── projects/
│ │ │ ├── $projectId/
│ │ │ │ ├── collaborators.tsx
│ │ │ │ ├── edit.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── settings.tsx
│ │ │ │ └── tasks.$taskId.tsx
│ │ │ ├── $projectId.tsx
│ │ │ └── new.tsx
│ │ ├── calendar.tsx
│ │ ├── index.tsx
│ │ └── projects.tsx
│ ├── __auth.tsx
│ ├── __public.tsx
│ └── dashboard.projects.$projectId.print.tsx
└── root.tsx

使用 v2_routeConvention 后变为这样:

app/
├── routes/
│ ├── _auth.login.tsx
│ ├── _auth.logout.tsx
│ ├── _auth.signup.tsx
│ ├── _auth.tsx
│ ├── _public._index.tsx
│ ├── _public.about-us.tsx
│ ├── _public.contact.tsx
│ ├── _public.tsx
│ ├── dashboard._index.tsx
│ ├── dashboard.calendar._index.tsx
│ ├── dashboard.calendar.$day.tsx
│ ├── dashboard.calendar.tsx
│ ├── dashboard.projects.$projectId._index.tsx
│ ├── dashboard.projects.$projectId.collaborators.tsx
│ ├── dashboard.projects.$projectId.edit.tsx
│ ├── dashboard.projects.$projectId.settings.tsx
│ ├── dashboard.projects.$projectId.tasks.$taskId.tsx
│ ├── dashboard.projects.$projectId.tsx
│ ├── dashboard.projects.new.tsx
│ ├── dashboard.projects.tsx
│ └── dashboard_.projects.$projectId.print.tsx
└── root.tsx

请注意,父路由现在被分组在一起,而不是在它们之间有几十个路由(如 auth 路由)。具有相同路径但嵌套不同的路由(如 dashboarddashboard_)也分组在一起。

使用新约定,任何路由都可以是一个目录,其中包含一个 route.tsx 文件来定义路由模块。这使得模块与它们所使用的路由可以共置:

例如,我们可以将 _public.tsx 移动到 _public/route.tsx,然后将路由使用的模块共置:

app/
├── routes/
│ ├── _auth.tsx
│ ├── _public/
│ │ ├── footer.tsx
│ │ ├── header.tsx
│ │ └── route.tsx
│ ├── _public._index.tsx
│ ├── _public.about-us.tsx
│ └── etc.
└── root.tsx

有关此更改的更多背景信息,请参阅 原始 扁平路由 提案

路由 headers

在 Remix v2 中,路由 headers 函数的行为略有变化。您可以通过 remix.config.js 中的 future.v2_headers 标志提前选择加入此新行为。

在 v1 中,Remix 仅使用叶子 渲染 路由 headers 函数的结果。您有责任向每个潜在叶子添加 headers 函数并相应地合并到 parentHeaders 中。这会很快变得乏味,而且当您添加新路由时也很容易忘记添加 headers 函数,即使您希望它只与其父路由共享相同的标头。

在 v2 中,Remix 现在使用它在渲染的路由中找到的最深 headers 函数。这让您可以更轻松地跨来自共同祖先的路由共享标头。然后,您可以根据需要将 headers 函数添加到更深的路由(如果它们需要特定行为)。

路由 meta

在 Remix v2 中,路由 meta 函数的签名以及 Remix 在后台处理元标记的方式已发生变化。

现在,您将返回一个描述符数组并自行管理合并,而不是从 meta 返回对象。这使 meta API 更接近 links,并且允许更灵活地控制元标记的呈现方式。

此外,<Meta /> 将不再为层次结构中的每个路由呈现元数据。只会呈现从叶路由中的 meta 返回的数据。您仍然可以通过访问 函数参数中的 matches 选择从父路由包含元数据。

有关此更改的更多背景信息,请参阅 原始 v2 meta 提案

在 v2 中使用 v1 meta 约定

您可以使用 @remix-run/v1-meta 包更新您的 meta 导出以继续使用 v1 约定。

使用 metaV1 函数,您可以传入 meta 函数的参数和它当前返回的相同对象。此函数将使用相同的合并逻辑将叶路由的元数据与其 直接父路由 元数据合并,然后将其转换为可在 v2 中使用的元描述符数组。

app/routes/v1-route.tsx
export function meta() {
return {
title: "...",
description: "...",
"og:title": "...",
};
}
// app/routes/v2-route.tsx good
import { metaV1 } from "@remix-run/v1-meta";
export function meta(args) {
return metaV1(args, {
title: "...",
description: "...",
"og:title": "...",
});
}

需要注意的是,默认情况下,此函数不会在整个层次结构中合并元数据。这是因为您可能有一些路由直接返回对象数组,而没有 metaV1 函数,这可能会导致不可预测的行为。如果您想在整个层次结构中合并元数据,请对所有路由的元数据导出使用 metaV1 函数。

parentsData 参数

在 v2 中,meta 函数不再接收 parentsData 参数。这是因为 meta 现在可以通过 matches 参数 访问您的所有路由匹配项,其中包括每个匹配项的加载器数据。

为了复制 parentsData 的 API,@remix-run/v1-meta 包提供了一个 getMatchesData 函数。它返回一个对象,其中每个匹配项的数据都由路由的 ID 键入。

app/routes/v1-route.tsx
export function meta(args) {
const parentData = args.parentsData["routes/parent"];
}

变成:

// app/routes/v2-route.tsx good
import { getMatchesData } from "@remix-run/v1-meta";
export function meta(args) {
const matchesData = getMatchesData(args);
const parentData = matchesData["routes/parent"];
}

更新到新的 meta

app/routes/v1-route.tsx
export function meta() {
return {
title: "...",
description: "...",
"og:title": "...",
};
}
// app/routes/v2-route.tsx good
export function meta() {
return [
{ title: "..." },
{ name: "description", content: "..." },
{ property: "og:title", content: "..." },
// you can now add SEO related <links>
{ tagName: "link", rel: "canonical", href: "..." },
// and <script type=ld+json>
{
"script:ld+json": {
some: "value",
},
},
];
}

matches 参数

请注意,在 v1 中,嵌套路由返回的对象都已合并,您现在需要使用 matches 自行管理合并:

// app/routes/v2-route.tsx good
export function meta({ matches }) {
const rootMeta = matches[0].meta;
const title = rootMeta.find((m) => m.title);
return [
title,
{ name: "description", content: "..." },
{ property: "og:title", content: "..." },
// you can now add SEO related <links>
{ tagName: "link", rel: "canonical", href: "..." },
// and <script type=ld+json>
{
"script:ld+json": {
"@context": "https://schema.org",
"@type": "Organization",
name: "Remix",
},
},
];
}

meta 文档有更多关于合并路由元数据的提示。

CatchBoundaryErrorBoundary

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
v2_errorBoundary: true,
},
};

在 v1 中,抛出的 Response 会渲染最近的 CatchBoundary,而所有其他未处理的异常都会渲染 ErrorBoundary。在 v2 中没有 CatchBoundary,所有未处理的异常都会渲染 ErrorBoundary、响应或其他。

此外,错误不再作为 props 传递给 ErrorBoundary,而是通过 useRouteError 钩子访问。

app/routes/v1-route.tsx
import { useCatch } from "@remix-run/react";
export function CatchBoundary() {
const caught = useCatch();
return (
<div>
<h1>Oops</h1>
<p>Status: {caught.status}</p>
<p>{caught.data.message}</p>
</div>
);
}
export function ErrorBoundary({ error }) {
console.error(error);
return (
<div>
<h1>Uh oh ...</h1>
<p>Something went wrong</p>
<pre>{error.message || "Unknown error"}</pre>
</div>
);
}

变成:

// app/routes/v2-route.tsx good
import {
useRouteError,
isRouteErrorResponse,
} from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
// when true, this is what used to go to `CatchBoundary`
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>Oops</h1>
<p>Status: {error.status}</p>
<p>{error.data.message}</p>
</div>
);
}
// Don't forget to typecheck with your own logic.
// Any value can be thrown, not just errors!
let errorMessage = "Unknown error";
if (isDefinitelyAnError(error)) {
errorMessage = error.message;
}
return (
<div>
<h1>Uh oh ...</h1>
<p>Something went wrong.</p>
<pre>{errorMessage}</pre>
</div>
);
}

formMethod

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
future: {
v2_normalizeFormMethod: true,
},
};

多个 API 返回提交的 formMethod。在 v1 中,它们返回方法的小写版本,但在 v2 中,它们返回大写版本。这是为了使其符合 HTTP 和 fetch 规范。

function Something() {
const navigation = useNavigation();
// v1
navigation.formMethod === "post";
// v2
navigation.formMethod === "POST";
}
export function shouldRevalidate({ formMethod }) {
// v1
formMethod === "post";
// v2
formMethod === "POST";
}

useTransition

此钩子现在称为 useNavigation,以避免与最近同名的 React 钩子混淆。它也不再具有 type 字段,并将 submission 对象扁平化为 navigation 对象本身。

app/routes/v1-route.tsx
import { useTransition } from "@remix-run/react";
function SomeComponent() {
const transition = useTransition();
transition.submission.formData;
transition.submission.formMethod;
transition.submission.formAction;
transition.type;
}
// app/routes/v2-route.tsx good
import { useNavigation } from "@remix-run/react";
function SomeComponent() {
const navigation = useNavigation();
// transition.submission keys are flattened onto `navigation[key]`
navigation.formData;
navigation.formMethod;
navigation.formAction;
// this key is removed
navigation.type;
}

您可以使用以下示例推导先前的 transition.type。请记住,可能有一种更简单的方法来获得相同的行为,通常检查 navigation.statenavigation.formData 或使用 useActionData 的操作返回的数据可以获得您想要的用户体验。欢迎在 Discord 中向我们提问,我们会帮助您 :D

function Component() {
const navigation = useNavigation();
// transition.type === "actionSubmission"
const isActionSubmission =
navigation.state === "submitting";
// transition.type === "actionReload"
const isActionReload =
navigation.state === "loading" &&
navigation.formMethod != null &&
navigation.formMethod != "GET" &&
// We had a submission navigation and are loading the submitted location
navigation.formAction === navigation.location.pathname;
// transition.type === "actionRedirect"
const isActionRedirect =
navigation.state === "loading" &&
navigation.formMethod != null &&
navigation.formMethod != "GET" &&
// We had a submission navigation and are now navigating to different location
navigation.formAction !== navigation.location.pathname;
// transition.type === "loaderSubmission"
const isLoaderSubmission =
navigation.state === "loading" &&
navigation.state.formMethod === "GET" &&
// We had a loader submission and are navigating to the submitted location
navigation.formAction === navigation.location.pathname;
// transition.type === "loaderSubmissionRedirect"
const isLoaderSubmissionRedirect =
navigation.state === "loading" &&
navigation.state.formMethod === "GET" &&
// We had a loader submission and are navigating to a new location
navigation.formAction !== navigation.location.pathname;
}

关于 GET 提交的说明

在 Remix v1 中,GET 提交(例如 <Form method="get">submit({}, { method: 'get' }))在 transition.state 中从 idle -> submitting -> idle 变为了。这在语义上并不完全正确,因为即使您正在 提交 表单,您也正在执行 GET 导航并且只执行加载程序(而不是操作)。从功能上讲,它与 <Link>navigate() 没有什么不同,只是用户可能通过输入指定搜索参数值。

在 v2 中,GET 提交更准确地反映为加载导航,因此变为 idle -> loading -> idle,以使 navigation.state 与正常链接的行为保持一致。如果您的 GET 提交来自 <Form>submit(),则将填充 useNavigation.form*,因此您可以根据需要进行区分。

useFetcher

useNavigation 类似,useFetcher 也扁平化了 submission 并删除了 type 字段。

app/routes/v1-route.tsx
import { useFetcher } from "@remix-run/react";
function SomeComponent() {
const fetcher = useFetcher();
fetcher.submission.formData;
fetcher.submission.formMethod;
fetcher.submission.formAction;
fetcher.type;
}
// app/routes/v2-route.tsx good
import { useFetcher } from "@remix-run/react";
function SomeComponent() {
const fetcher = useFetcher();
// these keys are flattened
fetcher.formData;
fetcher.formMethod;
fetcher.formAction;
// this key is removed
fetcher.type;
}

您可以使用以下示例推导先前的 fetcher.type。请记住,可能有一种更简单的方法来获得相同的行为,通常检查 fetcher.statefetcher.formData 或从 fetcher.data 上的操作返回的数据可以获得您想要的用户体验。欢迎在 Discord 中询问我们,我们会帮助您 :D

function Component() {
const fetcher = useFetcher();
// fetcher.type === "init"
const isInit =
fetcher.state === "idle" && fetcher.data == null;
// fetcher.type === "done"
const isDone =
fetcher.state === "idle" && fetcher.data != null;
// fetcher.type === "actionSubmission"
const isActionSubmission = fetcher.state === "submitting";
// fetcher.type === "actionReload"
const isActionReload =
fetcher.state === "loading" &&
fetcher.formMethod != null &&
fetcher.formMethod != "GET" &&
// If we returned data, we must be reloading
fetcher.data != null;
// fetcher.type === "actionRedirect"
const isActionRedirect =
fetcher.state === "loading" &&
fetcher.formMethod != null &&
fetcher.formMethod != "GET" &&
// If we have no data we must have redirected
fetcher.data == null;
// fetcher.type === "loaderSubmission"
const isLoaderSubmission =
fetcher.state === "loading" &&
fetcher.formMethod === "GET";
// fetcher.type === "normalLoad"
const isNormalLoad =
fetcher.state === "loading" &&
fetcher.formMethod == null;
}

关于 GET 提交的说明

在 Remix v1 中,GET 提交(例如 <fetcher.Form method="get">fetcher.submit({}, { method: 'get' }))在 fetcher.state 中从 idle -> submitting -> idle 变为了。这在语义上并不完全正确,因为即使您正在 提交 表单,您也正在执行 GET 请求并且只执行加载器(而不是操作)。从功能上讲,它与 fetcher.load() 没有什么不同,只是用户可能通过输入指定搜索参数值。

在 v2 中,GET 提交更准确地反映为加载请求,因此进入 idle -> loading -> idle 以使 fetcher.state 与正常 fetcher 加载的行为保持一致。如果您的 GET 提交来自 <fetcher.Form>fetcher.submit(),则 fetcher.form* 将被填充,因此您可以根据需要进行区分。

链接 imagesizesimagesrcset

路由 links 属性应全部为 React 驼峰式大小写值,而不是 HTML 小写值。这两个值在 v1 中以小写形式出现。在 v2 中,只有驼峰式大小写版本有效:

app/routes/v1-route.tsx
export const links: LinksFunction = () => {
return [
{
rel: "preload",
as: "image",
imagesrcset: "...",
imagesizes: "...",
},
];
};
app/routes/v2-route.tsx
export const links: V2_LinksFunction = () => {
return [
{
rel: "preload",
as: "image",
imageSrcSet: "...",
imageSizes: "...",
},
];
};

browserBuildDirectory

在您的 remix.config.js 中,将 browserBuildDirectory 重命名为 assetsBuildDirectory

// remix.config.js lines=[3]
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
browserBuildDirectory: "./public/build",
};
// remix.config.js good lines=[3]
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
assetsBuildDirectory: "./public/build",
};

devServerBroadcastDelay

从您的 remix.config.js 中删除 devServerBroadcastDelay,因为需要此选项的竞争条件已在 v2 或 v2_dev 中消除。

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
devServerBroadcastDelay: 300,
};

devServerPort

在您的 remix.config.js 中,将 devServerPort 重命名为 future.v2_dev.port

// remix.config.js lines=[3]
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
devServerPort: 8002,
};
// remix.config.js good lines=[3-7]
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
// While on v1.x, this is via a future flag
future: {
v2_dev: {
port: 8002,
},
},
};

从 v1 升级到 v2 后,这将变为 根级 dev 配置

serverBuildDirectory

remix.config.js 中,将 serverBuildDirectory 重命名为 serverBuildPath,并指定模块路径,而不是目录。

// remix.config.js lines=[3]
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverBuildDirectory: "./build",
};
// remix.config.js good lines=[3]
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverBuildPath: "./build/index.js",
};

Remix 过去会为服务器创建多个模块,但现在会创建一个文件。

serverBuildTarget

无需指定构建目标,而是使用 remix.config.js 选项来生成服务器目标所需的服务器构建。此更改允许 Remix 部署到更多 JavaScript 运行时、服务器和主机,而无需 Remix 源代码了解它们。

以下配置应替换您当前的 serverBuildTarget

arc

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/_static/build/",
serverBuildPath: "server/index.js",
serverMainFields: ["main", "module"], // default value, can be removed
serverMinify: false, // default value, can be removed
serverModuleFormat: "cjs", // default value in 1.x, add before upgrading
serverPlatform: "node", // default value, can be removed
};

cloudflare-pages

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "functions/[[path]].js",
serverConditions: ["worker"],
serverDependenciesToBundle: "all",
serverMainFields: ["browser", "module", "main"],
serverMinify: true,
serverModuleFormat: "esm", // default value in 2.x, can be removed once upgraded
serverPlatform: "neutral",
};

cloudflare-workers

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "build/index.js", // default value, can be removed
serverConditions: ["worker"],
serverDependenciesToBundle: "all",
serverMainFields: ["browser", "module", "main"],
serverMinify: true,
serverModuleFormat: "esm", // default value in 2.x, can be removed once upgraded
serverPlatform: "neutral",
};

deno

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "build/index.js", // default value, can be removed
serverConditions: ["deno", "worker"],
serverDependenciesToBundle: "all",
serverMainFields: ["module", "main"],
serverMinify: false, // default value, can be removed
serverModuleFormat: "esm", // default value in 2.x, can be removed once upgraded
serverPlatform: "neutral",
};

node-cjs 版本

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
publicPath: "/build/", // default value, can be removed
serverBuildPath: "build/index.js", // default value, can be removed
serverMainFields: ["main", "module"], // default value, can be removed
serverMinify: false, // default value, can be removed
serverModuleFormat: "cjs", // default value in 1.x, add before upgrading
serverPlatform: "node", // default value, can be removed
};

serverModuleFormat

默认服务器模块输出格式已从 cjs 更改为 esm。您可以在 v2 中继续使用 CJS,但应用中的许多依赖项可能与 ESM 不兼容。

remix.config.js 中,您应该指定 serverModuleFormat: "cjs" 以保留现有行为,或指定 serverModuleFormat: "esm" 以选择新行为。

browserNodeBuiltinsPolyfill

默认情况下,浏览器不再提供 Node.js 内置模块的 Polyfill。在 Remix v2 中,您需要根据需要明确重新引入任何 polyfill(或空白 polyfill):

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
browserNodeBuiltinsPolyfill: {
modules: {
buffer: true,
fs: "empty",
},
globals: {
Buffer: true,
},
},
};

尽管我们建议明确说明浏览器软件包中允许使用哪些 polyfill,尤其是由于某些 polyfill 可能非常大,但您可以使用以下配置快速恢复 Remix v1 中的全套 polyfill:

remix.config.js
const { builtinModules } = require("node:module");
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
browserNodeBuiltinsPolyfill: {
modules: builtinModules,
},
};

serverNodeBuiltinsPolyfill

对于非 Node.js 服务器平台,不再默认提供 Node.js 内置模块的 Polyfill。

如果您的目标是非 Node.js 服务器平台,并且想要选择 v1 中的新默认行为,则在 remix.config.js 中,您应该首先通过为 serverNodeBuiltinsPolyfill.modules 明确提供一个空对象来删除所有服务器 polyfill:

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverNodeBuiltinsPolyfill: {
modules: {},
},
};

然后,您可以根据需要重新引入任何 polyfill(或空白 polyfill)。

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverNodeBuiltinsPolyfill: {
modules: {
buffer: true,
fs: "empty",
},
globals: {
Buffer: true,
},
},
};

作为参考,可以手动指定 v1 中的完整默认 polyfill 集,如下所示:

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
serverNodeBuiltinsPolyfill: {
modules: {
_stream_duplex: true,
_stream_passthrough: true,
_stream_readable: true,
_stream_transform: true,
_stream_writable: true,
assert: true,
"assert/strict": true,
buffer: true,
console: true,
constants: true,
crypto: "empty",
diagnostics_channel: true,
domain: true,
events: true,
fs: "empty",
"fs/promises": "empty",
http: true,
https: true,
module: true,
os: true,
path: true,
"path/posix": true,
"path/win32": true,
perf_hooks: true,
process: true,
punycode: true,
querystring: true,
stream: true,
"stream/promises": true,
"stream/web": true,
string_decoder: true,
sys: true,
timers: true,
"timers/promises": true,
tty: true,
url: true,
util: true,
"util/types": true,
vm: true,
wasi: true,
worker_threads: true,
zlib: true,
},
},
};

installGlobals

为了准备使用 Node 内置的 fetch 实现,安装 fetch 全局变量现在是应用服务器的责任。如果您使用的是 remix-serve,则无需执行任何操作。如果您使用的是自己的应用服务器,则需要自行安装全局变量。

server.ts
import { installGlobals } from "@remix-run/node";
installGlobals();

删除导出的 polyfill

Remix v2 也不再从 @remix-run/node 导出这些 polyfill 实现,而您应该只使用全局命名空间中的实例。一个可能出现并需要更改的地方是您的 app/entry.server.tsx 文件,您还需要通过 createReadableStreamFromReadable 将 Node PassThrough 转换为 web ReadableStream

app/entry.server.tsx
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node"; // or cloudflare/deno
import { Response } from "@remix-run/node"; // or cloudflare/deno
import { createReadableStreamFromReadable } from "@remix-run/node"; // or cloudflare/deno
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({ /* ... */ }) { ... }
function handleBotRequest(...) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer ... />,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(body, {
new Response(createReadableStreamFromReadable(body), {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
...
onShellError(error: unknown) { ... }
onError(error: unknown) { ... }
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(...) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer ... />,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(body, {
new Response(createReadableStreamFromReadable(body), {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) { ... },
onError(error: unknown) { ... },
}
);
setTimeout(abort, ABORT_DELAY);
});
}

source-map-support

源地图支持现在是应用服务器的责任。如果您使用的是 remix-serve,则无需执行任何操作。如果您使用的是自己的应用服务器,则需要自行安装 source-map-support

Terminal window
npm i source-map-support
server.ts
import sourceMapSupport from "source-map-support";
sourceMapSupport.install();

Netlify 适配器

@remix-run/netlify 运行时适配器已被弃用,取而代之的是 @netlify/remix-adapter@netlify/remix-edge-adapter 并且从 Remix v2 开始已被删除。请更新您的代码,将所有 @remix-run/netlify 导入更改为 @netlify/remix-adapter

请记住,@netlify/remix-adapter 需要 @netlify/functions@^1.0.0,与 @remix-run/netlify 中当前支持的 @netlify/functions 版本相比,这是一个重大变化。

由于删除了此适配器,我们还删除了我们的 Netlify 模板,转而使用 官方 Netlify 模板

Vercel 适配器

@remix-run/vercel 运行时适配器已被弃用,转而使用开箱即用的 Vercel 功能,并且现在已从 Remix v2 中删除。请通过从您的 package.json 中删除 @remix-run/vercel@vercel/node、删除您的 server.js/server.ts 文件以及从您的 remix.config.js 中删除 serverserverBuildPath 选项来更新您的代码。

由于删除了此适配器,我们还删除了我们的 Vercel 模板,转而使用 官方 Vercel 模板

内置 PostCSS/Tailwind 支持

在 v2 中,如果您的项目中存在 PostCSS 和 / 或 Tailwind 配置文件,则这些工具会自动在 Remix 编译器中使用。

如果您在 Remix 之外有自定义 PostCSS 和 / 或 Tailwind 设置,并且希望在迁移到 v2 时保留这些设置,则可以在 remix.config.js 中禁用这些功能。

remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
postcss: false,
tailwind: false,
};

故障排除

ESM / CommonJS 错误

Terminal window
"SyntaxError: Named export '<something>' not found. The requested module '<something>' is a CommonJS module, which may not support all module.exports as named exports."

请参阅 serverModuleFormat 部分。