☰
Vue图片压缩插件Irz实战:base64输出与上传优化
2026/10/31 3:30:41 网站建设 项目流程

1. 先搞清楚前端压缩这件事的边界

1.1 一张手机照片的真实体积账单

做 Vue 项目做久了,上传功能是最容易被低估的一环。用户随手拍一张照片,直出 JPG 通常在 3MB 到 8MB 之间,某些高像素机型的原图能到 12MB 以上。你以为传的是"一张图",实际是十几兆的二进制流压在移动网络的上行带宽上。上行带宽跟下行完全不是一个量级,地铁里、电梯里、地下车库,上传进度条能卡在那儿半分钟一动不动,用户以为页面挂了,直接关掉。

这时候 Vue 图片压缩插件 Irz 就派上用场了。它做的事情非常明确:在浏览器里把用户选中的图片重新编码一遍,压到指定体积以内,并且直接吐出 base64 字符串,省掉你自己 FileReader 那一圈转换。标题里点出的"可直接获得图片的 base64 格式"是它最实用的一个特性——很多后端接口、富文本编辑器、第三方表单服务,吃的就是data:image/jpeg;base64,...这种字符串,而不是multipart/form-data。少一层转换,少一堆兼容代码。

这篇内容适合三类人看:正在给 Vue2 或 Vue3 项目做上传优化的;对接的接口只吃 base64、又不想在后端做压缩的;以及踩过"canvas 压缩完图片变糊、变黑、变横"这些坑想找一套完整方案的。基础的前端知识我默认你有,但每个关键点我都会把"为什么这么干"讲清楚。

1.2 前端压缩能解决什么、解决不了什么

先把预期摆正。前端压缩解决的是体积和带宽问题:把 6MB 的原图压到 300KB,上传时间从 20 秒变成 1 秒,这是最直接的收益。顺带还解决存储成本:十万张图每张省 5MB,账自己算。

它解决不了的是画质要求。压缩本质是有损重编码,你把 4000×3000 的图压到 800×600,细节就是没了,再怎么调 quality 也找不回来。所以做之前要先问一句:这张图后面是怎么用的?用户头像、聊天图片、工单附件,压到 1280 长边、200KB 以内完全够用;但如果是合同扫描件、身份证正面、票据凭证,就得留足分辨率,宁可体积大一点,文字边缘一旦糊了,后端 OCR 直接识别失败,那是给自己挖坑。

还有个隐含前提:前端压缩不可信。浏览器里的 JS 可以被绕过,用户换个方式直接 POST 原图,你的限制就形同虚设。所以前端的活是"优化体验",真正的体积和格式校验必须放在服务端再做一遍。这个认知在日常开发里特别重要,很多人只在前端卡了一道,结果接口被刷得很惨。

2. Irz 的定位与它背后的技术路线

2.1 这个插件到底做了什么

拆开看,Irz 的底层就是浏览器原生能力的一套封装:FileReader读文件 →Image对象加载 →canvas.drawImage缩放 →canvas.toDataURL('image/jpeg', quality)输出 base64。这条链路任何做过上传的人都不陌生,但自己从头写要处理一堆边界:图片还没加载完就画导致空白、iOS 上drawImage的尺寸限制、透明 PNG 转 JPEG 后背景变黑、EXIF 方向信息丢失导致照片躺倒。

Irz 的价值在于把这些杂活收口了。它接受一个File或Blob,让你传几个参数描述"我想要什么结果",然后返回一个 Promise。参数一般围绕这几类:目标体积(类似maxSize,单位 KB)、画质(quality,0 到 1)、尺寸上限(宽高)、输出格式(mimeType),以及输出形态(要 base64 还是要 File)。

注意:不同小版本的参数命名会有一点出入,装完包之后花两分钟翻一眼包目录里的 README,比在网上搜一堆过期示例靠谱得多。我在项目里通常会在utils下写一个薄封装,把参数名收敛在一个文件里,将来升级版本只改一处。

2.2 为什么它直接给 base64 而不是 Blob

这是个值得说的选择。用canvas.toBlob拿 Blob 更省内存、更接近上传场景,但 base64 有它不可替代的场景:

第一,接口协议限制。很多老系统、内部表单引擎、第三方开放平台,上传接口的字段就是一个字符串,服务端Base64.decode之后落盘。你手上只有 Blob,还得自己FileReader.readAsDataURL转一圈。

第二,即时预览。<img :src="base64">立刻就能显示,不需要URL.createObjectURL再记得revokeObjectURL,也就不会有一堆没人回收的对象 URL。

第三,本地留存和回填。表单草稿存到 localStorage、编辑页回显历史图片,base64 字符串直接塞进去就行,不用处理临时对象失效的问题。

代价也必须说清楚:base64 的字符串长度大约是原始二进制的1.37 倍(严格算是 4/3 再叠加换行和前缀)。一个 200KB 的 JPEG,转成 base64 约 273KB,字符串还得多占内存。所以我的做法通常是:预览用 base64,提交用 File,两套都给,看场景挑。Irz 这类库一般两种输出都支持,具体看参数。

2.3 为什么不自己手写 canvas 压缩

能写,我写过,也能跑。但真上生产,你得补这些:图片加载失败的兜底、超大图的降采样分步处理(一次性把 8000px 画到 1200px 会有明显的锯齿和摩尔纹)、循环逼近目标体积的终止条件、透明通道检测、iOS Safari 的内存上限保护。这些零碎加起来是两三百行代码加一堆真机测试。

用现成插件的取舍很明确:换开发速度,牺牲一点可控性。如果你的场景非常标准(用户头像、普通配图),插件完全够;如果你的场景特殊(需要精确控制重采样算法、需要保留 EXIF、需要处理超长截图),那还是自己写更省心。我的经验分界线是:单图、常规比例、目标体积 > 100KB,用插件;长图、超宽图、目标体积 < 50KB,自己写。

3. 装好就能跑:最小可用配置

3.1 环境准备与安装

常规 Vue 工程都行,Vue2 + webpack 或 Vue3 + Vite 都没问题,这个库不依赖框架运行时,本质是纯 JS。装的时候注意一件事:先确认包能正常解析。

npm i irz # 或者 pnpm add irz

装完先在node_modules/irz里看一眼package.json的main/module字段和 README 示例。有些包默认导出和具名导出混着来,写错了报的是is not a function,排查半天还以为是压缩失败,其实压根没导入成功。

// 常见的两种导入方式,按包里实际导出的形态选一个 import irz from 'irz' // 或 import { default as irz } from 'irz'

如果你在 Vite 下遇到global is not defined这类报错,多半是包里用了 Node 环境下的判断,在vite.config.js里加个define: { global: 'globalThis' }通常就解决了。这类运行时差异属于常见实践中的补丁,具体还得看报错栈指向哪里。

3.2 十行代码跑通第一次压缩

先不搞组件,直接在页面里跑通链路,确认库本身没问题。

import irz from 'irz' async function handleFile(file) { const res = await irz(file, { maxSize: 200, // 目标体积上限,单位 KB quality: 0.85, // 初始画质 maxWidth: 1600, // 长边上限 maxHeight: 1600, mimeType: 'image/jpeg', type: 'base64' // 要 base64 输出 }) // 不同版本返回结构可能是字符串,也可能是 { base64, file, size } const base64 = typeof res === 'string' ? res : (res.base64 || res.data) console.log(base64.slice(0, 60), base64.length) return base64 }

跑通之后你会看到控制台打出一串data:image/jpeg;base64,/9j/4AAQ...。到这里链路就通了。接下来别急着往业务里塞,先用几张极端图试:手机原图(5MB 以上)、透明 PNG、竖拍照片、超长截图。这四类图能提前暴露后面 80% 的坑。

3.3 参数怎么定:一份可抄的推荐值

参数没有万能值,但场景是可以分类的。下面这张表是我在几个项目里反复调过之后收敛出来的,直接照着用基本不会翻车:

场景长边上限目标体积quality 起点输出格式说明
用户头像40060KB0.9image/jpeg尺寸比画质更重要
聊天/评论配图1280200KB0.85image/jpeg平衡点,肉眼几乎无损
商品图1600300KB0.88image/jpeg细节多,画质别压太狠
证件/票据2000500KB0.92image/jpeg文字边缘优先
需要透明通道1200250KB0.9image/png见 6.2 节黑底问题
长截图/表格3000800KB0.9image/jpeg常规算法会发虚,见 6.4 节

这里有个容易被忽略的点:maxSize和quality是互相博弈的。你给 0.85 的画质,结果体积还是超了 200KB,库通常会自动降画质重试;但如果图片本身信息量爆炸(比如满屏树叶、草地),降到 0.5 也压不进 200KB。所以只在尺寸上做减法往往比死磕质量更有效——先把 4000px 降到 1600px,体积自然掉到 500KB 以下,再谈质量精细调节。

4. 在 Vue 里封装一个真正好用的上传组件

4.1 组件该暴露什么、内部该管什么

不要在每个页面里import irz然后复制粘贴那十行。正确的做法是抽一个组件,把复杂度吃掉。我通常的输入输出约定是这样的:

  • 对外(props):场景类型(avatar/chat/doc)、是否允许多选、最大文件数、是否返回 base64、禁用状态。
  • 对外(emits):update:modelValue给表单用,done事件回传结果数组,每项含base64、file、width、height、size、originSize。
  • 内部:类型校验、体积预检、压缩队列、进度、错误收集、缩略图生成、撤销删除。

关键在于内部要有一个统一的结果结构,别今天返回字符串明天返回对象,下游会哭。

4.2 Vue3 组合式写法

把压缩逻辑抽成一个 composable,组件只负责 UI,这样在富文本编辑器、拖拽区、多处上传点都能复用。

// useImageCompress.js import { ref } from 'vue' import irz from 'irz' const PRESETS = { avatar: { maxWidth: 400, maxHeight: 400, maxSize: 60, quality: 0.9 }, chat: { maxWidth: 1280, maxHeight: 1280, maxSize: 200, quality: 0.85 }, doc: { maxWidth: 2000, maxHeight: 2000, maxSize: 500, quality: 0.92 } } export function useImageCompress(preset = 'chat') { const loading = ref(false) const progress = ref(0) const list = ref([]) async function compress(file) { const opt = PRESETS[preset] const raw = await irz(file, { ...opt, mimeType: 'image/jpeg', type: 'base64' }) const base64 = typeof raw === 'string' ? raw : raw.base64 return { name: file.name, base64, originSize: file.size, size: Math.round((base64.length - base64.indexOf(',') - 1) * 3 / 4) } } async function handleFiles(files) { loading.value = true progress.value = 0 const arr = Array.from(files) const result = [] // 串行处理,避免多张大图同时占内存把页面拖死 for (let i = 0; i < arr.length; i++) { try { result.push(await compress(arr[i])) } catch (e) { result.push({ name: arr[i].name, error: 'compression_failed' }) } progress.value = Math.round(((i + 1) / arr.length) * 100) } list.value = result loading.value = false return result } return { list, loading, progress, handleFiles } }

注意size那一行:base64 里逗号前面是data:image/jpeg;base64,这样的前缀,长度不算进实际数据,减掉之后按 3/4 折算才是真实字节数。这个换算在显示"原图 6.2MB → 压缩后 187KB"的时候必须准,否则数字对不上,测试会来找你。

4.3 Vue2 项目的对应改法

Vue2 里没有setup,用选项式 API 包一个methods就行,逻辑完全一样,只是状态放在data里。

export default { data() { return { list: [], loading: false, preset: 'chat' } }, methods: { async onSelect(e) { const files = e.target.files if (!files || !files.length) return this.loading = true const out = [] for (const file of files) { try { const res = await irz(file, { maxWidth: 1280, maxSize: 200, quality: 0.85, type: 'base64' }) out.push({ name: file.name, base64: typeof res === 'string' ? res : res.base64 }) } catch (err) { this.$message && this.$message.error(`[${file.name}] 处理失败`) } } this.list = out this.loading = false this.$emit('done', out) e.target.value = '' // 关键:清空后同一张图可以再次选择 } } }

e.target.value = ''这行是个小细节但极其重要。不清空的话,用户删掉图片想重新选同一个文件,change事件根本不触发,用户会以为按钮坏了。

4.4 和表单、提交接口怎么衔接

如果后端接口吃 JSON 里的 base64 字符串,直接JSON.stringify提交就行,但要注意接口体积限制。Nginx 默认client_max_body_size是 1M,你塞两张 300KB 的图进去,base64 膨胀到 800KB,再加上其他字段,很快就打满,返回的是 413 而不是你预期的业务错误码。上线前一定要跟运维确认这个值,或者干脆改成 File +FormData走二进制。

如果是FormData提交,那就别转 base64,直接要 File:

const res = await irz(file, { maxWidth: 1600, maxSize: 300, quality: 0.85, type: 'file' }) const compressed = typeof res === 'string' ? null : (res.file || res) const fd = new FormData() fd.append('file', compressed, file.name.replace(/\.\w+$/, '.jpg'))

文件名后缀记得改。原图是IMG_2034.HEIC或.png,你压缩成了 JPEG,文件名还挂着原后缀,服务端按扩展名校验就直接拒了。这个坑我在两个项目里都踩过,属于"不看日志永远想不到"的那一类。

5. 参数背后的计算与实现细节

5.1 base64 体积换算:那 37% 从哪来的

有必要把账算清楚,因为所有"压缩后体积"的判断都建立在它上面。

base64 用 64 个可打印字符表示二进制,每 3 个字节(24 bit)编成 4 个字符(32 bit,每字符只用 6 bit 有效)。所以编码后长度 = 原始长度 × 4 / 3 ≈ 1.333 倍。再叠加data:image/jpeg;base64,这 23 个字符的前缀,以及某些实现里每 76 字符插入的换行,实际膨胀系数在1.37 左右。

反推公式:原始字节 ≈ (base64 总长 − 前缀长度) × 3 / 4。如果你拿到 base64 后想判断"到底有没有压到位",用它算一下就知道,别直接拿字符串长度当文件大小去跟maxSize比——那会大出三成多,你会误以为压缩失败了。

还有一个内存账:一张 6MB 的图,加载成 Image 解码后在内存里是宽 × 高 × 4 字节。4000×3000 就是 48MB。中低端安卓机上,同时处理两三张这样的图,标签页直接被系统回收,表现为"页面突然白屏重载"。这就是 4.2 节里我写串行for循环而不用Promise.all的真实原因——并行是快,但内存峰值也是三倍。

5.2 尺寸与质量:两条完全不同的降体积路径

压缩有两条路,效果差异极大,很多人只用了其中一条。

降尺寸:drawImage按比例缩小,像素总数按面积衰减。4000×3000 缩到 1600×1200,像素数是原来的 16%,体积大致同比例掉。这条路径收益巨大且几乎无损(因为屏幕本来也显示不了那么多像素),代价是绝对分辨率下降。

降质量:JPEG 量化表变粗,高频细节被丢弃。从 0.95 降到 0.7 可能省 40% 体积,但继续往下压,块状伪影就上来了,人脸、文字最先崩。

合理的策略是先降尺寸到目标显示的 1.5 到 2 倍,再在质量上微调。手机屏幕物理宽度 1080px 左右,2 倍图 2160px,所以 1600 到 2000 是个甜点区间。压到 800px 以下还要在详情页放大看,那必然糊,这不是算法问题是物理问题。

至于"逼近目标体积",标准做法是二分或步进:给一个初始 quality,算体积,超了就quality -= 0.1重算,最多试 5 到 6 轮,到阈值或触底为止。这个过程是 CPU 密集的,一张图可能重编码五六次,中端机上单次耗时 200 到 500ms。所以别在input的change回调里同步跑,一定要包一层 loading,否则用户点完选择,界面卡住半秒,他以为没反应又点一次。

5.3 主线程阻塞与 Worker 兜底

canvas.toDataURL是同步的,跑在主线程。单张图几百毫秒还能忍,十几张图批量选上来,页面就冻了,滚动都卡。想彻底解决,把压缩逻辑丢进 Web Worker。

思路是:Worker 里没有 DOM 的Image对象,但可以用createImageBitmap(blob)拿到位图,再配合OffscreenCanvas做缩放和编码。主线程只负责把 File 传过去、把 base64 传回来。

// worker.js self.onmessage = async (e) => { const { file, maxWidth } = e.data const bmp = await createImageBitmap(file) const scale = Math.min(1, maxWidth / Math.max(bmp.width, bmp.height)) const w = Math.round(bmp.width * scale) const h = Math.round(bmp.height * scale) const canvas = new OffscreenCanvas(w, h) canvas.getContext('2d').drawImage(bmp, 0, 0, w, h) const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 }) const base64 = await new Promise((r) => { const fr = new FileReader() fr.onload = () => r(fr.result) fr.readAsDataURL(blob) }) self.postMessage({ base64 }) }

OffscreenCanvas在主流移动端浏览器的支持已经比较稳了,但老版本安卓 WebView 上仍然可能缺失。我的做法是能力探测 + 降级:typeof OffscreenCanvas !== 'undefined'就走 Worker,否则回主线程串行。功能不能因为优化方案而挂掉。

提示:Worker 里拿不到document,任何依赖 DOM 的库方法都会报错。所以 Worker 方案不要直接调 Irz 的高层 API,只用它的思路自己实现,或者确认这个包明确支持 Worker。

5.4 压缩前的那道预检,比压缩本身更重要

很多人把精力全放在压缩参数上,却漏了前置校验,结果白白浪费 CPU 还给出错误反馈。我现在的预检清单是这样:

检查项阈值处理方式
文件类型image/jpeg、image/png、image/webp不支持的直接拒绝,提示文案要具体
单文件原始体积> 30MB拒绝并提示"图片过大,请先裁剪"
像素总数> 4000 万(约 7300×5500)提示可能失败,走分步降采样
文件数> 9拒绝或截断,避免队列过长
尺寸小于 100×100提示"图片过小,可能不清晰"

为什么要卡 30MB?因为超过这个量级,FileReader读进内存再解码,在中低端机上大概率直接崩,用户看到的是白屏,你收到的是一句"你们网站有毒"。主动拒绝比崩溃好一百倍,哪怕提示语不好听。

6. 踩过的坑:常见问题速查

6.1 竖拍照片躺倒了,方向全错

这是最高频的问题。手机竖着拍照,传感器记录的像素其实是横向的,方向信息存在 EXIF 的 Orientation 字段里。iOS 和部分安卓机型在渲染时会自动按 EXIF 旋转,但一旦你把图丢进 canvas 重绘,EXIF 就被丢掉了,结果图躺倒 90 度。

解法有两条:一是压缩前读取 EXIF 的Orientation,手动在 canvas 上做rotate/scale变换;二是直接用createImageBitmap(blob, { imageOrientation: 'from-image' }),让浏览器帮你把方向摆正再画。第二条更省事,主流浏览器都已支持。

const bmp = await createImageBitmap(file, { imageOrientation: 'from-image' }) // 此时 bmp.width / bmp.height 已经是摆正后的尺寸

如果用的库版本没处理这块,压缩完发现方向不对,别怀疑,就是 EXIF 丢了。

6.2 透明 PNG 压完背景变黑

PNG 有 alpha 通道,JPEG 没有。canvas.toDataURL('image/jpeg')遇到透明像素时,不同浏览器的填充色不一致,Chrome 通常填黑,Safari 有时填白。你上传一个带透明背景的图标,回来变成黑底方块,用户直接投诉。

处理办法:转 JPEG 前先铺一层白底。

const ctx = canvas.getContext('2d') ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.drawImage(img, 0, 0, canvas.width, canvas.height)

或者干脆判断一下:如果源文件是 PNG 且需要保留透明,就输出image/png,代价是体积大很多(PNG 无损,压不动多少)。业务上大多数场景白底完全可接受,我是默认铺白,只有明确需要透明的场景(Logo、贴纸)才走 PNG 分支。

6.3 压完体积反而变大了

这情况真实存在,而且不罕见。原因通常有三个:

第一,原图本来就是高压缩比的 JPEG。微信、微博下载下来的图片已经被压过一轮,你再以 0.9 的质量重编码,画质损失叠加,体积却可能因为量化表不同而变大。

第二,小图被放大。原图 200×200,你的maxWidth是 1600,某些库会按比例"放大"到 1600,体积暴涨,画质还更差。所以缩放系数一定要Math.min(1, target / origin),只缩不放。

第三,输出格式选错。源图是 WebP(体积很小),你转成 JPEG,反而变大。

兜底策略很朴素:压完跟原图比一下,大了就用原图。这一行判断能省掉无数解释。

const finalBase64 = compressedBytes < file.size ? compressed : originBase64

6.4 长截图和文字图变糊

超长截图(比如 1080×12000 的网页长图)是压缩算法的天敌。按长边 1600 缩放,宽度只剩 144px,字全糊成一团。这类图不能按长边等比压,得按宽度约束,长边放宽。

判断方法:宽高比超过 1:4(或 4:1)就识别为长图,单独走一套参数——maxWidth: 1080,长边给到 8000 甚至不限制,quality提到 0.92。体积会大一些,但至少字能看清。

另外,文字类图片压完发虚还有个原因:canvas 的默认缩放插值质量不高。设置ctx.imageSmoothingQuality = 'high'能明显改善,这行代码经常被忘,加上之后边缘锐度肉眼可见变好。

6.5 HEIC、动图、SVG 这些特殊格式

  • HEIC/HEIF:iPhone 默认拍照格式,Chrome 和大部分安卓浏览器根本解不了,new Image()直接onerror。要么在服务端转码,要么在输入端接受,但必须给出明确提示,别让用户对着"上传失败"发懵。
  • GIF 动图:drawImage只取第一帧,压完变静态图。业务上如果必须保留动效,就只能跳过压缩直传。
  • SVG:矢量图不需要压缩,且canvas渲染 SVG 有字体和外部资源丢失的风险,建议直接排除在白名单外。
  • WebP:现代浏览器都支持解码,但要注意 Safari 老版本对 WebP 编码不支持,toDataURL('image/webp')会静默回退成 PNG,体积反而变大。

6.6 服务端收到 base64 解析失败

最常见的两个原因:一是前缀没剥掉,服务端直接把data:image/jpeg;base64,xxx丢给 Base64 解码器,第一个字符就非法;二是URL 编码问题,base64 里的+在表单提交时会被当成空格,服务端解出来是乱码。

前端能做的:提交前用base64.split(',')[1]只发纯数据段,或者明确跟后端约定好带前缀。走application/json一般没事,走application/x-www-form-urlencoded就必须encodeURIComponent一下。

7. 工程化收尾:把它做成能长期维护的东西

7.1 一份真机验证清单

代码写完只是开始,真机走一遍才算交付。我的清单固定这几项:iPhone Safari 竖拍照片(验方向)、iPhone 相册里的 HEIC(验失败提示)、安卓中低端机连选 9 张 5MB 图(验内存和卡顿)、透明 PNG 图标(验黑底)、1080×12000 长截图(验文字清晰度)、弱网下上传(验进度和超时)、重复选同一张图(验input清空)。

这几项全过,基本可以放心上线。少测一项,问题就会在某个特定机型上等着你。

7.2 给压缩结果加个缓存

同一个文件反复压缩是浪费。用文件名 + 大小 + 修改时间拼一个 key,结果存进内存的Map,超过 50 条就淘汰最旧的。用户删了图又撤回、在编辑页来回切换,都能直接命中,体验立竿见影。

更进一步的,可以把 base64 存进IndexedDB做草稿恢复。localStorage 有 5MB 上限,塞两张图就满了,而且同步读写会阻塞,别用它存大字符串。

7.3 留一个服务端兜底

不管前端多小心,永远有绕过去的路径,也永远有机型兼容问题。所以我的习惯是:前端压缩尽力做,服务端再校验一次并做二次压缩。服务端拿到的图,检查尺寸和体积,超了就用自己的图像处理库再压一遍。前端那次是为了省带宽和省时间,服务端那次是为了保底,两道关各司其职,缺一不可。


这套东西我在几个项目上反复打磨过,最后留下来最有用的一条经验是:别把压缩当成一个技术点,当成一条链路。入口预检、压缩参数、异常兜底、结果缓存、服务端二次校验,任何一环缺失,都会在最不该出问题的时候爆出来。至于 Irz 这个插件本身,它的定位很清楚——用最少的代码拿到可用的 base64,把 canvas 那些琐碎细节包进去。它不解决业务判断,参数怎么定、什么图该拒绝、压完要不要跟原图比体积,这些还得你自己想。我个人的习惯是永远保留compressedBytes < file.size ? compressed : origin这个判断,一行代码,能挡掉大量"压完更大了"的奇怪工单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询