ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue项目Quill富文本编辑器表格支持方案:从1.x到2.0的完整实践

Vue项目Quill富文本编辑器表格支持方案:从1.x到2.0的完整实践 1. 需求就这么简单但一查资料全是坑最近在做一个内容管理后台产品提了一个很常规的需求富文本编辑器里要能插表格。业务场景就是类似商品参数表、规格对照这种不是 Excel 那种重操作但至少得有行列、能合并单元格、能填写文字。我第一反应是“这有什么难的找个富文本编辑器不都自带吗”结果查了一圈发现这个需求放在 Quill 上还真没那么省心。当时项目的情况是 Vue 3 Vite编辑器用的是 Quill 1.3.7因为整条业务链路上的老代码都是基于 1.x 的 Delta 格式在跑后端存的也是这个版本生成的 JSON贸然升级影响面很大。也就是说摆在我面前的问题很现实继续用 Quill 1.x但让它在不变更数据格式的前提下支持表格。为了搞清楚这事我把 Quill 的表格生态翻了一遍又亲自把几种方案都跑通了一遍。这篇就完整讲清楚Quill 富文本想在 Vue 项目里真正支持表格有哪些路可以走每条路背后是什么原理实际动手会遇到哪些坑以及最后我是怎么落地并让测试和生产环境都稳定跑起来的。如果你正在做 Vue Quill 的项目或者准备给老项目加表格但不确定该升级还是装插件这篇应该能帮你省掉好几天的摸索时间。2. 为什么 Quill 的表格支持如此混乱先看它的文档模型在动手前我强烈建议先理解 Quill 的底层设计。不搞懂这个你会在方案选型上反复摇摆。Quill 和其他富文本编辑器不太一样它内部不是直接操作 DOM而是用一套叫Delta的数据结构来表达文档。Delta 本质上是一个线性的操作序列比如 insert、retain、delete。你把光标放到某一行、输入几个字符、加粗一段文字最后quill.getContents()拿到的就是一系列有顺序的 JSON 操作。问题就出在这个“线性”上。表格是二维结构有行有列单元格之间还有跨行跨列的关系。如果把这个二维结构硬塞进一个一维的 Delta 序列里就得定义一套新的操作符来表示行的开始、单元格的嵌套、单元格的边界。Quill 1.x 时代官方一直没有认真做这件事所以社区只能靠自定义 blot 去模拟。而 Quill 2.0 把这块原生做掉了但很多老项目又不可能为了一个表格功能去整体升级。这也就解释了为什么你在搜索引擎里查“Quill 表格”会看到两种完全不同的解决方案方案适用版本实现方式功能完整度维护状态quill-better-tableQuill 1.x自定义 blot 模块扩展支持插入行列、合并拆分单元格、选区操作基本停更但在 1.x 上能用Quill 2.x 官方表格模块Quill 2.0编辑器内置支持支持表格创建和单元格编辑合并相关能力还在迭代官方维护自研 table blot任意版本自己定义 Delta 操作非常灵活但工作量大自己的锅自己背一句话总结如果你能升级到 Quill 2用官方方案如果项目被迫留在 1.x那就用 quill-better-table。这两个方案我都在项目里验证过下面分别给出可落地的完整过程。3. Quill 1.x quill-better-table 完整接入过程3.1 安装与注册注意版本匹配老项目里我使用了 quill-better-table这是目前 Quill 1.x 生态里最常用的表格扩展。安装时如果直接用最新版本很可能会拉到一个为 Quill 2 适配的分支或存在 peerDependencies 冲突建议明确指定版本。npm install quill1.3.7 quill-better-table3.2.1注册模块时有一个关键点Quill.register()的第二个参数要传true表示强制覆盖。如果不传部分版本会在初始化时报BetterTable is already defined或注册遗漏。import Quill from quill import BetterTable from quill-better-table import quill/dist/quill.snow.css import quill-better-table/dist/quill-better-table.css Quill.register( { modules/better-table: BetterTable }, true )另外要注意 CSS 的引入顺序。quill-better-table.css必须在quill.snow.css之后加载否则表格的边框、单元格高亮、右键菜单样式会被 snow 主题覆盖。在 Vite 里如果你把 import 写在同一个文件里顺序从上到下不会出问题但如果你把两个样式放在不同模块里编译以后 CSS 顺序被打乱就会出现“表格功能能用但看起来乱七八糟”的情况。3.2 在 Vue 组件里初始化编辑器并配置表格工具栏这个方案的难点在于初始化配置。我用 Vue 3 Composition API 写的组件核心代码是这样template div classrich-editor-wrapper div refeditorRef/div /div /template script setup import { ref, onMounted, onBeforeUnmount } from vue import Quill from quill import BetterTable from quill-better-table const editorRef ref(null) let quill null onMounted(() { quill new Quill(editorRef.value, { theme: snow, modules: { table: false, better-table: { enableMenu: true, menuConfig: [row, col, merge, split, background, align] }, toolbar: { container: [ [{ header: [1, 2, 3, false] }], [bold, italic, underline, strike], [{ list: ordered }, { list: bullet }], [link, image, code-block], [table] ], handlers: { table: function () { // 点击工具栏 table 按钮时弹出交互框让用户输入行列数 const rows 4 const cols 5 this.quill.getModule(better-table).insertTable(rows, cols) } } } } }) }) onBeforeUnmount(() { quill null }) /script这里有两个点需要特别解释。第一table: false是用来关闭 Quill 1.x 内置的table模块。Quill 1.x 实际上有一个非常早期的 table 概念但它极其简陋只能创建规整的表格无法插入删除行列也没有合并能力。quill-better-table 和它会产生模块命名冲突所以显式关闭。第二工具栏里的table不是 Quill 默认认识的格式名但 Quill 允许在工具栏里声明自定义格式并在handlers里提供处理函数。这样你不需要额外写按钮组件直接在自带工具栏里就有一个“表格”入口。实际项目里我没有直接插入固定行列而是做了一个小的弹窗组件在弹窗里让用户输入行数和列数点击确定后再调用insertTable()。3.3 数据回写与 v-model 双向同步表格内容本质是 Quill 的 Delta 数据结构这意味着你不能像操作普通 HTML 那样直接读取。我推荐的做法是只持久化 Delta不持久化 HTML。这样不仅回显准确而且在未来版本升级时数据迁移成本最低。quill.on(text-change, () { const delta quill.getContents() const value JSON.stringify(delta) // 抛出给父组件 emit(update:modelValue, value) })回显也很简单watch( () props.modelValue, (val) { if (!quill) return if (!val) { quill.setContents([]) return } const delta typeof val string ? JSON.parse(val) : val quill.setContents(delta) } )这里有一个非常容易踩的坑如果父组件的值本身就来源于text-change的更新那么 watch 和text-change会互相触发导致编辑器内容抖动。我通常会在组件内部加一个isInternalChange的标记text-change回调里先置为trueemit 之后在下一个 tick 再置回falsewatch 里发现这个标记就直接跳过。这个方法虽简单但非常有效强烈建议保留。4. 想彻底解决表格问题Qulli 2 官方原生方案怎么用如果你的项目还没有锁定在 1.x或者愿意做一次从 1.x 到 2.x 的升级那整个体验会好非常多。Quill 2 的表格模块是在 Delta 设计层面就纳入支持的不再是社区补救方案。因为 Quill 2.0 已经发布并逐渐成熟新项目我直接推荐你用 2.x。4.1 初始化与插入表格安装方面Quill 2 的包结构有所变化npm install quill^2.0.0初始化时表格模块已经默认注册不需要像 better-table 那样手动注册。只需要在工具栏里加上 table 操作import Quill from quill import quill/dist/quill.snow.css const quill new Quill(#editor, { theme: snow, modules: { toolbar: [ [bold, italic, underline], [{ table: insert-table }] ] } })注意工具栏配置里的{ table: insert-table }这是一段 Quill 2 识别特殊操作渲染出来就是一个表格图标按钮点击后会出现一个小的网格选择器你通过在网格上滑动选择行列数松手就完成插入交互体验很接近 Notion 的表格插入方式。如果你需要以编程方式插入可以直接调用quill.insertTable(3, 4)这时候 Quill 会把一个 3 行 4 列的空表格插入到当前光标位置。4.2 获取表格选区与操作插件Quill 2 表格模块暴露了一组更清晰的 API。比如想知道当前光标落在哪个表格的哪个单元格可以这样const tableModule quill.getModule(table) const selection quill.getTableSelection() if (selection selection.getTable()) { const cellRange selection.getCellRange() console.log(当前单元格行列, cellRange?.rowIndex, cellRange?.colIndex) }当多处使用 Quill 2 表格模块的时候我建议把getTableSelection()的调用封装成一个 composable例如export function useTableSelection(quill) { function getCurrentCell() { const tableModule quill.getModule(table) const selection quill.getTableSelection() if (!selection) return null const table selection.getTable() const range selection.getCellRange() return { table, row: range?.rowIndex, col: range?.colIndex } } return { getCurrentCell } }这样在 Vue 组件里想实现“选中单元格后设置背景色”之类的功能就会变得非常简单。4.3 需要注意的差异与兼容问题Quill 2 的官方表格虽好但也有它的脾气。我的实测经验是跨行跨列合并Quill 2 早期版本对合并单元格的支持非常有限。如果你需要复杂合并仍然得引入额外的扩展或者干脆用 better-table 那套自定义 blot 思路自己拓展。好在对于大多数内容后台场景规整的行列已经够用。升级迁移从 1.x 升级到 2.xDelta 数据结构有兼容性调整老的表格类 Delta 不一定能原封不动回显。如果线上已经存了大量 1.x 数据要做一次数据映射转换不能直接替换依赖就上线。Vue 的响应式包装Quill 实例内部维护自己的状态不要用 Vue 的 reactive 去包裹它。我在项目里因为把quill放进了reactive({ quill })结果初始化后一段时间编辑器无法正常输入排查半天才发现是响应式代理把 Quill 内部的事件绑定搞乱了。5. 表格数据究竟怎么存落库、回显与服务端渲染5.1 推荐方案只存 Delta JSON上面的接入方案已经提到我建议直接存储 Delta。原因有三回显准确表格的行列、单元格内容、嵌套结构都被 Delta 以结构化的方式记录重新setContents()就能还原。避免 XSS不会把用户粘贴的 HTML 原样存进数据库从源头上减少脚本注入风险。版本升级可控Delta 是统一的数据结构要做数据迁移也只需要针对操作序列做转换。一个典型的存库字段{ id: doc_123, title: 商品介绍, content: {\ops\:[{\insert\:{\table\:{\rows\:2,\columns\:2}}},{\insert\:\\n\},{\insert\:{\tableCell\:{\data\:{}}}}...]}, updated_at: 2025-01-18T10:20:30Z }内容字段建议用 JSON 字符串不用 JSONB 也可以因为大多数情况下查询不会深入到 Delta 内部。5.2 一定要用 HTML 时的安全姿势有些时候你不得不在后端直接渲染 HTML比如生成静态页、做 SEO 快照或者打印 PDF。这时候如果只存了 Delta就需要一个“Delta 转 HTML”的能力。前端做转换的思路比较取巧构造一个不可见的 Quill 实例setContents(delta)以后读取root.innerHTML。export function deltaToHtml(delta) { const container document.createElement(div) const quill new Quill(container, { theme: snow, modules: { toolbar: false, better-table: true } }) quill.setContents(delta) return container.querySelector(.ql-editor).innerHTML }注意这个实例必须挂载到 DOM 上Quill 初始化时对容器有要求如果容器不在文档流里部分版本会异常。可以做成position: fixed; opacity: 0; left: -9999px的隐藏容器。服务端渲染的场景下推荐先用jsdom或linkedom模拟 DOM 环境再运行同样的转换逻辑。对于 quill-better-table 生成的表格 Delta如果直接用社区现成的quill-delta-to-html这类库经常会漏掉tableCell和tableRow等自定义操作输出结果完全不是表格。所以我是建议自己写转换逻辑识别 Delta 里的 table 类操作输出对应的table、tr、td标签。虽然要写点代码但可控性高以后加行列删除操作也不用被第三方库卡住。5.3 从 HTML 反向导入表格内容还有一种常见需求用户在 Excel 或 Word 里做好了表格直接粘贴到编辑器。Quill 对 Excel 粘贴的支持我觉得一言难尽它会把表格解析成一大段内联样式混乱的 HTML甚至直接丢失结构。这个问题我放在下一节详细说因为如果处理不好你的编辑器“看起来支持表格”实际用户一粘贴表格就崩。6. 实战踩坑记录这几类问题是真的会让表格模块白写6.1 Excel 复制粘贴表格格式全乱或直接失效Excel 复制出的 HTML 结构非常复杂里面会夹带大量mso-*等名称空间还有一堆meta、style。Quill 的 clipboard 解析器会把很多元素过滤掉最后经常只剩文字表格线、列宽、背景色通通消失。我的处理思路是监听粘贴事件当检测到剪贴板里的 HTML 包含table时不直接交给 Quill而是先把 HTML 清洗成干净结构再手动插入。editorRef.value.addEventListener(paste, (e) { const html e.clipboardData.getData(text/html) if (!html || !html.includes(table)) return e.preventDefault() const cleanHtml html .replace(/style[\s\S]*?\/style/gi, ) .replace(/meta[\s\S]*?/gi, ) .replace(/!--[\s\S]*?--/g, ) .replace(/o:p[\s\S]*?\/o:p/gi, ) .replace(/\sclass[^]*/gi, ) .replace(/\sstyle[^]*/gi, ) .replace(/\sid[^]*/gi, ) // 直接粘贴清洗后的 HTML 到光标位置 const range quill.getSelection(true) quill.clipboard.dangerouslyPasteHTML(range.index, cleanHtml) })清洗规则里我保留了table、tr、td、th这些标签丢掉了所有内联样式。这样贴进来是一张干净的基础表格颜色、宽度这些交给 CSS 统一样式去控制反而更符合内容平台的整体视觉。如果你想保留 Excel 里的单元格背景色那清洗逻辑就得复杂很多要把td的style做白名单解析只保留background-color、width等少数属性。具体保留哪些取决于产品需求没有通用答案。6.2 单元格宽度调整失效表格超出容器这可能是表格功能上线后被吐槽最多的问题。现象是用户拖动表格列宽或者从 Excel 粘贴了一张宽表格结果页面排版直接撑破。常规操作是先给表格加一个固定边框和table-layout: fixed的兜底样式.ql-editor table { border-collapse: collapse; width: 100%; table-layout: fixed; } .ql-editor table th, .ql-editor table td { border: 1px solid #d9d9d9; padding: 6px 8px; vertical-align: top; }但table-layout: fixed会有副作用它优先遵循第一行单元格的宽度设定如果第一行是合并单元格或者空单元格后面行的列宽会变得不受控制。我的经验是不要全局写死 table-layout而是给表格外面包一层.table-responsive的 div设置overflow-x: auto这样即使某张表格真的超过容器宽度也不会破坏整体布局表格内部拖动列宽仍然正常。.rich-editor-wrapper .ql-editor .table-wrap { overflow-x: auto; max-width: 100%; }在生成表格的时候我统一用div classtable-wraptable.../table/div的结构既支持横向滚动又不会影响正常表格。6.3 Tab 键在表格单元格中移动失效quill-better-table 的快捷键体验在部分版本中并不完善。按 Tab 键应该跳到下一个单元格但有时候却插入了一个空格。如果你们的产品经理比较在意键盘操作效率这个问题必须解决。我自己的做法是在编辑器容器上监听 keydown当光标落在表格内时自己接管 Tab 键行为editorRef.value.addEventListener(keydown, (e) { if (e.key ! Tab) return const table quill.getModule(better-table) const selection table?.getSelection() if (!selection || !selection.table) return e.preventDefault() table.moveCursorToNextCell() })不同版本的 quill-better-table 对方法名有细微差异有的叫moveNextCell有的版本没有暴露这个方法。如果找不到可用方法就手动算当前选区所在单元格的行列然后用setSelection把光标移动到下一个 cell 的开头。这种手写方式的代码我给不了通用版本因为你必须根据 Delta 中行跨度的实际表达方式来做。但排查思路是正确的路径先确定表格模块暴露了哪些方法再决定用官方的快捷方式还是自己计算坐标。6.4 Vite 构建后 CSS 顺序错乱表格样式丢失前面提到过 better-table 的样式必须在 snow 主题之后加载但 Vite 在生产构建时CSS 打包顺序经常被 code-splitting 打乱。表现是开发环境正常部署到测试环境表格边框不见了、菜单弹层错位。我的解决方式是把表格关键样式抽到项目自己的全局样式里不依赖插件的 CSS 顺序。比如下面这段只保留最核心的“让表格看起来像个表格”的部分.ql-editor table { border-collapse: collapse; width: 100%; } .ql-editor table th, .ql-editor table td { border: 1px solid #ddd; padding: 6px 8px; } .ql-editor table tr:first-child th, .ql-editor table tr:first-child td { background-color: #fafafa; font-weight: 600; }这样即便插件的 CSS 因为构建顺序问题没生效核心表格结构也是正常的。按钮的右键菜单这些次要部分等交互出现问题时再单独调。6.5 撤销重做 URL 与表格 Delta 不一致用 quill-better-table 时还容易遇到一个诡异问题编辑表格后按 CtrlZ编辑器内容和历史快照对不上甚至出现“撤销后表格完全消失但输入的文字还在”的状况。根本原因是自定义 blot 没有正确实现delta相关的静态属性或者getLength()、insertInto()等方法的返回格式不兼容 Quill 的 undo/redo 栈。这个坑我排查了很久后来发现升级到特定补丁版本可以解决大半。如果你用的 very 旧版本建议把 quill-better-table 升到 3.2.1因为它在 undo 栈处理上做了不少修复。如果你升级后问题依旧那只能自己实现history模块的getChanges监听在 union/redo 操作后手动setContents()重置。不过这条路非常绕我建议优先考虑换 Quill 2 版本因为原生模块在 undo/redo 上的处理是要严谨很多的。7. 把表格功能做得更顺手的小优化表格功能从无到有是一个阶段从能用到好用是另一个阶段。这块我可以分享几个实用的打磨点。7.1 插入表格时先给用户一个行列弹窗默认的工具栏点击方式要么是固定行列要么是网格选择器。但我发现对于很多内容运营同事他们希望插入表格时能先输入“行数、列数、是否带表头”这三项。我在项目里做了一个很小的对话框组件用 Element Plus 的 el-dialog里面放两个数字输入框和一个开关确认后调用quill.getModule(better-table).insertTable(rows, cols)对用户来说直觉化很多。如果你用 Quill 2对应的调用就是quill.insertTable(rows, cols)。7.2 给单元格加对齐和背景色操作表格内文字对齐在 quill-better-table 里可以通过选中单元格后在额外菜单中调整。但 Quill 2 原生表格的对齐支持就不太充分。我在 Quill 2 里实现了一个更简单的途径自定义工具栏按钮触发时对当前选区所在单元格设置 text-align 和 background-color。这需要定位到单元格 Blot 并在其 DOM 节点上修改属性和样式。function setCellStyle(quill, styleObj) { const selection quill.getTableSelection() if (!selection) return const cell selection.getCellRange() const dom cell?.row?.domNode if (!dom) return Object.assign(dom.style, styleObj) // 同时写入>const table dom.querySelector(table) table.style.width 100% const firstRow table.querySelector(tr) if (firstRow) { const cells firstRow.children const widthPercent 100 / cells.length Array.from(cells).forEach((cell) { cell.style.width widthPercent % }) }这样插入的表格就是页面宽度的 100%不会出现一个小窄表在正文中间飘着的尴尬。8. 最终落地的选择与后续思考做完整套调研和实战之后我个人的结论是这样的如果这是一套全新项目直接上 Quill 2 原生表格不要犹豫。官方维护是最好的保障而且 Delta 数据结构在设计层面就考虑了表格后续业务再复杂也不会出现无法收场的局面。如果项目已经深绑 Quill 1.x数据存量巨大可以继续用 quill-better-table。它能覆盖大部分“能在富文本里编辑一个表格”的需求而且我把完整的接入、初始化、粘贴清洗、样式兜底都验证过了生产环境稳定运行没有任何问题。只是合并单元格这种高级操作在跨行跨列场景下偶尔会有边界 bug需要在产品侧做一些操作限制。如果业务要求编辑器里的表格能力强到接近 Excel那富文本编辑器就不是适合的载体。让用户在一个富文本编辑器里去实现动态增删行列、拖拽复制、公式计算体验一定不会好。真正合理的做法是在编辑器里嵌入一个在线表格组件或使用单独的表格编辑器页面最后把结果以静态截图或结构化数据嵌入正文。这个判断我在项目复盘中反复跟产品对齐最后大家也认可这个边界。最后再分享一个小技巧无论你选哪种方案上线前一定要在编辑器和详情展示页里分别做一次“从 Excel 复制带合并单元格的表格、粘贴、保存、回显、发布”的完整链路测试。这个操作组合几乎覆盖了表格模块 80% 的隐藏风险点我见过的表格模块踩坑案例有一大半都是在这个链路上翻车的。提前测完你后续收到的工单会少很多。
返回列表