01 multipart/form-data 与基础上传

1. 学习目标

2. 为什么不能使用普通 JSON 上传文件

JSON 适合文本结构:

{
  "name": "Tom",
  "age": 20
}

文件是二进制内容。虽然可以转成 Base64 放进 JSON,但会:

multipart/form-data 把请求分成多个 part:

Content-Type: multipart/form-data; boundary=----abc

part: title = 头像
part: avatar = 文件内容

每个文件 part 都有自己的 headers 和 body。

3. 安装与 TypeScript

npm install multer
npm install --save-dev @types/multer

当前 multer@2.2.0 包没有内置 TypeScript 声明,因此 TypeScript 工程通常还需要 @types/multer

import multer from "multer";

当前项目开启了 esModuleInterop,可以使用默认导入。TypeScript 最终仍按项目配置编译成 CommonJS。

4. 最小单文件接口

import express, {
  type Request,
  type Response,
} from "express";
import multer from "multer";

const router = express.Router();

const upload = multer({
  dest: "uploads/",
});

router.post(
  "/avatar",
  upload.single("avatar"),
  (req: Request, res: Response): void => {
    if (req.file === undefined) {
      res.status(400).json({
        message: "请选择头像文件",
      });
      return;
    }

    res.status(201).json({
      data: {
        fieldname: req.file.fieldname,
        originalname: req.file.originalname,
        mimetype: req.file.mimetype,
        size: req.file.size,
        filename: req.file.filename,
      },
    });
  },
);

upload.single("avatar") 表示只接受字段名为 avatar 的一个文件,结果放在:

req.file

如果客户端使用其他字段名,通常会产生 LIMIT_UNEXPECTED_FILE

5. 浏览器 FormData

const input = document.querySelector<HTMLInputElement>(
  "#avatar",
);

const file = input?.files?.[0];

if (file === undefined) {
  throw new Error("请选择文件");
}

const formData = new FormData();

formData.append("avatar", file);
formData.append("description", "用户头像");

const response = await fetch("/api/upload/avatar", {
  method: "POST",
  body: formData,
});

不要手动写:

headers: {
  "Content-Type": "multipart/form-data",
}

浏览器需要自动生成包含 boundary 的 Content-Type:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...

手动覆盖后缺少正确 boundary,服务器可能无法解析请求。

6. Axios

浏览器环境中同样直接传 FormData:

await axios.post("/api/upload/avatar", formData);

一般不需要手动设置 Content-Type。Node.js 端使用 Axios 构造 multipart 时,具体 Header 处理取决于 FormData 实现,需要使用该实现生成的 headers。

7. HTML form

<form
  action="/api/upload/avatar"
  method="post"
  enctype="multipart/form-data"
>
  <input type="text" name="description" />
  <input type="file" name="avatar" />
  <button type="submit">上传</button>
</form>

两个关键点:

enctype="multipart/form-data"
name="avatar"

name 必须与服务端 .single("avatar") 对应。

8. req.bodyreq.file

console.log(req.body.description);
console.log(req.file);

Multer 会把 multipart 文本字段放入 req.body,文件放入 req.filereq.files

但在 DiskStorage 的 destination()filename() 回调执行时,req.body 不一定已经完整,因为客户端可以先发送文件 part,再发送文本 part。

因此不要在 storage 回调里依赖尚未确定出现顺序的文本字段决定安全关键路径。

9. 文件信息

每个文件可能包含:

属性 含义 来源/存储类型
fieldname 表单字段名 客户端协议
originalname 用户电脑上的原始文件名 不可信
encoding multipart 编码信息 客户端声明
mimetype multipart 声明的 MIME 不可信
size 文件字节数 Multer 统计
destination 保存目录 DiskStorage
filename 保存名称 DiskStorage
path 保存路径 DiskStorage
buffer 完整文件 Buffer MemoryStorage

originalnamemimetype 都不能作为安全结论。

10. express.json() 与 Multer

express.json() 不解析 multipart,Multer 也不解析普通 JSON 文件上传。

可以在应用中保留:

app.use(express.json());

上传路由仍然需要:

upload.single("avatar")

它们处理不同 Content-Type,不是互相替代。

11. 不要全局安装 Multer

错误示意:

app.use(multer().any());

这会让原本不应该接收文件的路由也能接收上传。Multer 应只出现在明确处理文件的路由上。

12. 最小限制

即使是学习接口,也应限制大小和数量:

const upload = multer({
  dest: "uploads/",
  limits: {
    fileSize: 2 * 1024 * 1024,
    files: 1,
    fields: 5,
    parts: 6,
  },
});

默认文件大小和文件数上限可能是 Infinity,不应依赖默认值保护生产接口。

13. 练习题

  1. 创建一个单头像上传接口,限制文件最大 1MB。
  2. 使用 HTML form 同时上传 avatar 和 nickname。
  3. 使用 fetch FormData 调用接口,不手动设置 Content-Type。
  4. 故意把前端字段名改成 photo,观察 Multer 错误。
  5. 对比 JSON 请求、urlencoded 请求和 multipart 请求的 req.body 解析方式。
  6. 输出 req.file 的全部属性,区分客户端信息和服务器信息。

官方参考