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 quill1.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 classeditor-wrapper div refeditorContainer/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 兼容问题锁定 quill1.3.7表格无边框样式漏引 quill-better-table.css全局引入样式文件右键菜单是英文未配置 operationMenu.items按中文 key 映射重写菜单项回显表格散架存储用了 HTML 而非 Delta改用 getContents().ops 保存getSelection 报错工具栏点击导致编辑器失焦对表格按钮 mousedown 做 preventDefaultBackspace 删不掉表格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 生态比较边缘的位置各种边界问题特别多。希望这篇记录能让你少走点弯路。