电子保函模板编辑器实战二:HTML→FTL 编译转换的设计与实现

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. 总结

这个前端模板编译引擎的设计思路其实很清晰:

  1. 两阶段管线:编辑器负责把节点转成标记,编译器负责把标记转成模板,职责分离。
  2. 词法→语法→代码:标准的编译器流程,先提取变量,再分组,最后生成代码。
  3. 结构适配:不仅转换变量,还会调整 HTML 结构以适应 FTL 渲染环境。
  4. 静态校验:发布前检查,尽早发现问题。

这套方案解决了一个实际问题:业务人员在可视化编辑器里操作,但系统需要输出结构化的模板。前端编译层作为中间桥梁,既保证了编辑体验,又保证了模板的正确性和可维护性。

相关推荐
英勇无比的消炎药1 小时前
表单筛选利器:TinyVue BaseSelect 基础选择器实战
vue.js
ricardo19731 小时前
SSR / SSG 性能对比:Next.js 三种渲染模式实测数据
前端·面试
Kel1 小时前
Node.js 本质:从全脉络视角理解运行时原理
javascript·设计模式·node.js
ikoala2 小时前
Codex 小白入门:从安装到插件、MCP、Skills,一篇把配置讲明白
前端·javascript·后端
先吃饱再说2 小时前
从零理解 Vue 3:组件化、数据绑定与响应式完全指南
vue.js·前端框架
雪隐2 小时前
用Flutter做背单词APP-04一个人,2周,从零撸出一个背单词App
前端·人工智能·后端
OpenTiny社区2 小时前
Skill 设计:封装、评测、运营,一次讲透
前端·github
大白要努力!2 小时前
uni-app 全面入门速查手册
前端·uni-app
索西引擎2 小时前
【React】虚拟 DOM:原理、机制与核心价值分析
前端·react.js·前端框架