1. 为什么需要前端做模板编译
这个项目里有一个看似矛盾的地方:编辑器给用户展示的是可视化的富文本,但最终要交给后端的却是 FreeMarker 模板。
也就是说,用户在页面上看到的是"投保人姓名"这样的蓝色标签,保存下来的是带 data-variable 属性的 HTML,最后生成的却是 ${info.insuredName!} 这样的 FTL 表达式。
中间这个转换过程,一开始是打算让后端来做的。但后来发现几个问题:
- 编辑器里的变量样式、边框容器、页眉图片这些结构信息,传到后端再解析很容易丢细节。
- 业务人员在编辑器里操作完之后,需要马上看到生成的模板效果,如果每次都走后端,响应太慢。
- 模板的校验规则(比如动态列表标记是否成对)放在前端能更早发现问题,减少无效提交。
所以最终决定在前端做一个完整的模板编译层,把 HTML 转换成 FTL。
2. 两阶段转换管线架构
整个转换过程不是一步到位的,而是分成了两个阶段:
text
阶段一:原始文档 → 带 {{变量路径}} 标记的 HTML
阶段二:带标记的 HTML → 完整 FTL 模板
阶段一是在编辑器内部完成的。当用户点击"生成"按钮时,getGenerateContent 会调用 convertVariablesForGenerate,把编辑器里的变量节点转换成 {{prop}} 或 {{prop||styleStr}} 这样的占位符。这一步主要是把可视化的节点变成字符串标记,方便后续处理。
阶段二就是 ftlGenerator.ts 做的事情。它拿到带标记的 HTML,经过词法分析、语法分组、代码生成、结构适配等步骤,最终输出完整的 FTL 模板。
这个两阶段的好处是职责清晰:阶段一只负责"把编辑器内容转成标记",阶段二负责"把标记编译成模板"。它们之间通过标准的占位符格式通信,解耦了编辑器实现和模板生成逻辑。
3. 词法分析:变量标记的提取与分类
编译的第一步是词法分析,也就是从 HTML 中找出所有变量标记,并给它们分类。
3.1 变量提取
代码里用了一个正则表达式来提取所有 {{prop}} 和 {{prop||styleStr}} 格式的标记:
ts
const regex = /\{\{([^}|]+)(?:\|\|([^}]*))?\}\}/g
这个正则的意思是:匹配 {{ 开头、}} 结尾的字符串,第一组捕获变量路径,第二组捕获可选的样式字符串。
3.2 构建映射表
为了给变量分类,需要先构建两个映射表:
buildArrayMap 会遍历变量配置树,找出所有 Array 类型的子字段,建立字段完整路径到数组路径的映射:
ts
export const buildArrayMap = (options: VariableOption[]): Map<string, { arrayPath: string; fieldName: string }> => {
const map = new Map<string, { arrayPath: string; fieldName: string }>()
const traverse = (opts: VariableOption[]) => {
for (const opt of opts) {
if (opt.type === 'Array' && opt.children) {
for (const child of opt.children) {
const fieldName = child.value.split('.').pop() || child.value
map.set(child.value, {
arrayPath: opt.value,
fieldName
})
}
}
if (opt.children) {
traverse(opt.children)
}
}
}
traverse(options)
return map
}
比如字段 elecPolicyVo.proposerList.insuredName 会被映射到 { arrayPath: 'elecPolicyVo.proposerList', fieldName: 'insuredName' }。
buildDynamicMap 则提取所有以 dynamic. 开头的变量,这些是控制变量,比如分隔符、序号等:
ts
export const buildDynamicMap = (options: VariableOption[]): Map<string, string> => {
const map = new Map<string, string>()
const traverse = (opts: VariableOption[]) => {
for (const opt of opts) {
if (opt.value && opt.value.startsWith('dynamic.')) {
map.set(opt.value, opt.label)
}
if (opt.children) {
traverse(opt.children)
}
}
}
traverse(options)
return map
}
3.3 变量分类
有了映射表之后,每个变量标记就可以被分类了。代码里定义了三种 MatchType:
ts
type MatchType = 'array' | 'dynamic' | 'normal'
array:数组类型变量,需要生成<#list>循环dynamic:动态控制变量,如分隔符、序号、列表开始/结束标记normal:普通变量,直接转成${prop!}
分类逻辑很简单:先查 arrayMap,如果命中就是 array 类型;再查 dynamicMap,如果命中就是 dynamic 类型;剩下的都是 normal 类型。
4. 语法分组:从线性扫描到结构化分组
词法分析得到的是一个线性的变量列表,但模板里的数组变量是需要分组的------同一个数组的多个字段应该被包裹在同一个 <#list> 循环里。
这一步就是语法分组,把线性的变量列表转换成结构化的分组。
4.1 两种分组类型
代码里定义了两种分组:
ts
interface ArrayGroup {
groupType: 'array'
arrayPath: string
allItems: MatchInfo[]
separatorIdx: number
separatorChar?: string
separatorStyle?: string
startIndex: number
endIndex: number
}
interface SingleGroup {
groupType: 'single'
member: MatchInfo
startIndex: number
endIndex: number
}
ArrayGroup 包含一组属于同一个数组的变量,以及分隔符信息;SingleGroup 就是单个独立变量。
4.2 动态列表的栈匹配
最复杂的是动态列表的处理。业务人员可以在编辑器里插入 dynamic.ListS(列表开始)和 dynamic.ListE.xxx(列表结束+分隔符)来标记一个数组循环区域。
代码里用了一个类似栈的逻辑来匹配:
ts
if (info.prop === 'dynamic.ListS') {
const allItems: MatchInfo[] = [info]
let j = i + 1
while (j < matchInfos.length) {
const nextInfo = matchInfos[j]!
allItems.push(nextInfo)
if (nextInfo.prop.startsWith('dynamic.ListE.')) {
j++
break
}
j++
}
// ... 构建 ArrayGroup
}
遇到 dynamic.ListS 就开始收集,直到遇到 dynamic.ListE.xxx 为止。同时还会从收集到的变量中找出数组路径,作为这个循环组的 arrayPath。
4.3 连续数组变量的自动归组
除了显式的 ListS/ListE 标记,代码还支持自动归组:相同 arrayPath 的相邻数组变量会被自动合并到一个循环组里。
ts
} else if (info.matchType === 'array') {
const allItems: MatchInfo[] = [info]
let j = i + 1
while (j < matchInfos.length) {
const nextInfo = matchInfos[j]!
if ((nextInfo.matchType === 'array' && nextInfo.arrayPath === info.arrayPath)
|| (nextInfo.matchType === 'dynamic'
&& nextInfo.prop !== 'dynamic.ListS'
&& !nextInfo.prop.startsWith('dynamic.ListE.'))) {
allItems.push(nextInfo)
j++
} else {
break
}
}
// ... 构建 ArrayGroup
}
这里的逻辑是:只要下一个变量是同数组的数组变量,或者是普通动态变量(不是 ListS/ListE),就继续归组。这样用户连续插入多个同数组的字段时,不需要手动加 ListS/ListE 标记。
5. 代码生成:FTL 模板的组装
分组完成后,就是最核心的代码生成阶段。代码会遍历所有分组,从后往前替换,把占位符变成真正的 FTL 表达式。
5.1 数组组的生成
对于 ArrayGroup,会生成三层结构:
ts
const listBlock = `<#if ${group.arrayPath}?? && ${group.arrayPath}?size gt 0><#list ${group.arrayPath} as info>${listBody}${separatorPart}</#list></#if>`
第一层是 #if 判断,确保数组存在且不为空;第二层是 #list 循环;第三层是循环体内容。
循环体内部还要处理每个变量:
- 数组变量转成
${info.fieldName!} - 序号变量
dynamic.N转成中文序号数组表达式 - 动态变量转成实际字符
- 分隔符用
<#if info_has_next>条件包裹
5.2 序号变量的处理
dynamic.N 是一个特殊变量,用于生成中文序号(一、二、三...)。代码里直接用了一个 FreeMarker 数组表达式:
ts
'${["一","二","三","四","五","六","七","八","九","十","十一","十二","十三","十四","十五","十六","十七","十八","十九","二十"][info_index]!((info_index+1)?string)}'
这个表达式的意思是:先用 info_index 作为索引从数组中取中文数字,如果索引超出范围(比如第 21 项),就回退到用数字序号。
5.3 样式透传
编辑器里用户给变量设置的字号、颜色等样式,也需要透传到 FTL 中。代码里用 wrapWithStyle 函数来处理:
ts
const wrapWithStyle = (ftlExpr: string, styleStr?: string): string => {
if (!styleStr) return ftlExpr
return `<span style="${styleStr}">${ftlExpr}</span>`
}
比如 ${info.insuredName!} 会被包装成 <span style="font-size:14pt;color:#000">${info.insuredName!}</span>,这样变量值渲染出来后还保持原来的样式。
5.4 单变量的处理
对于 SingleGroup,处理就简单多了:
- 普通变量转成
${prop!} - 动态变量转成实际字符
dynamic.N在非循环上下文中直接删除(因为它只在循环里有用)
6. HTML 结构适配
除了变量转换,编译过程中还要处理 HTML 结构,让它更适合 FTL 渲染环境。
6.1 DOMParser 解析
代码先用 DOMParser 把 HTML 解析成 DOM 树,然后对特定元素进行转换:
ts
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
这样比纯正则替换更可靠,能正确处理嵌套结构。
6.2 边框容器转 table
编辑器里的边框容器用的是 div + CSS border,但 FTL 渲染环境对 div border 的支持不稳定。代码会把它转换成 table 结构:
ts
const borderBoxes = doc.querySelectorAll('[data-border-box]')
borderBoxes.forEach((box) => {
const borderType = box.getAttribute('data-border-box') || 'all'
const innerContent = box.innerHTML || ''
let tableHtml = ''
if (borderType === 'topBottom') {
tableHtml = `<table style="width:100%;margin:8px 0;border-collapse:collapse;"><tr><td style="border-top:1px solid #000;border-bottom:1px solid #000;border-left:none;border-right:none;padding:16px;">${innerContent}</td></tr></table>`
} else {
tableHtml = `<table style="width:100%;margin:8px 0;border-collapse:collapse;"><tr><td style="border:1px solid #000;padding:16px;">${innerContent}</td></tr></table>`
}
box.replaceWith(doc.createRange().createContextualFragment(tableHtml))
})
6.3 页眉容器转 table 三列布局
页眉图片容器在编辑器里用的是 flex + space-between,但 FTL 渲染环境对 flex 支持不完整,删除一侧图片后会导致另一侧位移。代码会把它转换成 table 三列布局:
ts
const headerContainers = doc.querySelectorAll('[data-header-image-container]')
headerContainers.forEach((container) => {
const leftImg = container.querySelector('img[data-position="left"]')
const rightImg = container.querySelector('img[data-position="right"]')
const leftHtml = leftImg
? `<img data-position="left" src="${leftImg.getAttribute('src')}" style="width:220px;height:auto;" />`
: '<span style="display:inline-block;width:220px;"></span>'
const rightHtml = rightImg
? `<img data-position="right" src="${rightImg.getAttribute('src')}" style="width:70px;height:auto;" />`
: '<span style="display:inline-block;width:70px;"></span>'
const tableHtml = `<table data-header-image-container="true" style="width:100%;border-collapse:collapse;"><tr><td style="width:220px;vertical-align:middle;padding:0;">${leftHtml}</td><td style="vertical-align:middle;padding:0;"></td><td style="width:70px;vertical-align:middle;padding:0;text-align:right;">${rightHtml}</td></tr></table>`
container.replaceWith(doc.createRange().createContextualFragment(tableHtml))
})
三列固定宽度,中间列自适应,这样无论哪侧图片被删除,另一侧的位置都不会变。
6.4 分页标记的 page-break-before 注入
附页标记会被转换成带 page-break-before:always 的段落,确保 FTL 渲染时正确分页:
ts
const markers = doc.querySelectorAll('p[data-follower-page-marker="true"]')
markers.forEach((marker) => {
const p = document.createElement('p')
p.setAttribute('style', 'page-break-before:always')
marker.replaceWith(p)
})
6.5 包装完整模板骨架
最后,wrapFtlTemplate 会给内容加上完整的 HTML 骨架和基础样式:
ts
const wrapFtlTemplate = (content: string): string => {
return `<html>
<#--xxxxxx-->
<head>
<META http-equiv="Content-Type" content="text/html; charset=utf-8">
<style type="text/css">
body {
font-family: SimHei;
width: 100%;
}
table {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
border-spacing: 0;
min-height: 50px;
}
tr, td {
border: 0px solid black;
}
</style>
<meta content="ygb" name="author">
</head>
<body>\n${content}\n</body>\n</html>`
}
7. 校验层:发布前的静态检查
编译之前,还有一个校验步骤 validateFtl,用来检查模板中的动态列表标记是否成对:
ts
export const validateFtl = (html: string): string[] => {
const errors: string[] = []
const regex = /\{\{([^}|]+)(?:\|\|[^}]*)?\}\}/g
const stack: { prop: string; index: number }[] = []
let match
while ((match = regex.exec(html)) !== null) {
const prop = match[1]!
if (prop === 'dynamic.ListS') {
stack.push({ prop, index: match.index })
} else if (prop.startsWith('dynamic.ListE.')) {
if (stack.length === 0) {
errors.push(`存在未匹配的动态列表结束标记 {{${prop}}},缺少对应的 {{dynamic.ListS}}`)
} else {
stack.pop()
}
}
}
if (stack.length > 0) {
errors.push(`存在 ${stack.length} 个未闭合的动态列表开始标记 {{dynamic.ListS}},缺少对应的 {{dynamic.ListE.xxx}}`)
}
// TODO: 后续扩展其他校验规则
return errors
}
这个校验用栈来匹配 ListS 和 ListE,确保它们成对出现。如果校验失败,业务人员在发布前就能看到错误提示,避免生成无效的 FTL 模板。
代码里中还可以扩展其他校验规则,比如变量路径是否存在、数组变量是否在循环内部使用等。
8. 总结
这个前端模板编译引擎的设计思路其实很清晰:
- 两阶段管线:编辑器负责把节点转成标记,编译器负责把标记转成模板,职责分离。
- 词法→语法→代码:标准的编译器流程,先提取变量,再分组,最后生成代码。
- 结构适配:不仅转换变量,还会调整 HTML 结构以适应 FTL 渲染环境。
- 静态校验:发布前检查,尽早发现问题。
这套方案解决了一个实际问题:业务人员在可视化编辑器里操作,但系统需要输出结构化的模板。前端编译层作为中间桥梁,既保证了编辑体验,又保证了模板的正确性和可维护性。