单次获取是一种新的数据加载策略和流式传输格式。启用单次获取后,Remix 将在客户端转换时向您的服务器发出单个 HTTP 调用,而不是并行发出多个 HTTP 调用(每个加载器一个)。此外,单次获取还允许您从 loader
和 action
发送裸对象,例如 Date
、Error
、Promise
、RegExp
等。
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
添加到两个位置,以用于流式单次获取实现
<RemixServer nonce={yourNonceValue}>
- 这会将 nonce
添加到此组件呈现的内联脚本,这些脚本处理客户端的流式数据entry.server.tsx
中,在 renderToPipeableStream
/renderToReadableStream
的 options.nonce
参数中。另请参阅 Remix 流式传输文档5. 替换 renderToString
(如果您正在使用它)
对于大多数 Remix 应用程序,您不太可能使用 renderToString
,但是如果您选择在 entry.server.tsx
中使用它,请继续阅读,否则您可以跳过此步骤。
为了保持文档和数据请求之间的一致性,turbo-stream
也用作在初始文档请求中发送数据的格式。这意味着,一旦选择加入单次获取,您的应用程序将无法再使用 renderToString
,并且必须在 entry.server.tsx
中使用 React 流式渲染器 API,例如 renderToPipeableStream
或 renderToReadableStream
)。
这并不意味着您必须将 HTTP 响应流式传输下来,您仍然可以通过利用 renderToPipeableStream
中的 onAllReady
选项或 renderToReadableStream
中的 allReady
Promise 来一次发送整个文档。
在客户端,这也意味着您需要将客户端的 hydrateRoot
调用包装在 startTransition
调用中,因为流式传输的数据将包装在 Suspense
边界中。
单次获取引入了一些重大更改 - 其中一些需要在您启用标志时提前处理,而另一些则可以在启用标志后逐步处理。您需要确保在更新到下一个主要版本之前已处理所有这些更改。
需要提前解决的更改
fetch
polyfill:旧的 installGlobals()
polyfill 不适用于单次获取,您必须使用原生 Node 20 fetch
API 或在您的自定义服务器中调用 installGlobals({ nativeFetch: true })
以获取 基于 undici 的 polyfillheaders
导出应用于数据请求:headers
函数现在将应用于文档和数据请求需要注意的更改,您可能需要随着时间的推移处理
turbo-stream
在底层使用新的流式传输格式,这意味着我们可以流式传输比 JSON 更复杂的数据loader
和 action
函数返回的裸对象不再自动转换为 JSON Response
,而是按原样通过网络序列化v3_singleFetch: true
增强 Remix 的 Future
接口action
重新验证:在 action
4xx
/5xx
Response
之后,重新验证现在是选择加入,而不是选择退出启用单次获取后,您可以开始编写利用更强大的流式格式的路由。
Future
接口,添加 v3_singleFetch: true
。您可以在类型推断部分中了解更多相关信息。
启用单次获取后,您可以从加载器返回以下数据类型:BigInt
、Date
、Error
、Map
、Promise
、RegExp
、Set
、Symbol
和 URL
。
// 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 做准备,我们建议您开始逐个路由地进行以下更改,因为这是验证更新标头和数据类型不会破坏任何内容的最佳方式。
在没有单次获取的情况下,从 loader
或 action
返回的任何普通 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;
}
}
现在,useLoaderData
、useActionData
以及任何其他使用 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 } }
}
clientLoader
和 clientAction
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
导航,那么我们需要运行 a
和 b
的服务器加载器以及 c
的 clientLoader
,它可能会(也可能不会)最终调用自己的服务器 loader
。我们无法决定在我们需要获取 a
/b
loader
时在单次获取调用中包含 c
服务器 loader
,也无法延迟到 c
实际进行 serverLoader
调用(或返回),而不会引入瀑布流。
因此,当您导出 clientLoader
时,该路由会选择退出单次获取,并且当您调用 serverLoader
时,它将进行单次获取以仅获取其路由服务器 loader
。所有不导出 clientLoader
的路由都将在单个 HTTP 请求中获取。
因此,在上述路由设置中,从 / -> /a/b/c
的导航将导致对路由 a
和 b
的前端进行单次获取调用。
GET /a/b/c.data?_routes=routes/a,routes/b
然后,当 c
调用 serverLoader
时,它将仅对 c
服务器 loader
进行自己的调用
GET /a/b/c.data?_routes=routes/c
由于单次获取使用新的流式格式,因此从 loader
和 action
函数返回的原始 JavaScript 对象不再通过 json()
实用程序自动转换为 Response
实例。相反,在导航数据加载中,它们会与其他加载器数据结合在一起,并在 turbo-stream
响应中流式传输。
这为资源路由带来了一个有趣的难题,因为资源路由的独特之处在于它们旨在单独命中,而并非总是通过 Remix API。还可以通过任何其他 HTTP 客户端(fetch
、cURL
等)访问它们。
如果资源路由旨在供内部 Remix API 使用,我们希望能够利用 turbo-stream
编码来解锁流式传输更复杂结构(如 Date
和 Promise
实例)的能力。但是,当从外部访问时,我们可能更喜欢返回更易于使用的 JSON 结构。因此,如果您在 v2 中返回原始对象,则行为会略有歧义 - 应该通过 turbo-stream
还是 json()
进行序列化?
为了简化向后兼容性并简化单次获取未来标志的采用,Remix v2 将根据它是从 Remix API 访问还是从外部访问来处理此问题。在未来,如果您不希望原始对象被流式传输以供外部使用,Remix 将要求您返回自己的 JSON 响应。
启用单次获取后,Remix v2 的行为如下
当从 Remix API(如 useFetcher
)访问时,原始 Javascript 对象将作为 turbo-stream
响应返回,就像普通的加载器和操作一样(这是因为 useFetcher
会将 .data
后缀附加到请求中)
当从外部工具(如 fetch
或 cURL
)访问时,我们将继续自动转换为 json()
,以便在 v2 中实现向后兼容性
Response
对象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
直接流式传输:BigInt
、Date
、Error
、Map
、Promise
、RegExp
、Set
、Symbol
和 URL
。只要 Error
的子类型在客户端上具有全局可用的构造函数(SyntaxError
、TypeError
等),也支持这些子类型。
启用单次获取后,这可能不需要立即对您的代码进行任何更改
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 将 默认重新运行这些加载器。如果您没有任何 shouldRevalidate
或 clientLoader
函数,这将是您应用程序的行为。
向任何活动路由添加 shouldRevalidate
或 clientLoader
将触发包含 _routes
参数的细粒度单次获取调用,该参数指定要运行的路由子集。
如果 clientLoader
在内部调用 serverLoader()
,这将触发对该特定路由的单独 HTTP 调用,类似于旧的行为。
例如,如果您位于 /a/b
并且您导航到 /a/b/c
shouldRevalidate
或 clientLoader
函数时: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
clientLoader
调用 serverLoader()
GET /a/b/c.data?_routes=routes/b
如果这种新行为对您的应用程序来说不是最优的,您应该可以通过在所需场景中向您的父路由添加返回 false
的 shouldRevalidate
来选择恢复为旧的不重新验证行为。
另一个选择是为昂贵的父加载器计算利用服务器端缓存。
以前,无论操作的结果如何,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
标头的情况相反)。