基于 WangEditor 的富文本编辑器定制实践

我用 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.isVoideditor.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-variabledata-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-sizedata-colordata-font-weight 等属性;生成报文前,再通过 convertVariablesForGenerate 把它们编码进占位符:

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

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

所以代码里拦截了 editor.addMarkeditor.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 子节点保留下来。图片数据应该存在 leftImageSrcrightImageSrc 字段里,而不是存在 children 里。

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

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

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

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

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

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

11. 这次定制的核心思路

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

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

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

页眉图片不是两张普通 img,它是带 leftImageSrcrightImageSrcheaderImageContainer 节点。

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

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

12. 总结

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

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

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

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

相关推荐
kyle~1 小时前
前端 --- Toast 轻量级弱反馈提示组件
前端
玉宇夕落1 小时前
Vue3 + 流式输出学习
前端
努力成为AK大王1 小时前
CSS 入门完整笔记
前端·css
微笑挖矿2 小时前
大模型流式渲染:为什么"逐字显示"远比你想的复杂
前端
weedsfly2 小时前
前端开发中的装饰器模式——给函数和组件“加壳”的艺术
前端·javascript·面试
zhedream2 小时前
那个从没改过的页面,为什么突然坏了?
前端·vue.js
工业HMI实战笔记2 小时前
HMI性能优化技巧:告别卡顿,打造流畅体验
前端·ui·性能优化·自动化·交互
QN1幻化引擎2 小时前
Gravity-Anchored Cognitive Field Architecture: The DalinX V8/V10 Implementation
java·前端·算法
光影少年2 小时前
react的View/Text/Image/ScrollView 常用组件注意事项
开发语言·前端·javascript·react.js·前端框架