电子保函模板编辑器实战一:基于 WangEditor 的富文本编辑器实践定制

作者:袖梨 2026-07-15

我用 WangEditor 做了个模板编辑器,说说踩过的坑

1. 为什么要做这个编辑器

这个项目的背景是这样的:保险保单/保函业务需要适配全国各地的差异化要求,模板数量多、更新频繁。之前一直是业务提需求,开发人员在代码里硬编码维护,一来一回沟通成本很高,理解上还容易有偏差,而且每次改模板都得等发版才能生效,响应速度跟不上业务节奏。

电子保函模板编辑器实战一:基于 WangEditor 的富文本编辑器定制实践

所以这次要做的,是一个让业务人员能自主配置的模板编辑器。核心目标很明确:业务人员自己可视化编辑模板、自己测试、自己管理版本、自己发布,不用再依赖开发人员介入。

这个编辑器的具体目标是让业务人员维护“报文模板”。模板里不只有普通文字,还会有各种变量,比如投保人姓名、保单金额这些。用户编辑完之后,系统得把内容转换成 FreeMarker 模板,也就是 FTL 报文,交给后端去渲染。

所以我要解决的问题其实是这些:

  • 基础排版得有:字体、字号、颜色、加粗、对齐、缩进这些。
  • 变量得能插入,而且看起来不能像一串 {{xxx}},得像个正常的标签。
  • 变量插入完不是终点,最后生成 FTL 的时候还要能变回去。
  • 模板有附页,得能标记分页位置。
  • 页眉有两张图片,左右各一张,还能单独删掉。
  • 有些内容需要边框,编辑时看着是边框,生成时得转成 FTL 能识别的结构。

这么一看,问题就不是“找个编辑器让用户打字”了,而是要找一个能深度扩展、能控制文档结构、能打通编辑态和生成态的编辑器。

2. 为什么选 WangEditor

选 WangEditor 不是因为它有多完美,而是它刚好满足了几个关键条件:

  • 基础排版功能现成的,不用从零写。
  • 有 Vue 3 组件,接入起来方便。
  • 底层是 Slate.js,文档是节点树结构,不是一团不可控的 HTML。

特别是第三点,Slate 的节点树结构让我能把“变量标签”“附页标记”这些业务概念变成编辑器真正认识的节点。

不过选了 WangEditor 不代表后面就顺利了。真正花时间的地方,都在它的扩展边界上:哪些节点要做成 inline,哪些要做成 void,哪些内容要序列化,哪些只在编辑态展示。

3. 这个编辑器里扩展了哪些业务元素

根据源码里的实现,编辑器主要扩展了 5 类自定义元素:

功能Slate type节点特点作用
变量标签variableinline void编辑态展示蓝色标签,生成态转换为变量占位符
附页标记followerMarkerblock void表示附页开始位置,生成时带分页样式
边框容器borderBoxblock 容器插入上下边框或全边框区域
页眉容器headerContainerblock 容器页眉布局容器
页眉图片容器headerImageContainerblock void放置左右两张页眉图片,并支持单独删除

这几个节点的性质不一样,所以处理方式也不一样。

比如变量标签要和普通文字排在同一行,所以它是 inline;但它不能让用户把光标放进标签内部编辑,所以它又是 void。附页标记和页眉图片容器是独立块级内容,所以它们不是 inline。页眉图片容器还要避免内部图片被 Slate 当成普通子节点处理,因此也被标记成 void。

在 handleCreated 里,编辑器会重写 editor.isVoid 和 editor.isInline:

 复制代码editor.isVoid = (element) => {
    const type = (element as { type?: string }).type
    if (type === 'variable' || type === 'followerMarker' || type === 'headerImageContainer') return true
    return originalIsVoid(element)
}editor.isInline = (element) => {
    const type = (element as { type?: string }).type
    if (type === 'variable') return true
    if (type === 'followerMarker' || type === 'borderBox' || type === 'headerContainer' || type === 'headerImageContainer') return false
    return originalIsInline(element)
}

这一步很重要,因为它决定了这些业务元素在 Slate 文档树里的行为。

4. 自定义元素要打通三条链路

在 WangEditor 里,一个自定义元素不是只写一个渲染函数就结束了。它至少要打通三条链路:

 复制代码registerRenderElem     编辑态怎么显示
registerParseElemHtml  HTML 怎么还原成 Slate 节点
registerElemToHtml     Slate 节点怎么序列化成 HTML

拿变量标签来说,编辑态要显示成一个不可编辑的蓝色标签;保存 HTML 时要带上 data-variable、data-label 和样式字段;再次加载 HTML 时,又要能根据这些属性还原成 type: 'variable' 的 Slate 节点。

也就是说,这里不是简单地“插入一段 span”。如果只插入普通 HTML,编辑器下一次解析、保存、生成 FTL 的时候都很容易丢信息。变量必须成为编辑器文档树里的正式节点。

5. 变量标签为什么要保存自己的样式

变量是这个编辑器里最麻烦、也最关键的元素。

在编辑器里,变量看起来是一个标签,例如“投保人姓名”。但生成模板时,它要变成类似 {{xxx}} 的占位符。更进一步,如果用户给这个变量设置了字号、颜色、加粗,那么这些样式也要跟着变量一起进入最终 FTL。

源码里的做法是:变量节点自己保存样式字段,例如:

 复制代码{
    type: 'variable',
    label,
    prop,
    fontSize,
    color,
    fontWeight,
    fontFamily,
    fontStyle,
    textDecoration,
    children: [{ text: '' }],
}

序列化成 HTML 时,这些字段会写成 data-font-size、data-color、data-font-weight 等属性;生成报文前,再通过 convertVariablesForGenerate 把它们编码进占位符:

 复制代码{{prop}}
{{prop||font-size:14pt;color:#000;font-weight:bold}}

这么做的原因很实际:变量是 void 节点,Slate 普通的 mark 机制不会自然作用到它内部的空文本节点上。如果只依赖选区上下文,变量一旦复制、移动、保存再回显,样式就容易丢。

所以代码里拦截了 editor.addMark 和 editor.removeMark。当选区覆盖变量节点时,不是只给普通文本加 mark,而是用 Transforms.setNodes 直接更新变量节点本身:

 复制代码editor.addMark = (key: string, value: unknown) => {
    const varPaths = getSelectedVariablePaths()
    if (varPaths.length > 0) {
        varPaths.forEach((varPath) => {
            if (key === 'bold') {
                Transforms.setNodes(editor, { fontWeight: value ? 'bold' : '' }, { at: varPath })
            } else if (key === 'color' || key === 'fontSize' || key === 'fontFamily') {
                Transforms.setNodes(editor, { [key]: value }, { at: varPath })
            }
        })
    }
    originalAddMark.call(editor, key, value)
}

这部分逻辑看起来有点绕,但解决的是一个很具体的问题:变量不是普通文字,但用户对它的操作又应该像普通文字一样自然。

6. 编辑态和生成态之间怎么转换

这个系统有两个状态:

  • 编辑态:变量是可视化标签,附页是可见标记,页眉图片可以删除。
  • 生成态:变量要变成占位符,附页要变成分页结构,边框和页眉要变成适合 FTL 输出的 HTML。

变量的转换分两步。

编辑态生成报文时,getGenerateContent 会先拿到编辑器 HTML,然后调用 convertVariablesForGenerate,把带 data-variable 的元素替换成 {{prop}} 或 {{prop||style}}。

从生成内容回显到编辑器时,setGenerateContent 会调用 restoreVariablesFromGenerate。它先用 setHtml 加载内容,再在 Slate 文档树中查找 {{prop}} 占位符,找到一个就删除文本,再插入一个真正的 variable 节点。

这里没有直接拼一段 HTML 再塞回编辑器,而是通过 Slate API 插入节点。这样做更稳,因为后续选中、高亮、工具栏格式联动都依赖这个节点类型。

7. 生成 FTL 时做了哪些处理

最终生成 FTL 的逻辑在 ftlGenerator.ts 里。

它不是简单把 {{prop}} 替换成 ${prop!},还做了几类处理:

  • 普通变量会转成 FreeMarker 表达式,例如 ${xxx!}。
  • 数组变量会根据变量配置识别数组路径,生成 <#list ... as info>。
  • 动态变量会根据配置替换成对应字符,例如分隔符、序号等。
  • 带样式的变量会用 <span style="..."> 包起来,保证变量值渲染出来后还有字号、颜色等样式。
  • 附页标记会转换成带 page-break-before:always 的分页段落。
  • 边框容器会从 div 转成 table,因为最终 FTL 渲染环境里 table 比 div border 更稳定。
  • 页眉图片容器也会从 flex div 转成 table 布局,避免渲染端对 flex 支持不完整导致左右图片错位。

这也是为什么编辑器里要保存比较完整的结构信息。编辑态可以用更适合交互的 DOM,生成态则要换成更适合模板渲染的结构。

8. 工具栏为什么用了 DOM 注入

项目里没有完全依赖 WangEditor 默认工具栏,还额外加了几个按钮:

  • A-、A+:按固定档位缩小或放大字号。
  • 上下边框:插入只有上下边框的区域。
  • 全边框:插入完整边框区域。
  • 页眉:插入左右页眉图片。

这些按钮是通过 DOM 注入到工具栏里的,不是通过 Boot.registerMenu 注册菜单。

源码注释里写得很清楚:这样做是为了避开开发环境 HMR 下菜单重复注册的问题。DOM 注入本身也做了防重复判断,比如发现 .custom-font-size-btn 已经存在就直接返回。

这不是最“框架化”的写法,但在这个项目里是一个比较务实的选择。它绕开了菜单注册表的重复注册问题,同时又能复用 WangEditor 原来的工具栏布局。

9. 页眉图片容器里的几个细节

页眉图片容器看起来只是左右两张图片,但实现里有几个容易踩坑的点。

第一,删除按钮要能点,但图片容器又不能破坏 Slate 的光标定位。代码里通过 pointer-events 做了分层:图片区域整体先设为不可响应,图片 wrapper 和删除按钮再恢复响应。这样中间空白区域不会抢事件,删除按钮又能正常点击。

第二,删除一侧图片后,另一侧图片不能跑到对面去。因为编辑态用的是 flex + space-between,如果直接把图片 display:none,布局空间就没了。源码里用的是 visibility:hidden 的占位元素,保留宽度但不显示内容。

第三,headerImageContainer 是 void 节点,所以反解析 HTML 时必须强制返回:

 复制代码children: [{ text: '' }]

不能把内部的 <img> 当成 Slate 子节点保留下来。图片数据应该存在 leftImageSrc 和 rightImageSrc 字段里,而不是存在 children 里。

10. 变量选中和工具栏联动

变量是 contenteditable="false" 的标签,用户点它的时候,浏览器不会像普通文字那样自然选中一段文本。

为了让工具栏的加粗、字号、颜色能作用到变量上,代码里做了两件事。

一是维护 selectedVariablePath,记录当前选中的变量节点路径。渲染变量标签时,如果路径匹配,就加上 variable-tag--selected 类名,让用户看得出来当前选中了哪个变量。

二是在编辑区域监听 mousedown。当用户点到 .variable-tag 时,通过 DomEditor.toSlateNode 找回对应的 Slate 节点,再设置一个覆盖整个 void 节点的选区。这样工具栏命令才能找到这个变量节点,并更新它的样式字段。

这个体验细节很重要。否则用户看到的是一个标签,但实际点不上、改不了样式,就会觉得编辑器是坏的。

11. 这次定制的核心思路

这套实现的核心不是“把 WangEditor 改得很复杂”,而是把业务概念放进编辑器的文档模型里。

变量不是普通 span,它是 variable 节点。

附页不是一段蓝色文字,它是 followerMarker 节点。

页眉图片不是两张普通 img,它是带 leftImageSrc、rightImageSrc 的 headerImageContainer 节点。

边框也不是随手加一个样式,它是后续生成 FTL 时要被转换的 borderBox 节点。

只要这些业务元素在编辑态就是明确的结构,后面的保存、回显、生成、校验才有基础。否则表面上编辑器能显示,真正进入模板生成链路时就会不断丢信息、错结构、补特殊逻辑。

12. 总结

这个模板编辑器的难点不在于富文本本身,而在于“编辑体验”和“模板生成”之间要保持一致。

WangEditor 提供了基础编辑能力,Slate.js 提供了可扩展的文档模型。项目真正做的事情,是把变量、附页、边框、页眉这些业务元素映射成自定义节点,再分别处理它们的渲染、解析、序列化和 FTL 生成。

最终结果是:用户在页面上看到的是比较自然的模板编辑体验,系统内部拿到的则是结构化的内容,能够稳定生成 FreeMarker 模板。

这也是这次选型和定制的出发点:不是为了做一个“更复杂的富文本编辑器”,而是为了让业务人员能维护模板,同时让系统仍然拿到可生成、可回显、可校验的模板内容。

相关文章

精彩推荐