富文本编辑器在后台管理系统、内容发布平台、社区论坛这类项目里几乎是标配需求。我做过好几个 Vue 2 的中后台项目,每次遇到"让运营同学能像用 Word 一样编辑文章"这个需求时,选型都会绕一圈,最后大概率还是回到vue-quill-editor上。原因很朴素:它基于 Quill,体积可控、API 干净、和 Vue 的响应式数据绑定天然契合,而且社区沉淀足够厚,遇到问题基本都能搜到答案。这篇就把我在实际项目里从零接入vue-quill-editor的完整过程拆开讲,包括安装、初始化、工具栏定制、图片上传改造、内容回显、以及几个我踩过的坑。代码部分我会给到可以直接复制运行的程度,适合正在做 Vue 2 项目、需要快速落地一个可用富文本编辑器的同学参考。
1. 为什么在 Vue 2 项目里我仍然优先选 vue-quill-editor
1.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 class="editor-wrapper"> <quill-editor v-model="content" :options="editorOptions" @change="onEditorChange" /> </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 地址最好,这样内容里的图片加载快。
注意:一定要在后端做文件类型校验,不能只信前端。前端校验是体验,后端校验是安全。我见过只在前端限制
accept="image/*"的项目,用户改个后缀就能传任意文件,这是隐患。
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是否为空,因为空编辑器里可能残留<p><br></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-model="form.contentA" ref="editorA" /> <quill-editor v-model="form.contentB" ref="editorB" />7. 完整前端代码与工程化建议
7.1 封装成可复用组件
把上面的东西整合成一个组件,对外暴露value和change事件,这样在表单里就能像普通输入框一样用:
<template> <div class="rich-editor"> <quill-editor ref="myQuillEditor" v-model="innerContent" :options="editorOptions" @change="onChange" @blur="onBlur" /> </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的完整链路。我个人在实际项目中的体会是,这个库最大的价值不在于功能多强,而在于它的"薄"——配置直观、逃生舱口清晰、遇到问题容易定位。真正花时间的往往不是编辑器本身,而是图片上传、内容回显、样式隔离这些周边问题,把这几块处理干净,剩下的就是水到渠成的事。