单次获取
此页内容

单次获取

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

概述

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

启用单次获取旨在 upfront 降低成本,然后允许您随着时间的推移迭代地采用所有重大变更。您可以从应用对 启用单次获取 所需的最小更改开始,然后使用 迁移指南 在您的应用程序中进行增量更改,以确保顺利、无缝升级到 React Router v7

请还查看 重大变更,以便您了解一些底层行为变更,尤其是关于序列化和状态/标头行为方面的变更。

启用单次获取

1. 启用 future 标志

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

2. 弃用 fetch polyfill

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

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

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

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

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

3. 调整 headers 实现(如有必要)

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

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

如果您对脚本有 内容安全策略,并且使用 nonce-sources,则需要在两个地方添加该 nonce 才能实现流式单次获取。

  • <RemixServer nonce={yourNonceValue}> - 这会将 nonce 添加到此组件呈现的内联脚本中,这些脚本在客户端处理流式数据。
  • 在您的 entry.server.tsx 中,将 nonce 参数传递给 renderToPipeableStream/renderToReadableStream。另请参见 Remix 的 流式文档

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

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

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

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

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

重大更改

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

需要立即解决的更改

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

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

  • 新的流式数据格式:单次获取在幕后使用 turbo-stream 的一种新的流式格式,这意味着我们能够流式传输比 JSON 更复杂的数据。
  • 不再自动序列化:从 loaderaction 函数返回的裸对象不再自动转换为 JSON Response,而是按原样通过网络序列化。
  • 类型推断更新:为了获得最准确的类型推断,您应该 增强 Remix 的 Future 接口,并将 v3_singleFetch 设置为 true
  • 默认重新验证行为更改为在 GET 导航上选择退出:普通导航上的默认重新验证行为从选择加入更改为选择退出,您的服务器加载器将在默认情况下重新运行。
  • 选择加入 action 重新验证action 4xx/5xx Response 后的重新验证现在是选择加入,而不是选择退出。

使用单次获取添加新路由

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

为了获得正确的类型推断,您需要 增强 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)访问时,为了在 v2 中保持向后兼容性,我们将继续这种自动转换为json()的行为。

    • 遇到这种情况时,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 影响。

采用单次获取后,建议你逐步删除应用程序中对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 导航上的默认重新验证行为。以前,Remix 不会重新运行重新使用的祖先路由的加载器,除非你通过shouldRevalidate选择加入。现在,Remix 会默认情况下在简单的单次获取请求(如GET /a/b/c.data)中重新运行它们。如果你没有任何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 响应的东西,那么重新验证行为将保持不变。这里的推理是,在大多数情况下,如果你返回4xx/5xx响应,你实际上没有修改任何数据,因此不需要重新加载数据。

如果你想要在 4xx/5xx 操作响应后继续重新验证一个或多个加载器,你可以通过从你的shouldRevalidate函数中返回true来选择加入按路由重新验证。还提供了一个新的actionStatus参数传递给函数,如果你需要根据操作状态代码来决定,可以使用它。

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

文档和示例根据 MIT