返回博客

RSC 模块图陷阱:Registry 把渲染函数拖进服务端 bundle

在搭建组件文档站时,我踩了一个让人费解的构建炸弹:next build 稳定跑了很久,某天加了第 N 个组件之后,SSG 阶段突然报错,提示「在服务端访问了 window」或「不能在 Server Component 中使用 X」。定睛一看,出错的模块根本不是我在服务端直接引用的文件——它顺着一条我以为"不会走到"的依赖链进来了。

现象

路由文件 app/components/[slug]/page.tsx 是一个 Server Component,里面只有一行用到 registry:

import { registry } from "@/lib/registry";
 
export function generateStaticParams() {
  return registry.map((r) => ({ slug: r.slug })); // 只读了 .slug
}

看起来人畜无害。但 registry 里每条记录都挂着一个 spec 对象,spec 里有 render() 方法,render 里用到了浏览器专属的 UI 库。

第一次 next build:通过。加到第 20 个组件时,某个新 spec 的依赖在模块初始化时摸了 window,于是构建直接炸掉——报错堆栈里完全看不到 generateStaticParams,只有那个 spec 文件本身。

根因

JavaScript 模块的 import静态的:不管你运行时只读了 .slug,打包器在构建时会把整个 registry 模块图——包括它传递引用的每一个 *.showcase / spec 文件——全部拉进当前 bundle。

这是一个构建时模块图问题,与运行时你实际访问了哪些字段无关。

坑:静态 import 不看你用了哪些字段
// 你以为只读了 slug…
import { registry } from "@/lib/registry";
registry.map((r) => r.slug);
 
// 但打包器把 registry 依赖的全部 spec 模块
// —— 包括 render()、JSX、浏览器专属副作用 ——
// 都编译进了服务端/SSG bundle。

结果:某个新 spec 触碰 window → 服务端构建爆炸,报错看起来完全"不相干"。

这个坑与另一个常见的 RSC 运行时陷阱(把 Client Element 作为 render prop 跨边界传递)是同一个 RSC 边界的两个维度:后者在运行时报错,这个在构建时爆炸,更难定位。

正解

把 registry 拆成两个文件,用结构隔离代替"大家记得别导错":

lib/manifest.ts — 纯数据,零 UI 库 import

export interface ComponentMeta {
  slug: string;
  name: string;
  description: string;
  category: "inputs" | "data-display" | "feedback" | "navigation";
  status: "stable" | "new";
}
 
export const manifest: ComponentMeta[] = [
  { slug: "button", name: "Button", description: "基础按钮", category: "inputs", status: "stable" },
  // …
];

lib/registry.tsx"use client",唯一引用 spec 的地方

"use client";
import type { ShowcaseSpec } from "@your/ui";
import { buttonShowcase } from "@your/ui";
 
export const specBySlug: Record<string, ShowcaseSpec> = {
  button: buttonShowcase,
  // …
};

Server Component 只碰 manifest,把 slug 字符串传给 Client Island

// app/components/[slug]/page.tsx — server
import { manifest } from "@/lib/manifest";
import { ComponentDoc } from "@/components/component-doc";
 
export function generateStaticParams() {
  return manifest.map((m) => ({ slug: m.slug }));
}
 
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = (await params);
  return <ComponentDoc slug={slug} />; // 传字符串,不传 spec 对象
}

Client Island 自行查 spec

"use client";
import { specBySlug } from "@/lib/registry";
 
export function ComponentDoc({ slug }: { slug: string }) {
  const spec = specBySlug[slug];
  return <>{spec?.states[0]?.render()}</>;
}
正解:manifest 给服务端,registry 给客户端,slug 字符串跨边界

服务端物理上无法触达 registry.tsx——它压根不在依赖链里。新增组件只需往 manifest 加一行数据、往 registry 加一行 spec 映射,构建永远不会因为 spec 的浏览器副作用炸掉。

验证

# 服务端路由文件里不应出现 registry 或 specBySlug
grep -n "registry\|specBySlug" app/**/page.tsx
# 期望:无输出

加一个合约测试断言:manifest 里每个 slug 在 specBySlug 里都有对应条目,防止"加了 manifest 忘了加 registry"在运行时才发现。

一句话外卖

静态 import 的代价是整个模块图,而不是你运行时读到的那几个字段——RSC 边界的隔离必须落到文件的依赖图层面,靠记忆和 code review 守不住。