关于 remix-v2-perf-ssr
用于审查 Remix v2 性能、服务端渲染、流式传输、缓存、预取及服务端与客户端边界的技能。它提供 API 选择指引,并帮助识别常见的渲染与缓存问题。
主要功能
- 01使用 defer 进行流式传输
- 02HTTP 缓存与路由 headers
- 03服务端和客户端模块隔离
- 04clientLoader 与 clientAction 指引
- 05Hydration 安全检查
- 06预取与资源预加载指引
适用场景
- 01检查次要慢速数据的加载方式
- 02核查文档与数据缓存策略
- 03查找客户端包中的服务端代码
- 04审查 Hydration 和预取行为
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ existential-birds/ beagle --skill remix-v2-perf-ssr安装后在 Claude Code 中输入/remix-v2-perf-ssr确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/headers-caching.mdhydration.mdlinks-preload.mdprefetch.mdserver-client-split.mdstreaming.md详细介绍
Remix v2 性能、流式传输、缓存、服务端/客户端拆分
Remix v2 没有内置图像优化器,也没有不透明的框架缓存——它将所有功能交给标准 HTTP 层处理。性能主要涉及四大支柱:流式传输(defer/<Await>)、HTTP 缓存(headers 导出)、预取(<Link prefetch> 和 <PrefetchPageLinks>),以及严格的服务端/客户端拆分(.server.* / .client.* 文件约定)。
快速参考
使用 SWR 的 headers 导出(将 loader 响应头转发到文档):
import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await cms.getPost(params.slug);
return json(post, {
headers: {
"Cache-Control":
"public, max-age=60, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
});
使用 .server.ts 标记仅限服务端的模块——如果文件泄漏到客户端依赖图中,构建会明确失败:
// app/lib/db.server.ts — never bundled into the client
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
使用 defer() 处理较慢的次要数据:
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.getProduct(params.id); // critical, awaited
const reviews = db.getReviews(params.id); // slow, not awaited
return defer({ product, reviews });
}
export default function Product() {
const { product, reviews } = useLoaderData<typeof loader>();
return (
<>
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>
</>
);
}
使用 defer 和 <Await> 进行流式传输
传给 defer 的每个 Promise 都必须在 loader 中任何 await 之前创建,否则 loader 仍会被较慢的调用阻塞,流式传输也就毫无收益。始终将 <Await> 与 errorElement 配对使用——否则,被拒绝的延迟 Promise 会冒泡到路由的 ErrorBoundary,导致整个路由被卸载,从而失去流式传输的优势。
完整说明请参阅 references/streaming.md。
通过 headers 实现 HTTP 缓存
max-age 控制浏览器缓存;s-maxage 控制共享缓存/CDN 缓存,并会在 CDN 处覆盖 max-age;stale-while-revalidate 允许 CDN 在后台刷新内容时提供过期内容。每条路由有两种缓存范围:文档响应(由 headers 导出控制)和数据请求(客户端导航时发出的 ?_data= JSON 请求,由 loader 的响应头控制)。它们可以——通常也应该——采用不同的策略。
父子合并遵循“最深层路由优先”:默认情况下,只有最深层匹配路由的 headers 会运行。如果子路由没有导出 headers,Remix 会向上查找最近一个有该导出的父路由。最稳妥的规则是:只在叶子路由上定义 headers,不要在包裹个性化子路由的布局上定义。否则,激进的父级策略可能会在不知不觉中将按用户区分的 HTML 缓存在 CDN 上。
在子路由中合并时,应选择较小的 max-age——绝不要在子路由中放宽父路由的缓存策略:
export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => {
const loader = parseCacheControl(loaderHeaders.get("Cache-Control"));
const parent = parseCacheControl(parentHeaders.get("Cache-Control"));
const maxAge = Math.min(loader["max-age"] ?? 0, parent["max-age"] ?? 0);
return { "Cache-Control": `private, max-age=${maxAge}` };
};
请参阅 references/headers-caching.md。
服务端/客户端拆分
编译器会从客户端包中剥离 loader、action 和 headers 导出,以及这些导出内部使用的依赖项——但前提是这些依赖项没有模块副作用。顶层的 new PrismaClient()、console.log、initializeApp 调用都会妨碍摇树优化。规则:任何导入 node:fs、prisma、bcrypt、jsonwebtoken 或读取 process.env 的模块,都应命名为 *.server.ts(或放在 app/.server/ 下——目录形式需要 Remix Vite 插件;Classic Compiler 仅支持文件名后缀)。如果模块进入客户端依赖图,构建会明确失败,从而杜绝静默泄漏。
公开环境变量通过根 loader 的 window.ENV 模式传递到浏览器。绝不要从 loader 返回原始 process.env。请参阅 references/server-client-split.md。
clientLoader 和 clientAction
v2 新增了可选的 clientLoader / clientAction 导出,它们会在浏览器中与服务端 loader/action 同时运行,或取代它们运行。默认情况下,clientLoader 不会在初始 hydration 时运行——服务端 loader 会对页面进行 SSR,而 clientLoader 只会在后续客户端导航时触发。若要启用首次渲染时执行,请设置 clientLoader.hydrate = true,并导出一个 HydrateFallback 组件,在其执行期间进行渲染:
import type { ClientLoaderFunctionArgs } from "@remix-run/react";
export async function loader() {
return json({ /* SSR data */ });
}
export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) {
const cached = clientCache.get();
if (cached) return cached;
const fresh = await serverLoader<typeof loader>(); // round-trip to server loader
clientCache.set(fresh);
return fresh;
}
clientLoader.hydrate = true; // opt in to running on initial hydration
export function HydrateFallback() {
return <Skeleton />;
}
适合使用 clientLoader 的场景:在客户端缓存服务端载荷、hydration 后从 IndexedDB / localStorage 读取数据、完全仅限客户端的路由(完全跳过 loader)。不要重新获取路由的 loader 已通过 SSR 输出的同一份服务端载荷——这会造成无用的往返请求;应改为调用 serverLoader() 并缓存,或只在页面切换时运行(将 hydrate 保持为 false)。
水合安全性
useHydrated() 在 SSR 期间以及客户端首次渲染时返回 false,随后在下一次渲染时变为 true——正是这种两阶段行为确保了 HTML 匹配。对于不应进行 SSR 的组件(例如地图、读取 window 的图表),请用 <ClientOnly fallback={...}> 包装。对于 SSR 安全的 ID,请使用 React 的 useId(),切勿在渲染时使用 Math.random() 或 crypto.randomUUID()。
水合不匹配的 grep 列表:new Date(、Math.random(、crypto.randomUUID(、Date.now(、window.、document.、localStorage、sessionStorage、navigator.、未指定显式区域设置的 Intl.DateTimeFormat()、Intl.NumberFormat、.toLocaleDateString、.toLocaleTimeString、.toLocaleString、组件主体中的 process.env.、会生成不同 JSX 的 typeof window 三元表达式、会修改 DOM 的第三方脚本、向 <body> 注入节点的浏览器扩展。请参阅 references/hydration.md。
预取
<Link prefetch> 有四种模式:"none"(默认)、"intent"(悬停/聚焦时)、"render"(渲染时立即触发)、"viewport"(滚动进入视口时)。预取会将 <link rel="prefetch"> 标签作为锚点的同级元素插入——请在 CSS 中使用 :last-of-type,而不是 :last-child,因为预取标签会短暂地成为最后一个子元素。
一个容易忽略的问题:如果 loader 未设置 Cache-Control,悬停预取会使请求数量翻倍,因为浏览器不会缓存预取响应。请在 loader 中检测 Purpose: prefetch 标头,并返回 Cache-Control: private, max-age=10。请参阅 references/prefetch.md。
通过 links 预加载资源
links 导出会将 <link> 标签注入文档 head 中——预加载关键字体和 CSS,并预取可能成为下一页所需的资源。Remix 没有内置的图像优化器;请在构建时调整图像尺寸(使用 sharp、unpic、remix-image),并始终设置 width/height。请参阅 references/links-preload.md。
判断门(决策顺序)
请按顺序回答。通过表示条件为真;在同一行选择对应的 API,然后停止。
defer() 与在 loader 中等待
- 这项数据是否为首次绘制、meta 标签或 SEO 所必需(例如商品标题、页面标题)?
- 通过 → 在 loader 中
await该数据,并通过json()返回。停止。 - 未通过 → 第 2 步。
- 通过 → 在 loader 中
- 该调用是否确实很慢(>~50ms、跨区域数据库、外部 API——不包括内存缓存)?
- 通过 → 通过
defer()传递尚未解析的 promise,并用<Suspense>+<Await errorElement={...}>包装。停止。 - 未通过 → 对其使用
await。延迟快速数据会增加流式传输开销,还会无谓地短暂显示骨架屏。
- 通过 → 通过
.server.ts 与运行时 typeof window 检查
- 该模块是否导入
node:*、prisma、bcrypt、jsonwebtoken、fs、path,或在顶层读取process.env?- 通过 → 将文件命名为
*.server.ts(或放在app/.server/下——目录形式需要 Remix Vite 插件;Classic Compiler 仅支持文件名后缀)。如果代码泄漏到客户端,构建会明确失败。停止。 - 未通过 → 第 2 步。
- 通过 → 将文件命名为
- 该模块是否仅在
loader/action/headers中调用,且没有顶层副作用?- 通过 → 为清晰起见,仍建议使用
.server.ts;摇树优化或许有效,但并不可靠。停止。 - 未通过 → 第 3 步。
- 通过 → 为清晰起见,仍建议使用
- 该代码是否确实需要同时在服务器和客户端运行,但必须根据运行环境进行分支(例如日志记录器、功能标志)?
- 通过 → 在函数主体内部使用
typeof window === "undefined"可以接受——绝不要放在模块顶层(死代码分支可能会将服务器依赖引入客户端依赖图)。
- 通过 → 在函数主体内部使用
<Link prefetch> 模式选择
- 链接是否敏感、开销大,或其 loader 有副作用(登出、带有分析埋点的页面浏览、会触发变更的 loader)?
- 是 →
prefetch="none"。停止。 - 否 → 步骤 2。
- 是 →
- 这是首屏关键导航链接,可能是用户接下来会点击的链接吗?
- 是 →
prefetch="render"。立即预取 loader、JS 和 CSS。停止。 - 否 → 步骤 3。
- 是 →
- 链接是否位于长列表中(表格行、搜索结果、信息流)?
- 是 →
prefetch="viewport"(滚动到视口内时触发)或"intent"(悬停时触发)。长列表中绝不要使用"render"。停止。 - 否 → 步骤 4。
- 是 →
- 默认值:标准导航(页眉、侧边栏、页脚)使用
prefetch="intent"。
补充文档
- Headers 和缓存:有关
HeadersFunction签名、loaderHeaders/parentHeaders/actionHeaders/errorHeaders、SWR 模式以及父子级合并语义,请参阅 references/headers-caching.md。 - 流式传输:有关
defer()、<Await>、<Suspense>、abortDelay、错误处理以及 CSP 交互,请参阅 references/streaming.md。 - 服务端/客户端拆分:有关
.server.*/.client.*(目录形式需要 Remix Vite 插件;Classic Compiler 仅支持文件名后缀)、环境变量处理以及window.ENV模式,请参阅 references/server-client-split.md。 - 水合:有关
useHydrated、<ClientOnly>、useId以及不匹配项 grep 列表,请参阅 references/hydration.md。 - 预取:有关
<Link prefetch>模式、<PrefetchPageLinks>以及Purpose: prefetch请求头技巧,请参阅 references/prefetch.md。 - 预加载链接:有关
links导出、字体/CSS 预加载以及图像指南,请参阅 references/links-preload.md。
对比:何时使用哪种 API
| 需求 | API | 模块 |
|---|---|---|
| 流式传输较慢的次要数据 | defer() + <Await> | @remix-run/node + @remix-run/react |
| 对文档响应使用 CDN 缓存 | headers 导出 | 路由模块 |
| 对数据响应使用 CDN 缓存 | json()/Response 上的 Cache-Control | loader 返回值 |
| 仅限服务端的模块 | *.server.ts 文件名 | 文件约定 |
| 仅限浏览器的模块 | *.client.ts 文件名 | 文件约定 |
| 客户端中的公共环境变量 | 通过 root loader 使用 window.ENV | 模式 |
| SSR 安全的 ID | useId() | react |
| 禁用单个组件的 SSR | <ClientOnly> | remix-utils/client-only |
| 水合后进行分支处理 | useHydrated() | remix-utils/use-hydrated |
| 悬停时预取 | <Link prefetch="intent"> | @remix-run/react |
| 渲染时预取(首屏可见区域) | <Link prefetch="render"> | @remix-run/react |
| 以编程方式预取 | <PrefetchPageLinks page="/absolute/path"> | @remix-run/react |
| 预加载字体/CSS | links 导出 | 路由模块 |