ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JS 图片转 Base64 与二进制流:原理、实现与避坑

JS 图片转 Base64 与二进制流:原理、实现与避坑 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 对象拿到 ArrayBufferFile和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 47JPEG 是FF D8 FFGIF 是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大图导致页面卡死一次性转换占用内存压缩后再转及时revokeObjectURLiOS 上预览空白直接塞File给img.src转成Blob URL或 base64base64 字符串特别长图片没压缩用 Canvas 压质量或缩小尺寸5.2 几个实战里攒下的避坑心得第一base64 别往 URL 里塞。一张 100KB 的图转成 base64 大概 133KBURL 长度在很多环境有 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 起步转换链条上任何一环没处理好都会很难看。
返回列表