数据与模板如何用 DSL 解耦:一份在线简历编辑器的架构实践
·5 分钟
数据与模板如何用 DSL 解耦:一份在线简历编辑器的架构实践
做在线简历产品时,最先踩的坑通常不是「编辑器难写」,而是内容和版式绑死了。
用户改完一页「经典黑白」简历,想换成双栏浅色模板——结果姓名、工作经历、项目描述全要重填。设计师想加一套新样式,前端却发现模板里塞满了示例文案。AI 想帮忙润色职责描述,又怕一改就把排版逻辑一起改坏。
我们在 IResume(仓库 ai-resume-generator)里用一套很克制的 Template DSL,把「写什么」和「长什么样」拆开。本文按真实代码结构讲清楚这套分离是怎么落地的。
先说结论:三件事,不要混在一个 JSON 里
| 层 | 存什么 | 谁改它 | 换它会发生什么 |
|---|---|---|---|
| Document(数据) | 姓名、经历、技能、模块顺序 | 用户 / AI / 编辑器 | 文案变,版式不变 |
| Template DSL(模板) | 布局树、配色、preset | 模板作者 / 设计师 | 版式变,正文不变 |
| ViewModel(绑定结果) | 渲染用的中间态 | 运行时计算,不落库 | 预览和 PDF 共用 |
一句话:DSL 里绝不内嵌用户正文;正文永远来自 ResumeDocument。
这也是用户在产品里能「一键换模板、内容不丢」的根因——不是魔法,是存储模型就没把二者绑在一起。你可以在 模板中心 亲自切换几套版式验证这一点。
为什么主题色不够用
早期版本只有粗粒度 theme:主色、辅色、是否侧边栏。换模板时,视觉几乎只是换了个颜色,版式结构没真正换过来。
于是引入 Template DSL,目标很明确:
- 布局可描述:至少能表达「页眉怎么排 + 模块列表怎么铺」
- 数据可绑定:同一份 Document,喂给不同 DSL,得到不同纸面
- 双端同源:Web 预览、小程序预览、PDF 导出,走同一套绑定规则
落地后我们刻意把 DSL 保持得很小,没有做成「万能排版语言」。真正的视觉差异,主要由 tokens.preset(如 classic-cn、suya、qianxun)驱动纸面 CSS 与分栏策略。JSON 负责契约,preset 负责风格——这是工程上的务实取舍。
第一层:ResumeDocument,只描述「内容」
数据侧是固定 schema 的 ResumeDocument(schemaVersion: 2.0.0)。结构大致如下:
type ResumeDocument = {
schemaVersion: '2.0.0';
meta: { locale?: string; revision?: number };
basics: {
name: LocalizedString;
headline: LocalizedString;
email: string;
phone: string;
location: { city: string };
photo: string | null;
jobIntent?: { position?: string; salaryRange?: string; city?: string };
// ...
};
sections: Array<{
id: string;
type: 'work' | 'project' | 'education' | 'skills' | 'advantage' | 'custom';
title: LocalizedString;
visible: boolean;
content?: string; // 文本型模块
items?: WorkItem[] | ...; // 列表型模块
}>;
presentation: {
sectionOrder: string[];
hiddenSectionIds: string[];
theme?: Record<string, string>; // 可选覆盖,不是版式真源
};
};
几个关键约束:
- 模块有序且可隐藏:顺序看
presentation.sectionOrder,隐藏看hiddenSectionIds - 模板关联在行级:简历记录挂
template_id,不在正文 JSON 里硬编码「我是哪套版式」 - 换模板只换关联:
basics/sections保持原值
AI 能力也因此边界清晰:润色、按 JD 改写,只动 Document 字段;不会去改 DSL 树。内容和智能都落在数据层,版式层保持稳定。
第二层:Template DSL,只描述「版式」
DSL 本身是一份可校验的 JSON:
{
"dslVersion": "1.0.0",
"tokens": {
"preset": "classic-cn",
"layout": "single",
"palette": "neutral",
"primary": "#111827",
"accent": "#374151"
},
"root": {
"type": "Page",
"children": [
{ "type": "Header", "props": { "variant": "classic-cn", "showPhoto": true } },
{ "type": "SectionList" }
]
}
}
当前节点白名单只有三类:Page、Header、SectionList。
看起来「简」,但这正是设计点:
SectionList不枚举用户有哪些 section——它只声明「按 Document 的可见模块顺序渲染」- Header 的
props是布局指令(变体、是否展示照片),不是字段路径表达式 - 双栏模板往往只改
tokens.preset/layout,树形状几乎不变;左右栏怎么拆,在绑定阶段按 preset 分流
发布前走白名单校验:未知节点类型、非法 layout / preset,直接拒绝落库。模板库里的遗留 theme 行,会通过 dslFromTheme 升成 DSL,缺 tokens.preset 时再用 slug 补全——兼容层存在,但不影响「Document 与 DSL 分离」这条主线。
第三层:buildViewModel,把数据和模板粘起来
渲染前不直接把 Document 丢给 UI,而是先做一次统一绑定:
Document + Template DSL
│
▼
resolveTemplateDsl() // 补全 preset / 兼容旧 theme
│
▼
buildViewModel(document, helpers, { preset })
│
▼
ResumeViewModel
- header(联系方式、求职意向、照片位…)
- sections(已按顺序、已过滤隐藏)
- leftSections / rightSections / bannerSection(preset 相关)
buildViewModel 做的事情包括:
normalizeDocument:兜底缺字段、统一 localegetOrderedSections:按 presentation 规则排出可见模块- 把 work / project / education 的 duties、achievements 整理成渲染块
- 按 preset 决定左右栏与 banner(例如某些双栏把技能或教育挪到侧栏)
预览和 PDF 吃的是同一份 ViewModel。 这也是「所见即所得」能站得住的关键:两边不是两套各自拼装的业务逻辑。
双编译:React 预览 + Handlebars PDF
绑定之后,分两条编译路径:
┌── ResumeView(React / 小程序)→ 交互预览
ViewModel + DSL ────┤
└── compileDslToHandlebars → HTML → PDF
- Web / Mini:按 DSL 的 Header + SectionList,用各端原语渲染;纸面样式按 preset 注入 CSS
- 导出:DSL 编译成 Handlebars 模板(可缓存
compiled_hbs),再套 ViewModel 出 HTML,最后走 PDF 管线
权威源是 Handlebars HTML(导出结果要对它负责);React 预览做高保真近似,并用结构对齐测试保证两边不跑偏。
共享包 @ai-resume/resume-template-dsl 负责 validate、bind、theme→DSL、HBS compile;各端只保留自己的 UI 原语。Document 刻意没有塞进 shared——通过 helpers 注入 normalizeDocument / getOrderedSections,避免多端文档类型强耦合。
这套分离在产品上换来什么
1. 换模板不丢内容
用户改的是 Document;换模板只改 template_id(以及允许的 presentation 覆盖)。这是产品卖点,也是存储契约。
2. AI 与版式互不踩脚
大模型适合改「职责描述怎么写更量化」,不适合改「这一行该不该 12px」。数据层给 AI,模板层给设计师。
3. 多端一致
同一份 DSL + 同一套 bind,Web 编辑器、小程序预览、服务端导出语义对齐。修一个 preset 的分栏逻辑,三端一起受益。
4. 新模板有明确增量路径
加一套风格,通常是:新 preset 常量 → dslXxx() 工厂 → 纸面 CSS → HBS 分支 → 样例夹具与 parity 测试。DSL 树不用每次重新发明。
刻意没做成的事(也值得说)
早期设计稿里出现过更「完整」的 DSL:Stack / Grid / SectionSlot、通用路径绑定(如 basics.name)。落地时我们收敛了:
- 没有通用 path 表达式引擎——绑定是命令式的
buildViewModel - 节点树几乎固定骨架——真正差异在 preset 与纸面 CSS
- React 与 HBS 各维护一套 markup——靠 ViewModel 同源 + 结构 parity,而不是单一 emit
这不是偷懒,是成本控制。简历版式的变量空间有限,用「小型 AST + preset」比「通用排版 DSL」更稳,也更容易保证 PDF 可读文本(对 ATS 友好)。
如果你正在设计类似「内容可复用、皮肤可替换」的编辑器,可以先问自己:用户真正要换的是哪些轴?把轴拆开,比先发明一套大而全的 DSL 更重要。
最小心智模型
可以把整条链路压成四句:
- Document = 用户写的简历内容(唯一正文真源)
- Template DSL = 版式契约(tokens + 小型节点树)
- ViewModel = Document 按 preset 规范后的渲染态
- React / Handlebars = 同一 ViewModel 的两种编译目标
内容、模板、渲染器各司其职。后面无论加 AI 润色、加岗位范例,还是加新视觉 preset,都沿着这条缝扩展,而不是往一个巨大的「简历 JSON」里继续堆字段。
想从用户视角感受这套架构的结果:打开 IResume 模板中心,选一份岗位示例,再连续切换几套版式——同一份内容会换皮,不会被清空。那就是「数据 + 模板 DSL 分离」在产品表面上的最终形态。