升级至 v2
On this page

升级至 v2

¥Upgrading to v2

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

所有 v2 API 和行为均可在 v1 中使用 未来标志。它们可以一次启用一个,以避免项目开发中断。启用所有标志后,升级到 v2 应该是非中断升级。

¥All v2 APIs and behaviors are available in v1 with Future Flags. They can be enabled one at a time to avoid development disruption of your project. After you have enabled all flags, upgrading to v2 should be a non-breaking upgrade.

如果你遇到问题,请参阅 故障排除 部分。

¥If you're having trouble see the Troubleshooting section.

有关一些常见升级问题的快速演练,请查看 🎥 2 分钟即可升级到 v2

¥For a quick walkthrough of some common upgrade issues checkout 🎥 2 minutes to v2.

remix dev

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

¥For configuration options, see the remix dev docs.

remix-serve

如果你使用的是 Remix 应用服务器 (remix-serve),请启用 v2_dev

¥If you are using the Remix App Server (remix-serve), enable v2_dev:

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

就是这样!

¥That's it!

自定义应用服务器

¥Custom app server

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

¥If you are using your own app server (server.js), then check out our templates for examples of how to integrate with v2_dev or follow these steps:

  1. 启用 v2_dev

    ¥Enable v2_dev:

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

    ¥Update scripts in package.json:

    • 将任意 remix watch 替换为 remix dev

      ¥Replace any remix watch with remix dev

    • 删除多余的 NODE_ENV=development

      ¥Remove redundant NODE_ENV=development

    • 使用 -c / --command 运行你的应用服务器

      ¥Use -c / --command to run your app server

    例如:

    ¥For example:

     {
       "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" 消息

    ¥Send a "ready" message to the Remix compiler once your app is running

    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

    ¥(Optional) --manual

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

    ¥If you were relying on require cache purging, you can keep doing so by using the --manual flag:

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

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

    ¥Check out the manual mode guide for more details.

从 v1 升级到 v2 后

¥After upgrading from v1 to v2

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

¥After you've enabled the future.v2_dev flag in v1 and gotten that working, you're ready to upgrade to v2. If you just had v2_dev set to true, you can remove it and things should work.

如果你使用 v2_dev 配置,则需要将其移至 dev 配置字段:

¥If you are using v2_dev config, you'll need to move it to the dev config field:

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

文件系统路由约定

¥File System Route Convention

不更改文件升级

¥Upgrading without changing files

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

¥You can keep using the old convention with @remix-run/v1-route-convention even after upgrading to v2 if you don't want to make the change right now (or ever, it's just a convention, and you can use whatever file organization you prefer).

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

升级至新约定

¥Upgrading to the new convention

  • 路由嵌套现在通过文件名中的点 (.) 来创建,而不是通过文件夹嵌套

    ¥Route nesting is now created by dots (.) in file names instead of folder nesting

  • suffixed_ 段中的下划线会选择不与可能匹配的父路由嵌套,而不是使用点(.)。

    ¥suffixed_ underscores in segments opt-out of nesting with a potentially matching parent route instead of dots (.).

  • _prefixed 段中的下划线会创建没有路径的布局路由,而不是使用 __double 下划线前缀。

    ¥_prefixed underscores in segments create layout routes without a path instead of a __double underscore prefix.

  • _index.tsx 文件创建索引路由,而不是 index.tsx

    ¥_index.tsx files create index routes instead of index.tsx

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

¥A routes folder that looks like this in 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 后变成这样:

¥Becomes this with 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_)也会分组在一起。

¥Note that parent routes are now grouped together instead of having dozens of routes between them (like the auth routes). Routes with the same path but not the same nesting (like dashboard and dashboard_) also group together.

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

¥With the new convention, any route can be a directory with a route.tsx file inside to define the route module. This enables co-location of modules with the route they're used in:

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

¥For example, we can move _public.tsx to _public/route.tsx and then co-locate modules the route uses:

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

有关此变更的更多背景信息,请参阅 原始 "监视路由" 提案

¥For more background on this change, see the original "flat routes" proposal.

路由 headers

¥Route headers

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

¥In Remix v2, the behavior for route headers functions has changed slightly. You can opt-into this new behavior ahead of time via the future.v2_headers flag in remix.config.js.

在 v1 版本中,Remix 只会使用叶子 "rendered" 路由 headers 函数的结果。你有责任为每个潜在的叶子节点添加一个 headers 函数,并相应地合并到 parentHeaders 中。这会很快变得繁琐,并且在添加新路由时也很容易忘记添加 headers 函数,即使你希望它只与其父级共享相同的标头。

¥In v1, Remix would only use the result of the leaf "rendered" route headers function. It was your responsibility to add a headers function to every potential leaf and merge in parentHeaders accordingly. This can get tedious quickly and is also easy to forget to add a headers function when you add a new route, even if you want it to just share the same headers from its parent.

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

¥In v2, Remix now uses the deepest headers function that it finds in the rendered routes. This more easily allows you to share headers across routes from a common ancestor. Then as needed you can add headers functions to deeper routes if they require specific behavior.

路由 meta

¥Route meta

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

¥In Remix v2, the signature for route meta functions and how Remix handles meta tags under the hood have changed.

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

¥Instead of returning an object from meta, you will now return an array of descriptors and manage the merge yourself. This brings the meta API closer to links, and it allows for more flexibility and control over how meta tags are rendered.

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

¥In addition, <Meta /> will no longer render meta for every route in the hierarchy. Only data returned from meta in the leaf route will be rendered. You can still choose to include meta from the parent route by accessing matches in the function's arguments.

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

¥For more background on this change, see the original v2 meta proposal.

在 v1 中使用 meta 约定 v2

¥Using v1 meta conventions in v2

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

¥You can update your meta exports with the @remix-run/v1-meta package to continue using v1 conventions.

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

¥Using the metaV1 function, you can pass in the meta function's arguments and the same object it currently returns. This function will use the same merging logic to merge the leaf route's meta with its direct parent route meta before converting it to an array of meta descriptors usable in v2.

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": "...",
  });
}

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

¥It's important to note that this function will not merge meta across the entire hierarchy by default. This is because you may have some routes that return an array of objects directly without the metaV1 function and this could result in unpredictable behavior. If you want to merge meta across the entire hierarchy, use the metaV1 function for all of your route's meta exports.

parentsData 参数

¥The parentsData argument

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

¥In v2, the meta function no longer receives the parentsData argument. This is because meta now has access to all of your route matches via the matches argument, which includes loader data for each match.

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

¥To replicate the API of parentsData, the @remix-run/v1-meta package provides a getMatchesData function. It returns an object where the data for each match is keyed by the route's ID.

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

变为:

¥Becomes:

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

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

更新至新的 meta

¥Updating to the new 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 参数

¥The matches argument

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

¥Note that in v1 the objects returned from nested routes were all merged, you will need to manage the merge yourself now with 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 文档提供了更多关于合并路由元数据的提示。

¥The meta docs have more tips on merging route meta.

CatchBoundaryErrorBoundary

¥CatchBoundary and ErrorBoundary

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

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

¥In v1, a thrown Response rendered the closest CatchBoundary while all other unhandled exceptions rendered the ErrorBoundary. In v2 there is no CatchBoundary and all unhandled exceptions will render the ErrorBoundary, response or otherwise.

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

¥Additionally, the error is no longer passed to ErrorBoundary as props but is accessed with the 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>
  );
}

变为:

¥Becomes:

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 规范。

¥Multiple APIs return the formMethod of a submission. In v1 they returned a lowercase version of the method but in v2 they return the UPPERCASE version. This is to bring it in line with HTTP and fetch specifications.

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 对象本身。

¥This hook is now called useNavigation to avoid confusion with the recent React hook by the same name. It also no longer has the type field and flattens the submission object into the navigation object itself.

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.formDatauseActionData 操作返回的数据即可获得你想要的用户体验。欢迎在 Discord 上提问,我们会为你提供帮助 :D

¥You can derive the previous transition.type with the following examples. Keep in mind, there's probably a simpler way to get the same behavior, usually checking navigation.state, navigation.formData or the data returned from an action with useActionData can get the UX you're looking for. Feel free to ask us in Discord, and we'll help you out :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 提交的说明

¥A note on GET submissions

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

¥In Remix v1, GET submissions such as <Form method="get"> or submit({}, { method: 'get' }) went from idle -> submitting -> idle in transition.state. This is not quite semantically correct since even though you're "submitting" a form, you're performing a GET navigation and only executing loaders (not actions). Functionally, it's no different from a <Link> or navigate() except that the user may be specifying the search param values via inputs.

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

¥In v2, GET submissions are more accurately reflected as loading navigations and thus go idle -> loading -> idle to align navigation.state with the behavior of normal links. If your GET submission came from a <Form> or submit(), then useNavigation.form* will be populated, so you can differentiate if needed.

useFetcher

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

¥Like useNavigation, useFetcher has flattened the submission and removed the type field.

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.formDatafetcher.data 操作返回的数据即可获得你想要的用户体验。欢迎在 Discord 上提问,我们会为你提供帮助 :D

¥You can derive the previous fetcher.type with the following examples. Keep in mind, there's probably a simpler way to get the same behavior, usually checking fetcher.state, fetcher.formData or the data returned from an action on fetcher.data can get the UX you're looking for. Feel free to ask us in Discord, and we'll help you out :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 提交的说明

¥A note on GET submissions

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

¥In Remix v1, GET submissions such as <fetcher.Form method="get"> or fetcher.submit({}, { method: 'get' }) went from idle -> submitting -> idle in fetcher.state. This is not quite semantically correct since even though you're "submitting" a form, you're performing a GET request and only executing a loader (not an action). Functionally, it's no different from a fetcher.load() except that the user may be specifying the search param values via inputs.

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

¥In v2, GET submissions are more accurately reflected as loading requests and thus go idle -> loading -> idle to align fetcher.state with the behavior of normal fetcher loads. If your GET submission came from a <fetcher.Form> or fetcher.submit(), then fetcher.form* will be populated, so you can differentiate if needed.

链接 imagesizesimagesrcset

¥Links imagesizes and imagesrcset

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

¥Route links properties should all be the React camelCase values instead of HTML lowercase values. These two values snuck in as lowercase in v1. In v2 only the camelCase versions are valid:

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

¥In your remix.config.js, rename browserBuildDirectory to 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 中消除。

¥Remove devServerBroadcastDelay from your remix.config.js as the race conditions that necessitated this option have been eliminated in v2 or with v2_dev.

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

devServerPort

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

¥In your remix.config.js, rename devServerPort to 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 配置

¥Once you upgrade from v1 to v2, this flattens to a root-level dev config.

serverBuildDirectory

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

¥In your remix.config.js, rename serverBuildDirectory to serverBuildPath and specify a module path, not a directory.

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

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

¥Remix used to create more than a single module for the server, but it now creates a single file.

serverBuildTarget

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

¥Instead of specifying a build target, use the remix.config.js options to generate the server build your server target expects. This change allows Remix to deploy to more JavaScript runtimes, servers, and hosts without Remix source code needing to know about them.

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

¥The following configurations should replace your current 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 不兼容。

¥The default server module output format has changed from cjs to esm. You can continue to use CJS in v2, many dependencies in your app might not be compatible with ESM.

在你的 remix.config.js 文件中,你应该指定 serverModuleFormat: "cjs" 以保留现有行为,或者指定 serverModuleFormat: "esm" 以启用新行为。

¥In your remix.config.js, you should specify either serverModuleFormat: "cjs" to retain existing behavior, or serverModuleFormat: "esm", to opt into the new behavior.

browserNodeBuiltinsPolyfill

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

¥Polyfills for Node.js built-in modules are no longer provided by default for the browser. In Remix v2 you'll need to explicitly reintroduce any polyfills (or blank polyfills) as required:

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

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

¥Even though we recommend being explicit about which polyfills are allowed in your browser bundle, especially since some polyfills can be quite large, you can quickly reinstate the full set of polyfills from Remix v1 with the following configuration:

const { builtinModules } = require("node:module");

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

serverNodeBuiltinsPolyfill

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

¥Polyfills for Node.js built-in modules are no longer be provided by default for non-Node.js server platforms.

如果你的目标平台是非 Node.js 服务器平台,并且希望使用 v1 中的新默认行为,则在 remix.config.js 中,你应该首先通过显式为 serverNodeBuiltinsPolyfill.modules 提供一个空对象来删除所有服务器 polyfill:

¥If you are targeting a non-Node.js server platform and want to opt into the new default behavior in v1, in remix.config.js you should first remove all server polyfills by explicitly providing an empty object for serverNodeBuiltinsPolyfill.modules:

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

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

¥You can then reintroduce any polyfills (or blank polyfills) as required.

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

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

¥For reference, the complete set of default polyfills from v1 can be manually specified as follows:

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

¥For preparation of using Node's built in fetch implementation, installing the fetch globals is now a responsibility of the app server. If you are using remix-serve, nothing is required. If you are using your own app server, you will need to install the globals yourself.

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

installGlobals();

移除导出的 polyfill

¥Removal of exported polyfills

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

¥Remix v2 also no longer exports these polyfilled implementations from @remix-run/node, and instead you should just use the instances in the global namespace. One place this is likely to surface and require a change is your app/entry.server.tsx file, where you'll also need to convert the Node PassThrough into a web ReadableStream via createReadableStreamFromReadable:

  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

¥Source map support is now a responsibility of the app server. If you are using remix-serve, nothing is required. If you are using your own app server, you will need to install source-map-support yourself.

npm i source-map-support
import sourceMapSupport from "source-map-support";

sourceMapSupport.install();

Netlify 适配器

¥Netlify adapter

@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 版本相比,这是一个重大变化。

¥The @remix-run/netlify runtime adapter has been deprecated in favor of @netlify/remix-adapter & @netlify/remix-edge-adapter and is now removed as of Remix v2. Please update your code by changing all @remix-run/netlify imports to @netlify/remix-adapter.\ Keep in mind that @netlify/remix-adapter requires @netlify/functions@^1.0.0, which is a breaking change compared to the current supported @netlify/functions versions in @remix-run/netlify.

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

¥Due to the removal of this adapter, we also removed our Netlify template in favor of the official Netlify template.

Vercel 适配器

¥Vercel adapter

@remix-run/vercel 运行时适配器已被弃用,取而代之的是开箱即用的 Vercel 功能,并且从 Remix v2 开始已被移除。请更新你的代码,从你的 package.json 中删除 @remix-run/vercel@vercel/node,删除你的 server.js/server.ts 文件,并从你的 remix.config.js 中删除 serverserverBuildPath 选项。

¥The @remix-run/vercel runtime adapter has been deprecated in favor of out of the box Vercel functionality and is now removed as of Remix v2. Please update your code by removing @remix-run/vercel & @vercel/node from your package.json, removing your server.js/server.ts file, and removing the server & serverBuildPath options from your remix.config.js.

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

¥Due to the removal of this adapter, we also removed our Vercel template in favor of the official Vercel template.

内置 PostCSS/Tailwind 支持

¥Built-in PostCSS/Tailwind support

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

¥In v2, these tools are automatically used within the Remix compiler if PostCSS and/or Tailwind configuration files are present in your project.

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

¥If you have a custom PostCSS and/or Tailwind setup outside of Remix that you'd like to maintain when migrating to v2, you can disable these features in your remix.config.js.

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

故障排除

¥Troubleshooting

ESM / CommonJS Errors

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

¥Please see the serverModuleFormat section.

Remix v2.17 中文网 - 粤ICP备13048890号