Upload组件上传文件的那些坑与最佳实践
优化前:卡得不行
上周上线了个文件上传功能,用户反馈说一上传大文件页面就卡死,点了没反应,刷新都不敢刷。我自己试了下,传个 300MB 的视频,内存直接飙到 1.5GB,Chrome 弹出“页面无响应”警告,我人都傻了。
这哪是上传组件,这是炸弹组件啊。不是 UI 不好看,是根本用不了。
最离谱的是,还没开始上传,只是调用了 file.slice() 做分片,页面就开始卡。我当时就意识到——问题不在网络,在本地处理逻辑上。
找到病颈了!
先开 DevTools 看 Performance 面板,录了一段操作:
- 选择一个 300MB 的 MP4 文件
- 触发 onChange 回调
- 执行分片逻辑
结果一看 Call Tree,98% 的时间都在 FileReader.readAsArrayBuffer 上面,而且主线程被完全阻塞。更惨的是,我这个组件还同步计算了每个分片的 hash(MD5),用来做秒传校验……这一下双杀:大文件 + 同步读取 + 同步加密计算 = 卡成幻灯片。
我还用了 Chrome 的 Memory 面板看了下堆快照,发现有多个 ArrayBuffers 被同时持有,GC 根本来不及回收。典型的内存泄漏征兆。
结论很明确:所有文件处理不能放在主线程,必须拆出去。
方案一:Web Worker 搞起
第一反应就是 Web Worker。把分片和 hash 计算扔进 Worker,主线程只负责交互。
但 FileReader 在 Worker 里不能用。查了文档,Blob.slice 是可以的,但读内容还得靠主线程传 ArrayBuffer 进去。于是改成了这样:
- 主线程读取小块 chunk(比如 5MB)
- 转成 ArrayBuffer 发给 Worker
- Worker 计算这块的 hash 并返回
- 主线程拼接最终 hash 或直接上传
代码大概长这样:
// main.js
const chunkSize = 5 * 1024 * 1024;
function processFileInWorker(file) {
const worker = new Worker('/upload-worker.js');
let chunks = [];
let index = 0;
function readNextChunk() {
if (index * chunkSize >= file.size) {
worker.postMessage({ type: 'complete', chunks });
return;
}
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const blob = file.slice(start, end);
const reader = new FileReader();
reader.onload = (e) => {
worker.postMessage({
type: 'chunk',
index,
data: e.target.result
}, [e.target.result]); // Transferable transfer
index++;
readNextChunk(); // 异步递归读取
};
reader.readAsArrayBuffer(blob);
}
worker.onmessage = (e) => {
if (e.data.type === 'hash') {
console.log('Final hash:', e.data.hash);
}
};
readNextChunk();
}
// upload-worker.js
let chunks = [];
let currentHash = spark();
self.onmessage = function(e) {
if (e.data.type === 'chunk') {
const arrayBuffer = e.data.data;
const uint8Array = new Uint8Array(arrayBuffer);
currentHash.update(uint8Array);
postMessage({ type: 'chunkProcessed', index: e.data.index });
} else if (e.data.type === 'complete') {
const finalHash = currentHash.digest();
postMessage({ type: 'hash', hash: finalHash });
}
};
这里注意我踩过好几次坑:
- 忘了在 postMessage 后面加
[buffer]做 transfer,导致内存复制而不是移交,内存还是涨 - 递归调用
readNextChunk()没控制并发,一次开了几十个 FileReader,浏览器直接崩 - Spark-md5 不支持增量更新,后来换成了
spark-md5的流式 API
方案二:Stream + ReadableStream 实验性搞法
后来想到,现在不是有 Response.body.pipeTo() 和 ReadableStream 了吗?能不能边读边算?
试了下,确实可以,但兼容性差,Safari 支持不好,最后只作为降级失败后的 fallback 没上线。不过思路可以留着:
async function hashWithStream(file) {
const stream = file.stream();
const reader = stream.getReader();
const hash = spark();
while (true) {
const { done, value } = await reader.read();
if (done) break;
hash.update(new Uint8Array(value));
}
return hash.digest();
}
这写法看着清爽,但在老版本 Chrome 里会卡,因为 getReader().read() 虽然是异步,但大文件下微任务队列积压严重。实测比 Worker 还卡。pass。
核心优化:节流 + 分帧处理
即使用了 Worker,上传进度条更新太频繁也会卡。比如每 10ms 更新一次 DOM,300次更新/sec,React 都扛不住。
我的解法是:用 requestAnimationFrame 包一层,控制 UI 更新频率。
let lastUpdateTime = 0;
function updateProgress(loaded, total) {
const now = performance.now();
if (now - lastUpdateTime < 16) return; // 最多 60fps
lastUpdateTime = now;
// 更新进度条
document.getElementById('progress').style.width = ${(loaded / total) * 100}%;
}
另外上传请求做了并发控制,最多同时传 4 个分片,避免 TCP 打满或浏览器限制。
const MAX_CONCURRENT = 4;
let activeUploads = 0;
let pendingUploads = [];
function uploadChunk(chunk) {
if (activeUploads >= MAX_CONCURRENT) {
pendingUploads.push(() => doUpload(chunk));
return;
}
doUpload(chunk);
}
function doUpload(chunk) {
activeUploads++;
fetch('https://jztheme.com/api/upload', {
method: 'POST',
body: chunk.data
}).finally(() => {
activeUploads--;
if (pendingUploads.length > 0) {
const next = pendingUploads.shift();
next();
}
});
}
还有个小细节:预览图生成也得懒
我们有个需求是图片上传后要显示缩略图。之前是直接 URL.createObjectURL(file) 就完事,但对于大图(比如 20MB 的 PNG),createObjectURL 后立马塞进 img.src,解码就会卡主线程。
解决方案是:用空 src 先占位,等 idle 时间再加载:
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = ''; // 先不赋值
container.appendChild(img);
// 利用 requestIdleCallback 懒加载
requestIdleCallback(() => {
img.src = URL.createObjectURL(file);
});
}
虽然小功能,但用户体验提升明显,尤其在低端机上。
优化后:流畅多了
改完之后再测 300MB 视频上传:
- 主线程 CPU 占用从 90%+ 降到 20% 以下
- 内存峰值从 1.5GB 降到 300MB 左右
- 页面可交互时间从“一直不可交互”变成能随时点击取消
- 分片 hash 计算时间从 12s 缩短到 3.5s(并行切割)
最关键的是,用户终于不会以为系统崩溃了。
性能数据对比
以下是三轮测试的平均数据(300MB MP4,MacBook Pro M1,Chrome 128):
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 主线程阻塞时间 | ~12s | ~800ms(分散在多个帧) |
| 内存峰值 | 1.5GB | 310MB |
| hash 计算耗时 | 12s | 3.4s |
| 页面可响应性 | 完全卡死 | 可滚动、可点击取消 |
虽然改完后还有点小瑕疵:Worker 通信偶尔延迟,hash 返回顺序乱,但这不影响主流程。整体已经可用。
以上是我的优化经验,有更优的实现方式欢迎评论区交流
这个组件折腾了我整整三天,中间一度想放弃用 Dropzone 换现成库。但最后自己动手调一遍,对文件 API 和性能瓶颈的理解深了很多。
目前方案不是最优,比如还没有上 Wasm 做 MD5 加速,但够用、稳定、不卡,就够了。
如果你也在搞大文件上传,建议早点上 Worker,别等用户投诉。真的一卡起来,谁都救不了你。

暂无评论