Vue 2 项目实战:vue-quill-editor 富文本编辑器从接入到图片上传改造

发布时间:2026/10/13 5:53:56
Vue 2 项目实战:vue-quill-editor 富文本编辑器从接入到图片上传改造 富文本编辑器在后台管理系统、内容发布平台、社区论坛这类项目里几乎是标配需求。我做过好几个 Vue 2 的中后台项目每次遇到让运营同学能像用 Word 一样编辑文章这个需求时选型都会绕一圈最后大概率还是回到vue-quill-editor上。原因很朴素它基于 Quill体积可控、API 干净、和 Vue 的响应式数据绑定天然契合而且社区沉淀足够厚遇到问题基本都能搜到答案。这篇就把我在实际项目里从零接入vue-quill-editor的完整过程拆开讲包括安装、初始化、工具栏定制、图片上传改造、内容回显、以及几个我踩过的坑。代码部分我会给到可以直接复制运行的程度适合正在做 Vue 2 项目、需要快速落地一个可用富文本编辑器的同学参考。1. 为什么在 Vue 2 项目里我仍然优先选 vue-quill-editor1.1 选型时真正要权衡的几个维度很多人选富文本编辑器只看功能多不多这是个误区。功能越多往往意味着体积越大、定制越难、和框架的耦合越深。我在实际项目里会从这几个维度去评估体积与加载性能后台系统虽然对首屏要求没那么苛刻但一个动辄几百 KB 的编辑器会明显拖慢页面。Quill 的核心压缩后大概在 40KB 出头vue-quill-editor只是它的 Vue 封装增量很小。数据格式是否可控Quill 默认输出 Delta 格式一种 JSON 描述文档结构的格式也可以直接拿 HTML。后台系统通常需要把内容存进数据库再回显HTML 是最省事的Delta 则更适合做协同编辑。工具栏定制成本运营的需求千奇百怪今天要加个字号明天要去掉某个按钮。Quill 的工具栏配置是声明式的改起来很直观。图片/视频处理能力默认的图片处理是把图片转成 base64 塞进内容里这在生产环境是灾难——内容体积爆炸、数据库压力大。必须能改成上传到自己的服务器Quill 提供了对应的钩子。维护活跃度vue-quill-editor本身更新不算频繁但它依赖的 Quill 一直在维护而且封装层足够薄即使封装库停更自己接管也不难。综合下来对于内容编辑 图文混排 存 HTML这类主流需求vue-quill-editor是性价比最高的选择。如果你的需求是复杂的表格编辑、协同编辑、或者需要极其精细的排版控制那可能要去看别的方案但那是另一个话题了。1.2 vue-quill-editor 和原生 Quill 的关系这里要理清一个概念避免后面配置时犯迷糊。vue-quill-editor并不是重写了一个编辑器它只是把 Quill 实例的生命周期和 Vue 组件的生命周期做了绑定同时通过v-model把内容同步到 Vue 的数据里。所以你看到的绝大多数配置项比如toolbar、modules、placeholder其实都是 Quill 原生的配置只是通过 props 透传进去。理解这一点很重要当你在网上搜到一段 Quill 的配置代码发现vue-quill-editor的文档里没写不要慌大概率直接塞进options里就能用。反过来如果某个功能vue-quill-editor没暴露你也可以通过ref拿到 Quill 实例直接操作。// 通过 ref 拿到 Quill 原生实例 this.$refs.myQuillEditor.quill拿到这个实例之后Quill 的所有 API 你都能用比如getContents()、setContents()、getText()等等。这是它比一些封装过度的库更讨喜的地方——封装层薄逃生舱口随时开着。2. 从零接入安装、注册与最小可用示例2.1 依赖安装与版本选择安装本身没什么难度但版本这块有个坑要提醒。vue-quill-editor是给 Vue 2 用的如果你项目是 Vue 3得用别的方案比如vueup/vue-quill。确认你的 Vue 版本是 2.x 再往下走。npm install vue-quill-editor quill --save注意这里我把quill也显式装了一遍。虽然vue-quill-editor的依赖里带了 Quill但显式安装可以让你锁定 Quill 的版本避免某天依赖树里 Quill 被升级导致样式或 API 变化。我一般会在package.json里把 Quill 固定在一个稳定版本比如1.3.7这个版本足够成熟社区资料也最全。2.2 全局注册还是局部注册两种方式我都用过结论是如果项目里只有一两个页面用到编辑器局部注册如果多个模块都要用全局注册。全局注册的写法// main.js import Vue from vue import VueQuillEditor from vue-quill-editor // 引入样式这两行千万别漏漏了工具栏会变成一堆没有样式的按钮 import quill/dist/quill.core.css import quill/dist/quill.snow.css import quill/dist/quill.bubble.css Vue.use(VueQuillEditor)样式引入是新手最容易翻车的地方。Quill 有两套主题snow是带工具栏的经典样式bubble是气泡式选中文字才弹出工具栏。你用什么主题就引什么样式但通常三个都引上也没坏处体积很小。我见过有人只引了 JS 没引 CSS结果编辑器渲染出来是一堆裸露的按钮排查半天。局部注册更适合按需加载import { quillEditor } from vue-quill-editor import quill/dist/quill.snow.css export default { components: { quillEditor } }2.3 最小可用示例先给一个能跑起来的最简版本把核心链路打通template div classeditor-wrapper quill-editor v-modelcontent :optionseditorOptions changeonEditorChange / /div /template script import { quillEditor } from vue-quill-editor import quill/dist/quill.snow.css export default { name: SimpleEditor, components: { quillEditor }, data() { return { content: p在这里开始你的创作.../p, editorOptions: { placeholder: 请输入内容, theme: snow } } }, methods: { onEditorChange({ html, text }) { // html 是富文本内容text 是纯文本 console.log(HTML:, html) console.log(纯文本:, text) } } } /script style scoped .editor-wrapper { min-height: 400px; } /style这段代码跑起来你就有了一个带默认工具栏的编辑器。v-model双向绑定内容change事件里能同时拿到 HTML 和纯文本纯文本在做字数统计、摘要生成时特别有用。提示v-model绑定的内容在编辑器初始化时如果为空字符串Quill 会显示 placeholder如果传了 HTML它会解析后渲染。注意传进去的 HTML 要符合 Quill 支持的标签范围太复杂的嵌套结构可能被它清洗掉。3. 工具栏定制把默认按钮换成运营真正需要的3.1 默认工具栏长什么样为什么需要改不配置toolbar时Quill 会渲染一套默认按钮加粗、斜体、下划线、删除线、标题、引用、代码块、列表、缩进、对齐、链接、图片、视频、清除格式。看起来挺全但实际项目里往往要动刀有些按钮运营根本不用比如代码块、视频留着反而让界面显得杂乱。有些功能默认没有比如字号选择、字体选择、行高。按钮顺序不符合使用习惯常用的应该放前面。3.2 用数组配置工具栏最直观的方式是用二维数组配置每个子数组代表工具栏的一行editorOptions: { placeholder: 请输入内容, theme: snow, modules: { toolbar: [ [bold, italic, underline, strike], // 加粗、斜体、下划线、删除线 [blockquote, code-block], // 引用、代码块 [{ header: 1 }, { header: 2 }], // 标题1、标题2 [{ list: ordered }, { list: bullet }], // 有序、无序列表 [{ script: sub }, { script: super }], // 上下标 [{ indent: -1 }, { indent: 1 }], // 缩进 [{ direction: rtl }], // 文字方向 [{ size: [small, false, large, huge] }], // 字号 [{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题级别 [{ color: [] }, { background: [] }], // 字体颜色、背景色 [{ font: [] }], // 字体 [{ align: [] }], // 对齐 [clean], // 清除格式 [link, image, video] // 链接、图片、视频 ] } }这个配置基本覆盖了常见需求。数组里的字符串就是 Quill 内置的格式名{ header: 1 }这种对象形式表示带参数的下拉或按钮。3.3 用对象配置实现更精细的控制数组配置够用但如果你想给工具栏加自定义按钮、或者想精确控制某个下拉框的选项就得用对象形式modules: { toolbar: { container: [ [bold, italic, underline], [{ header: [1, 2, 3, false] }], [{ color: [] }, { background: [] }], [image, link], [clean] ], handlers: { // 自定义图片处理后面会详细讲 image: function() { // 这里可以打开自己的上传弹窗 } } } }container定义按钮布局handlers定义每个按钮点击后的行为。默认情况下 Quill 会用自己的逻辑处理但你可以覆盖它。这是后面做图片上传改造的关键入口。3.4 一个我常用的精简工具栏配置实际项目里我一般会砍掉视频、代码块这些运营用不上的加上字号和颜色最终配置大概是这样editorOptions: { placeholder: 请输入文章内容..., theme: snow, modules: { toolbar: { container: [ [{ header: [1, 2, 3, false] }], [bold, italic, underline, strike], [{ color: [] }, { background: [] }], [{ list: ordered }, { list: bullet }], [{ indent: -1 }, { indent: 1 }], [{ align: [] }], [blockquote, link, image], [clean] ] } } }这个配置在多个项目里验证过运营上手快界面也不乱。字号那块我通常不加因为 Quill 默认的字号选项small/large/huge在中文排版里效果一般不如直接用标题级别来控制。4. 图片上传改造告别 base64 塞满数据库4.1 默认图片处理为什么不能用于生产Quill 默认的图片按钮点击后会弹出一个文件选择框选完图片后它把图片读成 base64 字符串直接塞进内容 HTML 里。小图还好一张几 MB 的图转成 base64 后体积会膨胀约 33%如果一篇文章插十张图内容字段轻松上百 KB 甚至上 MB。后果很直接数据库单条记录变大、接口响应变慢、页面渲染卡顿。所以生产环境必须改成上传到服务器内容里只存图片 URL。4.2 覆盖 image handler 的完整实现思路是拦截图片按钮的默认行为弹出自己的文件选择上传到后端拿到 URL 后手动插入到编辑器光标位置。data() { return { content: , editorOptions: { placeholder: 请输入内容, theme: snow, modules: { toolbar: { container: [ [bold, italic, underline], [{ header: [1, 2, 3, false] }], [image, link], [clean] ], handlers: { image: this.handleImageUpload } } } } } }, methods: { handleImageUpload() { // 创建一个隐藏的 input 来选文件 const input document.createElement(input) input.setAttribute(type, file) input.setAttribute(accept, image/*) input.click() input.onchange async () { const file input.files[0] if (!file) return // 简单的前置校验 const isLt5M file.size / 1024 / 1024 5 if (!isLt5M) { this.$message.error(图片大小不能超过 5MB) return } const formData new FormData() formData.append(file, file) try { // 这里换成你自己的上传接口 const res await this.$axios.post(/api/upload/image, formData, { headers: { Content-Type: multipart/form-data } }) if (res.data.code 0) { const imageUrl res.data.data.url // 拿到 Quill 实例在光标处插入图片 const quill this.$refs.myQuillEditor.quill const range quill.getSelection(true) quill.insertEmbed(range.index, image, imageUrl) // 插入后把光标移到图片后面 quill.setSelection(range.index 1) } else { this.$message.error(图片上传失败) } } catch (err) { this.$message.error(图片上传异常) } } } }几个关键点解释一下quill.getSelection(true)里的true表示如果编辑器失焦了也返回最后一次的光标位置。这个参数很重要因为点击文件选择框时编辑器会失焦不加这个参数range可能是null。insertEmbed是 Quill 提供的插入嵌入内容的方法除了图片还能插视频、公式等。插入后调setSelection把光标往后挪一位否则连续插入两张图时第二张会插到第一张前面。4.3 上传接口的约定与后端配合前端改造完了后端接口也得对得上。我一般约定上传接口返回这样的结构{ code: 0, msg: success, data: { url: https://your-cdn.com/images/2024/xxx.jpg } }后端要做的事接收multipart/form-data、校验文件类型和大小、存到对象存储或本地、返回可访问的 URL。如果用了 CDN返回的 URL 直接是 CDN 地址最好这样内容里的图片加载快。注意一定要在后端做文件类型校验不能只信前端。前端校验是体验后端校验是安全。我见过只在前端限制acceptimage/*的项目用户改个后缀就能传任意文件这是隐患。4.4 图片插入后的样式问题图片插进去之后默认是原始尺寸大图会撑破编辑器宽度。可以在全局样式里加一条.ql-editor img { max-width: 100%; height: auto; }这样图片最大宽度不会超过编辑器高度自适应。如果想让图片能居中、能调大小那就得引入额外的 Quill 模块比如quill-image-resize-module但那个模块和 Vue 的集成稍微麻烦点需要处理 webpack 配置不是刚需的话可以先不做。5. 内容回显、清空与表单校验的实战处理5.1 编辑场景下的内容回显新增和编辑通常共用一个组件。编辑时父组件把已有内容传进来编辑器要正确渲染。这里有个时序问题如果内容是在组件mounted之后才异步拿到的直接改v-model绑定的值Quill 不一定能正确更新。我一般这样处理props: { value: { type: String, default: } }, watch: { value(newVal) { // 避免死循环只有外部值和内部值不一致时才更新 if (newVal ! this.content) { this.content newVal } } }如果发现异步赋值后编辑器没更新可以强制刷新一下this.$nextTick(() { const quill this.$refs.myQuillEditor.quill quill.root.innerHTML this.content })直接操作quill.root.innerHTML是绕过 Quill 数据层的方式能强制渲染但要注意它不会更新 Quill 内部的 Delta 状态。一般场景够用如果后续还要基于 Delta 做操作建议用quill.clipboard.dangerouslyPasteHTML()或者setContents()。5.2 清空编辑器的正确姿势表单提交成功后要清空编辑器直接this.content 有时不生效因为 Quill 内部状态没同步。稳妥的做法resetEditor() { const quill this.$refs.myQuillEditor.quill quill.setContents([]) // 清空内容 quill.setSelection(0) // 光标回到开头 this.content }setContents([])传空数组表示清空所有内容这是 Quill 官方推荐的方式。5.3 和表单校验库配合如果用 Element UI 的el-form富文本编辑器不是原生表单控件校验规则不会自动触发。常见做法是自定义校验rules: { content: [ { required: true, message: 请输入内容, trigger: blur }, { validator: (rule, value, callback) { // 去掉 HTML 标签后判断是否有实际内容 const text value.replace(/[^]/g, ).trim() if (!text) { callback(new Error(内容不能为空)) } else { callback() } }, trigger: change } ] }关键点不能直接判断value是否为空因为空编辑器里可能残留pbr/p这种标签。要去掉标签后再判断纯文本。这个坑我踩过明明没输入内容校验却通过了。6. 那些年我踩过的坑与排查思路6.1 工具栏图标不显示或样式错乱现象编辑器渲染出来了但工具栏按钮是一堆没有图标的方块或者按钮挤在一起。排查链路先看 CSS 有没有引入。打开浏览器控制台看.ql-toolbar相关的样式是否加载。如果没加载检查import quill/dist/quill.snow.css这行在不在。如果 CSS 引入了还不对看是不是被项目的全局样式覆盖了。有些项目会写button { background: none }这种全局重置把 Quill 的按钮背景干掉了。再检查 Quill 版本。不同版本的 CSS 类名可能有差异vue-quill-editor和quill版本不匹配时会出现样式错位。解决确保 CSS 引入必要时用::v-deep或全局样式给 Quill 的类名加权重.editor-wrapper ::v-deep .ql-toolbar { border-color: #dcdfe6; } .editor-wrapper ::v-deep .ql-container { border-color: #dcdfe6; min-height: 300px; }6.2 内容里的样式在展示页丢失现象编辑器里排版好好的存到数据库再在详情页展示时加粗、颜色、对齐全没了。原因详情页没有引入 Quill 的样式文件。Quill 输出的 HTML 依赖.ql-*这些类名来呈现样式比如对齐是靠.ql-align-center这个类实现的。详情页不引 CSS这些类就没有对应样式。解决在详情页也引入quill/dist/quill.snow.css或者把 Quill 的核心样式抽出来放到全局。我一般会在项目的全局样式里引一次省得每个页面都引。6.3 粘贴外部内容带进来一堆垃圾样式现象从 Word 或别的网页复制内容粘贴进来带进来一堆style属性、class、甚至script标签把页面搞乱。原因Quill 默认会尽量保留粘贴内容的格式但它的清洗规则不够激进。解决配置clipboard模块的matchers或者干脆在粘贴时只保留纯文本modules: { clipboard: { matchers: [ // 匹配所有节点只保留纯文本 [*, (node, delta) { // 这里可以做更精细的过滤 return delta }] ] } }更简单的做法是监听粘贴事件手动处理。不过大多数情况下Quill 默认的清洗已经够用除非你发现确实有脏数据进来再去针对性处理。6.4 编辑器高度不随内容增长现象内容多了之后编辑器内部出现滚动条而不是整体变高。原因.ql-container默认有固定高度或者overflow设置。解决覆盖样式让容器自适应.editor-wrapper ::v-deep .ql-container { min-height: 300px; height: auto; } .editor-wrapper ::v-deep .ql-editor { min-height: 300px; }设min-height保证空内容时也有足够点击区域height: auto让内容多了自动撑开。6.5 多个编辑器实例互相干扰现象一个页面放了两个编辑器改一个另一个也跟着变。原因v-model绑定了同一个数据或者ref重名了。解决每个编辑器绑定独立的数据字段ref也要区分开。如果是在v-for里循环生成编辑器ref会变成数组取值时要注意。quill-editor v-modelform.contentA refeditorA / quill-editor v-modelform.contentB refeditorB /7. 完整前端代码与工程化建议7.1 封装成可复用组件把上面的东西整合成一个组件对外暴露value和change事件这样在表单里就能像普通输入框一样用template div classrich-editor quill-editor refmyQuillEditor v-modelinnerContent :optionseditorOptions changeonChange bluronBlur / /div /template script import { quillEditor } from vue-quill-editor import quill/dist/quill.core.css import quill/dist/quill.snow.css export default { name: RichEditor, components: { quillEditor }, props: { value: { type: String, default: }, placeholder: { type: String, default: 请输入内容... }, uploadUrl: { type: String, default: /api/upload/image } }, data() { return { innerContent: this.value, editorOptions: { placeholder: this.placeholder, theme: snow, modules: { toolbar: { container: [ [{ header: [1, 2, 3, false] }], [bold, italic, underline, strike], [{ color: [] }, { background: [] }], [{ list: ordered }, { list: bullet }], [{ indent: -1 }, { indent: 1 }], [{ align: [] }], [blockquote, link, image], [clean] ], handlers: { image: this.handleImageUpload } } } } } }, watch: { value(val) { if (val ! this.innerContent) { this.innerContent val } }, innerContent(val) { this.$emit(input, val) } }, methods: { onChange({ html, text }) { this.$emit(change, { html, text }) }, onBlur() { this.$emit(blur) }, handleImageUpload() { const input document.createElement(input) input.type file input.accept image/* input.click() input.onchange async () { const file input.files[0] if (!file) return if (file.size / 1024 / 1024 5) { this.$message this.$message.error(图片不能超过 5MB) return } const formData new FormData() formData.append(file, file) try { const res await this.$axios.post(this.uploadUrl, formData, { headers: { Content-Type: multipart/form-data } }) if (res.data.code 0) { const quill this.$refs.myQuillEditor.quill const range quill.getSelection(true) quill.insertEmbed(range.index, image, res.data.data.url) quill.setSelection(range.index 1) } } catch (e) { this.$message this.$message.error(上传失败) } } }, clear() { const quill this.$refs.myQuillEditor.quill quill.setContents([]) quill.setSelection(0) this.innerContent } } } /script style scoped .rich-editor ::v-deep .ql-container { min-height: 300px; height: auto; } .rich-editor ::v-deep .ql-editor { min-height: 300px; } .rich-editor ::v-deep .ql-editor img { max-width: 100%; height: auto; } /style这个组件对外只暴露value、placeholder、uploadUrl三个 props以及change、blur事件和一个clear方法用起来很清爽。7.2 按需加载与打包优化如果编辑器只在少数页面用建议用异步组件按需加载避免打进主包const RichEditor () import(/components/RichEditor.vue) export default { components: { RichEditor } }这样编辑器相关的代码会被单独打成一个 chunk只有进入编辑页面才加载。对于后台系统这个优化能明显改善首屏。7.3 内容存储格式的选择建议最后聊聊存什么格式。我的建议是存 HTML理由回显简单直接v-html渲染配合引入 Quill 样式即可。后端处理方便做全文检索、敏感词过滤时直接对 HTML 做处理记得先过滤标签。迁移成本低将来换编辑器HTML 是通用格式。Delta 格式虽然更纯粹但回显时需要重新用 Quill 渲染且不利于后端处理。除非你要做协同编辑或者版本对比否则没必要用 Delta。提示存 HTML 时记得对内容做 XSS 过滤尤其是允许用户投稿的场景。前端可以用 DOMPurify 清洗后端也要做一层过滤双保险。整套流程走下来从安装到图片上传改造再到组件封装基本覆盖了 Vue 2 项目里使用vue-quill-editor的完整链路。我个人在实际项目中的体会是这个库最大的价值不在于功能多强而在于它的薄——配置直观、逃生舱口清晰、遇到问题容易定位。真正花时间的往往不是编辑器本身而是图片上传、内容回显、样式隔离这些周边问题把这几块处理干净剩下的就是水到渠成的事。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询