Quill 什么都好,就是不支持表格。这个痛点,做 Vue 后台项目的人八成遇到过。产品经理一句“富文本里要能插表格”,你就得在编辑器选型上重新纠结一轮。我最早以为 Quill 只是“没做”表格功能,去官方仓库翻了一圈才发现,表格在 Quill 的架构里属于“又想要又不敢碰”的模块——涉及 Selection、Delta 结构、DOM 映射的改动实在太大,官方干脆没做。于是社区里冒出了不少替代方案,其中被用得最多、也让我从踩坑到真正跑通的,就是 quill-better-table 这个插件。
这篇文章会把我整个实操过程记录下来:从方案选型、依赖安装、模块注册,到工具栏配置、右键操作菜单中文化、数据保存回显,以及我在实际项目中遇到过的一堆诡异问题。适合正在 Vue 2 / Vue 3 项目里集成富文本,又对表格有硬性需求的前端同学参考。我用的还是最常见的技术栈:Vue + Vuex(或 Pinia)+ Quill,后端接口直接存 Delta JSON 字符串,这样最稳。
1. 为什么 Quill 做不了表格?先聊聊方案选型
1.1 官方为什么不支持表格
Quill 本身有一套非常完整的 Blot 体系,它把文本、图片、公式、视频都抽象成 blot,并且用 Delta 来描述文档内容。按理说要支持表格,只需要定义一个新的 blot 就能搞定,但问题远远没这么简单。
表格的结构和其他行内元素不一样:它是二维的,有行和列,有单元格合并拆分的状态,还涉及单元格内嵌套普通编辑器内容这一层递归。Quill 的 Delta 是一个线性的操作序列,它目前没有原生的结构去表达“一个单元格包含一段格式化文本”这种嵌套关系。官方在 GitHub 的 issue 里也多次回应,认为表格需要非常大范围的架构调整,所以一直没做。
所以我劝各位别等官方了,这条路短时间内走不通。要么自己基于 Quill 的 Blot API 手写一个表格模块,要么就直接引入社区插件,后者明显更现实。
1.2 三条路,我为什么选了 quill-better-table
市面上给 Quill 加表格的方案我大致梳理过,主要就是下面三种,各有各的坑。
| 方案 | 实现难度 | 稳定性 | 维护状态 | 我的评价 |
|---|---|---|---|---|
| 自己基于 Blot 写表格 | 极高 | 一般 | 无 | 适合想深入源码的人,项目里不建议 |
| 用较老的 quill-table 插件 | 低 | 差 | 已停更 | 核心功能不完整,合并单元格都支持不好 |
| 用 quill-better-table | 低 | 良好 | 活跃 | 功能最全,社区用户最多 |
先说自研方案。如果你去看 Quill 1.3 的文档,会发现 Blot 体系确实很开放,理论上通过自定义 blot 和 embed 类型可以做表格。但你要自己处理光标在单元格之间的跳转、跨行选择、合并单元格后的坐标计算,光这三点就够写几百行代码了。我之前有个同事尝试过,最后在“光标从表格最后一个单元格回到正文”这一步卡了半个月,产品上线日期被硬生生推迟。所以如果没有特别特殊的需求,别走这条路。
中间那个 quill-table 插件我也试过,功能太基础。它只支持简单的行列插入,合并单元格要看运气,而且操作菜单丑得没法给客户看。最关键的问题是它的代码停留在多年前,Quill 1.3 一出就不兼容了,属于老古董。
最后我选了 quill-better-table。这个插件名字很直白,就是冲着“更好的表格”去的。它基于 Quill 的 blot 机制实现了 Table 和 TableCell 两种 blot,支持插入行、插入列、合并单元格、拆分单元格、删除行、删除列、删除整个表格,以及右键操作菜单。从功能完整度上看,基本覆盖了业务里能用到的所有表格操作。
1.3 插件底层是怎么工作的
简单说,quill-better-table 在 Quill 内部注册了行级 blot 和单元格级 blot。插入表格时,它会根据你传入的行列数,在 Delta 里生成对应行数的 table row 结构,每个单元格内部又是一个独立的容器,可以继续承载普通的文本格式。
这里要特别注意的是,表格在 Delta 里不是一整个 embed,而是分为多个行和单元格的操作,这对后面的数据存储与回显方式有很大影响。如果你不按它约定的方式保存和恢复,表格就会在回显时散架。
2. 实战准备:依赖安装与模块注册
2.1 依赖安装与版本匹配
开始动工前,先确认一下你项目里的 Quill 版本。quill-better-table 的文档上说它支持 Quill 2.0 / 1.3,但我在实际使用中发现,Quill 2.0 刚发布时和这个插件的兼容性并不好,容易在初始化或插入表格时报错。我目前的项目锁在 Quill 1.3.7,非常稳定。
安装命令很简单,两条一起装:
npm install quill@1.3.7 quill-better-table --save如果你的项目里已经装了 Quill,记得先确认版本。如果已经是 2.x,建议要么锁定回 1.3.7,要么去 quill-better-table 的 GitHub 仓库看一下最新 release 是否修复了兼容问题。就我实测下来的经验,宁可版本旧一点,也不要为了追新给自己添堵。
2.2 入口文件的注册顺序
安装完依赖,接下来就是在代码里注册。这一步有很多人漏了样式,或者注册顺序不对导致插件完全没生效。我一般在项目的 main.js 或公共组件里统一处理,保证全局只注册一次。
import Quill from 'quill' import 'quill/dist/quill.snow.css' import QuillBetterTable from 'quill-better-table' import 'quill-better-table/dist/quill-better-table.css' Quill.register({ 'modules/better-table': QuillBetterTable }, true)注意这里注册的 key 是'modules/better-table',但后面在 Quill 初始化配置里写模块名时用的是table。这是 quill-better-table 约定好的写法,不太符合直觉,但照着官方文档来就行。后面那个true参数也很关键,表示允许覆盖同名模块,不传的话在某些场景下会注册失败。
样式文件我顺便提一句:quill.snow.css是你的主题基础样式,quill-better-table.css是表格模块样式,两个都必须要。漏了第二个最常见的现象是表格能插入,但是没有任何边框和操作菜单,看起来就像一堆文字堆在那里。
2.3 Vue 组件里的最小可运行模板
注册好模块后,就可以在业务组件里直接用了。以一个 Vue 2 组件为例,核心逻辑是这样的:
<template> <div class="editor-wrapper"> <div ref="editorContainer"></div> </div> </template> <script> import Quill from 'quill' export default { name: 'RichTextEditor', data() { return { quill: null } }, mounted() { this.initEditor() }, methods: { initEditor() { this.quill = new Quill(this.$refs.editorContainer, { theme: 'snow', modules: { table: { operationMenu: { items: {} } }, toolbar: [ [{ header: [1, 2, 3, false] }], ['bold', 'italic', 'underline', 'strike'], ['table'] ] } }) // 可选:初始化时默认插入一个 3 行 4 列的表格 this.quill.getModule('table').createTable(3, 4) } } } </script> <style scoped> .editor-wrapper :deep(.ql-container) { min-height: 300px; font-size: 14px; } </style>这段代码跑起来,你就能在页面上看到一个带“表格”按钮的富文本编辑器。点一下表格按钮,默认会弹出行数和列数的选择面板,选择后表格就插入了。
我在这里遇到过的第一个问题是,工具栏里忘了加['table']。你以为模块注册好了就有表格按钮,实际上不会,Quill 的工具栏是按配置渲染的。所以如果你发现页面上没有任何表格入口,先检查 toolbar。
3. 核心功能落地:从菜单按钮到单元格操作
3.1 插入表格的交互方式
工具栏的'table'按钮点开后,quill-better-table 默认会给一个行列选择器,类似在表格里拖拽行数列数。这个交互对产品来说还算可以接受,但如果你希望默认固定行列数,可以跳过用户选择,直接调用createTable(rowCount, colCount)。
代码刚才已经写了。它在编辑器挂载后执行,经典场景是“新建商品详情时,初始就给一个 3 行 4 列的空表格用来填参数”。一定要在this.quill初始化完成之后再调用,否则拿不到模块实例。
如果你希望完全禁止用户手动插入表格,也可以通过自定义 tooltip 配置做到,但一般业务不太需要这种限制。我建议保留默认入口,用户在表格内右键还能看到操作菜单,这些操作都依赖工具栏按钮存在,千万别移除。
3.2 右键操作菜单的中文化
插进去的表格,鼠标右键会出现一个菜单,默认是英文的,比如“Insert Row Above”“Insert Column Left”这类。客户看到英文菜单肯定会提意见,所以中文化这一步必须在交付前做完。
中文字段配置是在初始化模块时写的,具体每一项对应一个默认菜单项。我直接给出我常用的中文配置:
modules: { table: { operationMenu: { items: { insertRowAbove: { iconName: 'insertRowAbove', name: '在上方插入行' }, insertRowBelow: { iconName: 'insertRowBelow', name: '在下方插入行' }, insertColLeft: { iconName: 'insertColLeft', name: '在左侧插入列' }, insertColRight: { iconName: 'insertColRight', name: '在右侧插入列' }, mergeCells: { iconName: 'mergeCells', name: '合并单元格' }, unmergeCells: { iconName: 'unmergeCells', name: '拆分单元格' }, deleteRow: { iconName: 'deleteRow', name: '删除当前行' }, deleteCol: { iconName: 'deleteCol', name: '删除当前列' }, deleteTable: { iconName: 'deleteTable', name: '删除整张表格' } } } } }这里面有两个细节。第一,iconName一定要保留,不能因为中文化就去掉。它是菜单项图标的标识,去掉之后菜单会变成纯文字,虽然能用但观感差很多,和编辑器自带图标的风格也对不上。第二,如果你只想隐藏某一个菜单项,不要在配置里显式设为false,而是用类似“item 不存在”的方式,具体做法是复制一份默认配置,删掉对应 key。因为某些旧版本插件把false当成正常配置处理,不会真的隐藏。
3.3 行列操作和合并单元格的边界情况
菜单里的每一项我都实际点过,这里单独说说合并单元格的体验。选中多个连续的单元格合并没问题,但如果选中区域不是矩形,比如你选了第一行的两个格子加第二行的一个格子,插件会拒绝合并或者直接报错。这是底层数据结构决定的,不是 bug,但你需要在体验上给用户一个提示。
拆分单元格的前提是这个单元格是被合并过的,否则拆分选项点了没反应。所以如果产品提需求说“每个单元格都要能任意拆分”,你要么底层改插件,要么直接告诉他这不现实。实际业务里,合并和拆分在商品参数、工程报价这类的表格里用得非常频繁,这套基础的矩形合并规则基本够用。
3.4 编辑后的数据保存与回显
表格一旦能编辑了,紧接着的问题就是数据怎么存。我强烈建议你直接保存 Delta 的 ops 数组,也就是quill.getContents().ops,而不是存 HTML 字符串。
为什么?因为 quill-better-table 的表格结构在 Delta 里是多行 blot 的组合,如果存 HTML,再回显的时候你很难保证 Quill 能正确解析出原来的表格结构。我踩过这个坑:第一次实现时我把this.quill.root.innerHTML存到后端,重新打开编辑页,表格直接消失了,内容变成了一堆脱离表格结构的纯文本。
正确的保存方式是:
// 保存时 const ops = this.quill.getContents().ops const contentJson = JSON.stringify(ops) // 把 contentJson 提交给后端回显时:
import Quill from 'quill' const Delta = Quill.import('delta') // 拿到后端返回的 contentJson const ops = JSON.parse(contentJson) this.quill.setContents(new Delta(ops))用这种方式,表格的行列结构、单元格合并状态、文本格式都能完整还原。我项目里后端字段名就叫content,类型是 longtext,存 JSON 字符串,读取时直接传给组件,效果很好。
如果你的项目后端已经固定要求存 HTML,那也能救。回显时先把 HTML 塞进一个临时 div,再通过new Quill(tempDiv)或直接赋值root.innerHTML然后手动触发 Quill 的 update 来让编辑器识别内容。但这种方式在表格场景下兼容性差一些,不是逼不得已我不会用。
4. 这些坑我替你踩过了:问题排查实录
4.1 插入表格后瞬间消失
这个问题我在网上见过很多次,自己也遇到过。点开表格按钮,选择行列数,表格插入进去了,结果光标一移开表格就没了,整个文档像没发生过任何操作一样。
排查思路分两步。第一步,确认你用的是不是 Quill 2.0。如果是 2.0,先换到 1.3.7,大版本冲突导致的概率非常高。第二步,检查你在注册模块时有没有给Quill.register传第二个参数true。如果不传,模块可能被 Quill 内部已存在的同名模块覆盖,导致插入后立刻被还原。
这个“消失”现象的本质是 Delta 变更被 Quill 内部校验拦住了,它认为这次操作不合法,于是把文档回滚到操作前状态。所以只要你注册和版本都正确,一般不会再出现。
4.2 控制台报错 Cannot read properties of null (reading 'getSelection')
这大概是 quill-better-table 下最常见的报错,几乎每天有人在 GitHub issue 区刷。触发场景很固定:在编辑器内选中一段文字后,马上去点工具栏的表格按钮,有时就会出现这个错误。
原因出在 Quill 的 selection 状态。当你点击工具栏按钮时,编辑器可能正处于失焦状态,Quill 的getSelection()返回 null,而 quill-better-table 内部没有做好 null 判断,直接取了一个字符串属性,于是报错。严格说这是插件的 bug,但我们可以在业务侧绕过去。
我的处理方式是给工具栏按钮的mousedown事件做一个预防,让编辑器先恢复聚焦状态:
const tableButton = document.querySelector('.ql-table') if (tableButton) { tableButton.addEventListener('mousedown', (e) => { e.preventDefault() this.quill.focus() }) }加了这段处理后,按钮点击不会让编辑器彻底失焦,报错概率大大降低。另外用一个全局错误捕获兜底,即使报错了也不影响页面其他功能:
window.onerror = function (msg) { if (msg.includes('getSelection')) { return true } }这不是根治,但能让你在交付前不被这种低级报错折磨。
4.3 回显后表格散架或变成纯文本
这个我在 3.4 已经详细说过,这里再补一个我后来发现的特殊情况:如果后端存的 HTML 不是 Quill 自己导出的 HTML,而是从 Word 或富文本粘贴过来再保存的,那回显时表格几乎必散。因为 Quill 的 clipboard 处理对 Word 的嵌套 table 结构兼容性很差。
所以最稳妥的规范是:后端统一存 Delta JSON,前端保存时JSON.stringify(this.quill.getContents().ops),回显时quill.setContents(new Delta(JSON.parse(contentJson)))。团队里如果有其他端在用同一个内容字段,也要提醒他们按照 Delta 格式来读写,不要私自改成 HTML。
4.4 表格列宽拖不动
表格插入后想拖动列宽,鼠标拖了半天没反应。这是 quill-better-table 老版本比较大的一个痛点,因为它的单元格宽度在插入时是固定平均分配的,没有暴露列宽拖拽的接口。
如果你用的是最新版本,部分表格的边框拖拽是支持的,但兼容性不如专业表格库。如果项目确实要求像 Word 那样随意拖动列宽,我的建议是不要死磕 Quill 生态,直接考虑用 x-spreadsheet 这类专门的表格组件嵌入页面,或者升级为整页表格编辑再同步回富文本。
如果只是希望列宽能调整,有个简单方案:自己在表格初始化后监听单元格的mousedown和mousemove,手动改对应col或td的宽度。这个方案我实践过,可用,但代码量不小,而且要考虑撤销、重做、重新渲染等联动问题。给产品报价的时候,最好把这个功能归为“定制开发”,别算在标准富文本里。
4.5 想删除空表格,Backspace 却删不掉
想删掉一个刚插入的空白表格,按 Backspace 没有反应,光标卡在表格里出不去。这也是 Quill 生态的经典问题。
Quill 本身对 embed 类型的删除处理是为了防止误删,但表格这种复合结构更特殊。用户会很自然地认为按 Backspace 能把整个表格删掉,但实际做不到。我在业务里把右键菜单的“删除整张表格”项提到了菜单最下面,并且在文档里给操作说明:删除整表请右键选择菜单底部的删除整张表格。
如果你想让 Backspace 也能删除表格,需要监听 beforeinput 或 keydown,判断当前光标是否在表格内且内容为空,然后手动调用表格模块的删除方法。这块逻辑复杂,而且很容易引入新的边界 bug,目前我没在正式项目里做,都是靠右键菜单解决。
4.6 常见问题速查表
每次遇到问题都要翻博客和 GitHub issue,太浪费时间。我把高频问题整理成了一张表,贴在项目手册里,团队其他人遇到同样问题直接自查。
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 表格按钮不存在 | 工具栏没配 table 项 | 在 toolbar 配置中加入'table' |
| 插入表格后消失 | Quill 2.0 兼容问题 | 锁定 quill@1.3.7 |
| 表格无边框样式 | 漏引 quill-better-table.css | 全局引入样式文件 |
| 右键菜单是英文 | 未配置 operationMenu.items | 按中文 key 映射重写菜单项 |
| 回显表格散架 | 存储用了 HTML 而非 Delta | 改用 getContents().ops 保存 |
| getSelection 报错 | 工具栏点击导致编辑器失焦 | 对表格按钮 mousedown 做 preventDefault |
| Backspace 删不掉表格 | Quill embed 类型默认行为 | 提示用户使用右键菜单删除 |
| 列宽拖不动 | 插件版本或功能限制 | 升级版本或定制列宽调整功能 |
5. 进阶封装:从“能用”到“好用”
5.1 封装成继承 Quill 的公共组件
表格功能开发完成后,我顺手把它封装成了一个公共组件,项目里所有需要富文本的地方都在复用。组件对外暴露value和change事件,父组件传 Delta JSON 字符串进来,编辑器内容变化时再把最新 Delta JSON 抛出去。
具体实现有两个关键点。第一,:value的监控要比较 delta 内容是否真的变化,别在编辑器每次输入时都用setContents强制刷一遍,否则光标会乱跳。我采用的方式是加一个标志位,只在外部传入值与编辑器当前值不同时才 setContents。第二,组件销毁时一定要调用this.quill = null,并把编辑器容器里的内容清空,不然在 Vue 的 keep-alive 场景下会出现表格内容残留。
5.2 粘贴外部表格的处理
业务里用户经常会从 Excel 或 Word 复制一个现成的表格,直接粘贴到编辑器里。Quill 的 clipboard 默认会把 HTML 转成 Delta,但 Excel 的表格 HTML 结构很复杂,粘贴结果基本是一堆乱块。
我现在的处理方式是监听 paste 事件,检查剪贴板里是否有text/html,如果包含<table>,就直接拦截原始粘贴行为,提示用户“请使用工具栏表格按钮手动创建表格”。虽然没那么智能,但至少不会把内容搞乱。如果你有更高的需求,也可以写一个 HTML to Delta 的解析器,把 Excel 表格的行列数据解析后重新生成 quill-better-table 的 Delta 结构,这个项目我下个版本准备做。
5.3 单元格样式和主题定制
quill-better-table 允许你给表格设置自定义 class 和行内样式。实际项目中,我在初始化后会给表格加一个custom-table类,然后在全局样式里覆盖:
.custom-table .ql-better-table-cell { padding: 8px 12px; line-height: 1.6; }这样能统一表格单元格的间距。如果你觉得默认边框或表头背景不够好看,也可以通过样式覆盖来解决。但是不要试图通过 Quill 主题配色去带动表格颜色,表格样式和 Quill 主题是两套独立的体系,分开维护更清晰。
单元格背景色、字体颜色这些富文本属性,Quill 本身支持,表格单元格里同样能用。用户选中单元格里的文字,用工具栏的颜色按钮就能改。这个功能不用额外开发,属于白捡的便利。
5.4 编辑大数据量表格时的性能
表格行数一多,比如超过 20 行,输入字符时会出现明显卡顿。我分析下来主要是 Quill 每次输入都要重算整个文档的 Delta,表格行数增加后计算量也跟着涨。
临时应对手段有几种。第一,减少不必要的watch,不要监听编辑器整个内容的变化去做实时校验或统计。第二,保存按钮再触发数据读取,不要在text-change里频繁getContents()。第三,如果实在卡到影响输入,可以分页展示表格,一次只渲染部分行,但 quill-better-table 没有这个能力,需要换方案。
我项目的实践证明,20 行出头的表格在主流电脑上还算流畅。如果你客户的表格动不动就上百行,我会认真劝他别用富文本里的表格,换成独立表格组件更合理。
6. 最后再分享一个实用小技巧
写到这里,核心内容差不多讲完了。最后给动手实现的朋友一个建议:在你完成接入后,一定找 10 份不同来源的文档做一遍粘贴、编辑、保存、回显的回归测试,尤其是从 Excel 和 WPS 复制的内容。这种“异常输入”才是项目上线后最容易出事故的地方。
另外,如果你和我一样锁定了 Quill 1.3.7,建议在 package.json 里把版本写死,不要用^或~,避免后续安装时意外拉到 Quill 2.x 导致表格模块崩溃。我用的是:
"quill": "1.3.7", "quill-better-table": "^1.2.10"插件的次版本可以放宽一点,因为它的 API 变化不大,但 Quill 大版本必须锁死。这是我踩了好几次坑之后强迫团队执行的规范。表格功能本身不复杂,但因为它处于 Quill 生态比较边缘的位置,各种边界问题特别多。希望这篇记录能让你少走点弯路。