1. 从一次文件上传说起:为什么前端必须掌握FileReader和Blob
前两周有个朋友跟我抱怨,说他们公司做内部管理系统,遇到一个特别头疼的需求:用户要上传几百MB的压缩包,还要能预览图片、查看文本文件内容,甚至要把Excel里的数据抽出来做校验。他第一反应是"这得走后端吧",结果后端同事冷笑一声说"文件解析本来就能在前端做,你查查FileReader"。
其实这句话对了一半。FileReader确实能在浏览器本地读取文件内容,但真正让前端文件操作从"花了呼哨"变成"生产力工具"的,是FileReader和Blob这对组合。一个负责把File/Blob数据读进内存并转成可用的文本、DataURL或二进制buffer,另一个负责用结构化方式描述、切分、组合二进制数据。两者互相配合,才能完成前端文件上传预览、分片传输、本地导出、图片压缩这些日常刚需。
这篇文章不打算讲那些官方文档里一抄一大段的API罗列。我从一个实际项目里踩过的坑出发,把FileReader的四个读取方法、Blob的切片和应用场景、以及两者配合时最容易出问题的地方全部掰开揉碎讲清楚。适合刚接触前端文件处理的技术新人,也适合想系统梳理一遍、避免在项目里踩坑的进阶开发者。
先花一分钟理解一个基础概念:浏览器里一切文件操作的本质,都是二进制数据操作。FileReader是将二进制转为可读内容的翻译官,Blob是二进制数据的容器和切分器。绕明白这两句话,后面全是细节。
2. FileReader:把文件读进内存的四把钥匙
FileReader说白了就是浏览器提供的一个"文件内容读取器"。你给它一个File对象或者Blob对象,它通过内部异步机制把内容读出来,然后通过事件回调把结果交给你。之所以叫Reader,是因为它只管"读",不管"写"——写文件、生成文件是另一码事,稍后讲Blob时再聊。
这里有个关键认知:FileReader的所有读取方法都是异步的。也就是说你调用readAsText(file)之后,文件内容不会立刻出现在返回值里,而是要等onload事件触发才能拿到结果。新手最容易犯的错误就是"刚调用完就立刻取result",结果自然是undefined。
2.1 readAsText:读文本文件的最短路径
readAsText(blob, encoding?)会把Blob内容按指定编码解析成字符串,第二个参数不传时默认UTF-8。这个方法最适合读纯文本、JSON、XML、CSV这类数据。
const input = document.querySelector('input[type="file"]'); input.addEventListener('change', async (e) => { const file = e.target.files[0]; const reader = new FileReader(); reader.onload = () => { console.log(reader.result); }; reader.readAsText(file, 'UTF-8'); });看起来很简单,但有几个细节值得注意。
第一,onload里拿到的reader.result是字符串。如果你读的是JSON文件,记得JSON.parse(reader.result),因为FileReader不管内容结构,它只负责把字节变成字符串。第二,编码参数很重要。很多系统里用户会上传GBK编码的CSV或TXT,这时不指定GBK或者GB18030,中文一定会乱码。实测下来某些浏览器并不认GBK这个标签,要写成GB18030才能兼容,这是我踩过的第一个坑。
第三,超大文本文件慎用readAsText。一个100MB的TXT文件会被整体读成一个超长字符串,内存占用翻倍,还会卡死渲染线程。遇到大文件,正确做法是先切片,再用readAsArrayBuffer逐段处理。后面讲分片上传时我会给完整示例。
2.2 readAsDataURL:图片预览的规范姿势
readAsDataURL把文件内容编码成Base64字符串,并且自动加上MIME类型前缀。比如读一张PNG图片,得到的结果长这样:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA...这种DataURL可以直接塞给img.src、a.href实现预览和下载。好处是简单直观,不需要额外生成临时URL,符合浏览器安全策略,不需要考虑内存释放。坏处是Base64编码会让体积膨胀约33%,而且一旦编码成字符串,你拿到的就不再是原始二进制数据,后续没法直接传给二进制上传接口。
我个人的经验是:图片预览优先用URL.createObjectURL而不是readAsDataURL,两者我都后文对比。但如果图片本身要参与表单提交、跨端传输或需要暂存在本地(比如存进localStorage做草稿箱),DataURL反而是更好的选择。
// 图片预览 - DataURL方案 const reader = new FileReader(); reader.onload = (e) => { previewImg.src = reader.result; // data:image/xxx;base64,.... }; reader.readAsDataURL(file);这里有一个非常隐蔽的坑:用readAsDataURL读大图片时,Base64字符串会非常长,放在img.src里虽然能显示,但内存压力很大,而且浏览器地址栏或某些组件的日志里如果打了这个字符串,页面直接卡死。我在一个后台管理系统里就遇到过,用户上传一张8MB的照片,console.log了预览字符串,整个DevTools直接崩溃。
2.3 readAsArrayBuffer:接触二进制原貌的入口
readAsArrayBuffer返回ArrayBuffer对象,它是一段固定长度的原始二进制数据缓冲,不包含任何字符串语义。你可以在后续用DataView或Uint8Array等TypedArray视图去解析、修改、读取其中的字节。
reader.onload = () => { const buffer = reader.result; // 这是ArrayBuffer const bytes = new Uint8Array(buffer); // 按字节视图解读 console.log(bytes.length, bytes[0]); // 例如文件长度、第一字节 }; reader.readAsArrayBuffer(file);这个方法几乎是所有前端二进制处理的基础。判断文件真实类型(魔数校验)、读取图片宽高、解析文件头、计算MD5/SHA1哈希、分片上传,统统需要先拿到ArrayBuffer再操作。
举个实际例子:图片的PNG格式前8个字节固定是89 50 4E 47 0D 0A 1A 0A,JPEG前3字节是FF D8 FF。想在前端做文件类型校验,不能只看input的accept属性,因为用户改扩展名就能绕过。正确做法是读ArrayBuffer后检查魔数:
function checkIsPng(buffer) { const bytes = new Uint8Array(buffer.slice(0, 8)); const pngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; return pngSignature.every((v, i) => bytes[i] === v); }这段代码依赖ArrayBuffer.prototype.slice,注意它与Blob.slice的返回类型不同,前者返回新的ArrayBuffer,后者返回新的Blob,新手很容易搞混。
2.4 生命周期事件与结果缓存
FileReader除了onload,还有onerror、onabort、onloadstart、onloadend、onprogress几个常用事件。实际项目中至少需要处理onerror和onloadend。onloadend无论成功失败都会触发,适合统一关闭loading状态。
const reader = new FileReader(); reader.onloadstart = () => { showLoading(); }; reader.onprogress = (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); updateProgress(percent); } }; reader.onload = () => { handleResult(reader.result); }; reader.onerror = (e) => { showError('读取失败'); }; reader.onloadend = () => { hideLoading(); }; reader.readAsText(file);还有两个不可不知的细节。
其一,FileReader.result在读取成功后会一直保留在对象上,不会自动清空。如果一个FileReader对象被复用去读多个文件,新结果会覆盖旧结果,但底层的旧内存并不会立刻释放。处理大量文件时,建议每读一个文件就新建一个FileReader,或者读完就reader = null,避免内存回收不及时。
其二,reader.abort()可以中断读取,触发onabort事件。但在文件很大的情况下,中断之后的内部状态有些浏览器处理得并不干净,最稳妥的做法是直接用新实例替换旧实例,而不是尝试复用。
3. Blob:二进制数据的乐高积木
如果说FileReader是"读文件内容",那Blob就是"描述和操作二进制数据本身"。Blob的全称是Binary Large Object,浏览器里它代表一个不可变的原始数据块。所有File对象实际上都继承自Blob——这点非常重要,意味着几乎所有能操作Blob的API,同样能操作File。
3.1 Blob的构造与type属性的门道
创建Blob有两种方式。一种是直接new:
const blob = new Blob(['hello world'], { type: 'text/plain' }); const jsonBlob = new Blob([JSON.stringify({a: 1})], { type: 'application/json' });另一种是从已有Blob里切出一段:
const sliced = largeBlob.slice(start, end, type);构造时传入的数组里可以是字符串、ArrayBuffer、TypedArray、DataView,甚至另一个Blob。浏览器会把它们按顺序拼接成一个整体。
type属性表示MIME类型,这个字段的违规率非常高。很多人随手填一个application/octet-stream完事,结果后续下载文件时扩展名不对、预览时找不到合适的渲染器。从实践角度讲:
- 图片:
image/png、image/jpeg、image/webp - 文本:
text/plain或text/markdown - JSON:
application/json - 压缩包:
application/zip - 通用二进制:
application/octet-stream
这个字段既是预览和下载的依据,也参与了文件类型识别。虽然真实文件头的魔数才是权威,但type字段如果填错了,很多场景下会直接导致功能异常。我在项目中遇到过:用Blob生成后端需要的JSON文件时type填了text/plain,下载下来系统识别不了。改回application/json即刻解决。
3.2 从Blob创建预览URL
前面提到图片预览有两种思路,这里全部展开。URL.createObjectURL(blob)**会根据Blob生成一段临时URL,格式是blob:http://域名/uuid`。它引用着内存里的Blob对象,浏览器渲染图片、视频、PDF时可以直接加载这段URL。
const objectUrl = URL.createObjectURL(file); previewImg.src = objectUrl;和DataURL相比,objectURL有三个明显优势:
- 体积零膨胀,不经过Base64编码,性能开销极小
- 生成速度快,支持大文件预览
- 保真性好,视频、PDF等二进制格式也能直接预览
但代价是:这段URL的生命周期必须由你手动管理。一旦不再使用,必须调用URL.revokeObjectURL(objectUrl)释放内存。很多项目里只create不revoke,后果就是页面越用越卡,尤其是上传预览图片频繁操作的页面,内存直线往上涨。
正确的姿势是在图片加载完成后尽快释放:
const img = new Image(); img.onload = () => URL.revokeObjectURL(objectUrl); img.src = objectUrl;需要注意的是,revoke要放在onload回调里,而不是调用create后立刻执行。revoke太早,img还没来得及加载,预览框变成一片空白。
3.3 Blob切片与分片上传的实现思路
Blob有一个极其实用的能力:slice(start, end, contentType)可以切出原始数据的任意子段,返回新的Blob。这个能力直接支撑起前端分片上传、断点续传、视频流加载等场景。
先看基础分片代码:
const CHUNK_SIZE = 2 * 1024 * 1024; // 每片2MB function sliceFile(file) { const chunks = []; let start = 0; while (start < file.size) { const end = Math.min(start + CHUNK_SIZE, file.size); chunks.push(file.slice(start, end)); start = end; } return chunks; }这个循环很基础,真正有讲究的是切完之后的上传逻辑。我在项目里用过一个组合思路:
async function uploadChunks(file) { const chunks = sliceFile(file); const total = chunks.length; for (let i = 0; i < total; i++) { const formData = new FormData(); formData.append('chunk', chunks[i], file.name); formData.append('index', i.toString()); formData.append('total', total.toString()); formData.append('filename', file.name); // 用fetch逐个上传 await fetch('/api/upload', { method: 'POST', body: formData }); progress.innerHTML = `已上传 ${i + 1}/${total}`; } }这段代码的实际效果是前端把大文件切成多个小Blob,每个独立上传,服务端接收后按index顺序合并。相比一次传整个文件,好处有三点:
第一,避免大文件单请求超时。很多代理服务和网关对请求体大小和时间限制得非常死,2MB一个的请求几乎不会触发超时。第二,支持并发控制。可以把循环改成Promise.all限制同时3~5个请求,实测上传速度比串行快很多。第三,具备断点续传的雏形。如果某个分片上传失败,重新上传失败分片即可,不需要整个文件重来。
但这里有一个最容易遗漏的细节:分片上传时Blob切片返回的是一个"视图式"的Blob,它内部引用的是原文件部分字节,不是复制出来的独立数据。也就是说,原文件对象如果被垃圾回收,切片切片可能出问题。因此分片期间必须保持原File对象引用不丢失。一些打包工具或浏览器优化可能会提前调整原Blob的生命周期,所以最好把原文件也挂在作用域里,或显式保留引用。
3.4 用Blob实现前端文件导出与下载
Blob第三大高频应用场景是前端生成文件并下载。比如数据导出到CSV,后端不参与,纯前端搞定。
function exportCSV(data) { const rows = data.map(row => row.join(',')).join('\n'); const blob = new Blob(['\uFEFF' + rows], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '导出数据.csv'; a.click(); URL.revokeObjectURL(url); }这段代码里的\uFEFF是BOM头,专门用来解决Excel打开CSV时中文乱码的问题。不加BOM,Windows上的Excel大概率以ANSI编码解读UTF-8的CSV,中文直接变乱码。加完BOM,Excel才会正确识别为UTF-8。
另一个细节是a.click()之后要立刻revoke吗?实践中最好用setTimeout延迟到点击事件完成后释放,或者干脆在下载事件响应后释放。有些浏览器在点击事件同步执行完之前释放URL,会导致下载失败。稳妥写法:
a.onclick = () => setTimeout(() => URL.revokeObjectURL(url), 1000);4. 工具选型与平台差异:这些坑不踩等于白学
FileReader和Blob虽说是浏览器原生能力,但在真正的项目中,必须考虑兼容性、性能边界、以及和现有框架的配合。
4.1 兼容性现状
一个残酷的事实:FileReader在IE10才完整支持,Blob和URL.createObjectURL更是要IE10+。如果项目还要兼容IE9及以下,这两个API基本没法用,只能借助后端或者Flash方案——不过现在这么老的浏览器几乎已经绝迹,这里只提一句考古。
现代浏览器中,两者的实现趋于一致,但仍有微妙的差异:
new Blob()的构造函数在Safari低版本上不支持,需要PolyfillFileReader的readAsBinaryString被标记废弃,不要使用URL.createObjectURL在部分Android WebView上生成的URL过期时间不稳定,必要时改用DataURL兜底
我在一个混合开发项目里遇到过:iOS的WKWebView对大Blob的objectURL支持很好,但Android的X5内核下,URL.revokeObjectURL执行时机稍晚就会导致内存泄漏。最终的处理方案是统一用FileReader.readAsDataURL做图片预览,虽然性能略差,但平台一致性更好。这个决策的依据是:项目里图片体积不大(几乎都在2MB以内),性能损耗在可接受范围内,而平台一致性直接决定了用户体感。
4.2 性能边界与内存管理
FileReader和Blob的配合最容易出性能问题的是"大文件全量读进内存"。比如一个200MB的压缩包,你调用readAsArrayBuffer想算MD5,瞬间浏览器内存上涨几百MB。不是不能做,而是要控制并发、控制单次读取量。
我在实际项目中总结了一套经验:
- 文件小于5MB:直接
readAsArrayBuffer读全量,无脑简单 - 文件5MB~50MB:先切片,每片2MB,逐片读取并计算hash或上传
- 文件大于50MB:必须结合
file.slice分片,且每读完一片,手动把对应的Blob引用置空
另外一个被忽视的细节是:FileReader读大文件时,result里的ArrayBuffer在读取结束后不会被自动释放,即使reader = null也只是断掉了引用。如果想强制回收内存,可以把reader.result也置空,或主动调用reader.abort()后再置空。实测在Chrome里,reader = null; reader.result = null;之后堆内存会明显下降。
4.3 FileReader与Blob对象的关系速查
很多文档把FileReader和Blob混在一起讲,导致新手搞不清谁是谁。我用一个小表格整理:
| 能力 | FileReader | Blob |
|---|---|---|
| 读取文件内容为文本 | 支持,readAsText | 不支持 |
| 读取文件为DataURL | 支持,readAsDataURL | 不支持 |
| 读取为ArrayBuffer | 支持,readAsArrayBuffer | 不支持 |
| 生成临时预览URL | 不支持 | 支持,URL.createObjectURL |
| 切片 | 不支持 | 支持,blob.slice |
| 构造新文件 | 不支持 | 支持,new Blob |
| 与FormData配合 | 不能直接append | File本身就是Blob子类,可直接append |
这表格我背了很久。只要记住这两句话:FileReader负责从Blob/File里把字节读出来,Blob负责组织和切割数据本身。当然File也继承了Blob的一切能力,所以file.slice能切,file.type能查,URL.createObjectURL(file)也能用。
5. 实操环节:一个文件预览与分片上传的综合Demo
讲了这么多理论,必须上点能直接抄的代码。我拿一个典型的"管理后台文件管理系统"场景举例,完成三件事:图片本地预览、文本文件内容读取、大文件分片上传。这三件事分别对应FileReader的两个方法和Blob切片。
5.1 界面与交互设计
页面上有一个文件选择框,支持多选,可以识别的文件类型包括图片、文本、CSV。选择后,页面分为三个区域:
- 图片预览区:用
URL.createObjectURL展示缩略图 - 文本内容区:用
readAsText读取并显示文本内容前1000个字符 - 上传进度区:无论什么文件,都走分片上传,显示进度条
这个设计瞄准的是实际需求:预览是给用户即时反馈的,内容读取是为了前端本地校验(比如检查CSV表头格式是否正确),分片上传是为了服务端接收大文件时更稳定。
5.2 核心代码实现
完整代码贴出来,可以直接放在一个HTML文件里跑:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>FileReader与Blob综合实操</title> </head> <body> <input type="file" id="fileInput" multiple /> <h3>图片预览</h3> <div id="preview"></div> <h3>文本内容</h3> <pre id="textContent"></pre> <h3>上传进度</h3> <div id="progress"></div> <script> const input = document.getElementById('fileInput'); const preview = document.getElementById('preview'); const textContent = document.getElementById('textContent'); const progress = document.getElementById('progress'); input.addEventListener('change', (e) => { const files = Array.from(e.target.files); files.forEach(handleFile); }); function handleFile(file) { // 根据type分流处理 if (file.type.startsWith('image/')) { previewImage(file); } else if (file.type.startsWith('text/') || file.type.includes('json') || file.type.includes('csv')) { readTextPreview(file); } uploadChunks(file); } function previewImage(file) { const url = URL.createObjectURL(file); const img = document.createElement('img'); img.src = url; img.style.maxWidth = '200px'; img.style.margin = '8px'; img.onload = () => URL.revokeObjectURL(url); preview.appendChild(img); } function readTextPreview(file) { const reader = new FileReader(); reader.onload = () => { const str = reader.result; // 只预览前1000字符,避免大文本卡页面 textContent.textContent = str.slice(0, 1000); // 检测是否JSON,并尝试格式化 if (file.type.includes('json')) { try { const obj = JSON.parse(str); textContent.textContent = JSON.stringify(obj, null, 2); } catch (err) { textContent.textContent = '解析JSON失败:' + err.message; } } }; reader.onerror = () => { textContent.textContent = '文件读取失败'; }; reader.readAsText(file, 'UTF-8'); } const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB一片 function sliceFile(file) { const chunks = []; for (let start = 0; start < file.size; start += CHUNK_SIZE) { const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); chunks.push(chunk); } return chunks; } async function uploadChunks(file) { const chunks = sliceFile(file); const total = chunks.length; for (let i = 0; i < total; i++) { // 模拟上传:实际上传到后端接口 await simulateUpload(chunks[i], i, total, file.name); progress.textContent = `文件 ${file.name}:${i + 1}/${total} 片`; } } function simulateUpload(chunk, index, total, filename) { return new Promise((resolve) => { setTimeout(() => { console.log(`第${index + 1}/${total}片上传完成,大小${chunk.size}字节`); resolve(); }, 200); }); } </script> </body> </html>三个模块的代码都不复杂,但组合起来就能覆盖日常大部分文件处理需求。这个Demo我用在一个内部的"需求文档上传中心"项目里,当时配合后端把接口对接好,全流程跑得还算顺利。
5.3 实操中发现的关键问题
第一个问题是input的multiple属性配合文件校验时,用户可能一次选多种类型文件,导致预览区卡片混排。我后来加了分组渲染,每种类型一个卡片组,UI更清晰。
第二个问题是文本预览的编码问题。浏览器通过file.type判断文本类型是靠扩展名和后端返回的MIME,但CSV文件的编码经常是GBK,readAsText(file, 'UTF-8')读到一半乱码。我后来加了一个降级策略:如果检测到乱码字符(\uFFFD),就用TextDecoder('gb18030')重新解码ArrayBuffer。这个坑在实际业务里太常见了。
function decodeText(buffer, fallbackEncoding) { try { return new TextDecoder(fallbackEncoding).decode(buffer); } catch (e) { return new TextDecoder('utf-8').decode(buffer); } }第三个问题是分片上传时Excel等二进制文件其实没必要去预览,但业务要求"选中文件后立即反馈"。所以我在上传前会先显示文件大小和类型元信息,再异步做预览和读取,避免阻塞流程。
6. 进阶:FileReader和Blob与现代框架如何共存
现在的项目基本都用了React、Vue或小程序框架。FileReader和Blob本质上属于浏览器原生能力,框架层面没有特殊适配,但组合使用时依然有几个值得留意的配合点。
6.1 在React/Vue中的状态管理
Blob对象和FileReader实例都不是React状态,也不应该放进state里。因为Blob可能很大,放进去会造成不必要的渲染和内存拷贝。正确做法是:File对象放在ref里引用,读取结果只把字符串或ArrayBuffer放入state。
我在React项目里写过类似这种代码:
const [text, setText] = useState(''); const fileRef = useRef(null); const handleChange = (e) => { fileRef.current = e.target.files[0]; const reader = new FileReader(); reader.onload = () => setText(reader.result); reader.readAsText(fileRef.current, 'UTF-8'); };这个细节看似简单,实际很多人把File对象直接setState,然后发现输入框的值还是空的,因为File对象的引用变了,导致内部状态混乱。保持File对象引用不变,结果字符串走state,是最稳的配合姿势。
在Vue3的setup里同理,用shallowRef或者普通ref都可以,但注意不要在computed里直接引用Blob对象做派生计算——每次依赖变化都会重新读文件,性能极差。
6.2 小程序端的局限性
再提一句小程序。小程序里没有标准FileReader和Blob,但微信小程序提供了wx.getFileSystemManager().readFile,支付宝小程序有自己的my.readFile。整体能力对标FileReader,但细节差异很大,尤其是编码和路径处理。如果你的团队同时做Web端和小程序端,建议封装一层统一的文件读取接口,Web端用FileReader,小程序端用各自API,上层业务逻辑保持一致。
我参与过一个跨端项目,最初直接在小程序里用Web的思维写文件处理,结果到处踩坑:小程序读取的是本地临时文件路径,不是File对象;读取JSON必须指定encoding为utf8;读取ArrayBuffer后要转字符串需要ArrayBuffer → Uint8Array → String.fromCharCode的方法链。后来统一封装成async readText(path)和async readArrayBuffer(path)两个方法,才把两端的复杂度隔离掉。
6.3 与装载器的配合
再说一个容易被忽略的场景:FileReader读取的文件如果最终要传给后端,用FormData时不需要自己读内容,直接formData.append('file', file)就行。只有要读取内容做预览或校验时才需要FileReader。很多新手把"上传文件"和"读取文件"混为一谈,以为不上传就不需要FileReader,或者以为上传前必须先读取。其实上传是底层协议的事,浏览器会自己读文件流,前端只需要把File对象交给FormData或fetch的Body即可。
理解这个边界之后,代码写起来会清晰非常多。FileReader用于"我要看、我要算、我要改、我要校验"的场景,Blob用于"我要切、我要拼、我要临时URL、我要生成文件"的场景。两者合起来,前端文件操作的全拼图才完整。
我在实际项目中还发现一个隐藏技巧:用FileReader.readAsArrayBuffer读取文件后,配合CryptoJS或浏览器内置的crypto.subtle.digest计算文件SHA256,在前端做文件去重、秒传判定,完全可行。这块如果大家感兴趣,我后续可以单独写一篇"前端计算大文件哈希的工程化方案"。
7. 常见问题排查与避坑速查表
最后把这段时间实战中高频遇到的坑集中列出来,给读者当速查手册。这些问题每一个我都实际碰过,有些甚至排查了整整一天。
7.1 高频报错与解决方案
| 报错/现象 | 根因 | 解决方案 |
|---|---|---|
reader.result为null | 读取还没完成就取值 | 等onload触发后再取 |
| 读取JSON中文乱码 | 编码指定错误或源文件非UTF-8 | readAsText时指定GB18030,或用TextDecoder降级 |
URL.createObjectURL后图片不显示 | revoke时机太早 | 在img.onload后再revoke |
| 大文件上传浏览器崩溃 | 全量读取ArrayBuffer | 分片、限制每次读取量 |
Blob转File时lastModified丢失 | 未显式赋值 | new File([blob], name, {lastModified}) |
| 分片上传后文件合并损坏 | 切片时end计算错误 | 使用Math.min(start+size, file.size) |
| readAsDataURL内存暴涨 | Base64膨胀33% | 改用objectURL |
| Safari下new Blob报错 | 低版本不支持构造参数 | 引入Blob Polyfill或改用File对象 |
7.2 实战排错实录
有一次线上反馈"用户上传的CSV文件在预览时前几行正常,后面全乱码"。排查过程是这样的:
第一步怀疑编码,但全文件前几行正常,后面乱,不太像整体编码问题。第二步怀疑文件太大,但文件只有3MB,不至于卡。第三步直接用ArrayBuffer读出来,发现乱码集中在某一行之后。仔细对比发现,这一行恰好是某个字段里出现了一个特殊字符。
最终定位:文件里包含的某个特殊字符被浏览器判定为二进制数据,导致FileReader的文本解码在那一行中断。解决方法是改用readAsArrayBuffer读取全量,再用TextDecoder异步分块解码。这种问题单纯看文档根本发现不了,必须动手读字节才能定位。
还有一次遇到"预览PDF时白屏"。排查发现URL.createObjectURL(blob)在Safari下能生成URL,但WebView的PDF预览组件不支持blob协议。最终方案是改用readAsDataURL生成DataURL递给PDF预览组件,同时通过window.open打开新窗口预览。这类兼容性适配经验,只能靠踩坑积累。
7.3 一些写在代码注释里的经验
最后分享几条我每次写文件处理代码时都会遵守的约定:
- 所有FileReader实例用完后置空引用,尤其循环读取时
- 创建ObjectURL后必须配套revoke,可以用try/finally保证
- 文本读取优先使用
TextDecoder而非readAsText,因为前者支持分块解码和按需结束字符串 - 文件大小校验放在所有读取动作之前,避免内存浪费
- 处理超大文件时用
requestIdleCallback分批处理Blob切片,避免长时间占用主线程
这些约定看着琐碎,但长期坚持下来,项目的稳定性提升非常明显。我见过太多"功能能跑但一到大文件就崩"的代码,基本都是在这几个细节上偷懒导致的。
文件操作虽然只是前端的一小部分,但FileReader和Blob这对组合掌握好之后,前面那些"只能靠后端"的需求会少一大半。下次再遇到文件预览、分片上传、本地导出,别急着向后端求助,先在浏览器里把数据的来龙去脉摸清楚。