☰
JS 图片转 Base64 与二进制流:原理、实现与避坑
2026/10/1 13:56:21 网站建设 项目流程

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 两条路线怎么选

对比项FileReaderCanvas
是否改动像素否,原样输出会重绘,可能损失质量
能否压缩不能可以(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 stackfromCharCode.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 起步,转换链条上任何一环没处理好都会很难看。

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

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

立即咨询