在内容管理系统、在线文档类项目中,表格几乎是富文本编辑器绕不开的功能。用户不仅要能插入表格,还常常需要合并单元格、调整边框样式。这两个需求看起来简单,实际做起来涉及选区计算、DOM 结构变更、内容迁移等一系列细节问题。本文结合 Vue 3 的技术栈,分析如何在富文本编辑器中优雅地实现这两类操作。

一、合并单元格的底层原理:colspan 与 rowspan
HTML 表格的合并不存在真正的"合并"动作,本质上是通过 colspan 和 rowspan 两个属性声明一个单元格横跨多列或多行。例如设置 colspan="2" 后,该单元格会占据两列的空间,而同一行中原本位于右侧的那个 <td> 元素需要被删除,否则表格总列数会溢出。
理解这一点非常重要,因为任何编辑器在执行合并时,都必须完成三件事:第一,确定用户当前选中的单元格范围;第二,把第一个单元格的属性改为跨行跨列;第三,把被覆盖的单元格中的内容迁移到保留单元格内并移除这些节点。内容迁移是容易被忽略的一步,如果直接删除单元格,用户辛苦填写的数据就丢了,体验会非常糟糕。
纵向合并时还要注意一个陷阱:rowspan 跨出的行中,位于同列的单元格也要删除,但如果被删除的单元格恰好是那一行唯一的内容单元格,删除后这一行可能会变成空行残留。因此在实现时通常需要先统计每一行的单元格总数,再决定是删单元格还是删整行。
二、基于 WangEditor 5 的表格操作方案
WangEditor 5 原生支持表格的插入、删除行列等基础操作,但默认没有开放合并单元格的完整能力,需要通过自定义菜单或者直接操作编辑器数据模型来实现。在 Vue 3 中的接入方式如下:
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { createEditor, IDomEditor } from '@wangeditor/editor'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css'
export default {
setup() {
const editorRef = shallowRef<IDomEditor>()
const toolbarConfig = { excludeKeys: [] }
const editorConfig = { placeholder: '请输入内容...' }
onMounted(() => {
// 编辑器创建后可通过 registerMenu 扩展表格相关按钮
})
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
return { editorRef, toolbarConfig, editorConfig }
},
components: { Editor, Toolbar }
}如果需要自己实现合并逻辑,可以监听编辑器的选区变化,通过 editor.getSelection() 拿到选区节点,再判断选区是否落在 <table> 内部。接着遍历选区覆盖到的 <td>,记录起始单元格坐标,执行属性修改和节点删除。WangEditor 的数据结构是基于 Slate 的,操作数据模型时务必使用它提供的 Transforms API,直接改 DOM 会导致数据与视图不同步。
边框设置方面,WangEditor 的表格模块暴露了 borderColor 等配置项,可以在创建编辑器时传入,也可以通过自定义样式面板动态修改。如果产品要求每个单元格边框粗细可调,比较务实的做法是给 table 外层套一个容器,用 CSS 变量控制边框宽度,避免逐个修改单元格属性带来的性能问题。
三、基于 Tiptap 的扩展实现
Tiptap 基于 ProseMirror,表格能力由官方扩展 @tiptap/extension-table 提供,开箱即支持合并单元格、拆分单元格、行列拖拽调整,是 Vue 3 项目中做表格编辑的首选方案之一。基本用法:
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `<table><tr><th>姓名</th><th>分数</th></tr></table>`,
})合并操作通过命令触发:editor.chain().focus().mergeCells().run(),前提是用户已经用鼠标选中了多个单元格。Tiptap 会自动处理内容迁移和属性更新,开发者不需要手动操作 DOM。拆分则使用 splitCell 命令。这套命令式的 API 与 Vue 3 的组合式 API 结合得很自然,可以把它们绑定到自定义工具栏按钮上。
边框样式如果内置的不能满足需求,可以扩展 TableCell,给它增加自定义属性:
import TableCell from '@tiptap/extension-table-cell'
const CustomCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
borderColor: {
default: '#d0d0d0',
parseHTML: element => element.getAttribute('data-border-color') || '#d0d0d0',
renderHTML: attributes => ({ 'data-border-color': attributes.borderColor }),
},
borderWidth: {
default: 1,
parseHTML: element => element.getAttribute('data-border-width') || 1,
renderHTML: attributes => ({ 'data-border-width': attributes.borderWidth }),
},
}
},
})渲染到 HTML 时带上 data 属性,再配合一段 CSS 就能实现每个单元格独立的边框颜色和粗细。相比直接写 style 内联样式,data 属性的方案更容易与后端存储、内容过滤逻辑兼容,发布到前台页面时也方便统一接管样式。
四、常见坑点与选型建议
第一个坑是选区判断。用户从表格外的文字拖选到表格内时,选区可能同时包含段落和单元格,直接执行合并会报错。正确做法是先校验选区是否完全落在同一个表格内,ProseMirror 提供的 isInTable 状态检查可以直接使用,自己实现时也要做同样的防御。
第二个坑是序列化与回显。合并后的表格存储时必须保留 colspan、rowspan 属性,某些内容清洗工具(比如 DOMPurify 的严格模式)默认可能剥离这些属性,导致内容回显时表格错位。上线前一定要测试完整的保存与加载链路。
第三个坑是边框的 border-collapse 问题。表格设置 border-collapse: collapse 后,相邻单元格边框会合并,两条 1px 的边框显示出来只有一条,如果产品预期是看到明显的双线效果,就要改用 border-collapse: separate 并控制 border-spacing。这个细节在还原设计稿时经常出问题。
选型上,如果项目对表格要求高——需要合并、拆分、拖拽列宽,建议直接选 Tiptap,官方扩展成熟,文档完善,二次扩展的 API 设计也清晰;如果只是偶尔插入简单表格,WangEditor 更轻量,中文文档友好,上手成本低。无论选哪个,都建议把表格操作封装成独立的业务组件,对外只暴露命令接口,方便后续替换底层编辑器。