1. 先把需求拆开:图片、Base64、二进制流到底是什么关系
前端处理图片上传、预览、裁剪、压缩这条链路上,JS把图片转成base64和二进制流,再把二进制流回写成base64,几乎是绕不开的一套基础动作。我最早踩这块是在做一个头像上传组件的时候,用户选完图要立刻预览、要能裁切、还要把裁切结果传回后端,中间来来回回就是在这三种数据形态之间倒腾。当时图省事直接把File对象丢给img.src,结果 iOS 上偶发不显示,后面才知道得先转一下格式才稳。
这篇内容适合谁看?一是刚接触前端文件处理、对File、Blob、ArrayBuffer、btoa这些概念还比较模糊的朋友;二是已经写过上传但被大图卡顿、乱码、内存爆掉折腾过的同学。我会把三种数据形态的边界、两条转base64的路线、二进制流转base64的分片写法,以及实际项目里踩过的坑,一点点摊开讲。核心关键词就三个:JS、base64、二进制流,所有内容都围绕它们展开。
先说清楚三个东西的本质。Base64是一种用 64 个可打印字符表示二进制数据的编码方式,它把每 3 个字节(24 位)重新切成 4 个 6 位小组,每个小组映射成一个字符,所以编码后体积会膨胀约 33%。你经常在 CSS 里看到的data:image/jpg;base64,/9j/4AAQSkZJRgABAQ...这种长串,就是图片的 base64 表达,浏览器能直接把它当图片地址用。
二进制流在JS里并不是单一类型,常见的有Blob、ArrayBuffer、Uint8Array这几种形态,它们都能装字节数据,但用途和可操作性不同。Blob更像一个"不可变的字节容器",适合直接上传;ArrayBuffer是一段固定长度的原始内存,适合做底层读写;Uint8Array则是架在ArrayBuffer上的视图,能按字节访问。理解它们的关系,后面转换时就不会晕。
图片文件本身(File对象)其实就是一个特殊的Blob,它继承自Blob,多了name、lastModified这些元信息。所以"图片转二进制流"这件事,本质上是把File转换成Blob或ArrayBuffer的过程,很多时候甚至不需要转,因为它本来就是。
提示:别把 base64 当成"压缩"。它体积反而更大,只是胜在能塞进文本协议里传输,比如 JSON 字段、CSS 内联、URL 参数这些地方。
2. 图片转Base64的两条路:FileReader 与 Canvas 各管一段
2.1 FileReader 路线:原样读取,最省心
最直接的写法就是FileReader,它能异步把文件内容读成dataURL,也就是带data:image/...;base64,前缀的字符串。这是大多数场景的首选,因为不改动图片任何像素,解出来的 base64 和原文件字节完全一致。
function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); // data:image/png;base64,xxxx reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } // 用法 const file = document.querySelector('#upload').files[0]; fileToBase64(file).then(base64 => { console.log(base64.slice(0, 50)); // data:image/png;base64,iVBORw0KG... });readAsDataURL底层会自己判断 MIME 类型,从文件的二进制头里嗅探,而不是单纯看扩展名,这点挺靠谱。它唯一的"缺点"是只能异步拿结果,没法同步返回,但这也避免了大文件读取时卡住主线程。
我一般会把onload里拿到的完整字符串分成两段处理:前缀部分data:image/png;base64,单独存起来,后面纯字节的 base64 部分存另一份。因为后端接口经常只想要后半段,你如果整串传过去,还得在服务端再切一次,容易出错。
2.2 Canvas 路线:可控压缩,能改尺寸
需要压缩、裁剪、改尺寸、加滤镜的时候,就得请出Canvas。思路是先把图片画到画布上,再用toDataURL导出,参数里能指定格式和质量。
function compressToBase64(file, maxWidth = 800, quality = 0.8) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let { naturalWidth: w, naturalHeight: h } = img; if (w > maxWidth) { h = Math.round(h * maxWidth / w); w = maxWidth; } const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); // 第二个参数是格式,第三个是质量(0~1) resolve(canvas.toDataURL('image/jpeg', quality)); }; img.onerror = reject; img.src = URL.createObjectURL(file); }); }这里有个需要留心的点:toDataURL的默认格式是image/png,而且PNG 不支持质量参数,你传quality进去它也会忽略。想要压缩率,就得显式指定image/jpeg或image/webp。WebP 在同质量下体积通常更小,但老一些的浏览器支持度要看,实际项目里我一般检测一下再决定。
另外URL.createObjectURL(file)生成的临时地址用完要记得URL.revokeObjectURL()释放,否则大图反复处理时会一直占内存。这个释放动作特别容易被忽略,我见过一个相册页面连拍十几张后浏览器直接崩掉的案例,问题就出在这。
2.3 两条路线怎么选
| 对比项 | FileReader | Canvas |
|---|---|---|
| 是否改动像素 | 否,原样输出 | 会重绘,可能损失质量 |
| 能否压缩 | 不能 | 可以(JPEG/WebP) |
| 能否裁剪缩放 | 不能 | 可以 |
| 大图性能 | 较好,异步 | 内存占用高,需释放 |
| 保留透明通道 | 是 | PNG/WebP 可以,JPEG 不行 |
| 适用场景 | 原始文件上传、存档 | 头像裁剪、缩略图、压缩上传 |
我的习惯是:如果只是把图传给后端存原图,直接FileReader,别折腾;如果用户要在前端裁一裁、压一压再传,就上Canvas。两者也可以组合,先用 Canvas 压好,再转成 Blob,最后走上传,效率比转 base64 再 decode 回来高不少。
3. 图片转二进制流:Blob、ArrayBuffer、Uint8Array 的转换细节
3.1 从 File 对象拿到 ArrayBuffer
File和Blob都自带arrayBuffer()方法,返回一个 Promise,直接给你一段原始内存。这是最干净的二进制化路径,没有编码转换,没有体积膨胀。
async function fileToArrayBuffer(file) { const buffer = await file.arrayBuffer(); console.log(buffer.byteLength); // 原始字节数 return buffer; }拿到ArrayBuffer之后,如果要做逐字节处理,得套一层视图。为什么?因为ArrayBuffer本身不能直接下标访问,buffer[0]是undefined。必须通过Uint8Array或DataView来读写。
const buffer = await file.arrayBuffer(); const bytes = new Uint8Array(buffer); console.log(bytes[0], bytes[1]); // 比如 PNG 的魔数是 137 80这里就能顺手做个文件类型校验:PNG 文件头是89 50 4E 47,JPEG 是FF D8 FF,GIF 是47 49 46 38。有些场景用户会把.txt改成.png传上来,只看扩展名会漏,读前几个字节比对魔数就稳多了。
3.2 Blob 与 ArrayBuffer 互转
这两种形态之间来回切很常见:后端返回二进制流是ArrayBuffer,但你要上传或交给<img>用,就得转成Blob。
// ArrayBuffer -> Blob function bufferToBlob(buffer, mime = 'image/jpeg') { return new Blob([buffer], { type: mime }); } // Blob -> ArrayBuffer async function blobToBuffer(blob) { return await blob.arrayBuffer(); }new Blob([buffer], { type })里那个数组可以塞多种东西,ArrayBuffer、Uint8Array、字符串都行,浏览器会把它们拼起来。有一次我需要把多张图片的字节合并成一个文件,就是往这个数组里依次 push 各个Uint8Array,最后生成一个大Blob,比一个个拼字符串高效得多。
注意:
Blob是不可变的,一旦创建就没法改内容。想批量处理,要么把原始数据攒齐再建 Blob,要么用slice切出新 Blob。
拿到Blob后,想给用户看就用URL.createObjectURL(blob),想上传直接塞进FormData,想存本地就用a标签加download属性。而ArrayBuffer更适合喂给 WebAssembly、音视频解码、加密算法这类底层模块。
4. 二进制流转Base64:为什么不能直接 btoa,分片怎么写
4.1 直接 btoa 会踩的坑
btoa这个函数看着简单,但它有个硬性限制:只能处理 Latin-1 字符集,也就是码点 0 到 255 的字符。你传一个ArrayBuffer或Uint8Array进去,它会先做toString(),得到一串类似"137,80,78,71,..."的东西,再逐字符转,结果自然全错。很多人第一次转二进制流就栽在这,解出来的 base64 拷到解码工具里要么报错要么是乱码。
正确姿势是先想办法把字节数组"变成"一串 0 到 255 的字符,再交给btoa。String.fromCharCode能做这个映射,但问题是它参数一多就爆栈——fromCharCode.apply(null, bytes)在几十万字节的图上会直接RangeError: Maximum call stack size exceeded。所以必须分片。
4.2 分片编码的完整实现
function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); const chunkSize = 0x8000; // 32768,经验值,安全且速度不错 let binary = ''; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, i + chunkSize); binary += String.fromCharCode.apply(null, chunk); } return btoa(binary); }chunkSize取0x8000是我试出来的平衡点。太小(比如 1024)会频繁拼接字符串,大文件慢;太大(比如 0x20000)在某些浏览器上仍有爆栈风险。0x8000在 Chrome、Firefox、Safari 上实测都稳。subarray相比slice的优势是不复制数据,只是视图,内存更省。
如果图片带透明通道或者要保证原样,转 base64 前要先决定加不加前缀。btoa出来的纯字符串没有data:image/xxx;base64,的头,想要直接当图片地址用,得自己拼:
const b64 = arrayBufferToBase64(buffer); const dataUrl = 'data:image/png;base64,' + b64;拼前缀时 MIME 一定要和原图对得上。我曾经把 JPEG 的字节拼了data:image/png;的头,浏览器虽然大多能靠内容嗅探显示出来,但个别严格场景(比如<canvas>里再次读取)会失败,排查了半天才想起来是这里的问题。
4.3 大文件下的性能对比
| 方案 | 10MB 图耗时(约) | 内存峰值 | 风险 |
|---|---|---|---|
btoa(unescape(...))老写法 | 较快但已废弃 | 高 | unescape已淘汰 |
fromCharCode.apply不分片 | - | 极高 | 直接爆栈 |
分片fromCharCode | 中等 | 中等 | 无明显风险 |
FileReader.readAsDataURL | 最快 | 中等 | 无法处理非文件源 |
如果数据本来就在内存里(比如接口返回的ArrayBuffer),就用分片方案;如果数据是Blob或File,直接用FileReader反而更省事也更快,因为它是浏览器原生实现的,不用你自己拼字符串。
5. 图片转Base64和二进制流的常见问题与排查实录
5.1 高频报错速查
| 现象 | 大概率原因 | 解决方式 |
|---|---|---|
| base64 解出来是乱码 | 直接btoa(arrayBuffer) | 用分片fromCharCode过渡 |
报Maximum call stack | fromCharCode.apply参数过多 | 缩小 chunkSize 分片 |
| 图片不显示 / 破图 | 前缀 MIME 与内容不符 | 查看真实字节头,改对 MIME |
| 大图导致页面卡死 | 一次性转换占用内存 | 压缩后再转,及时revokeObjectURL |
| iOS 上预览空白 | 直接塞File给img.src | 转成Blob URL或 base64 |
| base64 字符串特别长 | 图片没压缩 | 用 Canvas 压质量或缩小尺寸 |
5.2 几个实战里攒下的避坑心得
第一,base64 别往 URL 里塞。一张 100KB 的图转成 base64 大概 133KB,URL 长度在很多环境有 2000 字符上下的限制,塞进去直接超。想传就用 POST body 或者 FormData。
第二,注意同步阻塞。分片转换虽然不会爆栈,但依然是同步循环。几十兆的大图转起来主线程会明显卡顿。此时可以配合requestIdleCallback或者干脆丢到Web Worker里,主线程只负责收结果。
第三,Base64 的换行问题。有些后端返回的 base64 里带\n或\r\n,直接传给atob在某些浏览器会报Invalid character错误。稳妥做法是先replace(/[\r\n]/g, '')清洗一遍。我一开始不信邪,被这个坑过一次线上问题,图像死活出不来,日志里全是atob报错。
第四,体积膨胀要有预期。后端如果对请求体大小有限制,比如网关限制 2MB,那你 1.5MB 的原图转 base64 之后就是 2MB,正好踩线被拒。转换前先算一下,算完再决定要不要压缩。
第五,注意 MIME 的坑。data:image/jpg;base64,这个写法里的image/jpg其实不是标准 MIME,标准应该是image/jpeg。浏览器大多宽容,但严格解析的地方会不认。顺手统一成image/jpeg少麻烦。
5.3 二进制流回写 base64 的一个真实场景
去年做一个拍照上传的功能,安卓机返回的相机数据是ArrayBuffer,得转成 base64 添加到 JSON 里提交。当时的实现就用了上面那套分片写法:
async function cameraDataToBase64(arrayBuffer) { const b64 = arrayBufferToBase64(arrayBuffer); return 'data:image/jpeg;base64,' + b64; } // 提交 const payload = { fileName: 'photo.jpg', content: await cameraDataToBase64(buf) }; await fetch('/api/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });当时遇到一个细节:某些机型返回的ArrayBuffer前面带了一段额外的元数据头,转出来的 base64 前面 200 多个字符是垃圾,图片能显示但顶部有条黑边。后来做了个兼容,跳过前若干字节再转,问题才解决。这类设备差异只能靠多机型实测踩出来,文档里基本不会写。
6. 把三次转换封装成一个顺手的工具模块
零散写三次转换太累,实际项目里我一般封一个工具对象,把常用动作归到一处。下面是简化版,可以直接抄:
const ImageCodec = { // File/Blob -> base64 dataUrl toBase64(source) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(source); }); }, // File/Blob -> ArrayBuffer async toBuffer(source) { return await source.arrayBuffer(); }, // ArrayBuffer -> base64 纯字符串(不带前缀) bufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; const step = 0x8000; for (let i = 0; i < bytes.length; i += step) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + step)); } return btoa(binary); }, // base64 字符串 -> Blob base64ToBlob(base64, mime = 'image/jpeg') { const pure = base64.replace(/^data:.*?;base64,/, '').replace(/[\r\n]/g, ''); const binary = atob(pure); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return new Blob([bytes], { type: mime }); } };这个模块把四个方向的转换补齐了:文件到 base64、文件到二进制、二进制到 base64、base64 回二进制。base64ToBlob里的清洗步骤很关键,它同时处理了带前缀和不带前缀两种输入,配合正则去掉换行,调用方就不用关心传进来是什么形态。
写base64ToBlob时循环用charCodeAt转字节,比fromCharCode反向操作要慢一点,但胜在直观好排查。如果发现性能成瓶颈,可以换成Uint8Array.from(binary, c => c.charCodeAt(0)),代码短一截,V8 下速度也不差。
再补一个裁剪场景的串联写法,把 Canvas 压缩、二进制、base64 串起来:
async function cropAndEncode(imgEl, rect) { const canvas = document.createElement('canvas'); canvas.width = rect.width; canvas.height = rect.height; const ctx = canvas.getContext('2d'); ctx.drawImage(imgEl, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height); const dataUrl = canvas.toDataURL('image/jpeg', 0.85); const blob = await fetch(dataUrl).then(r => r.blob()); const buffer = await blob.arrayBuffer(); return { base64: dataUrl, buffer, pureBase64: dataUrl.split(',')[1] }; }这里用fetch(dataUrl)把 dataURL 直接转Blob是个小技巧,比手动atob再拼字节要短,浏览器内部做了优化,处理速度也过得去。如果你需要精确控制内存,还是手动转更透明。
我在实际使用中体会到的是,这几种转换没有绝对的"最优解",关键在于先想清楚数据要去哪:进 JSON 就转 base64,进 FormData 就留Blob,进底层计算就转ArrayBuffer。方向对了,代码就简单;方向错了,后面全是补丁。另外就是大图一定要留个压缩兜底,别指望用户上传的都是几百 KB 的小图,一张手机原图动辄 5MB 起步,转换链条上任何一环没处理好都会很难看。