1. 项目概述:一次打通前端文件上传的三大核心形态
做前端开发,尤其是做后台管理系统、文件上传组件、图片预览这类功能时,几乎没有人能绕开input type=file这个标签。但恰恰是这个看起来再简单不过的标签,背后牵扯出来的File对象、Blob、Base64字符串、Object URL这几种数据形态之间的转换,足以让不少开发者在业务里卡上好几天。
为什么这么说?因为我在实际项目里遇到过太多次类似的场景:用户选了一个图片,我们需要在提交前做本地预览;或者要把文件内容直接塞进 JSON 请求体里;又或者后端接口要求的不是 multipart 表单上传,而是直接接收一个 Base64 字符串。每种场景对应的"读取方式"和"数据形态"都不一样,一旦混着用,轻则报错,重则页面直接卡死。
这篇文章不聊空泛的概念,直接围绕input type=file从"用户选中文件"到"拿到 Base64 字符串"再到"转成 Blob 继续操作"这条完整链路,把每一步的原理、代码、坑位全讲透。我默认你至少写过一点原生 JavaScript,但就算你是刚接触前端的新人,跟着示例敲一遍也能跑通。适合谁看?想做上传组件的、被FileReader各种事件搞得头晕的、以及想彻底搞清楚Blob和Base64到底啥区别的朋友。
2. 为什么需要多种文件形态:从选完文件那一刻说起
很多初学者第一次接触文件上传时,以为input type=file选中文件后,我们能直接拿到文件的"路径"或者"内容",然后就能上传了。但实际上,浏览器出于安全考虑,你永远无法通过前端代码获取用户本机文件的绝对路径。你能拿到的,是一个File对象,而这个对象本质上是一种特殊的Blob。
这里我先把两个最核心的概念捋清楚。Blob的全称是 Binary Large Object,翻译过来就是"二进制大对象"。它代表了一段不可变的原始二进制数据。你在前端经常见到的new Blob([data], { type: 'text/plain' }),就是在内存里凭空创建一段二进制数据,并且给它标注一个 MIME 类型。File对象继承自Blob,只不过它在Blob的基础上增加了name(文件名)、lastModified(最后修改时间)这两个和文件系统相关的属性。
所以你看,当我们通过input type=file拿到e.target.files[0]时,它既是一个File,也是一个Blob。这就意味着,所有Blob能用的方法它都能用,比如.slice()方法、.size属性、.type属性。
那么 Base64 又是什么?Base64 是一种用 64 个可打印字符(A-Z、a-z、0-9、+、/)来表示二进制数据的编码方式。它的核心用途是把二进制数据变成纯文本字符串,这样就能方便地嵌入到 HTML 属性、CSS 样式或者 JSON 数据里。我们把一张图片转成 Base64 后,就会得到类似data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...这样一串东西。
结论很简单:Base64 是文本形态,Blob 是二进制形态。文本形态适合传输、存储和展示(比如嵌入图片 src),二进制形态适合切片、上传和内存操作。而在浏览器里,把File转成 Base64 的标准工具就是FileReader。
3. 核心细节解析:FileReader 的前世今生与正确用法
3.1 FileReader 到底在做什么
FileReader这个名字听起来挺唬人,但你可以把它理解成一个"文件内容读取器"。它允许 Web 应用异步读取存储在用户计算机上的文件内容。注意"异步"这个关键词,因为读取文件涉及磁盘 I/O(或者是内存文件系统),浏览器不能让你主线程卡在那里等着,所以FileReader的所有读取操作都是异步的,通过事件回调来接收结果。
FileReader提供了三个主流的方法,这里我做一个对比:
| 方法 | 输出形态 | 典型用途 |
|---|---|---|
readAsDataURL(file) | Base64 字符串(含 MIME 前缀) | 图片预览、嵌入 JSON 提交 |
readAsText(file, encoding) | 纯文本字符串 | 读取 txt、json、csv 文件内容 |
readAsArrayBuffer(file) | ArrayBuffer(原始二进制) | 文件哈希计算、切片处理 |
我们这篇文章的核心是readAsDataURL(file),它生成的字符串格式是data:[<mediatype>][;base64],<data>。比如data:image/jpeg;base64,/9j/4AAQSkZJRg...。前面的data:image/jpeg;base64,是数据头,后面那一大串才是真正的 Base64 编码内容。
3.2 如何拿到用户选中的 File 对象
这一步是全部操作的地基。先看最标准的写法:
<input type="file" id="fileInput" accept="image/*" />const fileInput = document.getElementById('fileInput'); fileInput.addEventListener('change', function (e) { const file = e.target.files[0]; if (!file) return; console.log(file.name); // 文件名 console.log(file.size); // 文件大小(字节) console.log(file.type); // MIME 类型 });这里有三个细节值得注意。
第一,e.target.files是一个FileList对象,它长得像数组但不是数组。如果你需要支持多选(input上加multiple属性),想遍历它,最好用Array.from(e.target.files)或者[...e.target.files]先转成真数组,直接用forEach会报错。
第二,accept="image/*"这个属性只对文件选择对话框起一个"默认过滤"作用,用户完全可以在对话框中切换成"所有文件"去选择非图片文件。所以前端一定要做二次校验,别把安全期望寄托在这个属性上。
第三,每次change事件触发时,最好先判断一下file是否存在。因为用户如果在文件选择对话框里点了取消,files的值为空,此时访问files[0]会得到undefined,后续代码全部失效。
3.3 标准姿势:用 FileReader 读取 Base64
拿到File对象之后,把它转成 Base64 的完整代码如下:
function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = function (e) { resolve(e.target.result); }; reader.onerror = function (e) { reject(new Error('文件读取失败')); }; reader.readAsDataURL(file); }); } // 用法示例 fileInput.addEventListener('change', async function (e) { const file = e.target.files[0]; if (!file) return; // 本地图片预览,直接用,什么 requestAnimationFrame 都不用 const base64Str = await fileToBase64(file); imgElement.src = base64Str; });这里我用Promise把回调式的 API 封装了一下,这样在async/await时代用起来顺手很多。reader.onload触发时,e.target.result就是完整的 Base64 字符串,而reader本身会自动回收,你不用手动清理。
不过,只看这段代码还是太"教科书"了。我在实际项目中提炼出两个改进了体验的关键点,你可以直接拿去用:
第一,进度反馈。FileReader有个onprogress事件,可以拿到读取进度。如果用户上传的是一个很大的视频文件,没有进度提示的话,界面会陷入一种"死等"的状态:
reader.onprogress = function (e) { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); console.log('读取进度:', percent + '%'); } };第二,取消读取。如果用户在读取大文件时想反悔,可以调用reader.abort()取消读取,并监听onabort事件。这个在长传大文件场景下几乎是必备能力。
3.4 为什么不建议用 FileReader 做超大文件的 Base64 预览
先给个结论:如果你只是想预览图片,优先用URL.createObjectURL(file),而不要用readAsDataURL。
因为 Base64 字符串的体积比原始二进制数据大约增加 33.3%(Base64 每 3 个字节编码成 4 个字符),而且浏览器把这张图解析成img标签可用的图片时,还需要额外的时间和内存去解码。我实测过一个 5MB 左右的 JPG 图片,转成 Base64 后字符串大小约 6.6MB,在img.src里加载时,部分设备上的绘制时间会有肉眼可见的延迟。
而URL.createObjectURL(file)的做法完全不同,它是给文件对象生成一个临时的内存 URL(形如blob:http://localhost:8080/uuid),浏览器可以直接以接近本地文件的速度读取,预览和原始缩放很快,而且不涉及字符串拷贝:
const objectUrl = URL.createObjectURL(file); imgElement.src = objectUrl; // 注意:用完之后一定要手动释放 URL.revokeObjectURL(objectUrl);这算是我做前端以来踩过最深的坑之一:早期做上传组件,预览图片用readAsDataURL,结果用户传了几张 10MB 的摄影原图之后,后台管理页面直接卡到滚动都费劲。后来全部改成createObjectURL,预览问题迎刃而解。只有当图片确认要提交给后端时,才在提交环节转成 Base64。
4. 实操过程与核心环节实现:Blob 与 Base64 的完整互转方案
做完了"File 转 Base64",还有一个高频需求同样绕不开:把 Base64 转回 Blob。可能有人会问,我都有 Base64 了,为什么还要转回 Blob?场景太多了:后端接口要求application/json格式但要包含文件;你把图片 Base64 存到了内存里,现在想用FormData上传,需要用new Blob([...])构造一个文件;或者你要做 canvas 截图转图片上传,canvas 的toDataURL()输出的就是 Base64,但上传时得变成 Blob。
4.1 Base64 转 Blob 的两种实现思路
思路一:使用fetch+blob()方法。
async function base64ToBlobByFetch(base64Str) { const response = await fetch(base64Str); return await response.blob(); }这个写法极其简洁,利用了fetch能直接处理 data URL 的特性。注意这里传的base64Str必须是完整的带前缀的 Data URL(即包含data:image/png;base64,部分),fetch才能识别。
思路二:手动解码转二进制。
function base64ToBlob(base64Str, mimeType) { // 去掉 data URL 前缀(如果有的话) const base64Data = base64Str.split(',')[1] || base64Str; // 将 Base64 字符串转为二进制字符串 const byteChars = atob(base64Data); // 将二进制字符串转为 Uint8Array(二进制的"数组"形式) const byteNumbers = new Array(byteChars.length); for (let i = 0; i < byteChars.length; i++) { byteNumbers[i] = byteChars.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); return new Blob([byteArray], { type: mimeType }); }方案二本质上是这样工作的:atob()解码得到的是一个二进制字符串,每个字符对应一个字节(0-255 的码点)。但这个"二进制字符串"仍然不是真正的二进制数据,它只是把字节数值映射成了 JS 字符串中的字符,所以我们还要通过charCodeAt()把每个字符转回对应的字节数值,再放进Uint8Array,最后交给Blob。这就是从文本到二进制的完整逆过程。
我个人在实际项目里这两种方案都用过。方案一更简洁,代码量少,语义清晰;方案二没有额外的fetch调用,在某些无法使用fetch的旧环境或者需要精细控制 MIME 类型时更可靠。但要注意,方案二对超大字符串性能不佳,因为单个超长字符串操作本身就费内存。如果处理几 MB 的图片还行,要是 Base64 字符串有几十 MB,还是考虑去用方案一或者直接把文件上传逻辑改为 FormData 直传。
4.2 File 转 Blob 其实不用转
这里必须帮很多新手省一个弯路:File本身就是一个Blob,所以你完全不需要把File转成Blob。
const file = e.target.files[0]; // file 本身就是 Blob,以下代码是等价的 const blob = new Blob([file], { type: file.type });上面这行代码虽然可以跑,但纯粹是脱裤子放屁。如果你要的是"文件"的形态,直接用原来的file对象就行。File和Blob真正需要互相转换的场景,集中在"Base64 数据"和"二进制数据"之间,而不是 File 和 Blob 之间。文件上传时如果后端要 FormData,直接formData.append('file', file)就行,也没必要转。
另外提一份 Blob 切片的使用场景,因为这是文件断点续传或者大文件分片上传里绕不开的技术点:
// 从 File 中切出前 1MB 的数据 const file = e.target.files[0]; const chunkSize = 1024 * 1024; // 1MB const chunk = file.slice(0, chunkSize); // 这时候 chunk 是一个 Blob // 如果你希望它"看起来像"一个独立的文件,可以包装成 File const chunkFile = new File([chunk], `${file.name}.part1`, { type: file.type });blob.slice(start, end, contentType)返回一个新的Blob对象,不改变原来的对象,这是切片上传的第一个动作。
4.3 完整场景:图片上传、回显、再构造,一气呵成
我拿一个真实后台系统的需求来串一遍:管理员上传一张商品图片,前端先做本地预览,提交表单时后端要求 JSON 格式的 Base64 字段。同时,图片要裁剪缩略图,裁剪后通过 canvas 拿到 Base64,再转成 Blob 放到 FormData 里传给一个独立的存储接口。
第一步,上传与预览。用URL.createObjectURL实现即时预览,不回传 Base64。
第二步,提交 JSON 时转 Base64。用前面封装好的fileToBase64(file)函数。
第三步,canvas 裁剪后,把 canvas 的 Base64 转成 Blob,扔进 FormData:
function canvasToBlob(canvas) { return new Promise((resolve) => { canvas.toBlob((blob) => { resolve(blob); }, 'image/jpeg', 0.9); }); } // 或走 Base64 中转 function canvasToBase64(canvas) { return canvas.toDataURL('image/jpeg', 0.9); } // Base64 -> Blob 上传 const finalBlob = base64ToBlob(canvasBase64, 'image/jpeg'); const formData = new FormData(); formData.append('image', finalBlob, 'cropped.jpg');这样整个流程就串起来了:input 拿 File → createObjectURL 预览 → 需要时 readAsDataURL 转 Base64 → 再按需把 Base64 转 Blob 走 FormData。
5. 工程化封装:一个函数搞定所有转换
单看代码是零散的,实际业务里我更建议把文件转换能力收拢成一个工具模块。这样每次做上传组件,直接import就能用,不用到处复制粘贴。下面是我在项目里沉淀下来的版本,你可以在自己的工程里直接参考:
/** * 文件工具集 */ // 将 input.file 或任意 Blob 转为 DataURL(Base64)字符串 export function fileToDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error(`读取失败:${file.name}`)); reader.readAsDataURL(file); }); } // 将 DataURL(Base64)字符串转为 Blob export function dataURLToBlob(dataURL) { return new Promise((resolve, reject) => { fetch(dataURL) .then((res) => res.blob()) .then(resolve) .catch(reject); }); } // 将 Blob / File 转为 Object URL,并返回释放函数 export function fileToObjectURL(file) { const url = URL.createObjectURL(file); return { url, revoke() { URL.revokeObjectURL(url); }, }; } // 便捷方法:Blob 转 File export function blobToFile(blob, fileName, options = {}) { return new File([blob], fileName, { type: blob.type, ...options }); } // 便捷方法:获取文件信息的统一摘要 export function getFileMeta(file) { return { name: file.name, size: file.size, type: file.type, lastModified: file.lastModified, }; }这几个函数我后续每个上传项目基本都在复用。特别是fileToObjectURL返回一个revoke释放函数,能提醒自己"记得清理",避免内存泄漏。之前开发时踩过坑:在一个 React 组件里反复URL.createObjectURL(file)却不revokeObjectURL,最后浏览器标签页的内存占用一路飙到 1GB 以上,页面直接崩溃白屏。
6. 常见问题与排查技巧实录
我在各个项目里收集了一堆和这个主题相关的 bug 和排查过程,挑几个典型的分享给各位,这些都是文档里不会直接告诉你的事情。
6.1 Base64 字符串无法在 img 中显示
表现:img.src设置成data:image/png;base64,xxxxx后图片不显示。
排查步骤:
- 第一步,先确认 Base64 字符串有没有
data:image/png;base64,完整前缀。FileReader.readAsDataURL生成的字符串自带前缀,但如果你用atob()解码后再拼接过,前缀可能丢了。 - 第二步,确认 MIME 类型是否和文件实际内容匹配。比如你把
image/png写成了image/jpg(注意是jpeg,不是一个jpg),有些浏览器会拒绝渲染。 - 第三步,如果图片能看到轮廓但渲染很慢,极有可能是 Base64 字符串太大,改用
createObjectURL方案。
6.2 input 选择了同一个文件,change 事件不触发
表现:第一次选择a.png,预览正常。再调出文件对话框,还是选a.png,预览不更新,change事件不触发。
这个问题的根源在于浏览器认为你选择的文件没有变化,所以不产生change事件。解决办法是在每次处理完文件后,手动把input的值清空:
fileInput.addEventListener('change', function (e) { const file = e.target.files[0]; if (file) { // 处理逻辑... } // 清理掉 value,保证同一个文件还能再次触发 change e.target.value = ''; });6.3readAsDataURL能跑,但页面内存暴涨
表现:连续预览多张合计几十 MB 的图片后,页面内存持续增长,不能回收。
原因有两个:一个是 Base64 字符串本身就是原文件大小约 1.33 倍的内存占用,另一个是大字符串和 DOM 节点引用纠缠在一起,垃圾回收不及时。
解决方案分三层:第一层,如果只是预览,坚决用createObjectURL;第二层,如果确实要 Base64,加一个文件大小前置判断,比如超过 2MB 直接弹提示"图片过大,请压缩后再上传";第三层,如果必须支持大图,先在前端用canvas做尺寸压缩,再转 Base64,这个方案我项目里一直在用,能把 10MB 的图压到 200KB 左右。
6.4 用 fetch(dataURL) 转 Blob 时出现 CORS 报错
表现:base64ToBlobByFetch方法在某些浏览器报跨域错误。
实际上fetch处理 data URL 的时候,如果字符串异常长或者格式有问题,部分浏览器会把它当作跨域资源处理。遇到这种情况,别纠结,直接换手写解码方案(atob+Uint8Array),既不依赖网络,又能避开 CORS 问题。
6.5 手机端拍照上传,图片方向不对
表现:用手机拍的照片通过 input 上传预览时,方向错乱(横的变的竖的等),但用系统相册看是正确的。
这是因为手机拍照的 EXIF 信息里存储了方向(Orientation),浏览器在渲染图片时部分情况下不会自动处理这个方向。排查方式是把图片放到 canvas 后用getImageData检查方向,或者直接用第三方库自动处理。前端层面最简单的兜底方案是:预览用createObjectURL,同时把图片传给 canvas 做一次重绘,从 canvas 导出的 Base64 方向就正常了。
7. 实测场景:控制台里该怎么一步步验证
说了这么多理论和大段代码,最后给你一套可以直接在浏览器控制台里运行的验证流程。打开任意一个网页(随便一个空白的 localhost 页面),粘进去这些代码试试,你会对这条链路理解得特别牢。
// 1. 创建一个 input 并模拟用户选择文件(这里用代码构造一个 File 对象代替) const fakeFile = new File(['Hello, world!'], 'hello.txt', { type: 'text/plain' }); // 2. 转 Base64 const reader = new FileReader(); reader.onload = () => { console.log('Base64:', reader.result); // 3. 再转回 Blob const base64Str = reader.result; fetch(base64Str) .then((res) => res.blob()) .then((blob) => { console.log('Blob:', blob); console.log('Blob 内容:', blob.size, blob.type); // 4. 用 Blob 生成 Object URL const url = URL.createObjectURL(blob); console.log('Object URL:', url); URL.revokeObjectURL(url); }); }; reader.readAsDataURL(fakeFile);你会发现 Base64 字符串的开头是data:text/plain;base64,SGVsbG8sIHdvcmxkIQ==,然后fetch能把它变回一个 size 和 type 都和原始文件一致的 Blob。整个过程一目了然。
我个人的经验是,遇到文件类 bug,先打印三个数据(file.size、file.type、file.name),再观察转换过程中间值是不是符合预期。很多问题本质上就是形态没搞对:Base64 字符串当成二进制喂给了错误的地方,或者 Blob 被写成了普通对象。一旦你把File → Base64 → Blob → ObjectURL这条链路彻底想通,再复杂的上传需求也就是换一层外衣而已。