React Router v7 已发布。 查看文档
单次获取
本页内容

单次获取

单次获取是一种新的数据加载策略和流式传输格式。启用单次获取后,Remix 将在客户端转换时向您的服务器发出单个 HTTP 调用,而不是并行发出多个 HTTP 调用(每个加载器一个)。此外,单次获取还允许您从 loaderaction 发送裸对象,例如 DateErrorPromiseRegExp 等。

概述

Remix 在 future.unstable_singleFetch 标志后引入了对“单次获取”的支持(RFC),该标志在 v2.9.0 中(后来在 v2.13.0 中稳定为 future.v3_singleFetch),允许您选择启用此行为。单次获取将成为 React Router v7 中的默认设置。

启用单次获取旨在降低前期工作量,然后允许您随着时间的推移迭代地采用所有重大更改。您可以先应用最少量的必要更改来启用单次获取,然后使用迁移指南在您的应用程序中进行增量更改,以确保平稳、无中断地升级到 React Router v7

另请查看重大更改,以便您了解一些底层行为更改,特别是关于序列化和状态/标头行为的更改。

启用单次获取

1. 启用未来标志

export default defineConfig({
  plugins: [
    remix({
      future: {
        // ...
        v3_singleFetch: true,
      },
    }),
    // ...
  ],
});

2. 已弃用的 fetch polyfill

单次获取要求使用 undici 作为您的 fetch polyfill,或者在 Node 20+ 上使用内置的 fetch,因为它依赖于此处可用的 API,而 @remix-run/web-fetch polyfill 中没有这些 API。请参阅下面的 2.9.0 版本说明中的 Undici 部分,了解更多详细信息。

  • 如果您使用的是 Node 20+,请删除对 installGlobals() 的任何调用,并使用 Node 的内置 fetch(这与 undici 相同)。

  • 如果您正在管理自己的服务器并调用 installGlobals(),则需要调用 installGlobals({ nativeFetch: true }) 以使用 undici

    - installGlobals();
    + installGlobals({ nativeFetch: true });
    
  • 如果您使用的是 remix-serve,则如果启用了单次获取,它将自动使用 undici

  • 如果您正在将 miniflare/cloudflare worker 与您的 remix 项目一起使用,请确保您的兼容性标志也设置为 2023-03-01 或更高版本。

3. 调整 headers 实现(如果需要)

启用单次获取后,即使需要运行多个加载器,现在在客户端导航上也只会发出一个请求。为了处理为调用的处理程序合并标头,headers 导出现在也将应用于 loader/action 数据请求。在许多情况下,您已经用于文档请求的逻辑对于新的单次获取数据请求应该已经足够了。

4. 添加 nonce(如果您使用 CSP)

如果您具有带有nonce-sources用于脚本的内容安全策略,则需要将该 nonce 添加到两个位置,以用于流式单次获取实现

5. 替换 renderToString(如果您正在使用它)

对于大多数 Remix 应用程序,您不太可能使用 renderToString,但是如果您选择在 entry.server.tsx 中使用它,请继续阅读,否则您可以跳过此步骤。

为了保持文档和数据请求之间的一致性,turbo-stream 也用作在初始文档请求中发送数据的格式。这意味着,一旦选择加入单次获取,您的应用程序将无法再使用 renderToString,并且必须在 entry.server.tsx 中使用 React 流式渲染器 API,例如 renderToPipeableStreamrenderToReadableStream)。

这并不意味着您必须将 HTTP 响应流式传输下来,您仍然可以通过利用 renderToPipeableStream 中的 onAllReady 选项或 renderToReadableStream 中的 allReady Promise 来一次发送整个文档。

在客户端,这也意味着您需要将客户端的 hydrateRoot 调用包装在 startTransition 调用中,因为流式传输的数据将包装在 Suspense 边界中。

重大更改

单次获取引入了一些重大更改 - 其中一些需要在您启用标志时提前处理,而另一些则可以在启用标志后逐步处理。您需要确保在更新到下一个主要版本之前已处理所有这些更改。

需要提前解决的更改

  • 已弃用的 fetch polyfill:旧的 installGlobals() polyfill 不适用于单次获取,您必须使用原生 Node 20 fetch API 或在您的自定义服务器中调用 installGlobals({ nativeFetch: true }) 以获取 基于 undici 的 polyfill
  • headers 导出应用于数据请求headers 函数现在将应用于文档和数据请求

需要注意的更改,您可能需要随着时间的推移处理

使用单次获取添加新路由

启用单次获取后,您可以开始编写利用更强大的流式格式的路由。

为了获得正确的类型推断,您需要增强 Remix 的 Future 接口,添加 v3_singleFetch: true。您可以在类型推断部分中了解更多相关信息。

启用单次获取后,您可以从加载器返回以下数据类型:BigIntDateErrorMapPromiseRegExpSetSymbolURL

// routes/blog.$slug.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";

export async function loader({
  params,
}: LoaderFunctionArgs) {
  const { slug } = params;

  const comments = fetchComments(slug);
  const blogData = await fetchBlogData(slug);

  return {
    content: blogData.content, // <- string
    published: blogData.date, // <- Date
    comments, // <- Promise
  };
}

export default function BlogPost() {
  const blogData = useLoaderData<typeof loader>();
  //    ^? { content: string, published: Date, comments: Promise }

  return (
    <>
      <Header published={blogData.date} />
      <BlogContent content={blogData.content} />
      <Suspense fallback={<CommentsSkeleton />}>
        <Await resolve={blogData.comments}>
          {(comments) => (
            <BlogComments comments={comments} />
          )}
        </Await>
      </Suspense>
    </>
  );
}

使用单次获取迁移路由

如果您目前从加载器返回 Response 实例(例如,json/defer),那么您应该不需要对应用程序代码进行太多更改即可利用单次获取。

但是,为了更好地为将来升级到 React Router v7 做准备,我们建议您开始逐个路由地进行以下更改,因为这是验证更新标头和数据类型不会破坏任何内容的最佳方式。

类型推断

在没有单次获取的情况下,从 loaderaction 返回的任何普通 Javascript 对象都会自动序列化为 JSON 响应(就像您通过 json 返回它一样)。类型推断假设是这种情况,并将裸对象返回推断为好像它们是 JSON 序列化的。

使用单次获取,裸对象将直接进行流式传输,因此一旦您选择启用单次获取,内置的类型推断将不再准确。例如,它们会假设 Date 将在客户端序列化为字符串 😕。

启用单次获取类型

要切换到单次获取类型,您应该增强 Remix 的 Future 接口,添加 v3_singleFetch: true。您可以在 tsconfig.json > include 中涵盖的任何文件中执行此操作。我们建议您在 vite.config.ts 中执行此操作,使其与 Remix 插件中的 future.v3_singleFetch 未来标志保持在同一位置。

declare module "@remix-run/server-runtime" {
  // or cloudflare, deno, etc.
  interface Future {
    v3_singleFetch: true;
  }
}

现在,useLoaderDatauseActionData 以及任何其他使用 typeof loader 泛型的实用程序都应该使用单次获取类型

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

export function loader() {
  return {
    planet: "world",
    date: new Date(),
  };
}

export default function Component() {
  const data = useLoaderData<typeof loader>();
  //    ^? { planet: string, date: Date }
}

函数和类实例

通常,函数无法可靠地通过网络发送,因此它们会被序列化为 undefined

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

export function loader() {
  return {
    planet: "world",
    date: new Date(),
    notSoRandom: () => 7,
  };
}

export default function Component() {
  const data = useLoaderData<typeof loader>();
  //    ^? { planet: string, date: Date, notSoRandom: undefined }
}

方法也不可序列化,因此类实例会精简为仅包含其可序列化属性

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

class Dog {
  name: string;
  age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  bark() {
    console.log("woof");
  }
}

export function loader() {
  return {
    planet: "world",
    date: new Date(),
    spot: new Dog("Spot", 3),
  };
}

export default function Component() {
  const data = useLoaderData<typeof loader>();
  //    ^? { planet: string, date: Date, spot: { name: string, age: number, bark: undefined } }
}

clientLoaderclientAction

请确保包含 clientLoader 参数和 clientAction 参数的类型,因为这是我们的类型检测客户端数据函数的方式。

来自客户端加载器和操作的数据永远不会被序列化,因此这些数据的类型会被保留

import {
  useLoaderData,
  type ClientLoaderFunctionArgs,
} from "@remix-run/react";

class Dog {
  /* ... */
}

// Make sure to annotate the types for the args! 👇
export function clientLoader(_: ClientLoaderFunctionArgs) {
  return {
    planet: "world",
    date: new Date(),
    notSoRandom: () => 7,
    spot: new Dog("Spot", 3),
  };
}

export default function Component() {
  const data = useLoaderData<typeof clientLoader>();
  //    ^? { planet: string, date: Date, notSoRandom: () => number, spot: Dog }
}

标头

启用单次获取后,headers 函数现在用于文档请求和数据请求。您应该使用该函数来合并从并行执行的加载器返回的任何标头,或者返回任何给定的 actionHeaders

返回的响应

使用单次获取,您不再需要返回 Response 实例,可以直接通过裸对象返回返回数据。因此,在使用单次获取时,应将 json/defer 实用程序视为已弃用。它们将在 v2 期间保留,因此您无需立即删除它们。它们很可能会在下一个主要版本中删除,因此我们建议您从现在到那时逐步删除它们。

对于 v2,您仍然可以继续返回普通的 Response 实例,并且它们的 status/headers 将以与在文档请求中相同的方式生效(通过 headers() 函数合并标头)。

随着时间的推移,您应该开始从加载器和操作中删除返回的响应。

  • 如果您的 loader/action 返回的是 json/defer,并且没有设置任何 status/headers,那么您可以直接删除对 json/defer 的调用并直接返回数据
  • 如果您的 loader/action 通过 json/defer 返回自定义的 status/headers,您应该切换为使用新的 data() 实用程序。

客户端加载器

如果您的应用程序具有使用 clientLoader 函数的路由,请务必注意,单次获取的行为会略有变化。由于 clientLoader 旨在为您提供一种选择不调用服务器 loader 函数的方法,因此单次获取调用执行该服务器加载器是不正确的。但是我们并行运行所有加载器,并且我们不想等待调用,直到我们知道哪些 clientLoader 实际上在请求服务器数据。

例如,考虑以下 /a/b/c 路由

// routes/a.tsx
export function loader() {
  return { data: "A" };
}

// routes/a.b.tsx
export function loader() {
  return { data: "B" };
}

// routes/a.b.c.tsx
export function loader() {
  return { data: "C" };
}

export function clientLoader({ serverLoader }) {
  await doSomeStuff();
  const data = await serverLoader();
  return { data };
}

如果用户从 / -> /a/b/c 导航,那么我们需要运行 ab 的服务器加载器以及 cclientLoader,它可能会(也可能不会)最终调用自己的服务器 loader。我们无法决定在我们需要获取 a/b loader 时在单次获取调用中包含 c 服务器 loader,也无法延迟到 c 实际进行 serverLoader 调用(或返回),而不会引入瀑布流。

因此,当您导出 clientLoader 时,该路由会选择退出单次获取,并且当您调用 serverLoader 时,它将进行单次获取以仅获取其路由服务器 loader。所有不导出 clientLoader 的路由都将在单个 HTTP 请求中获取。

因此,在上述路由设置中,从 / -> /a/b/c 的导航将导致对路由 ab 的前端进行单次获取调用。

GET /a/b/c.data?_routes=routes/a,routes/b

然后,当 c 调用 serverLoader 时,它将仅对 c 服务器 loader 进行自己的调用

GET /a/b/c.data?_routes=routes/c

资源路由

由于单次获取使用新的流式格式,因此从 loaderaction 函数返回的原始 JavaScript 对象不再通过 json() 实用程序自动转换为 Response 实例。相反,在导航数据加载中,它们会与其他加载器数据结合在一起,并在 turbo-stream 响应中流式传输。

这为资源路由带来了一个有趣的难题,因为资源路由的独特之处在于它们旨在单独命中,而并非总是通过 Remix API。还可以通过任何其他 HTTP 客户端(fetchcURL 等)访问它们。

如果资源路由旨在供内部 Remix API 使用,我们希望能够利用 turbo-stream 编码来解锁流式传输更复杂结构(如 DatePromise 实例)的能力。但是,当从外部访问时,我们可能更喜欢返回更易于使用的 JSON 结构。因此,如果您在 v2 中返回原始对象,则行为会略有歧义 - 应该通过 turbo-stream 还是 json() 进行序列化?

为了简化向后兼容性并简化单次获取未来标志的采用,Remix v2 将根据它是从 Remix API 访问还是从外部访问来处理此问题。在未来,如果您不希望原始对象被流式传输以供外部使用,Remix 将要求您返回自己的 JSON 响应

启用单次获取后,Remix v2 的行为如下

  • 当从 Remix API(如 useFetcher)访问时,原始 Javascript 对象将作为 turbo-stream 响应返回,就像普通的加载器和操作一样(这是因为 useFetcher 会将 .data 后缀附加到请求中)

  • 当从外部工具(如 fetchcURL)访问时,我们将继续自动转换为 json(),以便在 v2 中实现向后兼容性

    • 当遇到这种情况时,Remix 将记录弃用警告
    • 您可以方便地更新受影响的资源路由处理程序以返回 Response 对象
    • 解决这些弃用警告将使您更好地为最终的 Remix v3 升级做好准备
    export function loader() {
      return {
        message: "My externally-accessed resource route",
      };
    }
    
    export function loader() {
      return Response.json({
        message: "My externally-accessed resource route",
      });
    }
    

其他详细信息

流式数据格式

以前,Remix 使用 JSON.stringify 通过网络序列化您的加载器/操作数据,并且需要实现自定义流式格式来支持 defer 响应。

使用单次获取,Remix 现在在底层使用 turbo-stream,它为流式传输提供了一流的支持,并允许您自动序列化/反序列化比 JSON 更复杂的数据。以下数据类型可以通过 turbo-stream 直接流式传输:BigIntDateErrorMapPromiseRegExpSetSymbolURL。只要 Error 的子类型在客户端上具有全局可用的构造函数(SyntaxErrorTypeError 等),也支持这些子类型。

启用单次获取后,这可能不需要立即对您的代码进行任何更改

  • ✅ 从 loader/action 函数返回的 json 响应仍将通过 JSON.stringify 进行序列化,因此如果您返回 Date,您将从 useLoaderData/useActionData 收到 string
  • ⚠️ 如果您返回的是 defer 实例或裸对象,它现在将通过 turbo-stream 进行序列化,因此如果您返回 Date,您将从 useLoaderData/useActionData 收到 Date
    • 如果您希望保持当前行为(不包括流式传输 defer 响应),您可以将任何现有的裸对象返回包装在 json

这也意味着您不再需要使用 defer 实用程序通过网络发送 Promise 实例!您可以将 Promise 包含在裸对象的任何位置,并在 useLoaderData().whatever 上获取它。您还可以根据需要嵌套 Promise,但请注意潜在的 UX 影响。

一旦采用单次获取 (Single Fetch),建议您逐步删除应用程序中 json/defer 的使用,转而返回原始对象。

流式传输超时

之前,Remix 在默认的 entry.server.tsx 文件中有一个内置的 ABORT_TIMEOUT 的概念,它会终止 React 渲染器,但它并没有特别地清理任何待处理的延迟 Promise。

现在,由于 Remix 在内部进行流式传输,我们可以取消 turbo-stream 处理,并自动拒绝任何待处理的 Promise,并将这些错误流式传输到客户端。默认情况下,这会在 4950 毫秒后发生 - 该值被选择为略低于大多数 entry.server.tsx 文件中当前的 5000 毫秒 ABORT_DELAY - 因为我们需要取消 Promise,并在中止 React 端之前让拒绝通过 React 渲染器流式传输。

您可以通过从您的 entry.server.tsx 导出数字值 streamTimeout 来控制此行为,Remix 将使用该值作为拒绝来自 loader/action 的任何未完成的 Promise 的毫秒数。建议将此值与您中止 React 渲染器的超时时间解耦 - 您应该始终将 React 超时设置为更高的值,以便它有时间从您的 streamTimeout 中向下流式传输底层拒绝。

// Reject all pending promises from handler functions after 5 seconds
export const streamTimeout = 5000;

// ...

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() {
          /* ... */
        },
        onShellError(error: unknown) {
          /* ... */
        },
        onError(error: unknown) {
          /* ... */
        },
      }
    );

    // Automatically timeout the react renderer after 10 seconds
    setTimeout(abort, 10000);
  });
}

重新验证

正常导航行为

除了更简单的心理模型和文档与数据请求的对齐之外,单次获取的另一个好处是更简单(并且希望更好)的缓存行为。通常,与之前的多次获取行为相比,单次获取将发出更少的 HTTP 请求,并希望更频繁地缓存这些结果。

为了减少缓存碎片,单次获取更改了 GET 导航上的默认重新验证行为。以前,除非您通过 shouldRevalidate 选择加入,否则 Remix 不会为重用的祖先路由重新运行加载器。现在,在像 GET /a/b/c.data 这样的简单情况下,Remix 默认重新运行这些加载器。如果您没有任何 shouldRevalidateclientLoader 函数,这将是您应用程序的行为。

向任何活动路由添加 shouldRevalidateclientLoader 将触发包含 _routes 参数的细粒度单次获取调用,该参数指定要运行的路由子集。

如果 clientLoader 在内部调用 serverLoader(),这将触发对该特定路由的单独 HTTP 调用,类似于旧的行为。

例如,如果您位于 /a/b 并且您导航到 /a/b/c

  • 当不存在 shouldRevalidateclientLoader 函数时:GET /a/b/c.data
  • 如果所有路由都有加载器,但 routes/a 通过 shouldRevalidate 选择退出
    • GET /a/b/c.data?_routes=root,routes/b,routes/c
  • 如果所有路由都有加载器,但 routes/b 有一个 clientLoader
    • GET /a/b/c.data?_routes=root,routes/a,routes/c
    • 然后,如果 B 的 clientLoader 调用 serverLoader()
      • GET /a/b/c.data?_routes=routes/b

如果这种新行为对您的应用程序来说不是最优的,您应该可以通过在所需场景中向您的父路由添加返回 falseshouldRevalidate 来选择恢复为旧的不重新验证行为。

另一个选择是为昂贵的父加载器计算利用服务器端缓存。

提交重新验证行为

以前,无论操作的结果如何,Remix 都会在任何操作提交后重新验证所有活动加载器。您可以通过 shouldRevalidate 在每个路由的基础上选择退出重新验证。

使用单次获取,如果 action 返回或抛出具有 4xx/5xx 状态代码的 Response,Remix 将默认不重新验证加载器。如果 action 返回或抛出任何不是 4xx/5xx Response 的内容,则重新验证行为保持不变。这里的理由是,在大多数情况下,如果您返回 4xx/5xx Response,您实际上并没有改变任何数据,因此无需重新加载数据。

如果您希望在 4xx/5xx 操作响应后继续重新验证一个或多个加载器,您可以通过从您的 shouldRevalidate 函数返回 true 来选择在每个路由的基础上进行重新验证。如果需要根据操作状态代码进行决策,还有一个新的 actionStatus 参数传递给该函数。

重新验证是通过单次获取 HTTP 调用上的 ?_routes 查询字符串参数来处理的,该参数限制了正在调用的加载器。这意味着当您进行细粒度重新验证时,您将拥有基于正在请求的路由的缓存枚举 - 但所有信息都在 URL 中,因此您不需要任何特殊的 CDN 配置(与通过自定义标头完成此操作时需要您的 CDN 遵守 Vary 标头的情况相反)。

文档和示例在以下许可下授权 MIT