返回博客

数据与模板如何用 DSL 解耦:一份在线简历编辑器的架构实践

·5 分钟

数据与模板如何用 DSL 解耦:一份在线简历编辑器的架构实践

做在线简历产品时,最先踩的坑通常不是「编辑器难写」,而是内容和版式绑死了。

用户改完一页「经典黑白」简历,想换成双栏浅色模板——结果姓名、工作经历、项目描述全要重填。设计师想加一套新样式,前端却发现模板里塞满了示例文案。AI 想帮忙润色职责描述,又怕一改就把排版逻辑一起改坏。

我们在 IResume(仓库 ai-resume-generator)里用一套很克制的 Template DSL,把「写什么」和「长什么样」拆开。本文按真实代码结构讲清楚这套分离是怎么落地的。

先说结论:三件事,不要混在一个 JSON 里

层 存什么 谁改它 换它会发生什么
Document(数据) 姓名、经历、技能、模块顺序 用户 / AI / 编辑器 文案变,版式不变
Template DSL(模板) 布局树、配色、preset 模板作者 / 设计师 版式变,正文不变
ViewModel(绑定结果) 渲染用的中间态 运行时计算,不落库 预览和 PDF 共用

一句话:DSL 里绝不内嵌用户正文;正文永远来自 ResumeDocument。

这也是用户在产品里能「一键换模板、内容不丢」的根因——不是魔法,是存储模型就没把二者绑在一起。你可以在 模板中心 亲自切换几套版式验证这一点。

为什么主题色不够用

早期版本只有粗粒度 theme:主色、辅色、是否侧边栏。换模板时,视觉几乎只是换了个颜色,版式结构没真正换过来。

于是引入 Template DSL,目标很明确:

  1. 布局可描述:至少能表达「页眉怎么排 + 模块列表怎么铺」
  2. 数据可绑定:同一份 Document,喂给不同 DSL,得到不同纸面
  3. 双端同源: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 做的事情包括:

  1. normalizeDocument:兜底缺字段、统一 locale
  2. getOrderedSections:按 presentation 规则排出可见模块
  3. 把 work / project / education 的 duties、achievements 整理成渲染块
  4. 按 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 更重要。

最小心智模型

可以把整条链路压成四句:

  1. Document = 用户写的简历内容(唯一正文真源)
  2. Template DSL = 版式契约(tokens + 小型节点树)
  3. ViewModel = Document 按 preset 规范后的渲染态
  4. React / Handlebars = 同一 ViewModel 的两种编译目标

内容、模板、渲染器各司其职。后面无论加 AI 润色、加岗位范例,还是加新视觉 preset,都沿着这条缝扩展,而不是往一个巨大的「简历 JSON」里继续堆字段。


想从用户视角感受这套架构的结果:打开 IResume 模板中心,选一份岗位示例,再连续切换几套版式——同一份内容会换皮,不会被清空。那就是「数据 + 模板 DSL 分离」在产品表面上的最终形态。