ARTICLE DETAIL

资讯详情

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

Vue项目中Quill富文本编辑器接入表格功能的实践指南

Vue项目中Quill富文本编辑器接入表格功能的实践指南 我做 Vue 后台管理系统做了快十年几乎每个项目都会被问到同一个需求“编辑器里能不能插个表格”而当你打开 Quill 官方文档的时候会发现一个很尴尬的事实这个被各大后台项目广泛使用的富文本编辑器默认连一个表格按钮都没有。不是没做而是它的数据模型里压根就没有表格这个原生概念。这篇文章不扯虚的直接把我自己在真实项目里给 Quill 富文本接入表格能力的过程、方案选型、完整代码和踩坑记录都摊开讲给正在被这个需求折磨的同学一条能直接走的路。1. 先搞清楚Quill 为什么默认就没有表格1.1 Quill 的 Delta 模型里没有“表格”这一个概念很多人第一反应是 Quill 偷懒其实不是。Quill 的底层数据模型叫 Delta它本质上是一个描述文档“操作序列”的结构用 insert、retain、delete 三个操作来表达文档变化。这种设计让 Quill 在多人协同、撤销重做、Diff 对比上非常强但它天生是为“流式文档”设计的也就是一行接一行往下排列的文本内容。表格是什么表格是二维结构有行、有列、有单元格单元格里有自己的独立内容还会出现合并单元格、跨行跨列这种操作。要把这个塞进“一行一行的操作序列”里Quill 官方一直没有给出一个特别优雅的解决方案。所以你在 Quill 1.x 版本里找不到 table 相关的内置模块在 2.x 版本里表格模块也曾经被移出过核心就是因为维护成本高、边界情况多。1.2 表格需求在富文本里的真实难度先说结论表格是富文本编辑器里最难做好的功能之一难到什么程度难到很多商业编辑器包括一些知名 SaaS 产品的表格也只是“画了个格子”而已并没有真正的单元格编辑模型。难点主要集中在三块光标与键盘事件用户按 Tab 应该跳到下一个单元格按 Enter 是换行还是新建行在最后一个单元格按方向键怎么处理合并单元格里光标又应该落在哪里HTML 结构的兼容性HTML 里表格是 table、thead、tbody、tr、td、th 一整套嵌套结构还有 rowspan、colspan、colgroup 这些属性而 Quill 的 Delta 模型里每个“节点”都应该是扁平的这两者天然冲突。单元格内容的独立性每个单元格内部应该可以独立设置对齐、字体、字号、图片等格式但又要保证整个表格的样式统一这需要非常精细的 blot 设计。所以当你听到有人说“自己写个表格模块吧”我的第一反应是劝你冷静。这里面水很深别拿业务项目的时间去试错。2. 方案选型第三方表格模块还是自己写 blot2.1 quill-better-table 能做什么、不能做什么我在对比了社区里几乎所有的 Quill 表格扩展方案后最终选了 quill-better-table。说一下它的能力边界方便你判断够不够用。它支持的功能有通过工具栏按钮一键插入表格可以自定义最大行列数默认是 10x10 的格子选择器单元格的合并、拆分表格行和列的增删整行上移下移用鼠标拖动调整列宽顶部有操作菜单点击单元格右上角的小三角弹出来提供了一些顶层 API比如table.getTable()、table.getTableRange()可以拿到当前光标所在的表格实例你细看会发现它没有的功能也很明显不支持合并单元格之后继续做斜线表头不支持直接把 Excel 表格原样粘贴进来自动识别单元格边框颜色、背景色需要通过自定义 format 去扩展它对 Quill 1.x 的兼容性要比 2.x 好得多2.2 自研表格模块的成本真的比你想象的高我身边真有一位同事拍着胸脯说“一个表格而已我自己写”。他花了两周时间把基本插入和删除调通了然后卡在了合并单元格的 Delta 表达上又花了一周处理各种光标穿越问题最后项目排期顶不住还是换回了第三方库。我说这个不是劝退你搞技术研究而是想让你算清楚账。业务项目的核心目标是稳定交付不是把富文本编辑器重新发明一遍。除非你的需求特殊到所有开源方案都满足不了比如要做类似飞书文档那样体验极高的表格那才值得投入几个人月去自研而且我建议你直接基于 ProseMirror 或 Tiptap 这类内核去改而不是硬磕 Quill 的 Parchment。2.3 版本怎么固定避免瞎踩坑版本这个问题我必须单独拿出来说因为踩过的都知道有多痛。quill-better-table 的 GitHub 仓库更新频率不高它对新版 Quill 的适配明显滞后。我当前项目里固定用这个组合quill 版本1.3.7quill-better-table 版本1.2.10这两个版本配合起来在我多个上线项目里验证过行为稳定。如果你用的 Vue 是 2.x 且继续使用 vue-quill-editor那需要确认 vue-quill-editor 内部依赖的 quill 版本也是 1.3.x否则会出现模块注册类型不对、工具栏按钮渲染不出来这类问题。顺便提醒一下如果你的新项目是 Vue 3我建议不要用任何 vue-quill 的 Vue 封装直接用原生 Quill 自己包一层组件。封装库的维护速率往往跟不上 Quill 的迭代而且封装的 API 反而会限制你的自定义能力。3. 在 Vue 项目里接入 quill-better-table 的具体操作3.1 安装依赖并注册模块先装包我用的是 npmnpm install quill1.3.7 quill-better-table1.2.10装的时候注意一下如果你的项目里之前装过其他 Quill 版本最好删掉 node_modules 里的 quill 重新装否则可能出现两个 Quill 实例的冲突问题表现就是注册模块的时候报already registers或者not a module。安装完成后在编辑器初始化之前注册模块。我这里强烈建议在项目的全局入口文件里一次性注册不要在组件里反复Quill.register避免重复注册导致的命名空间污染// 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 )注意第二个参数传true表示覆盖已有的模块注册。有些同学没写这个参数遇到重复注册就直接抛异常。3.2 封装成 Vue 组件的完整代码我实际项目里通常封装一个Editor.vue组件把 Quill 的生命周期管理起来。这里贴一个 Vue 2 的版本逻辑清晰Vue 3 也就是把 mounted、beforeDestroy 换成 onMounted、onBeforeUnmount 的问题template div classeditor-wrapper div refeditor classeditor-content/div /div /template script 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 ) export default { name: VueQuillTableEditor, props: { value: { type: String, default: } }, data() { return { quill: null } }, mounted() { const toolbarOptions [ [bold, italic, underline], [{ header: [1, 2, 3, false] }], [{ list: ordered }, { list: bullet }], [table], [image] ] this.quill new Quill(this.$refs.editor, { theme: snow, modules: { table: false, better-table: { operationMenu: { items: { // 默认配置即可也可以在这里覆盖按钮 } } }, toolbar: { container: toolbarOptions } } }) // 初始值回填 if (this.value) { this.quill.clipboard.dangerouslyPasteHTML(this.value) } // 内容变更事件 this.quill.on(text-change, () { this.$emit(input, this.quill.root.innerHTML) }) }, beforeDestroy() { this.quill null } } /script这里有一个非常关键的配置modules里必须写table: false。因为 Quill 在某些版本里会内置一个 table 模块如果不显式关掉它会和 better-table 抢逻辑最终导致你点了表格按钮没反应或者报Cannot read properties of undefined之类的错误。3.3 工具栏按钮和操作菜单配置工具栏里加table这个字符串quill-better-table 会自动把它渲染成一个表格图标。这个按钮的事件由 better-table 模块自己监听你不需要写 handler。点击表格按钮后会出现一个格子选择器默认是 10 列行数也会相应显示。用户可以在上面移动鼠标选择要插入的表格大小松手就插入。如果你觉得 10x10 太大或者太小可以在模块配置里调better-table: { operationMenu: { items: { insertTable: { maxRows: 8, maxCols: 8 } } } }这个配置我试过多次注意它不是想当然的maxRow而是maxRows拼错的话不会报错但是配置不生效很坑。操作菜单就是单元格右上角那个小三角点开后会出现一堆操作项向上插入行、向下插入行、向左插入列、向右插入列、删除行、删除列、合并单元格、拆分单元格等。这些默认就有不需要额外配置。3.4 常用表格 API 与调用的正确姿势在业务里你经常会需要动态操作表格比如“点击某个按钮自动插入一个 3x4 的表格并填入默认数据”。这时候可以通过模块 API 来做const betterTable this.quill.getModule(better-table) const table betterTable.insertTable(3, 4)insertTable会返回表格实例之后可以通过实例方法操作行列数量table.insertRowBelow() table.insertColRight()还有一个我经常用的 API是拿到当前光标所在表格的实例const table betterTable.getTable() if (table) { // table 存在说明当前光标在表格内 const range betterTable.getTableRange() }getTableRange()返回的是一个 Selection 范围对象包含 index 和 length可以用来对表格内容做整体操作比如设置整表样式。这个 API 的语义我踩过一次坑它返回的 index 是基于文档 Delta 的绝对索引不是表格内部的相对索引如果你把多个表格插入在文档不同位置拿到的 index 需要结合 Quill 的getLength再计算否则很容易定位错地方。4. 数据保存、回显与粘贴导入的那些坑4.1 存 HTML 还是存 Delta我建议直接存 HTMLQuill 提供了两种数据导出方式quill.root.innerHTML返回 HTML 字符串quill.getContents()返回 Delta 对象。很多初学者会纠结到底存哪个我直接给出结论如果你的后端没有特殊的内容版本管理需求纯存 HTML 字符串最省心。原因是 quill-better-table 生成的表格结构比较复杂包含大量自定义 class 和 table 标签Delta 序列化成 JSON 之后一旦后续升级组件版本或者换编辑器Delta 的解析逻辑就可能对不上而 HTML 是事实上的标准格式几乎任何富文本渲染器都能直接渲染。回显的时候我的做法统一是this.quill.clipboard.dangerouslyPasteHTML(savedHtmlContent)有人会问用dangerouslyPasteHTML会不会有 XSS 安全问题。这里我说明一下Quill 的 clipboard 在解析 HTML 的时候默认会经过匹配器过滤脚本标签和事件属性会被剥离。但保险起见服务端保存前最好再做一次 HTML 清洗别把这层安全寄托在编辑器上。4.2 粘贴 Excel/Word 表格进来怎么尽量保留格式这是真实项目里被问到最多的需求“我要把 Excel 表格直接粘贴到编辑器里最好表格样式也能保留。”现实很骨感Excel 复制出来的内容是浏览器剪切板的 HTML 片段里面充斥着大量微软特有的内联样式Quill 默认的 clipboard matcher 不认识这些标签会把它拆成纯文本甚至直接丢弃。要改善这个情况需要注册自定义的 clipboard matcher把系统粘贴进来的table标签转成 quill-better-table 能理解的结构。我这里提供一个简化思路this.quill.clipboard.addMatcher(TABLE, (node, delta) { // node 是原始 DOM 里的 table // 你需要在外面手动把 node 转成 better-table 需要的 HTML再走 dangerouslyPasteHTML const html node.outerHTML const tempQuill new Quill(document.createElement(div), { modules: { better-table: true } }) tempQuill.clipboard.dangerouslyPasteHTML(html) return tempQuill.getContents() })这里我用了临时 Quill 实例来转换是为了直接复用数据处理管线。但你需要意识到这个方案并不完美复杂的合并单元格、跨行跨列、列宽设置转换过去之后很可能会丢样式原因还是 Delta 模型对二维结构的表达能力有限。能保证的是普通行列的表格能完整保留数据内容。4.3 合并单元格在数据回显时丢失怎么办quill-better-table 的合并单元格操作底层是通过设置单元格的 rowspan/colspan 来实现的。当你用quill.root.innerHTML获取内容时HTML 里 rowspan/colspan 属性是在的保存到数据库没问题。问题出在回显。dangerouslyPasteHTML在解析这个 HTML 时Quill 内部会把标签转换成 Delta而 quill-better-table 在定义自己的 blot 时依赖的是td这个标签以及它内部包含的格式标识。如果表格里有合并单元格解析器对行列关系的重建就会比较吃力。我遇到的现象是合并过的单元格内容出现在错误位置或者整个表格渲染出来后行列错乱。我的处理方案是加一个“表格净化”函数在保存数据前把合并单元格的 HTML 结构做一次校验和压平。具体做法是如果一个单元格同时包含 rowspan 和 colspan且数值都大于 1就记录它的位置和原始内容然后把多余的 span 属性临时清掉再保存一份纯净版本。这在业务上牺牲了一部分合并能力但换来了数据稳定性。如果产品硬要求必须保留复杂合并结构你就得做好自定义 serializer 的准备那不是简单一两天能搞定的。5. 线上环境避坑实录常见表格问题排查5.1 表格列宽无法拖拽多半不是插件的锅我在项目上线后收到过几次反馈说“表格列宽拖不动”。第一反应是插件 bug翻源码查了好一阵才发现问题出在项目全局样式上。quill-better-table 的列宽拖拽依赖的是 table 元素上的table-layout: fixed属性和 colgroup 结构。如果你的项目为了兼容旧样式手动设置了table { width: 100% !important; table-layout: auto; }那拖拽逻辑就会被破坏。排查顺序建议是打开浏览器控制台检查 table 元素的 table-layout 计算样式检查是不是有全局样式覆盖了.ql-container .ql-editor table的宽度设置确认 better-table 的 CSS 文件有没有被打包进去有些脚手架会把 CSS 中的字体文件过滤掉导致样式缺失解决方式也比较粗暴可以给编辑器作用域内加一层优先级更高的覆盖.editor-content table { table-layout: fixed !important; width: 100% !important; }5.2 表格样式被全局 CSS 冲掉这个坑几乎每个后台系统都会遇到新项目脚手架里内置了 Element UI 或者 Ant Design 的样式重置会重置td、th的 padding、border、background 等属性结果富文本里的表格看起来就像一块扁平的铁板一点立体感都没有。最快的处理办法是在表格的外层父容器加一个独立作用域只针对编辑器内部的表格做样式补全.editor-wrapper { ::v-deep .ql-editor { table { border-collapse: collapse; width: 100%; } table td, table th { border: 1px solid #ccc; padding: 5px 10px; } } }这里用::v-deep是穿透 scoped 样式不同构建工具写法略有区别但思路一致。你要注意别把td的全局样式也改了否则富文本以外的页面表格全部会受影响。5.3 图片上传和表格编辑器的冲突我的项目里图片上传是自定义 handler用户选择图片后先调用接口上传拿到 URL 再insertEmbed插入到编辑器。这个流程单独跑没问题但在表格单元格里插入图片时出现了异常图片插入的位置偏到了表格外面。原因在于插入图片时需要用 Quill 的getSelection来确定光标位置而光标在表格单元格内时quill-better-table 的选区模型和 Quill 原生选区存在偏移。解决办法是插入图片前先调用betterTable.getTableRange()判断光标是否在表格内如果是就用表格内的选中位置来辅助定位不要直接用 Quill 的 selection。代码大致如下const betterTable this.quill.getModule(better-table) const tableRange betterTable.getTableRange() if (tableRange) { this.quill.setSelection(tableRange.index tableRange.length, 0) } this.quill.insertEmbed(this.quill.getSelection().index, image, url)这个顺序是先移动光标到表格单元格末尾再插入图片实测能解决大部分偏移问题。5.4 窄屏和移动端的体验问题后台系统基本都在 PC 上用但还是会有产品经理提出“平板能不能看”这种要求。表格在窄屏下会直接撑破编辑器容器横向出现滚动条都算好的严重的是表格内容直接溢出看不见。我的方案是给编辑器容器加横向滚动同时把表格最小宽度控制住.editor-content { overflow-x: auto; } .editor-content .ql-editor table { min-width: 600px; }这样在窄屏上可以在容器层面横向滑动而不是页面整体变形。缺点也很明显超过 600px 宽度的表格在小屏上不看全貌但至少不会破坏整个页面布局属于能接受的妥协方案。6. 再往前一步按业务需求定制表格能力6.1 自定义单元格背景色与对齐方式quill-better-table 默认的单元格样式只有基础边框和间距产品经常提出“把某一行标黄”“某一列右对齐”这样的需求。这需要你基于 Quill 的 format 机制扩展自定义 blot 格式。我先注册一个简单的cellBackground格式const CellBackgroundStyle Quill.import(attributors/style/background) Quill.register(CellBackgroundStyle, true)然后在工具栏加一个颜色选择器通过quill.format(background, color)给当前单元格设置背景色。注意 Quill 只对当前选区的内容起作用如果你把整个单元格区域选中后再设置它可能会给单元格内部的文本都加上背景而不是给单元格本身加背景这个行为需要反复测试。更靠谱的方式是遍历当前表格的所有单元格逐个设置属性const table betterTable.getTable() table.getCells().forEach(cell { cell.format(background, color) })getCells()返回的是表格内所有单元格的 blot 实例这是 quill-better-table 暴露的方法比直接操作原生 DOM 安全很多。6.2 一键生成长度固定的表格有些业务场景里表格结构是固定的比如报销单、考勤表你希望用户点一下按钮就生成一个 5 行 6 列的表格并且第一行默认是表头样式。这个完全可以封装成工具函数function insertSettlementTable() { const betterTable this.quill.getModule(better-table) const table betterTable.insertTable(6, 6) // 首行设置表头样式 const cells table.getCells() cells.slice(0, 6).forEach(cell { cell.format(header, 1) }) }注意insertTable(rows, cols)的第一个参数是行数第二个是列数顺序别搞反。我在一个同事的代码里看到他把参数传反了生成出来的表格变成了 6 行 6 列还好如果是 3 行 8 列和 8 行 3 列业务数据返工的时候非常痛苦。6.3 表格内容导出为 Markdown 或 Word最后说说导出。运营同事经常要求把编辑好的内容一键复制到公众号后台或者 Word 文档里。公众号后台一般接受 HTML 粘贴直接用剪贴板内容就行。但如果你要生成 Markdown 文件就需要把 HTML 表格转成 GitFlavored Markdown 的管道语法。我用的方案是 Turndown自定义一个 table 的 ruleimport TurndownService from turndown const turndownService new TurndownService({ headingStyle: atx, codeBlockStyle: fenced }) turndownService.addRule(strikethrough, { filter: [del, s, strike], replacement: content ~~ content ~~ }) const markdown turndownService.turndown(htmlString)Turndown 自带 table 转换能力生成的 Markdown 表格能在 GitHub、语雀、飞书等平台正常渲染。不过它对合并单元格支持有限如果你表格里有 rowspan/colspan导出时大概率会变成普通表格这是 Markdown 语法本身的限制无解。如果你需要导出 Word另一个思路是直接把 HTML 保存为.doc后缀Word 能识别大部分 HTML 标签但严谨一点的方案是用 html-docx-js 或者 pandoc 服务端转换这个可以根据你项目的技术栈取舍。最后说几句体己话给 Quill 加表格这个事我前后折腾了小一个月最终稳定运行的方案就是 quill 1.3.7 quill-better-table 1.2.10。中间无数次想放弃换编辑器但业务上已经被 Quill 的其他定制功能绑死了。如果你是新项目说实话我会认真评估 Tiptap 或者 wangEditor尤其是 Tiptap它基于 ProseMirror表格体验比 Quill 好很多。但如果你和我一样只能守着现有 Quill 项目改造那 quill-better-table 是目前最值得投入的路径。唯一要记住的是别轻易升级版本别贪多自定义功能先让表格能稳定插入、保存、回显再慢慢加东西。改动任何核心模块代码之前先备份一份能跑的版本这个习惯救过我很多次。
返回列表