主分支
分支
主分支 (2.15.2)开发分支
版本
2.15.21.19.3v0.21.0
React Router v7 已经发布。 查看文档
升级到 v2
本页内容

升级到 v2

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

所有 v2 API 和行为在 v1 中都可以通过 Future Flags 获得。它们可以一次启用一个,以避免对您的项目造成开发中断。在您启用所有标志后,升级到 v2 应该是一个非破坏性的升级。

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

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

remix dev

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

remix-serve

如果您正在使用 Remix 应用服务器 (remix-serve),请启用 v2_dev

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

就是这样!

自定义应用服务器

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

  1. 启用 v2_dev

    /** @type {import('@remix-run/dev').AppConfig} */
    module.exports = {
      future: {
        v2_dev: true,
      },
    };
    
  2. 更新 package.json 中的 scripts

    • 将所有 remix watch 替换为 remix dev
    • 删除冗余的 NODE_ENV=development
    • 使用 -c / --command 来运行您的应用服务器

    例如

     {
       "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'",
       }
     }
    
  3. 在您的应用运行后,向 Remix 编译器发送一个“ready”消息

    import { broadcastDevReady } from "@remix-run/node";
    // import { logDevReady } from "@remix-run/cloudflare" // use `logDevReady` if using CloudFlare
    
    const BUILD_DIR = path.join(process.cwd(), "build");
    
    // ... code setting up your server goes here ...
    
    const port = 3000;
    app.listen(port, async () => {
      console.log(`👉 http://localhost:${port}`);
      broadcastDevReady(await import(BUILD_DIR));
    });
    
  4. (可选)--manual

    如果您依赖于 require 缓存清除,您可以使用 --manual 标志继续这样做

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

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

从 v1 升级到 v2 后

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

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

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

文件系统路由约定

在不更改文件的情况下升级

即使升级到 v2 后,如果您现在(或永远)不想进行更改,您仍然可以使用 @remix-run/v1-route-convention 来保留旧的约定(这仅仅是一个约定,您可以使用任何您喜欢的文件组织方式)。

npm i -D @remix-run/v1-route-convention
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);
  },
};

升级到新约定

  • 路由嵌套现在通过文件名中的点 (.) 而不是文件夹嵌套来创建
  • 带有 suffixed_ 下划线的片段会选择不使用点 (.) 与可能匹配的父路由进行嵌套。
  • 带有 _prefixed 下划线的片段会创建没有路径的布局路由,而不是 __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

请注意,父路由现在被分组在一起,而不是它们之间有数十个路由(例如身份验证路由)。具有相同路径但嵌套不同的路由(例如 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 返回一个描述符数组并自己管理合并,而不是从 meta 返回一个对象。这使得 meta API 更接近 links,并且可以更灵活地控制 meta 标签的渲染方式。

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

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

在 v2 中使用 v1 meta 约定

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

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

export function meta() {
  return {
    title: "...",
    description: "...",
    "og:title": "...",
  };
}
import { metaV1 } from "@remix-run/v1-meta";

export function meta(args) {
  return metaV1(args, {
    title: "...",
    description: "...",
    "og:title": "...",
  });
}

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

parentsData 参数

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

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

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

变为

import { getMatchesData } from "@remix-run/v1-meta";

export function meta(args) {
  const matchesData = getMatchesData(args);
  const parentData = matchesData["routes/parent"];
}

更新到新的 meta

export function meta() {
  return {
    title: "...",
    description: "...",
    "og:title": "...",
  };
}
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 管理合并

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 文档提供了有关合并路由 meta 的更多提示。

CatchBoundaryErrorBoundary

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

在 v1 中,抛出的 Response 会渲染最近的 CatchBoundary,而所有其他未处理的异常都会渲染 ErrorBoundary。在 v2 中,没有 CatchBoundary,所有未处理的异常都会渲染 ErrorBoundary,无论是 response 还是其他异常。

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

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

变为

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

/** @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

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

import { useTransition } from "@remix-run/react";

function SomeComponent() {
  const transition = useTransition();
  transition.submission.formData;
  transition.submission.formMethod;
  transition.submission.formAction;
  transition.type;
}
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 的 action 返回的数据可以获得您想要的用户体验。请随时在 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 导航并且只执行加载程序(而不是 action)。从功能上讲,它与 <Link>navigate() 没有什么不同,只是用户可以通过输入指定搜索参数值。

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

useFetcher

useNavigation 一样,useFetcher 已展平 submission 并删除了 type 字段。

import { useFetcher } from "@remix-run/react";

function SomeComponent() {
  const fetcher = useFetcher();
  fetcher.submission.formData;
  fetcher.submission.formMethod;
  fetcher.submission.formAction;
  fetcher.type;
}
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 上的 action 返回的数据可以获得您想要的用户体验。请随时在 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 请求并且只执行加载程序(而不是 action)。从功能上讲,它与 fetcher.load() 没有什么不同,只是用户可以通过输入指定搜索参数值。

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

路由 links 属性都应该是 React camelCase 值,而不是 HTML 小写值。这两个值在 v1 中偷偷地变成了小写。在 v2 中,只有 camelCase 版本有效

export const links: LinksFunction = () => {
  return [
    {
      rel: "preload",
      as: "image",
      imagesrcset: "...",
      imagesizes: "...",
    },
  ];
};
export const links: V2_LinksFunction = () => {
  return [
    {
      rel: "preload",
      as: "image",
      imageSrcSet: "...",
      imageSizes: "...",
    },
  ];
};

browserBuildDirectory

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

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  browserBuildDirectory: "./public/build",
};
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  assetsBuildDirectory: "./public/build",
};

devServerBroadcastDelay

从你的 remix.config.js 中移除 devServerBroadcastDelay,因为在 v2 或 v2_dev 中,导致需要此选项的竞态条件已被消除。

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

devServerPort

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

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  devServerPort: 8002,
};
/** @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,并指定模块路径,而不是目录。

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

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

serverBuildTarget

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

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

arc

/** @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

/** @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

/** @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

/** @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

/** @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)。

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

尽管我们建议明确指定允许在浏览器捆绑包中使用哪些 polyfill,特别是考虑到某些 polyfill 可能相当大,但你可以使用以下配置快速恢复 Remix v1 中的完整 polyfill 集:

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。

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

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

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

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

/** @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,则无需任何操作。如果你正在使用自己的应用服务器,则需要自己安装全局变量。

import { installGlobals } from "@remix-run/node";

installGlobals();

移除导出的 polyfill

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

  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

Source map 支持现在是应用服务器的责任。如果你正在使用 remix-serve,则无需任何操作。如果你正在使用自己的应用服务器,则需要自己安装 source-map-support

npm i source-map-support
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 中移除 server & serverBuildPath 选项来更新你的代码。

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

内置 PostCSS/Tailwind 支持

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

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

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

故障排除

ESM / CommonJS 错误

"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 部分。

文档和示例根据以下许可 MIT