Node.js文件上传原理

0 阅读2分钟

一、文件上传原理

浏览器通过 <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:大文件上传的完整方案?

  1. 前端用 file.slice() 切片
  2. 用 SparkMD5 算文件哈希(秒传判断 + 标识文件)
  3. 并发上传切片
  4. 服务端收齐后合并
  5. 支持断点续传(记录已上传的切片序号)

Q3:如何实现秒传?

上传前先算文件 MD5 哈希,发给服务端查询:

  • 如果服务端已有相同哈希的文件 → 直接返回"上传成功"(秒传)
  • 如果没有 → 走正常上传流程