一、文件上传原理
浏览器通过 <input type="file"> 选择文件,用 multipart/form-data 格式发送给服务器。
普通表单:Content-Type: application/x-www-form-urlencoded
文件上传:Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
multipart/form-data会把文件拆成多个"部分",每部分有独立的 Header,用 boundary 分隔。
请求报文长什么样?
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----abc123
------abc123
Content-Disposition: form-data; name="username"
张三
------abc123
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
(二进制文件内容...)
------abc123--
二、Node.js 实现文件上传
方案一:Multer(Express 常用)
npm install multer
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
// 配置存储
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/'); // 存到 uploads 目录
},
filename: (req, file, cb) => {
// 文件名:时间戳 + 原始扩展名
const ext = path.extname(file.originalname);
cb(null, Date.now() + ext);
}
});
// 文件过滤
const fileFilter = (req, file, cb) => {
const allowTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (allowTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('只允许上传图片'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: { fileSize: 5 * 1024 * 1024 } // 最大 5MB
});
// 单文件上传
app.post('/upload', upload.single('avatar'), (req, res) => {
res.json({
message: '上传成功',
filename: req.file.filename,
size: req.file.size
});
});
// 多文件上传
app.post('/uploads', upload.array('photos', 5), (req, res) => {
res.json({
message: '上传成功',
files: req.files.map(f => f.filename)
});
});
方案二:Koa + @koa/multer
const Koa = require('koa');
const multer = require('@koa/multer');
const upload = multer({ dest: 'uploads/' });
app.use(router.post('/upload', upload.single('file'), (ctx) => {
ctx.body = { file: ctx.file };
}));
三、前端上传代码
FormData 方式
// 原生 JS
const input = document.querySelector('input[type="file"]');
const formData = new FormData();
formData.append('avatar', input.files[0]);
fetch('/upload', {
method: 'POST',
body: formData // 不要手动设置 Content-Type,浏览器会自动加 boundary
});
上传进度
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
console.log(`上传进度:${percent}%`);
}
};
xhr.send(formData);
四、大文件上传(分片上传)
大文件一次上传容易超时或失败,解决方案:切片上传 + 合并。
原理
大文件(100MB)
↓ 前端切片
[片1: 0-5MB] [片2: 5-10MB] ... [片20: 95-100MB]
↓ 逐个/并发上传
服务端接收所有切片
↓ 合并
完整文件
前端切片
function sliceFile(file, chunkSize = 5 * 1024 * 1024) {
const chunks = [];
let start = 0;
while (start < file.size) {
chunks.push(file.slice(start, start + chunkSize));
start += chunkSize;
}
return chunks;
}
// 上传所有切片
async function uploadChunks(file) {
const chunks = sliceFile(file);
const hash = await calculateHash(file); // 用 MD5/SparkMD5 算文件哈希
// 并发上传
await Promise.all(chunks.map((chunk, index) => {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('hash', hash);
formData.append('index', index);
formData.append('total', chunks.length);
return fetch('/upload/chunk', { method: 'POST', body: formData });
}));
// 通知服务端合并
await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash, filename: file.name, total: chunks.length })
});
}
服务端合并
const fs = require('fs');
const path = require('path');
app.post('/upload/merge', async (req, res) => {
const { hash, filename, total } = req.body;
const chunkDir = path.join('uploads/chunks', hash);
const outputPath = path.join('uploads', filename);
// 按顺序合并
const writeStream = fs.createWriteStream(outputPath);
for (let i = 0; i < total; i++) {
const chunkPath = path.join(chunkDir, String(i));
const data = fs.readFileSync(chunkPath);
writeStream.write(data);
fs.unlinkSync(chunkPath); // 删除切片
}
writeStream.end();
fs.rmdirSync(chunkDir);
res.json({ message: '合并成功', path: outputPath });
});
五、断点续传
在分片上传基础上,记录已上传的切片,下次从断点继续。
// 上传前先问服务端:哪些切片已经传过了?
app.get('/upload/progress/:hash', (req, res) => {
const chunkDir = path.join('uploads/chunks', req.params.hash);
if (!fs.existsSync(chunkDir)) {
return res.json({ uploaded: [] });
}
const uploaded = fs.readdirSync(chunkDir).map(Number);
res.json({ uploaded });
});
// 前端:跳过已上传的切片
const { uploaded } = await fetch(`/upload/progress/${hash}`).then(r => r.json());
const needUpload = chunks.filter((_, i) => !uploaded.includes(i));
六、高频面试题
Q1:前端上传文件为什么不能手动设置 Content-Type?
因为 multipart/form-data 需要带 boundary(分隔符),浏览器会自动生成。手动设置就没有 boundary,服务器解析不了。
Q2:大文件上传的完整方案?
- 前端用
file.slice()切片 - 用 SparkMD5 算文件哈希(秒传判断 + 标识文件)
- 并发上传切片
- 服务端收齐后合并
- 支持断点续传(记录已上传的切片序号)
Q3:如何实现秒传?
上传前先算文件 MD5 哈希,发给服务端查询:
- 如果服务端已有相同哈希的文件 → 直接返回"上传成功"(秒传)
- 如果没有 → 走正常上传流程