mcpField
R
remix-v2-perf-ssr

remix-v2-perf-ssr

github.com/existential-birds开发8540 次安装11 周前更新
代码仓库

关于 remix-v2-perf-ssr

用于审查 Remix v2 性能、服务端渲染、流式传输、缓存、预取及服务端与客户端边界的技能。它提供 API 选择指引,并帮助识别常见的渲染与缓存问题。

主要功能

  1. 01使用 defer 进行流式传输
  2. 02HTTP 缓存与路由 headers
  3. 03服务端和客户端模块隔离
  4. 04clientLoader 与 clientAction 指引
  5. 05Hydration 安全检查
  6. 06预取与资源预加载指引

适用场景

  1. 01检查次要慢速数据的加载方式
  2. 02核查文档与数据缓存策略
  3. 03查找客户端包中的服务端代码
  4. 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.md
hydration.md
links-preload.md
prefetch.md
server-client-split.md
streaming.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 中等待
  1. 这项数据是否为首次绘制、meta 标签或 SEO 所必需(例如商品标题、页面标题)?
    • 通过 → 在 loader 中 await 该数据,并通过 json() 返回。停止。
    • 未通过 → 第 2 步。
  2. 该调用是否确实很慢(>~50ms、跨区域数据库、外部 API——不包括内存缓存)?
    • 通过 → 通过 defer() 传递尚未解析的 promise,并用 <Suspense> + <Await errorElement={...}> 包装。停止。
    • 未通过 → 对其使用 await。延迟快速数据会增加流式传输开销,还会无谓地短暂显示骨架屏。
.server.ts 与运行时 typeof window 检查
  1. 该模块是否导入 node:*、prisma、bcrypt、jsonwebtoken、fs、path,或在顶层读取 process.env?
    • 通过 → 将文件命名为 *.server.ts(或放在 app/.server/ 下——目录形式需要 Remix Vite 插件;Classic Compiler 仅支持文件名后缀)。如果代码泄漏到客户端,构建会明确失败。停止。
    • 未通过 → 第 2 步。
  2. 该模块是否仅在 loader/action/headers 中调用,且没有顶层副作用?
    • 通过 → 为清晰起见,仍建议使用 .server.ts;摇树优化或许有效,但并不可靠。停止。
    • 未通过 → 第 3 步。
  3. 该代码是否确实需要同时在服务器和客户端运行,但必须根据运行环境进行分支(例如日志记录器、功能标志)?
    • 通过 → 在函数主体内部使用 typeof window === "undefined" 可以接受——绝不要放在模块顶层(死代码分支可能会将服务器依赖引入客户端依赖图)。
<Link prefetch> 模式选择
  1. 链接是否敏感、开销大,或其 loader 有副作用(登出、带有分析埋点的页面浏览、会触发变更的 loader)?
    • 是 → prefetch="none"。停止。
    • 否 → 步骤 2。
  2. 这是首屏关键导航链接,可能是用户接下来会点击的链接吗?
    • 是 → prefetch="render"。立即预取 loader、JS 和 CSS。停止。
    • 否 → 步骤 3。
  3. 链接是否位于长列表中(表格行、搜索结果、信息流)?
    • 是 → prefetch="viewport"(滚动到视口内时触发)或 "intent"(悬停时触发)。长列表中绝不要使用 "render"。停止。
    • 否 → 步骤 4。
  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-Controlloader 返回值
仅限服务端的模块*.server.ts 文件名文件约定
仅限浏览器的模块*.client.ts 文件名文件约定
客户端中的公共环境变量通过 root loader 使用 window.ENV模式
SSR 安全的 IDuseId()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
预加载字体/CSSlinks 导出路由模块

评论

支持 Markdown