OSS上传实战技巧与前端优化避坑指南
优化前:卡得不行
上周上线了个图片上传功能,用户反馈说“传个图要等半天”,一开始没当回事,觉得可能是网络问题。直到我自己在测试机上点了一下——好家伙,选完照片页面直接卡死两秒,进度条还没动。再传个大点的视频?直接白屏,控制台报错内存溢出。
这哪行啊,赶紧看下数据。用 DevTools 录了下性能面板,发现主线程被一个叫 FileReader.readAsArrayBuffer 的操作占了整整 2.8 秒,期间 UI 完全无响应。而且是同步阻塞的那种卡,滑都滑不动。
问题很明显了:我们把整个文件读取 + 分片上传的逻辑全塞在主线程里跑,大文件一来,浏览器直接跪了。
找到瓶颈了!
我先上了 Performance 面板跑了一遍记录,重点关注 Tasks 列表。果然看到一大段红色长条,持续时间超过 3 秒,点击进去一看调用栈:
handleFileSelect → readFile → new FileReader() → readAsArrayBuffer- 后面还跟着一堆 base64 编码和分片处理逻辑
这里注意我踩过好几次坑:很多人以为 FileReader 是异步的就不会卡 UI,但其实它虽然回调是异步的,中间读取过程还是会占用主线程资源,尤其是大文件。再加上我们之前为了兼容老逻辑,先把文件转成 base64 再分片,等于多了一倍的数据复制开销。
我还用 performance.mark() 手动打了几个点测耗时,结果更吓人:一个 50MB 的视频文件,光读取+转码就花了 3.2s,上传才用了 1.8s。也就是说,处理比传输还慢。
核心优化:扔到 Worker 里去
第一反应就是:这种纯计算型任务必须挪出主线程。于是上了 Web Worker。不过 FileReader 在 Worker 里不能用,得换方案——改用 file.arrayBuffer(),这个可以在 Worker 中安全调用。
下面是关键改造代码:
// worker.js
self.onmessage = async function(e) {
const { file, chunkSize } = e.data;
const arrayBuffer = await file.arrayBuffer();
const chunks = [];
for (let start = 0; start < arrayBuffer.byteLength; start += chunkSize) {
const end = Math.min(start + chunkSize, arrayBuffer.byteLength);
const chunk = arrayBuffer.slice(start, end);
chunks.push({
index: start / chunkSize,
data: chunk,
total: Math.ceil(arrayBuffer.byteLength / chunkSize)
});
// 每处理完一块发个进度
self.postMessage({ type: 'progress', loaded: end, total: arrayBuffer.byteLength });
}
// 发送所有分片(可以用 postMessage 传输 ArrayBuffer 零拷贝)
self.postMessage({ type: 'done', chunks }, [chunks.map(c => c.data).reduce((a,b)=>[...a,b],[])]);
};
主线程这边创建 Worker 并通信:
// main.js
function uploadFile(file) {
const worker = new Worker('/worker-upload.js');
const chunkSize = 5 * 1024 * 1024; // 5MB 分片
worker.postMessage({ file, chunkSize }, [file]);
worker.onmessage = function(e) {
if (e.data.type === 'progress') {
updateProgress(e.data.loaded, e.data.total);
} else if (e.data.type === 'done') {
// 开始并发上传分片
e.data.chunks.forEach(chunk => uploadChunk(chunk));
worker.terminate();
}
};
}
这里有个重点:传递 file 给 Worker 时要用 postMessage(file, [file]) 启用 Transferable Objects,避免结构化克隆带来的性能损耗。不然大文件照样卡。
顺手优化了几处细节
除了主流程移到 Worker,还有几个小地方也改了:
- 取消了中间 base64 转码步骤,直接传 ArrayBuffer 给 OSS SDK
- OSS 分片上传启用了并发(最多 3 个 concurrent request),原来是一个传完再传下一个
- 加了个简单的节流,progress 回调每 200ms 更新一次 UI,防止频繁 render
其中并发上传这块改得比较简单:
async function uploadChunk(chunk) {
const { index, data, total } = chunk;
const blob = new Blob([data]);
const formData = new FormData();
formData.append('file', blob, chunk-${index});
return fetch('https://jztheme.com/api/oss/upload-chunk', {
method: 'POST',
body: formData
}).then(res => res.json());
}
// 并发控制
async function uploadChunks(chunks) {
const results = [];
const concurrency = 3;
const executing = [];
for (const chunk of chunks) {
const p = uploadChunk(chunk).then(res => {
executing.splice(executing.indexOf(p), 1);
return res;
});
executing.push(p);
if (executing.length >= concurrency) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
优化后:流畅多了
改完重新压测一遍。这次用的是 80MB 视频文件,在中端安卓机上测试:
- 优化前:UI 卡死 3.5s,总上传时间约 6.2s
- 优化后:UI 响应正常,可滚动、可点击取消;上传总时间降到 2.4s
最关键的是用户体验变了——不再是“点了没反应”,而是立刻看到进度条动起来,哪怕上传慢点也能接受。
后来我又试了 Service Worker 和 Comlink 封装 Worker 通信,但提升不大,反而增加了复杂度。最后决定就用原生 Worker + Transferable Objects 这套最稳的组合。
性能数据对比
这是我在三台不同设备上测的平均值(80MB 文件):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 主线程阻塞时长 | 3480ms | 120ms | 96.5% |
| 文件处理耗时 | 3200ms | 800ms | 75% |
| 总上传时间 | 6200ms | 2400ms | 61.3% |
| 最大内存占用 | ~400MB | ~180MB | 55% |
内存下降明显是因为避免了 base64 编码产生的额外字符串副本,加上 Transferable Objects 实现零拷贝传输。
还有点小问题
当然也不是完美。现在的问题是 Safari 对 Worker 中使用 file.arrayBuffer() 支持不太好,偶尔会报 DOMException: The operation was aborted。目前临时方案是在不支持的环境降级回主线程处理,加个 loading 提示。
另外 Worker 调试确实麻烦,console.log 得靠 onmessage 转发,source map 也不太准。折腾了半天才发现是 chunk slice 时边界算错了。
以上是我的优化经验
这次优化核心就一条:别让文件处理挤占主线程。Worker + 分片 + 并发上传,这套组合拳打下来效果立竿见影。虽然方案不算新颖,但在真实项目中依然经常被忽略。
代码我已经简化抽离了一份可运行 demo 放在 GitHub,有兴趣可以搜关键词“oss-upload-worker”找到。如果有更好的并发控制或断点续传实现方式,欢迎评论区交流。

暂无评论