概述
在现代 Web 开发中,处理文件上传和表单数据是常见需求。File.stream() 和 FormData 是两个关键的 API,分别用于高效读取文件内容和构建多部分表单请求。本文将深入解析这两个技术的原理、用法、兼容性及最佳实践。
一、File.stream() 详解
1.1 什么是 File.stream()
File 接口继承自 Blob,而 Blob.prototype.stream() 方法(在支持的浏览器中可通过 File.stream() 调用)返回一个可读流(ReadableStream),允许以流式方式逐块读取文件内容,避免一次性将整个文件加载到内存中。
⚠️ 注意:
File对象本身没有stream()方法,但因其继承自Blob,所以可以调用file.stream()。
1.2 基本用法
javascript
const input = document.querySelector('input[type="file"]');
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const stream = file.stream();
const reader = stream.getReader();
let chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
console.log('Received chunk:', value); // Uint8Array
}
// 合并所有 chunk
const uint8Array = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
let offset = 0;
for (const chunk of chunks) {
uint8Array.set(chunk, offset);
offset += chunk.length;
}
const text = new TextDecoder().decode(uint8Array);
console.log('Full content:', text);
});
1.3 使用 for await...of 简化代码(需转换为异步迭代器)
javascript
const stream = file.stream();
const decoder = new TextDecoder();
let result = '';
for await (const chunk of stream) {
result += decoder.decode(chunk, { stream: true });
}
result += decoder.decode(); // flush
console.log(result);
✅ 提示:
for await...of需要将ReadableStream转换为异步可迭代对象。某些浏览器可能不直接支持,可使用stream.values()或 polyfill。
1.4 优势
- 内存友好:适用于大文件(如视频、日志等),避免 OOM(内存溢出)。
- 响应式处理:可边读取边处理(如实时上传、加密、压缩)。
- 与 Fetch API 无缝集成 :可直接作为
fetch的 body。
1.5 兼容性
| 浏览器 | 支持情况 |
|---|---|
| Chrome | ≥ 71 |
| Firefox | ≥ 69 |
| Safari | ≥ 14.1 |
| Edge | ≥ 79 |
📌 参考 MDN Blob.stream()
二、FormData 详解
2.1 什么是 FormData
FormData 是一个用于构造键值对的接口,主要用于通过 XMLHttpRequest 或 fetch 发送表单数据(尤其是包含文件的 multipart/form-data 请求)。
2.2 基本用法
从 HTML 表单构建
html
<form id="uploadForm">
<input name="username" value="alice">
<input type="file" name="avatar">
</form>
javascript
const form = document.getElementById('uploadForm');
const formData = new FormData(form);
// 自动包含所有带 name 的字段
手动构造
javascript
const formData = new FormData();
formData.append('username', 'bob');
formData.append('avatar', file); // File 对象
formData.set('token', 'xyz123'); // set 会覆盖同名字段
2.3 与 Fetch 配合上传文件
javascript
const response = await fetch('/upload', {
method: 'POST',
body: formData
// 注意:不要手动设置 Content-Type!
// 浏览器会自动设置 boundary
});
❗ 重要:不要手动设置
Content-Type头,否则会破坏 multipart boundary,导致服务器无法解析。
2.4 读取 FormData 内容(调试用)
javascript
for (const [key, value] of formData.entries()) {
console.log(key, value); // value 可能是 string 或 File
}
2.5 兼容性
FormData 在所有现代浏览器中广泛支持(包括 IE10+)。但注意:
- IE 不支持
entries()、keys()、values()等迭代方法。 FormData构造函数接受<form>元素作为参数的功能在 IE 中不可用。
三、结合使用:流式上传大文件
虽然 FormData 本身不直接支持流,但我们可以结合 File.stream() 实现分块上传(chunked upload):
示例:分片上传
javascript
async function uploadChunked(file, chunkSize = 1024 * 1024) {
const totalChunks = Math.ceil(file.size / chunkSize);
const fileId = crypto.randomUUID();
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
// 创建文件切片(仍是 File/Blob)
const chunk = file.slice(start, end, file.type);
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('chunk', chunk);
await fetch('/upload-chunk', {
method: 'POST',
body: formData
});
}
// 通知服务器合并
await fetch('/merge-chunks', {
method: 'POST',
body: JSON.stringify({ fileId, filename: file.name }),
headers: { 'Content-Type': 'application/json' }
});
}
💡 虽然这里没有直接使用
file.stream(),但file.slice()是实现分块的基础。若需更精细控制(如加密每个 chunk),可结合stream()读取原始字节。
四、常见问题与最佳实践
Q1: 能否将 ReadableStream 直接放入 FormData?
不能 。FormData.append() 只接受 string、Blob、File 或类似对象。ReadableStream 不是有效的值。
✅ 解决方案:
-
先将流转换为
Blob(适用于小文件):javascriptconst blob = await new Response(stream).blob(); formData.append('file', blob); -
或采用分块上传策略(推荐用于大文件)。
Q2: 如何监控上传进度?
使用 XMLHttpRequest 的 upload.onprogress:
javascript
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
console.log(`Uploaded: ${percent.toFixed(2)}%`);
}
};
xhr.open('POST', '/upload');
xhr.send(formData);
fetch原生不支持上传进度,需借助ReadableStream+ 自定义body实现(较复杂)。
Q3: 安全注意事项
- 永远不要信任客户端文件名(
file.name可伪造)。 - 服务端应校验文件类型(MIME + 文件头),而非仅依赖扩展名。
- 限制上传大小,防止 DoS 攻击。
五、总结
| 特性 | File.stream() |
FormData |
|---|---|---|
| 主要用途 | 流式读取文件内容 | 构建 multipart/form-data 请求 |
| 内存效率 | 高(适合大文件) | 低(整个文件需加载) |
| 与 Fetch 集成 | 可作为 body | 可作为 body(自动设 Content-Type) |
| 浏览器支持 | 现代浏览器(Chrome 71+) | 广泛(IE10+) |
| 是否支持文件上传 | 需配合其他逻辑 | 原生支持 |
最佳实践建议:
- 小文件(<10MB):直接使用
FormData上传。 - 大文件或需进度/断点续传:使用
File.slice()+ 分块上传。 - 需要实时处理文件内容(如压缩、加密):使用
File.stream()。
参考资料
FormData到底会不会引起内存溢出
我试了好多次上传大文件,并不会,很多文档说会 ------ 使用 FormData + 原生 <input type="file"> 的 File 对象上传大文件,通常不会导致浏览器内存溢出(OOM)。这是现代浏览器为大文件上传专门优化的安全机制。
但前提是你没有主动把文件读入 JavaScript 内存。下面详细解释:
✅ 一、为什么 FormData 上传大文件 不会 内存溢出?
浏览器底层做了 流式(streaming)处理
当你这样写:
js
const file = document.querySelector('input').files[0]; // File 对象
const formData = new FormData();
formData.append('file', file); // ← 关键:传的是 File 引用,不是内容!
axios.post('/upload', formData);
file是一个File对象 ,它只是一个文件句柄(handle),包含路径、大小、类型等元数据- 文件内容并未加载到 JS 堆内存中
- 浏览器在发送 HTTP 请求时,会直接从磁盘分块读取 → 网络发送 (类似
fs.createReadStream()) - 整个过程 内存占用恒定(通常几 MB),与文件大小无关
📌 这是 W3C 标准设计,专为大文件上传优化。
⚠️ 二、什么情况下 会 导致内存溢出?
❌ 错误做法:用 FileReader / ArrayBuffer 读整个文件
js
// 危险!将整个文件加载到内存
const reader = new FileReader();
reader.onload = (e) => {
const arrayBuffer = e.target.result; // ← 1GB 文件 = 占用 1GB JS 内存!
const blob = new Blob([arrayBuffer]);
formData.append('file', blob);
axios.post('/upload', formData);
};
reader.readAsArrayBuffer(file);
💥 后果:
- 上传 2GB 文件 → 浏览器可能卡死、崩溃、标签页被杀
- 内存监控(DevTools Memory)会看到明显峰值
❌ 其他危险操作:
await file.arrayBuffer()(ES2022)new Response(file).arrayBuffer()- 把文件转成 Base64:
btoa(await file.text())
这些都会强制将文件全部载入内存。
✅ 三、如何安全上传超大文件(如 10GB)?
方案 1:直接用 FormData(推荐,适用于 ≤ 几 GB)
js
const file = input.files[0];
const fd = new FormData();
fd.append('file', file); // 安全!
// 可加进度监听
axios.post('/upload', fd, {
onUploadProgress: (e) => {
console.log(`${(e.loaded / e.total * 100).toFixed(1)}%`);
}
});
✅ 优点:简单、原生支持
⚠️ 注意:单个请求上传超大文件可能受服务器/网络限制(如 Nginx 默认 1MB)
方案 2:分片上传(Chunked Upload)------ 用于 >5GB 或断点续传
js
const chunkSize = 10 * 1024 * 1024; // 10MB 每片
for (let start = 0; start < file.size; start += chunkSize) {
const chunk = file.slice(start, start + chunkSize); // ← slice 不复制内存!
const fd = new FormData();
fd.append('chunk', chunk);
await axios.post('/upload-chunk', fd);
}
✅
file.slice()返回的是 新的 File/Blob 引用 ,不会复制数据到内存!
后端需合并分片(如用 fs.createWriteStream 追加写入)。
🔍 四、验证:如何确认没占内存?
- 打开 Chrome DevTools → Memory 面板
- 点击 "Take heap snapshot" 记录初始内存
- 上传一个 1GB+ 文件 (用
FormData + File) - 上传过程中再拍快照
- 对比:JS Heap 大小几乎不变
如果你看到内存暴涨 → 说明你无意中读了文件内容!
🛡️ 五、服务端注意事项(避免 Node.js OOM)
虽然浏览器安全 ,但Node.js 服务端可能 OOM,如果:
- 使用
multer.memoryStorage()(文件进内存) - 上传 1GB 文件 → 占用 1GB Node.js 堆内存
✅ 正确做法:用磁盘存储
js
const upload = multer({
dest: 'uploads/', // 文件直接写磁盘,不进内存
limits: { fileSize: 5 * 1024 * 1024 * 1024 } // 限制 5GB
});
✅ 总结
| 上传方式 | 是否内存安全 | 适用场景 |
|---|---|---|
FormData + File(直接 append) |
✅ 安全 | 小到中大文件(≤ 几 GB) |
FileReader 读整个文件 |
❌ 危险 | 应避免 |
分片上传(file.slice()) |
✅ 安全 | 超大文件、断点续传 |
服务端用 memoryStorage |
❌ 危险 | 改用 diskStorage |
🎯 只要你不主动读文件内容,
FormData上传任意大小文件都不会导致浏览器内存溢出。
如果你正在实现大文件上传,建议:
- 前端用
FormData + File - 后端用
multer({ dest: '...' }) - 超大文件(>2GB)考虑分片 + 断点续传