首页 > 网页制作 >电子保函模板编辑器实战二:HTML转FTL编译转换设计与实现

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

来源:互联网 2026-07-15 19:39:00

1. 为什么需要前端做模板编译 这个项目里其实藏着一个看似矛盾的点:用户在前端编辑器里看到的是活生生的富文本,可最终要交给后端的,却是 FreeMarker 模板。对,就是那种带 ${} 和 的模板。 具体来说,用户在页面上瞧见的是“投保人姓名”这样带背景色的蓝色标签,编辑器里存的是含 data-v

1. 为什么需要前端做模板编译

这个项目里其实藏着一个看似矛盾的点:用户在前端编辑器里看到的是活生生的富文本,可最终要交给后端的,却是 FreeMarker 模板。对,就是那种带 ${}<#list> 的模板。

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

长期稳定更新的攒劲资源: >>>点此立即查看<<<

具体来说,用户在页面上瞧见的是“投保人姓名”这样带背景色的蓝色标签,编辑器里存的是含 data-variable 属性的 HTML,但最终生成出来的却是 ${info.insuredName!} 这种 FTL 表达式。中间这个转换,一开始打算丢给后端处理。但后来发现几个硬伤:

  • 编辑器里那些变量样式、边框容器、页眉图片之类的结构信息,传到后端再解析,很容易丢细节。好比把一幅画撕碎了再拼,总会有对不齐的地方。
  • 业务人员操作完编辑器,往往想立刻看到生成的模板效果。每次都要走后端,响应太慢,体验很差。
  • 模板的校验规则(比如动态列表标记是否成对)放在前端,能更早发现问题,减少无效提交——与其等后端报错,不如在编辑阶段就拦住。

所以最后决定,在前端搞一个完整的模板编译层,直接在浏览器里把 HTML 转成 FTL。

2. 两阶段转换管线架构

整个转换过程不是一步到位的,而是分成了两个阶段,职责清晰,互不干扰。

 复制代码阶段一:原始文档 → 带 {{变量路径}} 标记的 HTML
阶段二:带标记的 HTML → 完整 FTL 模板

阶段一是在编辑器内部完成的。当用户点击“生成”按钮时,getGenerateContent 会调用 convertVariablesForGenerate,把编辑器里的变量节点转换成 {{prop}}{{prop||styleStr}} 这样的占位符。这一步说白了就是把可视化的节点变成字符串标记,方便后续处理。好比把积木块拆成零件,贴上标签。

阶段二就是 ftlGenerator.ts 做的事情。它拿到带标记的 HTML,经过词法分析、语法分组、代码生成、结构适配等步骤,最终输出完整的 FTL 模板。

两阶段的好处很明显:阶段一只负责“把编辑器内容转成标记”,阶段二只负责“把标记编译成模板”。它们通过标准的占位符格式通信,编辑器实现和模板生成逻辑彻底解耦。

3. 词法分析:变量标记的提取与分类

编译的第一步是词法分析,也就是从 HTML 中找出所有变量标记,并给它们分门别类。

3.1 变量提取

代码里用了一个正则表达式来提取所有 {{prop}}{{prop||styleStr}} 格式的标记:

 复制代码const regex = /{{([^}|]+)(:||([^}]*))}}/g

这个正则的意思是:匹配 {{ 开头、}} 结尾的字符串,第一组捕获变量路径,第二组捕获可选的样式字符串。

3.2 构建映射表

为了给变量分类,需要先构建两个映射表。

buildArrayMap 会遍历变量配置树,找出所有 Array 类型的子字段,建立字段完整路径到数组路径的映射:

 复制代码export const buildArrayMap = (options: VariableOption[]): Map<string, { arrayPath: string; fieldName: string }> => {
    const map = new Map<string, { arrayPath: string; fieldName: string }>()
    const tra verse = (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) {
                tra verse(opt.children)
            }
        }
    }
    tra verse(options)
    return map
}

比如字段 elecPolicyVo.proposerList.insuredName 会被映射到 { arrayPath: 'elecPolicyVo.proposerList', fieldName: 'insuredName' }

buildDynamicMap 则提取所有以 dynamic. 开头的变量,这些是控制变量,比如分隔符、序号等:

 复制代码export const buildDynamicMap = (options: VariableOption[]): Map<string, string> => {
    const map = new Map<string, string>()
    const tra verse = (opts: VariableOption[]) => {
        for (const opt of opts) {
            if (opt.value && opt.value.startsWith('dynamic.')) {
                map.set(opt.value, opt.label)
            }
            if (opt.children) {
                tra verse(opt.children)
            }
        }
    }
    tra verse(options)
    return map
}

3.3 变量分类

有了映射表之后,每个变量标记就可以被分类了。代码里定义了三种 MatchType

 复制代码type MatchType = 'array' | 'dynamic' | 'normal'
  • array:数组类型变量,需要生成 <#list> 循环
  • dynamic:动态控制变量,如分隔符、序号、列表开始/结束标记
  • normal:普通变量,直接转成 ${prop!}

分类逻辑很简单:先查 arrayMap,如果命中就是 array 类型;再查 dynamicMap,如果命中就是 dynamic 类型;剩下的都是 normal 类型。

4. 语法分组:从线性扫描到结构化分组

词法分析得到的是一个线性的变量列表,但模板里的数组变量是需要分组的——同一个数组的多个字段应该被包裹在同一个 <#list> 循环里。

这一步就是语法分组,把线性的变量列表转换成结构化的分组。

4.1 两种分组类型

代码里定义了两种分组:

 复制代码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(列表结束+分隔符)来标记一个数组循环区域。

代码里用了一个类似栈的逻辑来匹配:

 复制代码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 的相邻数组变量会被自动合并到一个循环组里。

 复制代码} 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,会生成三层结构:

 复制代码const listBlock = `<#if ${group.arrayPath} && ${group.arrayPath}?size gt 0><#list ${group.arrayPath} as info>${listBody}${separatorPart}`

第一层是 #if 判断,确保数组存在且不为空;第二层是 #list 循环;第三层是循环体内容。

循环体内部还要处理每个变量:

  • 数组变量转成 ${info.fieldName!}
  • 序号变量 dynamic.N 转成中文序号数组表达式
  • 动态变量转成实际字符
  • 分隔符用 <#if info_has_next> 条件包裹

5.2 序号变量的处理

dynamic.N 是一个特殊变量,用于生成中文序号(一、二、三...)。代码里直接用了一个 FreeMarker 数组表达式:

 复制代码'${["一","二","三","四","五","六","七","八","九","十","十一","十二","十三","十四","十五","十六","十七","十八","十九","二十"][info_index]!((info_index+1)string)}'

这个表达式的意思是:先用 info_index 作为索引从数组中取中文数字,如果索引超出范围(比如第 21 项),就回退到用数字序号。

5.3 样式透传

编辑器里用户给变量设置的字号、颜色等样式,也需要透传到 FTL 中。代码里用 wrapWithStyle 函数来处理:

 复制代码const wrapWithStyle = (ftlExpr: string, styleStr: string): string => {
    if (!styleStr) return ftlExpr
    return `${styleStr}">${ftlExpr}`
}

比如 ${info.insuredName!} 会被包装成 ${info.insuredName!},这样变量值渲染出来后还保持原来的样式。

5.4 单变量的处理

对于 SingleGroup,处理就简单多了:

  • 普通变量转成 ${prop!}
  • 动态变量转成实际字符
  • dynamic.N 在非循环上下文中直接删除(因为它只在循环里有用)

6. HTML 结构适配

除了变量转换,编译过程中还要处理 HTML 结构,让它更适合 FTL 渲染环境。

6.1 DOMParser 解析

代码先用 DOMParser 把 HTML 解析成 DOM 树,然后对特定元素进行转换:

 复制代码const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')

这样比纯正则替换更可靠,能正确处理嵌套结构。

6.2 边框容器转 table

编辑器里的边框容器用的是 div + CSS border,但 FTL 渲染环境对 div border 的支持不稳定。代码会把它转换成 table 结构:

 复制代码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 = `
${innerContent}
`
} else { tableHtml = `
${innerContent}
`
} box.replaceWith(doc.createRange().createContextualFragment(tableHtml)) })

6.3 页眉容器转 table 三列布局

页眉图片容器在编辑器里用的是 flex + space-between,但 FTL 渲染环境对 flex 支持不完整,删除一侧图片后会导致另一侧位移。代码会把它转换成 table 三列布局:

 复制代码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
         `电子保函模板编辑器实战二:HTML转FTL编译转换设计与实现${leftImg.getAttribute('src')}" style="width:220px;height:auto;" />`
        : ''
    const rightHtml = rightImg
         `电子保函模板编辑器实战二:HTML转FTL编译转换设计与实现${rightImg.getAttribute('src')}" style="width:70px;height:auto;" />`
        : ''
    const tableHtml = `
${leftHtml}${rightHtml}
`
container.replaceWith(doc.createRange().createContextualFragment(tableHtml)) })

三列固定宽度,中间列自适应,这样无论哪侧图片被删除,另一侧的位置都不会变。

6.4 分页标记的 page-break-before 注入

附页标记会被转换成带 page-break-before:always 的段落,确保 FTL 渲染时正确分页:

 复制代码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 骨架和基础样式:

 复制代码const wrapFtlTemplate = (content: string): string => {
    return `
<#--xxxxxx-->

    
    
    

n${content}nn`
}

7. 校验层:发布前的静态检查

编译之前,还有一个校验步骤 validateFtl,用来检查模板中的动态列表标记是否成对:

 复制代码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. 静态校验:发布前检查,尽早发现问题。

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

侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述

热游推荐

更多
湘ICP备14008430号-1 湘公网安备 43070302000280号
All Rights Reserved
本站为非盈利网站,不接受任何广告。本站所有软件,都由网友
上传,如有侵犯你的版权,请发邮件给xiayx666@163.com
抵制不良色情、反动、暴力游戏。注意自我保护,谨防受骗上当。
适度游戏益脑,沉迷游戏伤身。合理安排时间,享受健康生活。