我用 WangEditor 做了个模板编辑器,说说踩过的坑
1. 为什么要做这个编辑器
这个项目的背景是这样的:保险保单/保函业务需要适配全国各地的差异化要求,模板数量多、更新频繁。之前一直是业务提需求,开发人员在代码里硬编码维护,一来一回沟通成本很高,理解上还容易有偏差,而且每次改模板都得等发版才能生效,响应速度跟不上业务节奏。
所以这次要做的,是一个让业务人员能自主配置的模板编辑器。核心目标很明确:业务人员自己可视化编辑模板、自己测试、自己管理版本、自己发布,不用再依赖开发人员介入。
这个编辑器的具体目标是让业务人员维护"报文模板"。模板里不只有普通文字,还会有各种变量,比如投保人姓名、保单金额这些。用户编辑完之后,系统得把内容转换成 FreeMarker 模板,也就是 FTL 报文,交给后端去渲染。
所以我要解决的问题其实是这些:
- 基础排版得有:字体、字号、颜色、加粗、对齐、缩进这些。
- 变量得能插入,而且看起来不能像一串
{{xxx}},得像个正常的标签。 - 变量插入完不是终点,最后生成 FTL 的时候还要能变回去。
- 模板有附页,得能标记分页位置。
- 页眉有两张图片,左右各一张,还能单独删掉。
- 有些内容需要边框,编辑时看着是边框,生成时得转成 FTL 能识别的结构。
这么一看,问题就不是"找个编辑器让用户打字"了,而是要找一个能深度扩展、能控制文档结构、能打通编辑态和生成态的编辑器。
2. 为什么选 WangEditor
选 WangEditor 不是因为它有多完美,而是它刚好满足了几个关键条件:
- 基础排版功能现成的,不用从零写。
- 有 Vue 3 组件,接入起来方便。
- 底层是 Slate.js,文档是节点树结构,不是一团不可控的 HTML。
特别是第三点,Slate 的节点树结构让我能把"变量标签""附页标记"这些业务概念变成编辑器真正认识的节点。
不过选了 WangEditor 不代表后面就顺利了。真正花时间的地方,都在它的扩展边界上:哪些节点要做成 inline,哪些要做成 void,哪些内容要序列化,哪些只在编辑态展示。
3. 这个编辑器里扩展了哪些业务元素
根据源码里的实现,编辑器主要扩展了 5 类自定义元素:
| 功能 | Slate type | 节点特点 | 作用 |
|---|---|---|---|
| 变量标签 | variable |
inline void | 编辑态展示蓝色标签,生成态转换为变量占位符 |
| 附页标记 | followerMarker |
block void | 表示附页开始位置,生成时带分页样式 |
| 边框容器 | borderBox |
block 容器 | 插入上下边框或全边框区域 |
| 页眉容器 | headerContainer |
block 容器 | 页眉布局容器 |
| 页眉图片容器 | headerImageContainer |
block void | 放置左右两张页眉图片,并支持单独删除 |
这几个节点的性质不一样,所以处理方式也不一样。
比如变量标签要和普通文字排在同一行,所以它是 inline;但它不能让用户把光标放进标签内部编辑,所以它又是 void。附页标记和页眉图片容器是独立块级内容,所以它们不是 inline。页眉图片容器还要避免内部图片被 Slate 当成普通子节点处理,因此也被标记成 void。
在 handleCreated 里,编辑器会重写 editor.isVoid 和 editor.isInline:
ts
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 里,一个自定义元素不是只写一个渲染函数就结束了。它至少要打通三条链路:
text
registerRenderElem 编辑态怎么显示
registerParseElemHtml HTML 怎么还原成 Slate 节点
registerElemToHtml Slate 节点怎么序列化成 HTML
拿变量标签来说,编辑态要显示成一个不可编辑的蓝色标签;保存 HTML 时要带上 data-variable、data-label 和样式字段;再次加载 HTML 时,又要能根据这些属性还原成 type: 'variable' 的 Slate 节点。
也就是说,这里不是简单地"插入一段 span"。如果只插入普通 HTML,编辑器下一次解析、保存、生成 FTL 的时候都很容易丢信息。变量必须成为编辑器文档树里的正式节点。
5. 变量标签为什么要保存自己的样式
变量是这个编辑器里最麻烦、也最关键的元素。
在编辑器里,变量看起来是一个标签,例如"投保人姓名"。但生成模板时,它要变成类似 {{xxx}} 的占位符。更进一步,如果用户给这个变量设置了字号、颜色、加粗,那么这些样式也要跟着变量一起进入最终 FTL。
源码里的做法是:变量节点自己保存样式字段,例如:
ts
{
type: 'variable',
label,
prop,
fontSize,
color,
fontWeight,
fontFamily,
fontStyle,
textDecoration,
children: [{ text: '' }],
}
序列化成 HTML 时,这些字段会写成 data-font-size、data-color、data-font-weight 等属性;生成报文前,再通过 convertVariablesForGenerate 把它们编码进占位符:
text
{{prop}}
{{prop||font-size:14pt;color:#000;font-weight:bold}}
这么做的原因很实际:变量是 void 节点,Slate 普通的 mark 机制不会自然作用到它内部的空文本节点上。如果只依赖选区上下文,变量一旦复制、移动、保存再回显,样式就容易丢。
所以代码里拦截了 editor.addMark 和 editor.removeMark。当选区覆盖变量节点时,不是只给普通文本加 mark,而是用 Transforms.setNodes 直接更新变量节点本身:
ts
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 时必须强制返回:
ts
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 模板。
这也是这次选型和定制的出发点:不是为了做一个"更复杂的富文本编辑器",而是为了让业务人员能维护模板,同时让系统仍然拿到可生成、可回显、可校验的模板内容。