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。
这是一个构建时模块图问题,与运行时你实际访问了哪些字段无关。
// 你以为只读了 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()}</>;
}服务端物理上无法触达 registry.tsx——它压根不在依赖链里。新增组件只需往 manifest 加一行数据、往 registry 加一行 spec 映射,构建永远不会因为 spec 的浏览器副作用炸掉。
验证
# 服务端路由文件里不应出现 registry 或 specBySlug
grep -n "registry\|specBySlug" app/**/page.tsx
# 期望:无输出加一个合约测试断言:manifest 里每个 slug 在 specBySlug 里都有对应条目,防止"加了 manifest 忘了加 registry"在运行时才发现。
一句话外卖
静态
import的代价是整个模块图,而不是你运行时读到的那几个字段——RSC 边界的隔离必须落到文件的依赖图层面,靠记忆和 code review 守不住。