Node.js文件上传原理

一、文件上传原理

浏览器通过 <input type="file"> 选择文件,用 multipart/form-data 格式发送给服务器。

css 复制代码
普通表单:Content-Type: application/x-www-form-urlencoded
文件上传:Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

multipart/form-data 会把文件拆成多个"部分",每部分有独立的 Header,用 boundary 分隔。

请求报文长什么样?

css 复制代码
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 常用)

bash 复制代码
npm install multer
js 复制代码
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

js 复制代码
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 复制代码
// 原生 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
});

上传进度

js 复制代码
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);

四、大文件上传(分片上传)

大文件一次上传容易超时或失败,解决方案:切片上传 + 合并

原理

css 复制代码
大文件(100MB)
    ↓ 前端切片
[片1: 0-5MB] [片2: 5-10MB] ... [片20: 95-100MB]
    ↓ 逐个/并发上传
服务端接收所有切片
    ↓ 合并
完整文件

前端切片

js 复制代码
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 })
  });
}

服务端合并

js 复制代码
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 });
});

五、断点续传

在分片上传基础上,记录已上传的切片,下次从断点继续。

js 复制代码
// 上传前先问服务端:哪些切片已经传过了?
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 哈希,发给服务端查询:

  • 如果服务端已有相同哈希的文件 → 直接返回"上传成功"(秒传)
  • 如果没有 → 走正常上传流程
相关推荐
Java水解1 天前
一篇文章让你彻底弄懂Spring Boot 自动配置原理详解
spring boot·后端
Java水解1 天前
【MYSQL】MYSQL学习的一大重点:MYSQL数据类型
后端·mysql
架构师沉默1 天前
为什么 Dubbo 从 ZooKeeper 转向 Nacos?
java·后端·架构
用户8307196840821 天前
Spring Prototype Bean的四种正确使用方式
java·spring boot·后端
代码丰1 天前
一篇讲透:Spring Boot + Redisson + 注解 + AOP 实现接口限流(可直接落地)
后端
蚂蚁集团分布式架构1 天前
🦐 不办 Meetup,开挑战赛!SOFAStack PR Challenge | SOFAStack 8 周年
后端·github·claude
untE EADO1 天前
SpringBoot:几种常用的接口日期格式化方法
java·spring boot·后端
青槿吖1 天前
第一篇:Redis集群从入门到踩坑:3主3从保姆级搭建+核心原理一次性讲透|面试必看
前端·redis·后端·面试·职场和发展·bootstrap·html
与硝酸1 天前
从 Claude Code 源码看 Agent 系统设计:主流框架都在解决的问题与各自的解法
人工智能·后端
文心快码BaiduComate1 天前
Comate AI IDE三大能力升级:支持语音输入& AI可操作浏览器 & Figma设计与代码双向转换
前端·后端·程序员