
把 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 typefile选中文件后浏览器只会给你一个封装好的 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。下面这张表是实际开发中最常用的成员你可以把它当成速查表成员BlobFile实际用途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 typefile webkitdirectory这种目录选择场景下才有值它保存的是文件相对于所选目录的路径。但要注意拖拽一个文件夹到页面上时DataTransfer.files里拿到的 File 对象通常没有这个相对路径需要走webkitGetAsEntry()自己去遍历目录树不然目录结构会丢得一干二净。2.3 slice() 的边界行为按字节切分不含 endBlob.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 endslice()返回的是一个 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 升级成 Fileconst 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?id123); 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()。假设你打算把每个分片设为 5MBconst 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 FFPDF 以%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 的坑会反复踩。魔数检测、分片调度和格式转换放在一起就是一个很够用的浏览器文件处理工具集了。