把 File 和 Blob 的关系做成前端面试题,我见过太多人回答“File 继承自 Blob”,然后就停住了。这个答案放在教科书里完全正确,但放进真实项目里几乎不解决任何问题。我自己也是在经历了一次又一次的翻车之后才意识到,真正值得理解的不是那句结论,而是这两个对象在浏览器里到底怎么存数据、怎么切数据、怎么被 formData、readAsDataURL 和 createObjectURL 来回倒腾。
如果你去搜索引擎里看 File 和 Blob,大概率会看到一堆看起来完全不相关的内容:vscode 头文件 no such file、license server manager 找不到 daemon、甚至还有 ed2k 链接里的 Windows ISO。它们跟浏览器里的 File 对象没有任何关系,但反而说明了一件事:“文件”在不同语境下代表的东西天差地别。在浏览器这个语境里,File 和 Blob 是两个底层对象,它们决定了文件上传、预览、下载、分片这些基础功能能不能写对。下面我就用实际项目的视角,把这两个对象掰开揉碎,聊一聊那些文档里不会写、但你在开发中一定会遇到的细节。
1. 一个常被忽略的前提:File 根本不是“文件”
1.1 Blob 是数据容器,File 是它的“身份升级”
Blob 全称是 Binary Large Object,直译过来就是“二进制大对象”。这个名字很容易让人误解,觉得它好像是后端才有的东西。实际上浏览器里的 Blob 对象就是一个原始二进制数据的容器,它内部保存的是一段字节数据,同时带一个 MIME 类型。它没有文件名,没有修改时间,没有路径,它只关心“这段字节是什么类型”和“这段字节到底有多长”。
而 File 是建立在 Blob 之上的更高层对象。File 继承了 Blob 的全部能力:它有 Blob 的 size、type,也有 Blob 的 slice、arrayBuffer、stream、text 等方法。区别在于,File 额外增加了三个非常重要的元信息属性:name、lastModified、webkitRelativePath。一个不太严谨但很好用的理解方式是:File = Blob + 文件元信息。
举一个生活化的例子:Blob 像一桶打印出来的纸,桶外只标了一张纸条写“这批纸是什么规格”;File 则像这桶纸外面又贴了一张完整的快递单,注明文件名、创建时间、原来放在哪个文件夹。但不管是桶还是纸,桶里的内容一旦装好就固定了,你没法在桶里直接抽掉某一页再塞回去。
这里想特别强调一个误区:JavaScript 里的 File 对象并不是操作系统里那个真实文件的完整镜像。用户通过<input type="file">选中文件后,浏览器只会给你一个封装好的 File 引用,底层可能是一段内存映射,也可能是一个临时文件,甚至是磁盘文件的一个只读视图。你能操作的是这个表示层对象,而不是文件系统里真实的路径。很多人以为拿到 File 就能拿到完整路径、可以直接拿去和 Node 的 fs 模块互通,这在浏览器安全模型下是不存在的。
1.2 不可变性决定了你无法“原地修改”文件对象
Blob 和 File 对象一旦创建出来,内部数据就不能在 JavaScript 层面被修改。这一点和字符串很像:你不能通过修改某个字符去改变一个已有字符串,任何操作都会生成一个新的字符串。Blob 也是这样,想修改内容,只能通过构造参数生成新的 Blob 或 File 对象。
这个设计对并发和缓存非常友好,但也让很多新手跑偏。比如有人想给正在上传的文件重新指定类型,直接写file.type = 'text/plain';在浏览器控制台里这样写通常不会报错,但也没有任何效果,因为 type 是只读属性。正确的做法是:
const renamedFile = new File([oldFile], 'config.txt', { type: 'text/plain', lastModified: Date.now() });这种“不可变”带来的后果在图片处理链路里体现得最明显。你想压缩一张图,不能直接修改原来的 File 对象,而是要把原图读取到 Image 或 Canvas 里,然后通过canvas.toBlob()得到一个新的 Blob,再把它包成一个新的 File。整个过程没有任何一步是“原地修改原文件”。
不可变性还有一个容易被忽略的好处:数据生命周期安全。当你用file.slice()切出一个分片时,切片后的 Blob 会持有对底层数据的一段引用,不会因为原变量被重新赋值就失效,也不会因为原 File 被垃圾回收导致分片突然读不出来。这也是为什么slice()在规范里被设计成“引用式切分”而不是“立即拷贝”的底气所在。
2. 拆开浏览器文件对象的内部结构:接口、属性与分段读取
2.1 接口关系:File 是 Blob 的扩展,不是平级
从接口角度去看,File继承自Blob。用file instanceof Blob永远返回true,但你用blob instanceof File就不一定是true,因为普通的 Blob 对象没有文件名和修改时间,不是 File。
下面这张表是实际开发中最常用的成员,你可以把它当成速查表:
| 成员 | Blob | File | 实际用途 |
|---|---|---|---|
size | ✅ | ✅ | 返回字节数,上传进度计算时必用 |
type | ✅ | ✅ | MIME 类型,经常是空字符串,需要兜底 |
slice() | ✅ | ✅ | 按字节范围切出新的 Blob,分片上传核心 |
arrayBuffer() | ✅ | ✅ | 返回 Promise ,读取二进制内容 |
stream() | ✅ | ✅ | 返回 ReadableStream,适合流式处理 |
text() | ✅ | ✅ | 按 UTF-8 解码字符串 |
name | ❌ | ✅ | 文件名,不含路径 |
lastModified | ❌ | ✅ | 文件修改时间,毫秒时间戳 |
webkitRelativePath | ❌ | ✅ | 目录选择时相对路径,普通选择为空 |
这里顺带说一个历史包袱:早期浏览器里 File 的切片方法叫File.webkitSlice()和File.mozSlice(),后来的版本才统一成Blob.slice()。如果你维护的是老项目,看到这种带前缀的方法名不要惊讶,直接按标准兼容写法迁移到slice()就行。另外,BlobBuilder 这种早就废弃的 API,现在的代码里不要再碰了。
还有一个容易忽略的点:File构造函数是后来才进标准库的。早期前端只能用<input>拿 File,想凭空构造一个 File 对象几乎不可能。现在环境好多了,new File([blob], 'name', { type: 'text/plain' })可以直接把一个 Blob 升级成 File,这在文件上传场景里非常实用。
2.2 属性背后的含义:size、type、lastModified 不只是数据
先说size。它返回的是字节数,不是字符数,也不是二进制位数。计算大文件分片时,经常要拿 size 除以分片大小,然后向上取整得到分片数量。有个细节:如果文件大小不是分片大小的整数倍,最后一片通常会小一些,代码里要处理边界,不能默认每片大小完全相等。
再说type。这个属性是浏览器根据文件扩展名或系统 MIME 信息推断出来的,很多时候并不靠谱。比如一个.md文件,在 Windows 上可能返回空字符串;一个.json文件,有的环境返回application/json,有的返回text/plain。所以上传到服务端时,不能完全相信file.type,最好结合文件头魔数做二次校验。后面我会给一个可以直接用的魔数检测函数。
lastModified返回的是 Unix 毫秒时间戳。一个非常实用的小技巧是:用new File()重新包装 Blob 时,可以手动指定lastModified,比如把服务端返回的最后修改时间套到本地 File 对象上,方便做增量上传的本地缓存比对。
webkitRelativePath则只在<input type="file" webkitdirectory>这种目录选择场景下才有值,它保存的是文件相对于所选目录的路径。但要注意,拖拽一个文件夹到页面上时,DataTransfer.files里拿到的 File 对象通常没有这个相对路径,需要走webkitGetAsEntry()自己去遍历目录树,不然目录结构会丢得一干二净。
2.3 slice() 的边界行为:按字节切分,不含 end
Blob.slice()的签名是blob.slice([start], [end], [contentType]),它的行为和数组的slice()类似:start可以省略,可以为负数;end不包含在结果范围内;第三个参数会覆盖返回的新 Blob 的type,但不会修改原 Blob 的type。
很多人以为slice()只是“把一段数据拷出来”,但规范实现上,多数浏览器对 Blob 和 File 的切片是引用式的:新 Blob 对象引用原底层数据的一段范围,而不是复制整个数据。这就意味着大文件分片时,用file.slice(start, end)切出来的分片对象非常轻量,不会因为切了 100 次就把文件内容复制 100 遍。
需要注意边界:start和end的单位是字节,不是编码后的字符。切中文文本文件时,如果你直接用字符位置去切,可能把多字节 UTF-8 字符从中间劈开,导致解码出来是乱码。分片上传后服务端合并时,如果不对齐到完整的字节切片,也会出类似问题。所以分片大小最好都按固定的字节数对齐,不要按行数或字符数。
还有一个小细节:如果start >= end,slice()返回的是一个 size 为 0 的 Blob,不会报错。这个空 Blob 在 FormData 上传时看起来是一个空文件,服务端可能直接少收一片。写分片逻辑时,建议在循环里加一个保护:
for (let start = 0; start < file.size; start += CHUNK_SIZE) { const end = Math.min(file.size, start + CHUNK_SIZE); if (start >= end) continue; const chunk = file.slice(start, end); // upload chunk }3. 文件预览、上传、下载:File 与 Blob 的真实分工
3.1 两条预览路线:FileReader 与 URL.createObjectURL
文件预览是 File 和 Blob 最典型的应用场景。一个图片文件被用户选中后,你需要把它显示在页面上。这里有两条路线,各有各的适用边界。
第一条路线是用URL.createObjectURL(file)生成一个blob:http://...形式的 URL,然后直接塞给<img src>或<video src>。这个路线的核心优势是快,它不要求先把整个文件读入内存,而是引用底层数据,浏览器自己会按需取用。缺点是创建出来的 URL 需要手动释放,否则会占内存直到页面卸载。
const url = URL.createObjectURL(file); const img = document.createElement('img'); img.src = url; img.onload = () => { URL.revokeObjectURL(url); };第二条路线是用FileReader.readAsDataURL(file)把文件读成 base64 字符串。这条路线的优点是兼容老环境、可以直接放在 JSON 里传输,缺点是 base64 编码会让数据膨胀约 33%,而且必须把整个文件内容读进内存。拿一个 200MB 的文件去 readAsDataURL,浏览器内存基本就撑不住了。所以我的经验是:小文件、老环境用 FileReader,大文件预览、需要稳定内存表现的场景优先用URL.createObjectURL。
这里还有一点:URL.createObjectURL()不仅仅能接收 File,也能接收 Blob。用 Canvas 导出的 Blob,或者从 fetch 响应里拿到的 Blob,都可以直接用它来生成预览地址。它不是 File 的专属 API,而是所有 Blob 的通用能力。
3.2 FormData 上传时 File 与 Blob 的差异
FormData 上传是 File 和 Blob 最容易踩坑的地方。先看一个经典问题:
const fd = new FormData(); fd.append('file', blob); fetch('/upload', { method: 'POST', body: fd });你以为这样上传的是一个文件,但请求发出去后,服务端拿到的文件名大概率是"blob",而不是你预期的名字。因为 FormData 在 append 一个 Blob 时,如果它是 File,就会自动使用file.name作为文件名;如果它只是一个普通 Blob,没有name属性,规范里默认的文件名就是字面量"blob"。
解决办法有两个,要么显式传第三个参数:
fd.append('file', blob, 'avatar.png');要么先把 Blob 升级成 File:
const file = new File([blob], 'avatar.png', { type: 'image/png' }); fd.append('file', file);第二种方法的额外好处是你可以同时指定type和lastModified,把文件的元信息补完整。我一般会在项目里约定:所有上传模块,只要处理的是“文件”语义,就一定先构造 File,不要拿裸 Blob 去 append。
还有一个高频坑是手动设置 FormData 的Content-Type。很多新手会写:
fetch('/upload', { method: 'POST', body: fd, headers: { 'Content-Type': 'multipart/form-data' } });这样写十有八九会出问题,因为浏览器在发送 FormData 时会自动生成一个带 boundary 的Content-Type,你手动设置后反而把 boundary 弄丢了,服务端解析不了。除非你在做后端 mock,否则不要手动设置这个请求头。
3.3 Canvas / fetch 生成 Blob 的常见链路
前端经常会遇到“把页面内容生成文件”的需求,比如截图导出、Canvas 导出图片、请求返回二进制数据。这些场景里,你最终拿到的一般不是用户选择的 File,而是一个新生成的 Blob。
Canvas 导出图片的经典写法是:
function canvasToFile(canvas, filename) { return new Promise((resolve) => { canvas.toBlob((blob) => { if (!blob) { resolve(null); return; } resolve(new File([blob], filename, { type: blob.type || 'image/png' })); }, 'image/png', 0.92); }); }注意canvas.toBlob()的回调里拿到的 blob 就是普通 Blob,它没有文件名。你把这张图传给后端时,如果直接fd.append('image', blob),服务端收到的文件名又是"blob"。所以我把new File([blob], filename)这个动作封装成了通用函数,所有导出链路最后都变成 File 再上传。
fetch 请求文件也同理:
const response = await fetch('/api/file?id=123'); const blob = await response.blob(); const file = new File([blob], 'report.pdf', { type: 'application/pdf' });这里的blob同样没有文件名,名字其实是由后端在Content-Disposition里给出的。如果你需要沿用后端文件名,可以解析响应头,而不是默认写死一个名字。当然,同源或跨域配置允许的情况下,response.headers.get('Content-Disposition')可以拿到。
3.4 分片上传时 slice 与 multipart 的配合
大文件上传基本绕不开分片。分片的核心就是Blob.prototype.slice()。假设你打算把每个分片设为 5MB:
const CHUNK_SIZE = 5 * 1024 * 1024; const chunks = []; for (let start = 0; start < file.size; start += CHUNK_SIZE) { const end = Math.min(file.size, start + CHUNK_SIZE); chunks.push(file.slice(start, end)); }每个分片都是一个独立的 Blob。上传时,你不仅要把分片数据放进 FormData,还要把分片序号、总分片数、原文件名等元信息一起传给服务端:
async function uploadChunk(file, chunk, index, total) { const fd = new FormData(); fd.append('file', chunk, `${file.name}.part${index}`); fd.append('index', String(index)); fd.append('total', String(total)); await fetch('/api/upload/chunk', { method: 'POST', body: fd }); }这里file.slice(start, end)返回的是 Blob,不是 File,所以在 append 时如果不传第三个参数,文件名又是默认的"blob"。我在项目里统一按${file.name}.part${index}来命名,这样就算服务端不看额外字段,也能从文件名里解析出原文件名和分片序号。
分片上传还有一个很多人会忽略的内存问题:如果你的第一个版本是先把整个文件file.arrayBuffer()读进内存,再手动从 ArrayBuffer 里截取分片,那大文件会在内存里完整复制一份,十几个分片线程同时操作时内存直接拉满。正确做法是用file.slice()切分,底层是引用式切分,内存压力要小得多。
4. 从我踩过的坑里总结出的 File/Blob 高频陷阱
4.1 拖拽文件夹时,File 对象里没有“路径”给你用
我接过一个文件管理需求,允许用户拖拽一个文件夹到页面上,然后上传所有文件并还原目录结构。第一版我天真地以为file.webkitRelativePath到处都有值,结果发现拖拽文件夹和<input webkitdirectory>拿到的 File 对象差别很大。
input.webkitdirectory会为每个文件填充webkitRelativePath,比如docs/sub/a.md。但拖拽时,e.dataTransfer.files里每个 File 的webkitRelativePath往往是空字符串。要还原目录结构,必须用DataTransferItem.webkitGetAsEntry()去递归遍历文件树:
async function walkEntry(entry, rootPath = '') { const path = rootPath ? `${rootPath}/${entry.name}` : entry.name; if (entry.isFile) { const file = await new Promise((resolve) => entry.file(resolve)); return [{ path, file }]; } const reader = entry.createReader(); const entries = await new Promise((resolve) => { reader.readEntries((items) => resolve(items)); }); const results = await Promise.all(entries.map((child) => walkEntry(child, path))); return results.flat(); }这个函数返回后,每个文件才有明确的相对路径。拿到路径后,再和 File 对象绑定,才能在上传时重建目录树。这个“浏览器不会给你完整绝对路径”的安全设计,其实是为了防止网页一拖拽就偷走你整个磁盘信息。
4.2 objectURL 不释放,内存会一点点涨上去
URL.createObjectURL()真的非常方便,但它把内存管理责任交到了你手上。我有一个运营后台,运营人员会连续预览几十张图片,最开始是用 createObjectURL 生成预览地址,但没有 revoke,结果页面越来越卡,最后直接崩溃。
原因很简单:每调用一次 createObjectURL,浏览器都会在内部建立一个 blob URL 到数据块之间的映射。即使旧的<img>被移除,这个映射仍然存在,内存不会自动回收。解决办法是在合适的时机调用URL.revokeObjectURL(url)。
什么时候算合适的时机?图片加载完成后是一个靠谱的点,因为加载完成后浏览器已经拿走了需要的数据:
const url = URL.createObjectURL(file); const img = new Image(); img.src = url; img.onload = () => URL.revokeObjectURL(url);如果是要做“下载文件”而不是“预览”,可以使用<a download>配合 blob URL,然后在点击后延迟 revoke。延迟是因为部分浏览器在点击下载的瞬间才去读数据,如果马上 revoke 可能让下载中断:
const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'file.pdf'; link.click(); setTimeout(() => URL.revokeObjectURL(link.href), 1000);4.3 类型和文件名丢了:一个 new File() 就能救回来
在文件处理流程里,File 对象经过一步转换就很容易“降级”成 Blob。最典型的是 FileReader 读取后的数据,你用new Blob([buffer])重新包装时,如果不传 type,就拿不到原来的 MIME 类型;后续上传时,服务端只能按通用二进制处理。
更常见的是 Canvas 导出、fetch 下载这些链路。我在一个发票导出功能里看到过后端报“文件类型不正确,需要 image/png”,结果排查下来,前端用canvas.toBlob(cb, 'image/jpeg', 0.9)生成了 JPEG 数据,却在new File([blob], name)时没传{ type: blob.type },最后新 File 对象的 type 变成空字符串,后端校验直接失败。
帮我修复这个坑的代码很简单:
const file = new File([blob], `${name}.jpg`, { type: blob.type || 'image/jpeg' });关键是不要偷懒省略 type。如果你不确定类型,用后面的魔数检测函数做个兜底,比盲目信任文件名扩展名靠谱得多。
4.4 structuredClone 与 postMessage 的兼容性问题
前端把 File 或 Blob 传给 Web Worker 时,你会用到postMessage。这里最大的坑是:Blob 和 File 是结构化可克隆对象,但不是可转移对象。
所谓“可转移对象”,是指像 ArrayBuffer、MessagePort 这类可以“转移所有权”的东西,转移后原上下文里的对象会变成 detached,底层数据不会复制。Blob 不在这类列表里,它是结构化克隆,本质上是创建一个引用同一份底层数据的新 Blob 对象。
很多优化心切的人会把 File 放进转移列表里:
worker.postMessage({ file }, [file]); // 会抛 DataCloneError这样写在现代浏览器里会直接报错,因为[file]是转移列表,不是普通的参数列表。正确的做法是直接传:
worker.postMessage({ file });同时,如果你在 Worker 里需要操作大文件,比较稳妥的方案是先把需要的分片slice()出来,再在 Worker 里读取;如果确实要传给主线程一个很大的 ArrayBuffer,那才应该把 ArrayBuffer 本身作为可转移对象:
const buffer = await file.arrayBuffer(); worker.postMessage({ buffer }, [buffer]);不过要注意,file.arrayBuffer()本身已经做了一次完整读取,内存占用并不小。绝大多数场景下直接传 Blob 引用反而是更省内存的。
5. 几个可以直接抄的 File/Blob 工具函数
5.1 用文件头魔数做类型兜底
file.type不可靠,但文件的二进制内容通常骗不了人。常见格式在文件头部都有固定的“魔数”,比如 PNG 前八个字节是固定的,JPEG 前三个字节是FF D8 FF,PDF 以%PDF开头。
下面这个函数读取文件的前 8 个字节,识别常见类型:
async function sniffFileType(fileOrBlob) { const buffer = await fileOrBlob.slice(0, 8).arrayBuffer(); const bytes = new Uint8Array(buffer); if (bytes.length >= 4 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) { return 'image/png'; } if (bytes.length >= 3 && bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) { return 'image/jpeg'; } if (bytes.length >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) { return 'application/pdf'; } if (bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4B && (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07)) { return 'application/zip'; } return fileOrBlob.type || 'application/octet-stream'; }这个函数可以放在上传前校验的位置。比如用户选了photo.jpg,但file.type是空字符串,你可以先用魔数检测兜底,得到正确的 MIME 类型后再重新new File()包装,保证服务端收到的类型是可信的。
5.2 并发分片上传的调度骨架
分片上传如果不做并发限制,浏览器高峰期一口气发几百个请求,服务端很容易被打挂。一个简单实用的方案是“固定并发池”:用 N 个 worker 循环从任务队列里拿分片执行,执行完再拿下一个,而不是一次性Promise.all所有分片。
async function uploadWithConcurrency(file, { chunkSize = 5 * 1024 * 1024, concurrency = 3, uploadPart, onProgress } = {}) { const chunks = []; for (let start = 0; start < file.size; start += chunkSize) { const end = Math.min(file.size, start + chunkSize); chunks.push(file.slice(start, end)); } let index = 0; let uploaded = 0; async function worker() { while (true) { const current = index++; if (current >= chunks.length) return; const fd = new FormData(); fd.append('file', chunks[current], `${file.name}.part${current}`); fd.append('index', String(current)); fd.append('total', String(chunks.length)); await uploadPart(fd, current); uploaded += 1; onProgress?.(uploaded, chunks.length); } } const workers = Array.from( { length: Math.min(concurrency, chunks.length) }, () => worker() ); await Promise.all(workers); }这个骨架非常轻量,没有做失败重试,也没有记录已完成分片,但它的并发控制和进度回调已经能满足大多数普通项目。想扩展到断点续传时,只需在上传前从服务端拉取“已上传分片序号”集合,在worker里跳过这些 index 即可。
5.3 数据 URL、Blob、File 之间的转换
做图片处理或接口对接时,经常要在几种格式间相互转换。我整理了自己一直在用的一组转换函数:
// Blob -> File function blobToFile(blob, filename, type = blob.type || 'application/octet-stream') { return new File([blob], filename, { type }); } // File -> ArrayBuffer async function fileToArrayBuffer(file) { return await file.arrayBuffer(); } // ArrayBuffer -> Blob function arrayBufferToBlob(buffer, type = 'application/octet-stream') { return new Blob([buffer], { type }); } // File -> DataURL function fileToDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } // DataURL -> Blob function dataURLToBlob(dataURL) { const [head, body] = dataURL.split(','); const mime = head.match(/data:(.*?);base64/)?.[1] || 'text/plain'; const binary = atob(body); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return new Blob([bytes], { type: mime }); }像这种工具函数,我习惯把所有牵涉 File/Blob 的转换都收拢到一个 utils 文件里,避免每个上传组件各写一套,否则 type 和 filename 的坑会反复踩。魔数检测、分片调度和格式转换放在一起,就是一个很够用的浏览器文件处理工具集了。