用 JavaScript 把图片转成 Base64(三种方式)
2026-09-30 · 1123 词
在浏览器里把图片转成 Base64 有三种常见方式,它们不能互换。其中两种精确保留你的字节,一种会悄悄重新编码图片;有一种无法用在用户尚未选择的文件上;而在大文件上,其中一种按最直白的写法会炸掉调用栈。
下面说清每种实际做了什么,以及什么时候该用它。
方式一:FileReader,用于绝不能碰的字节
如果你要的是用户选中文件的精确字节 —— 不重新压缩、不丢失元数据 —— FileReader.readAsDataURL 就是工具。它读取文件、做 Base64 编码,交给你一个含前缀的完整 data URI。
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result); // "data:image/png;base64,iVBOR..."
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
const uri = await fileToDataUrl(file);
document.querySelector('img').src = uri;
注意 reader.result 里是什么:整个 data URI,前缀也在内。如果你的 API 只要载荷,就在第一个逗号处切分 —— 并且要知道声明的类型来自文件的 type 属性,它是从扩展名推出来的,可能是错的。对 API 来说,值得拿字节去核对签名。
FileReader 是流式读取文件而不是先把它变成数组,所以三者中对内存最友好。它接受 Blob 和 File 对象,也就是说它同样适用于你自己生成的数据:
const blob = new Blob([json], { type: 'application/json' });
const uri = await fileToDataUrl(blob); // 任何 Blob 都行,不只是用户文件
方式二:canvas,当你想要像素而不是文件时
canvas.toDataURL() 通过浏览器自己的编码器重新编码图片。如果你要缩放、裁剪或统一格式,这正是你想要的;如果你在意原始文件,这正是你必须避开的。
async function reencode(file, maxWidth = 1200, quality = 0.85) {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxWidth / bitmap.width);
const canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/jpeg', quality);
}
用它之前值得知道的四个后果:
- 字节变了。 JPEG 会经历一次解码和一次全新编码,所以每做一次就损失一代画质。
- 元数据被丢掉。 EXIF —— 相机型号、方向、GPS 坐标 —— 无法穿过 canvas 往返。有时这正是目的,有时是一次要紧的静默数据丢失。
quality只对有损格式有效。 对image/png它被忽略;给 PNG 传 0.1,产出的文件与传 1.0 一样大。- 输出类型取决于你要什么,而不是进来的是什么。对 JPEG 调
toDataURL('image/png')会产出 PNG,体积大好幾倍。
透明度在这里也要注意:把带透明通道的 PNG 转成 image/jpeg,透明区域会被填成黑色,因为 JPEG 没有 alpha 通道。
方式三:原始字节,当你需要不带前缀的载荷时
有时你只想要 Base64 载荷本身,或者你想自己拼出 data URI 以便控制声明的类型。把文件读成 ArrayBuffer 再编码:
async function fileToBase64Payload(file) {
const buf = new Uint8Array(await file.arrayBuffer());
return bytesToBase64(buf);
}
最后一步的朴素实现就是人们被烧到的地方:
// ✗ 大文件上会炸
btoa(String.fromCharCode(...bytes));
String.fromCharCode(...bytes) 把每个字节展开成参数,而参数列表有长度上限 —— 多数引擎里实际约 65,535。在 200 KB 的图片上你会得到 RangeError: Maximum call stack size exceeded,读起来像递归 bug,实际上是参数个数上限。
修法是分块处理缓冲区:
function bytesToBase64(bytes) {
const CHUNK = 0x8000; // 每次调用 32 KiB 字节
let binary = '';
for (let i = 0; i < bytes.length; i += CHUNK) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(binary);
}
还有两个只在真实数据上才会暴露的细节。第一,btoa 处理的是码元而不是字节 —— 喂给它一个含大于 0x7F 内容的字符串会抛 InvalidCharacterError,这就是上面"字节转字符串"这一步用 fromCharCode 处理原始字节、而不是处理已解码文本的原因。第二,反方向有镜像的同一个陷阱:Uint8Array.from(atob(s), c => c.charCodeAt(0)) 是正确的,而把 atob 的结果当文本、再传给 Blob,会让所有大于 0x7F 的字节损坏。
该用哪一种
- 上传或传输图片且不做改动 →
FileReader或原始字节。绝不要用 canvas。 - 预览本地文件 → 对象 URL 完胜 Base64:见 data URI 与 Blob URL。
- 缩小、裁剪或转换格式 → canvas。记住那是一次重新编码。
- 生成小资源内联进 CSS 或 HTML → 生成像素用 canvas;然后把一张照片内联之前,先读读 为什么 Base64 会让页面变大。
字符串形态的内存成本
Base64 输出大约是输入字节的 1.37 倍,而在 JavaScript 里这个字符串以 UTF-16 存储 —— 每字符 2 字节。这个乘法值得算一次,因为它解释了"直接在浏览器里转一下"为什么在某个文件大小上开始不管用:
- 1 MB 图片 → Base64 字符串约 1.37 MB → 内存中约 2.7 MB
- 10 MB 图片 → 约 13.7 MB → 内存中约 27 MB
- 100 MB 图片 → 约 137 MB → 内存中约 274 MB
再加上你通常还握着的原始缓冲区,一张 10 MB 的图片在转换的那一刻大约要 37 MB。这在桌面上能扛,在手机上就难受了 —— 这也是我们工具的编码侧接受最大 100 MB 的图片用于预览、但绝不把生成的字符串放进 DOM 的原因:一个装着 137 MB UTF-16 的 textarea,正是让移动浏览器杀掉标签页的那种失败模式。
如果你必须处理超大图片,就按 blob 处理,永远不要构建完整字符串:直接上传那个 Blob,或者分块流式处理、每块单独编码。Base64 之所以方便,正是因为它是文本,而文本是最昂贵的表示。
关于为特定消费方拼字符串
前缀是一个声明,不同的消费方要不同的形状。CSS 的 url() 需要带引号的完整 data URI;<img src> 需要同样的字符串;JSON API 通常要裸载荷加一个单独的 content type;邮件模板常常要求每行折在 76 个字符。我们的 图片转 Base64 工具主要就是为产出这些变体而存在的 —— 包括 URL 安全输出、折行输出以及外层引号 —— 因为把外壳写错,比把编码写错更常见。如果你在代码里生成这个字符串,请写一条测试:解码你自己的输出,与输入逐字节比较;这一条断言能抓住上面列出的所有外壳错误。