
去年在给一个 SaaS 后台加“导出发票 PDF”功能时,调研过三条路:pdfmake(声明式 DSL 但类型支持弱)、@react-pdf/renderer(原生 React 但样式全靠手写 StyleSheet)、react-pdf-tailwind(Tailwind 语法但已停更两年)。最后妥协用了 @react-pdf/renderer + 手写几百行 StyleSheet,维护成本极高——每改一个字号、间距,都要在 JS 对象里找对应 key,毫无复用可言。
今年看到 PDFx(1.1k ⭐,MIT 协议),标语是 “Pre-built PDF components for React. Copy them into your project, own them completely.” 核心思想:像 shadcn/ui 对待 UI 组件一样对待 PDF 组件——源码直接复制到你的项目,零运行时依赖,想改随便改。底层仍是 @react-pdf/renderer,但封装了 Heading、Text、Badge、Table、QRCode、Chart 等 20+ 组件,配套 CLI(pdfx add heading)一键把组件源码拉到本地。决定周末把它从 monorepo 源码跑通,顺便摸摸注册表、CLI、主题系统的底层细节。
部署实录
环境与命令
# 1. 克隆 & 进目录
git clone https://github.com/akii09/pdfx.git
cd pdfx
# 2. 装依赖(必须 pnpm 10+,lockfile 写死)
pnpm install
# 3. 起文档站 + 注册表服务(Vite + Hono,端口 3000)
pnpm dev:www
# 4. 另开终端,起 CLI 开发模式(tsup --watch)
pnpm --filter=pdfx-cli devpnpm dev:www 跑起后,浏览器打开 http://localhost:3000 能看到文档站 + 组件预览;注册表 API 在 http://localhost:3000/r/<component>.json。CLI 端 pnpm --filter=pdfx-cli dev 会把 packages/cli/src/index.ts 编译到 dist/index.js,可直接 node dist/index.js add heading 测试。
生产构建与发包
# 全量构建(Turbo 并行跑 packages/* + apps/www)
pnpm build
# 仅构建注册表 JSON(供 CLI 消费)
pnpm build:registry
# CLI 单独打包(tsup → dist/,含 ESM + d.ts)
pnpm --filter=pdfx-cli build
# 发布流程(changeset 管理版本 + npm publish)
pnpm changeset # 交互式选包、写 changelog
pnpm version-packages
pnpm release # 等同于 pnpm build && changeset publish --tag beta产物:packages/cli/dist/ 下 index.js/index.d.ts/mcp/index.js,可直接 npx pdfx-cli@latest init 使用。Monorepo 用 pnpm workspace + Turbo,turbo.json 定义了 build、dev、lint、typecheck、test 等 pipeline,缓存命中率极高。
踩坑记录
pnpm installonly-allow pnpm 失败 | package.json 的 preinstall 强制 pnpm | corepack enable && corepack prepare pnpm@10.29.3 --activate |
pnpm dev:www | PORT=3001 pnpm dev:wwwvite.config.ts | |
pdfx add heading 报 fetch failed | apps/www 正在跑,CLI 读 .pdfxrc.json 的 registryUrl | |
@react-pdf/renderer | pdfx theme initpdfx-theme.ts 里 fontFamilies: { sans: [{ name: 'NotoSansSC', src: '...ttf' }] } 并 Font.register() | |
pnpm build@react-pdf/types 版本不一致 | pnpm-workspace.yaml + pnpm overrides 对齐到 2.11.1(已在 #172 修复) |
核心亮点
1. shadcn/ui 范式迁移到 PDF 领域:注册表 + CLI + 复制即拥有
ARCHITECTURE.md 里讲得很透彻:“the website that documents the components is the registry that serves them”。数据流向:
apps/www/src/registry/components/<name>/<name>.tsx (source of truth)
|
└── build-registry.ts (pnpm build:registry)
| strips @pdfx/shared imports, inlines shared helpers
v
public/r/<name>.json (served over HTTPS)
|
└── pdfx add <name> (CLI fetches JSON, writes files)
v
user-project/src/components/pdfx/<name>/ (component lives here)• 源码即真理: apps/www/src/registry/components/heading/heading.tsx里既是文档站渲染的组件,也是注册表发布的源码。• 构建时内联: build-registry.ts把@pdfx/shared的类型、Zod schema、主题预设、样式工厂函数全部内联到输出 JSON,CLI 拉下来的文件零外部依赖,直接能跑。• 用户完全拥有代码: pdfx add heading会在你的项目里创建src/components/pdfx/heading/{heading.types.ts, heading.styles.ts, heading.tsx, index.ts}四个文件。想改字号、加动画、换字体——直接改源码,不用等 npm 发版,不用 fork 维护。
这就是 “No runtime dependency” 的含义:PDFx 不是一个你 npm install 的库,而是一个“代码生成器 + 组件规范”。
2. Tailwind-like Utility Class API:把 CSS-in-JS 写成类名字符串
@react-pdf/renderer 原生只接受 StyleSheet.create({ container: { flexDirection: 'row', padding: 12 } }) 这种 JS 对象。PDFx 封装了 tw 工具函数(在 packages/shared/src/theme.ts),支持类 Tailwind 语法:
// 用户项目里复制来的 Heading 组件用法
import { Heading } from '@/components/pdfx/heading'
<Heading level={1} className="text-3xl font-bold text-gray-900 mb-4">
Invoice #1042
</Heading>
// 内部实现:tw('text-3xl font-bold text-gray-900 mb-4') → StyleSheet 对象实测支持的 utility 类:spacing (p-4m-2gap-3)、typography (text-xlfont-mediumleading-relaxedtracking-tight)、color (text-gray-900bg-blue-50border-red-300)、flex/grid (flexflex-colitems-centerjustify-between)、border (rounded-lgborderborder-gray-200)。不支持:任意值 (p-[13px])、响应式前缀 (md:flex)、伪类 (hover:bg-gray-100)、CSS Grid 复杂布局。这是 @react-pdf/renderer 的原生限制(只支持 Yoga/Flexbox 子集),也是 PDFx 刻意不实现的——保持轻量。
3. 主题系统:配置即代码,运行时零魔法
npx pdfx-cli theme init
# 生成 pdfx-theme.ts(用户项目里)// pdfx-theme.ts - 用户完全拥有、可随意改
import { createPdfxTheme, type PdfxTheme } from '@react-pdf/renderer' // 实际是内联的类型
export const pdfxTheme = createPdfxTheme({
name: 'professional',
colors: {
primary: { 50: '#eff6ff', ..., 900: '#1e3a5f' },
gray: { 50: '#f9fafb', ..., 900: '#111827' },
// ...
},
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, ... },
fontFamilies: {
sans: [{ name: 'Helvetica', src: 'helvetica' }], // 或自定义 TTF
mono: [{ name: 'Courier', src: 'courier' }],
},
borderRadius: { none: 0, sm: 2, md: 4, lg: 8, full: 9999 },
})组件里通过 usePdfxTheme()(内联的极简 hook)读取:无 Context Provider、无全局状态、无魔法。主题就是个普通 TS 对象,类型安全,IDE 跳转随意。三套内置预设(professional、modern、minimal)分别对应不同视觉风格,pdfx theme init --preset modern 可选。
4. 预置 Block 模板:发票、报表、收据一键生成
除了原子组件(Heading、Text、Badge、Table、QRCode、Chart、SignatureBlock 等 20+),PDFx 还提供 Block——预组合的完整页面模板:
npx pdfx-cli block add invoice-modern
npx pdfx-cli block add report-financial
npx pdfx-cli block add receipt-standard生成的文件结构:
user-project/
src/
components/
pdfx/
heading/ # 原子组件
table/
...
blocks/
invoice-modern/ # 完整发票页面
invoice-modern.tsx
types.ts
index.tsinvoice-modern.tsx 里直接组合 Heading、Table、Badge、QRCode、SignatureBlock,数据通过 props 传入。实测把一份 30 行明细的发票数据喂进去,renderToFile 生成 A4 PDF 耗时 ~380ms (Node 20, M2 Pro),文件大小 42 KB。对比手写 @react-pdf/renderer 同等复杂度发票,代码量从 400 行降到 80 行(含数据)。
5. MCP (Model Context Protocol) 集成:让 AI Agent 懂 PDFx 组件结构
packages/cli/src/mcp/ 实现了 MCP Server,npx pdfx-cli@latest mcp init --client cursor 一键生成 Cursor/Claude Code/Continue 等客户端的 MCP 配置。启动后,AI Agent 能:
• 搜索组件: pdfx:search_components(query="table with pagination")→ 返回匹配的组件名、参数、示例• 获取组件完整源码: pdfx:get_component(name="table")→ 直接读注册表 JSON,含 types/styles/tsx 全量源码• 生成 Block 代码: pdfx:generate_block(name="invoice-modern", data={...})→ 输出可直接跑的 TSX
这是把 “文档站即注册表” 推向极致:AI 不用爬网页、不用读 markdown,直接用结构化 schema 拿到最新组件定义。实测在 Cursor 里问 “帮我用 PDFx 生成一张带二维码的收据”,Agent 能自动调用 get_component('qr-code') + get_component('receipt-standard') 组装出完整代码,零幻觉、零过时 API。
横向对比:为什么不是别的方案?
| 核心模式 | |||||
| 类型安全 | |||||
| 样式复用 | |||||
| 中文/字体 | |||||
| 服务端渲染 | renderToFile/Buffer | ||||
| AI Agent 友好 | |||||
| 维护活跃度 | 已停更 2 年 | ||||
| 学习曲线 |
结论:要 “服务端生成 PDF、类型安全、样式可复用、可二开、AI 友好” → PDFx 目前是最优解;要 “前端页面直接存为 PDF、不想装包” → html2pdf/print() 足矣。
避坑与总结
| 安装门槛 | npx pdfx-cli@latest init 一条命令接入现有 React/Next.js 项目,无需改构建配置 |
| 运行环境 | @react-pdf/renderer 同构) |
| 字体处理 | 必须手动注册 TTFpdfx theme init 只生成配置,字体文件需自行放入 public/fonts/ 并 Font.register() |
| Flexbox 限制 | @react-pdf/renderer 的 Yoga 布局引擎:不支持 grid、不支持 position: absolute 复杂定位、不支持 CSS 变量。复杂布局只能嵌套 View (flex) 实现 |
| 包体积 | |
| 工程规范 | |
| 维护活跃度 |
值不值得折腾?
值得,如果:
• 你在写 Node.js 后端 / Next.js API Route / Electron / Tauri 需要服务端生成 PDF(发票、报表、合同、证书) • 你想把 “PDF 组件” 像 UI 组件一样 复用、版本化、主题化,且不想被 npm 依赖锁死 • 你在用 Cursor/Claude Code/Continue 等 AI IDE,想让 Agent 直接生成可跑的 PDF 代码(MCP 加持) • 你需要 中文/多语言 PDF,且想把字体、配色、间距集中在一个 pdfx-theme.ts里管控
不值得,如果:
• 只是偶尔在前端 “把当前页面存成 PDF”, window.print()或html2pdf更省事• 你的 PDF 布局极度复杂(多栏流式、绝对定位重叠、CSS Grid),超出 Yoga/Flexbox 能力范围 • 团队完全不懂 React/TypeScript,学习成本高于收益 • 追求 极致性能/超大 PDF (100+ 页), @react-pdf/renderer单线程渲染会成为瓶颈,需考虑分页流式渲染或换pdf-lib/Puppeteer
个人判断:PDFx 是近半年见过 “工程味最正、抽象层级最清晰” 的 React PDF 方案。它没发明新 DSL、没搞运行时魔法、没把用户锁在 npm 包里,而是把 shadcn/ui 成熟的 “注册表 + CLI + 复制即拥有” 范式 完整复刻到 PDF 领域:apps/www 既是文档站又是注册表服务端,packages/shared 只做类型契约,packages/cli 只做代码生成,每层职责单一、边界清晰、可独立替换。
MCP 集成更是把 “文档即代码” 推向了 “文档即 Agent 上下文” —— 这是 AI 时代组件库该有的样子。
后续我会在自用分支加两个补丁:1) packages/shared/src/theme.ts 的 tw() 解析器补上 gap-x/y、space-x/y 等 Tailwind v3.4+ 新增 utility;2) packages/cli/src/commands/block.ts 加上 --data-json 参数,支持从 stdin 读 JSON 数据直接渲染 Block 到 stdout(方便 CI/CD 管道生成 PDF)。若作者合并相关 PR,会同步回主分支。
推荐阅读:
支付宝可直接付款,3分钟搞定 ChatGPT/Gemini/Claude订阅
我用自然语言写了个带后台的App。AI“零代码”终于脱离玩具时代了
手慢无:送出 5 个免手续费汇款名额(最高 US$600),AI 开发者自取。
👇👇👇点击识别下方账号名片关注「YouywayAI」获取更多学习编程、AI开发相关的趣工具和实用资源!
夜雨聆风