Vue 2富文本编辑器图片上传改造:从base64到服务端上传的完整实践

发布时间:2026/10/15 1:43:01
Vue 2富文本编辑器图片上传改造:从base64到服务端上传的完整实践 1. 为什么图片上传是富文本编辑器里最容易翻车的一环做过后台管理系统的人大概率都碰过这个场景运营同学跑过来跟你说“我在文章里插了张图怎么预览的时候裂了”你打开数据库一看存进去的是一长串data:image/png;base64,iVBORw0KGgo...几兆的字符串直接怼进了正文字段里。再查一下接口响应时间原本 200ms 的列表查询变成了 3 秒。这就是富文本编辑器图片上传最典型的翻车现场。vue-quill-editor是 Vue 2 生态里用得非常多的一款富文本编辑器封装它底层是 Quill。Quill 本身设计得挺克制默认只给你一个最基础的图片处理方式——把图片转成 base64 塞进内容里。这个设计在写个便签、记个笔记的场景下没问题但一旦放到真实的内容管理系统里base64 方案会带来三个致命问题内容体积爆炸、数据库压力陡增、图片无法被 CDN 缓存。所以几乎每一个用vue-quill-editor的项目最终都要做一件事把默认的 base64 上传改成走自己服务器的文件上传接口。这篇内容就是围绕这件事展开的。我会把整个改造过程拆开讲清楚Quill 的工具栏是怎么配置的、默认的图片按钮背后到底发生了什么、怎么用自定义 handler 接管上传逻辑、上传成功后怎么把图片地址插回编辑器、以及在这个过程中我踩过的那些坑——比如光标位置丢失、图片插入后不显示、上传中状态处理、多图连续上传等等。适合正在用 Vue 2 vue-quill-editor做后台项目的同学也适合想理解富文本编辑器图片处理机制的人。哪怕你用的是别的编辑器底层的思路也是相通的。先说清楚一个前提这篇讲的是 Vue 2 环境下的vue-quill-editor如果你用的是 Vue 3对应的库一般是vueup/vue-quillAPI 有差异但核心原理一致思路可以直接迁移。2. Quill 默认的图片处理链路到底做了什么2.1 工具栏按钮与 handler 的绑定关系要改造图片上传第一步得先搞明白 Quill 的工具栏是怎么工作的。vue-quill-editor的toolbar配置项支持两种写法一种是直接给一个数组比如[[bold, italic], [image]]另一种是给一个对象可以更精细地控制每个按钮。当你写[image]的时候Quill 会渲染出一个图片按钮点击后触发它内置的 image 模块。关键点在于Quill 内置的 image handler 做的事情非常单一——弹出一个系统文件选择框用户选完图片后用FileReader把文件读成 base64然后调用quill.insertEmbed(range.index, image, base64String)把图片以 base64 的形式插入到编辑器里。整个过程不经过任何服务器图片数据直接变成了文档内容的一部分。这就解释了为什么很多人第一次用的时候觉得“哇好方便不用写上传接口”但用着用着就发现不对劲了。因为 base64 字符串的长度大约是原图体积的 1.37 倍一张 2MB 的图片转成 base64 就是将近 3MB 的文本。你想想一篇文章插五张图正文字段直接膨胀到 15MB数据库的TEXT类型都未必扛得住更别说每次查询都要把这坨东西读出来。2.2 为什么必须换成服务端上传把 base64 换成服务端上传收益是多方面的。最直接的是内容体积回归正常正文里只存一个图片 URL几十个字符数据库压力瞬间降下来。其次是图片可以被独立缓存走 CDN 或者 Nginx 缓存后同一张图被多次访问不会重复消耗服务器带宽。第三是可以做图片处理上传到服务端后你可以顺手做压缩、裁剪、加水印、生成缩略图这些在 base64 方案里根本没法做。还有一个容易被忽略的点base64 图片在编辑器里渲染时浏览器需要解析这一大坨字符串图片多了之后编辑器的输入会明显卡顿。换成 URL 后浏览器只需要发起一个普通的图片请求渲染性能好很多。所以无论从哪个角度看服务端上传都是必选项问题只是怎么优雅地接进去。2.3 改造的核心思路接管 image handler改造的核心就一句话把 Quill 默认的 image handler 替换成我们自己的。具体做法是在toolbar配置里把image按钮的 handler 指向一个自定义函数。这个函数里我们不再用FileReader读 base64而是创建一个隐藏的input[typefile]用户选完文件后走FormData上传到后端接口拿到返回的图片 URL 后再调用quill.insertEmbed把 URL 插进去。听起来简单但魔鬼在细节里。比如怎么拿到quill实例insertEmbed的 index 参数怎么确定上传是异步的用户在上传过程中又点了别的地方光标位置变了怎么办上传失败怎么提示这些才是真正决定这个功能好不好用的地方。下面我会一步步拆开讲。3. 从零搭建自定义图片上传的完整实现3.1 环境准备与依赖确认先确认你的项目环境。vue-quill-editor依赖quill一般安装的时候会自动带上但版本要留意。vue-quill-editor的 3.x 版本对应的是 Quill 1.x这是目前最稳定的组合。如果你装的是 Quill 2.x部分 API 有变化vue-quill-editor可能不兼容建议锁定 Quill 1.3.x。npm install vue-quill-editor quill --save # 建议锁定版本 npm install quill1.3.7 --save-exact然后在组件里引入import { quillEditor } from vue-quill-editor import quill/dist/quill.snow.css // 引入 snow 主题样式 import quill/dist/quill.core.css import quill/dist/quill.bubble.css export default { components: { quillEditor }, data() { return { content: , editorOption: { // 工具栏配置下面详细讲 } } } }这里有个小细节quill.snow.css是必须引入的否则工具栏会变成一堆没有样式的裸按钮看起来像坏了。很多人第一次用的时候忘了引 CSS然后到处找“为什么工具栏这么丑”其实就是样式没加载。3.2 工具栏配置把 image 按钮的 handler 换掉editorOption里的toolbar配置是改造的入口。推荐用对象写法因为数组写法没法指定 handler。配置大概长这样editorOption: { toolbar: { container: [ [bold, italic, underline, strike], [{ header: 1 }, { header: 2 }], [{ list: ordered }, { list: bullet }], [image, link], [clean] ], handlers: { image: function() { // 这里的 this 指向的是 Quill 实例 // 我们在这里接管图片上传 } } } }注意handlers里的image函数它的this上下文是 Quill 实例本身这一点非常关键因为后面插入图片要靠这个实例。但这里有个坑如果你用普通函数写法this是 Quill 实例没错但你就没法直接访问 Vue 组件里的this比如this.$axios、this.uploadUrl这些。解决办法有两个一是用箭头函数但箭头函数里this会变成外层的 Vue 组件拿不到 Quill 实例二是用普通函数然后通过闭包或者提前把 Quill 实例存起来。我一般用第二种在mounted或者ready事件里把 Quill 实例存到组件的一个变量上data() { return { quillInstance: null, editorOption: { toolbar: { container: [...], handlers: { image: () { this.handleImageUpload() } } } } } }, methods: { onEditorReady(quill) { this.quillInstance quill }, handleImageUpload() { // 这里 this 是 Vue 组件可以随便用 // 通过 this.quillInstance 操作编辑器 } }模板里绑定ready事件quill-editor v-modelcontent :optionseditorOption readyonEditorReady /这样就把 Quill 实例和 Vue 组件的上下文都拿到了后面操作起来就顺手了。3.3 自定义 handler 的完整代码实现下面是我在实际项目里用的完整实现拆成几个部分讲。第一步创建隐藏的文件输入框并触发点击handleImageUpload() { 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 // 校验文件类型和大小 if (!file.type.startsWith(image/)) { this.$message.error(只能上传图片文件) return } if (file.size 5 * 1024 * 1024) { this.$message.error(图片不能超过 5MB) return } await this.uploadImage(file) } }这里有几个细节值得说。acceptimage/*只是给系统文件选择框一个过滤提示用户还是可以手动选“所有文件”所以后端和前端都要做类型校验。大小限制我一般设 5MB因为再大的图片在网页里展示也没意义而且上传慢、失败率高。这个阈值可以根据业务调整但一定要有否则用户传个 20MB 的原图上来体验会很差。第二步上传文件并插入图片async uploadImage(file) { const formData new FormData() formData.append(file, file) // 记录当前光标位置这步很关键 const range this.quillInstance.getSelection(true) const insertIndex range ? range.index : this.quillInstance.getLength() // 插入一个占位提示 this.quillInstance.insertText(insertIndex, 图片上传中..., uploading) try { const res await this.$axios.post(/api/upload/image, formData, { headers: { Content-Type: multipart/form-data } }) // 删除占位文字 this.quillInstance.deleteText(insertIndex, 图片上传中....length) if (res.data res.data.url) { // 插入图片 this.quillInstance.insertEmbed(insertIndex, image, res.data.url) // 光标移到图片后面 this.quillInstance.setSelection(insertIndex 1) } else { this.$message.error(上传返回数据异常) } } catch (err) { // 上传失败删除占位文字 this.quillInstance.deleteText(insertIndex, 图片上传中....length) this.$message.error(图片上传失败请重试) } }这段代码是整个功能的核心里面藏着好几个坑我逐个解释。坑一光标位置。getSelection(true)返回的是当前选区true表示如果编辑器失焦了也返回最后一次的位置。如果不传true用户点击工具栏按钮后编辑器失焦getSelection()可能返回null你就拿不到插入位置了。拿到range.index后要把它存下来因为上传是异步的等接口返回时用户可能已经点了别的地方光标位置变了这时候再用getSelection()拿到的就是新位置图片会插错地方。坑二占位提示。上传需要时间如果什么都不显示用户会以为没反应可能重复点击。插入一段“图片上传中...”的文字让用户知道正在处理。这里用了一个自定义格式uploading需要在 Quill 里注册这个格式或者干脆用普通文本也行看你的需求。如果不想搞自定义格式直接insertText(insertIndex, 图片上传中...)也可以。坑三插入后光标位置。insertEmbed插入图片后光标默认还在图片前面用户继续打字会打在图片前面体验很怪。所以插入后要setSelection(insertIndex 1)把光标移到图片后面。坑四失败处理。上传失败时一定要把占位文字删掉否则编辑器里会残留一段“图片上传中...”用户看着莫名其妙。删除的时候要注意长度计算中文字符和英文字符在 Quill 里都算一个字符所以图片上传中....length是准确的。3.4 后端接口的约定与返回格式前端上传逻辑写好了后端接口得配合。我一般约定接口返回这样的结构{ code: 0, message: success, data: { url: https://cdn.example.com/images/2024/01/abc123.jpg, width: 800, height: 600 } }url是必须的前端拿它来插入图片。width和height可选如果你想让图片按原始比例显示可以在插入时设置。不过 Quill 的insertEmbed只接受一个值要设置宽高得用format或者直接操作 DOM稍微麻烦点一般项目里用 CSS 控制图片最大宽度就够了。后端存储这块我建议不要把图片存到应用服务器的本地磁盘。原因很简单一旦你部署多个实例用户上传的图片只存在于其中一台机器上其他机器访问就 404 了。正确的做法是存到对象存储比如云厂商的 OSS、COS 之类或者至少挂一个共享存储。如果项目规模小、单机部署存本地也不是不行但要记得配置静态资源映射让上传目录能通过 URL 访问到。还有一个细节文件名要重命名。用户上传的文件名可能包含中文、空格、特殊字符直接存会有各种编码问题。我一般用时间戳加随机字符串比如20240115143022_a3f8b2.jpg既保证唯一性又避免文件名冲突。4. 那些让我熬夜排查的坑与对应解法4.1 图片插入后不显示路径与跨域问题图片上传成功、URL 也插进去了但编辑器里显示一个裂图。这种情况我遇到过好几次原因基本逃不出两个URL 路径不对或者跨域限制。路径问题最常见。后端返回的可能是相对路径/uploads/2024/01/abc.jpg而前端页面部署在另一个域名下浏览器会基于当前页面域名去解析这个相对路径结果请求到了错误的地方。解决办法是后端直接返回完整 URL或者前端拼接一个基础路径。我倾向于后端返回完整 URL因为前端拼接容易在环境切换开发、测试、生产时出错。跨域问题稍微隐蔽一点。如果图片存在独立的图片服务器上而那个服务器没有配置允许你的域名访问浏览器加载图片时会被 CORS 策略拦截。注意img标签加载图片本身不受 CORS 限制但如果你的编辑器对图片做了 canvas 处理比如某些主题会做就会触发跨域。更常见的是图片服务器配置了防盗链检查Referer头如果你的域名不在白名单里图片就加载不出来。这种情况要去图片服务器那边加白名单。排查这类问题有个简单办法把插入的 URL 复制出来直接在浏览器地址栏打开。能打开说明 URL 没问题问题在编辑器渲染打不开说明 URL 本身或者服务器配置有问题。4.2 光标丢失与插入位置错乱的排查过程前面提到过光标位置的问题这里展开讲讲我是怎么发现并解决的。最初的实现是这样的// 错误写法 async uploadImage(file) { const formData new FormData() formData.append(file, file) const res await this.$axios.post(/api/upload/image, formData) // 直接在这里拿光标位置 const range this.quillInstance.getSelection() this.quillInstance.insertEmbed(range.index, image, res.data.url) }测试的时候发现如果用户点击图片按钮后立刻选文件上传图片能插到正确位置。但如果上传过程中用户点了编辑器其他地方图片就插到了新位置。更糟的是如果用户点击按钮后编辑器失焦getSelection()返回null代码直接报错Cannot read property index of null。修复方法就是在触发文件选择之前就把光标位置存下来而不是等上传完成后再拿。这就是为什么前面的代码里const range this.quillInstance.getSelection(true)要放在input.click()之后、await之前。这个顺序很关键我当初就是没注意顺序调了半天才发现问题。还有一个边界情况如果编辑器是空的用户直接点图片按钮getSelection(true)可能返回{ index: 0, length: 0 }这是正常的。但如果返回null就要兜底用this.quillInstance.getLength()这个值在空编辑器时是 1Quill 默认末尾有个换行符插入位置用getLength() - 1更准确。不过实际测试下来getSelection(true)在大多数情况下都能返回有效值兜底逻辑主要是防御性的。4.3 多图连续上传时的状态管理用户可能会连续上传多张图片。如果第一张还在上传中用户又点了图片按钮选了第二张这时候两个上传请求并发各自记录的光标位置可能相同导致两张图片插入位置重叠或者顺序错乱。我的处理方式是加一个上传中的标志位上传期间禁用图片按钮data() { return { uploading: false } }, methods: { handleImageUpload() { if (this.uploading) { this.$message.warning(上一张图片还在上传中请稍候) return } // ... 触发文件选择 }, async uploadImage(file) { this.uploading true try { // ... 上传逻辑 } finally { this.uploading false } } }这样虽然牺牲了一点并发性但避免了位置错乱的问题。对于大多数内容编辑场景用户不会真的需要同时上传多张图串行处理完全够用。如果你确实需要支持多选上传那就得用队列来管理每张图片记录自己的插入位置实现起来复杂不少一般项目没必要。4.4 编辑器内容为空时的边界处理还有一个容易被忽略的场景编辑器内容为空时插入图片。Quill 在空状态下文档里其实有一个默认的换行符getLength()返回 1。如果你用getSelection(true)拿到{ index: 0 }插入图片后文档变成“图片 换行符”这是正常的。但如果你用getLength()作为插入位置图片会插到换行符后面看起来就像图片前面多了一个空行。我的建议是统一用getSelection(true)只有在它返回null时才用getLength() - 1兜底。这样能保证大多数情况下插入位置符合用户预期。另外图片插入后如果用户直接保存内容里就是pimg src.../p这样的结构。有些后端会对内容做 XSS 过滤可能会把img标签过滤掉导致保存后图片消失。这个问题要在后端过滤规则里放行img标签和src属性同时注意过滤onerror等危险属性。前端这边可以在保存前打印一下this.content确认图片标签确实在内容里。5. 上传体验的进阶优化与工程化建议5.1 图片压缩在上传前把体积降下来用户从手机相册选的图片动辄好几兆直接上传既慢又浪费存储。我一般会在前端做一层压缩用canvas把图片按最大宽度缩放再转成Blob上传。核心代码如下compressImage(file, maxWidth 1200, quality 0.8) { return new Promise((resolve) { const reader new FileReader() reader.readAsDataURL(file) reader.onload (e) { const img new Image() img.src e.target.result img.onload () { const canvas document.createElement(canvas) let { width, height } img if (width maxWidth) { height Math.round(height * maxWidth / width) width maxWidth } canvas.width width canvas.height height const ctx canvas.getContext(2d) ctx.drawImage(img, 0, 0, width, height) canvas.toBlob((blob) { resolve(new File([blob], file.name, { type: image/jpeg })) }, image/jpeg, quality) } } }) }这段代码把图片最大宽度限制在 1200px质量 0.8一般能把几兆的图片压到几百 KB肉眼几乎看不出差别。注意canvas.toBlob是异步的要用 Promise 包一下。压缩后的文件类型统一转成image/jpeg因为 PNG 压缩效果不明显而且体积大。如果原图有透明通道转 JPEG 会变黑底这种情况要保留 PNG 格式判断一下file.type就行。5.2 上传进度与失败重试如果图片比较大上传需要几秒最好给用户一个进度提示。axios支持onUploadProgress回调const res await this.$axios.post(/api/upload/image, formData, { headers: { Content-Type: multipart/form-data }, onUploadProgress: (e) { if (e.total) { const percent Math.round((e.loaded / e.total) * 100) // 更新占位文字为进度 this.quillInstance.deleteText(insertIndex, currentPlaceholder.length) const newText 图片上传中 ${percent}% this.quillInstance.insertText(insertIndex, newText, uploading) currentPlaceholder newText } } })这里要注意每次更新进度都要先删掉旧的占位文字再插入新的否则文字会越堆越长。用一个变量记录当前占位文字的长度删除时用这个长度。这个细节不做的话进度文字会变成“图片上传中 10%图片上传中 20%...”这种鬼样子。失败重试的话我一般不做自动重试因为图片上传失败通常是网络问题或者文件太大自动重试大概率还是失败。更好的做法是提示用户“上传失败请重试”让用户自己决定。如果要做自动重试最多重试一次并且要加延迟避免瞬间打爆接口。5.3 把上传逻辑抽成可复用的 mixin如果你的项目里有多个地方用到富文本编辑器把图片上传逻辑抽成 mixin 会省很多事。大概结构是这样// mixins/quillImageUpload.js export default { data() { return { quillInstance: null, uploading: false } }, methods: { onEditorReady(quill) { this.quillInstance quill }, handleImageUpload() { // ... 完整上传逻辑 } } }组件里引入 mixin配置好toolbar的 handler 指向handleImageUpload就行。这样每个用到编辑器的组件只需要关心自己的业务逻辑上传这块统一维护。如果哪天要换上传接口或者加压缩逻辑改一个文件就够了。5.4 关于图片存储与访问的几点经验最后聊聊图片存储。前面提过不要存本地磁盘这里补充几个实操细节。用对象存储的话上传一般有两种方式前端直传和服务端中转。前端直传是前端从后端拿一个临时签名直接传到对象存储不经过应用服务器速度快、服务器压力小。服务端中转是前端传给后端后端再传到对象存储多一跳但控制力强可以做更严格的校验和处理。小项目用服务端中转就够了实现简单。大项目或者图片量大的话前端直传更合适。不管哪种方式图片的访问 URL 最好走 CDN并且配置好缓存策略。图片这种静态资源缓存时间可以设得很长比如一年因为图片内容不会变文件名带唯一标识。这样用户第二次访问同一张图时直接命中缓存速度飞快。还有一个安全细节上传接口一定要做鉴权不能裸奔。否则别人可以往你的存储里塞任意文件轻则浪费空间重则上传恶意文件。鉴权用项目里现有的登录态就行比如带上 token。另外后端要校验文件类型不能只信前端传的Content-Type要读文件头判断真实类型防止有人把可执行文件改名成.jpg传上来。这套方案我在几个后台项目里都用过稳定跑了挺长时间。核心就是那几行insertEmbed的代码但周边的细节——光标、占位、压缩、错误处理——才是决定这个功能好不好用的关键。把这些都考虑到基本就不会再收到“图片怎么裂了”的反馈了。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询