文件上传与静态资源

multer、express.static、云存储——处理文件的一切

🎯 学习目标

学完本章,你将能够:

  • 使用 multer 处理文件上传,支持磁盘和内存两种存储模式
  • 实现图片压缩和格式转换等常见处理
  • 配置静态文件服务和 SPA 回退路由
  • 使用云存储(S3/R2)和预签名 URL 实现可扩展的文件服务
  • 对上传文件进行安全检查,防止恶意文件攻击

你有没有想过

你在社交媒体上传一张照片,服务器是怎么接收并保存的?

普通的 JSON 请求,Express 用 express.json() 就能解析。但文件不一样——它是二进制数据,需要特殊的中间件来处理。

这一章你将学会:用 multer 处理文件上传、用 express.static 托管静态文件、把文件存到云存储。

Part 1: 文件上传

从接收文件到安全校验——构建可靠的文件上传服务

multer 是 Express 生态中最流行的文件上传中间件,支持单文件、多文件上传和自定义存储策略。本部分你将学会 multer 的完整配置、图片处理、文件下载,以及前端上传代码的实现。

multer 配置

安装后配置存储位置、文件名和过滤规则

$ npm install multer
$ npm install -D @types/multer
// src/middleware/upload.ts
import multer, { FileFilterCallback } from 'multer';
import { Request } from 'express';
import path from 'path';
import crypto from 'crypto';

const storage = multer.diskStorage({
    destination: (req, file, cb) => cb(null, 'uploads/'),
    filename: (req, file, cb) => {
        const uniqueSuffix = crypto.randomUUID();
        const ext = path.extname(file.originalname);
        cb(null, `${uniqueSuffix}${ext}`);
    },
});

const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const fileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
    if (ALLOWED_MIMES.includes(file.mimetype)) cb(null, true);
    else cb(new Error(`不支持的文件类型: ${file.mimetype}`));
};

export const upload = multer({
    storage,
    fileFilter,
    limits: { fileSize: 5 * 1024 * 1024, files: 5 }, // 5MB,最多5个
});

上传路由

单文件、多文件、多字段——三种上传模式

// 单文件上传
router.post('/avatar', upload.single('avatar'), (req, res) => {
    if (!req.file) return res.status(400).json({ error: '请选择文件' });
    res.json({
        originalName: req.file.originalname,
        filename: req.file.filename,
        size: req.file.size,
    });
});

// 多文件上传(同一字段名,最多5个)
router.post('/images', upload.array('images', 5), (req, res) => {
    const files = req.files as Express.Multer.File[];
    res.json({ count: files.length });
});

// 多字段上传
router.post('/profile', upload.fields([
    { name: 'avatar', maxCount: 1 },
    { name: 'gallery', maxCount: 10 },
]), (req, res) => {
    const files = req.files as { [key: string]: Express.Multer.File[] };
    res.json({ avatar: files['avatar']?.[0]?.filename });
});

内存存储模式

文件不落盘,直接在内存中处理——适合上传到云存储

// src/config/upload-memory.ts
import multer from 'multer';

export const memoryUpload = multer({
    storage: multer.memoryStorage(), // 文件在 req.file.buffer 中
    limits: { fileSize: 10 * 1024 * 1024 },
});

磁盘存储适合本地开发;内存存储适合需要直接上传到 S3/R2 等云存储的场景。文件不需要写入磁盘,直接从内存转发。

图片处理

用 sharp 库对上传的图片做缩放、格式转换和水印

$ npm install sharp
$ npm install -D @types/sharp
// src/utils/image.ts
import sharp from 'sharp';
import path from 'path';

// 缩放 + 格式转换
export async function processImage(buffer: Buffer, options?: {
    width?: number; format?: 'webp' | 'jpeg' | 'png';
}) {
    const { width = 800, format = 'webp' } = options ?? {};
    return sharp(buffer)
        .resize(width, null, { withoutEnlargement: true })
        .toFormat(format, { quality: 80 })
        .toBuffer();
}

// 生成缩略图
export async function createThumbnail(buffer: Buffer) {
    return sharp(buffer)
        .resize(200, 200, { fit: 'cover' })
        .webp({ quality: 70 })
        .toBuffer();
}

// 添加水印
export async function addWatermark(buffer: Buffer, text: string) {
    const svg = `<svg><text x="10" y="30" fill="white"
        opacity="0.5" font-size="20">${text}</text></svg>`;
    return sharp(buffer)
        .composite([{
            input: Buffer.from(svg),
            gravity: 'southeast',
        }])
        .toBuffer();
}

sharp 基于 libvips,处理速度比 ImageMagick 快 5-10 倍,是 Node.js 图片处理的最佳选择。建议统一把用户上传的图片转为 WebP 格式,体积减少 30-50%。

图片处理实战

上传后自动缩放 + 转换 + 生成缩略图的完整流程

// src/routes/upload-image.ts
import { memoryUpload } from '../config/upload-memory';
import { processImage, createThumbnail } from '../utils/image';
import { uploadToCloud } from '../config/storage';

router.post('/upload/image',
    memoryUpload.single('image'),
    async (req, res) => {
        const file = req.file!;
        try {
            // 1. 处理原图:缩放到最大宽度 1200px,转 WebP
            const processed = await processImage(file.buffer, {
                width: 1200, format: 'webp',
            });
            // 2. 生成缩略图
            const thumb = await createThumbnail(file.buffer);
            // 3. 上传到云存储
            const [original, thumbnail] = await Promise.all([
                uploadToCloud(processed, 'image.webp'),
                uploadToCloud(thumb, 'thumb.webp'),
            ]);
            res.json({ original: original.url, thumbnail: thumbnail.url });
        } catch (err) {
            res.status(500).json({ error: '图片处理失败' });
        }
    }
);

文件下载端点

三种下载方式:直接下载、流式传输、分片请求

// 方式一:res.download() —— 触发浏览器下载对话框
router.get('/download/:key', async (req, res) => {
    const filePath = path.join(UPLOAD_DIR, req.params.key);
    res.download(filePath, '下载文件.pdf'); // 自定义下载文件名
});

// 方式二:流式传输 —— 适合大文件,不占用整个内存
router.get('/stream/:key', (req, res) => {
    const filePath = path.join(UPLOAD_DIR, req.params.key);
    const stream = fs.createReadStream(filePath);
    stream.pipe(res);
});

// 方式三:Range 请求 —— 支持视频/音频拖拽进度条
router.get('/media/:key', (req, res) => {
    const filePath = path.join(UPLOAD_DIR, req.params.key);
    const stat = fs.statSync(filePath);
    const range = req.headers.range; // bytes=0-1024

    if (range) {
        const [start, end] = range.replace(/bytes=/, '').split('-')
            .map(Number);
        res.status(206)
            .setHeader('Content-Range', `bytes ${start}-${end || stat.size - 1}/${stat.size}`)
            .setHeader('Content-Length', (end || stat.size) - start + 1);
        fs.createReadStream(filePath, { start, end }).pipe(res);
    } else {
        res.setHeader('Content-Length', stat.size);
        fs.createReadStream(filePath).pipe(res);
    }
});

res.download() 会自动设置 Content-Disposition 头,浏览器弹出保存对话框。fs.createReadStream 适合大文件,分块传输不撑爆内存。Range 请求是视频/音频播放拖拽的必要条件。

前端上传代码

从原生表单到 React/Vue 组件——前端上传的完整写法

<!-- 最简单的表单上传 -->
<form action="/api/upload" method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" accept="image/*" />
  <button type="submit">上传</button>
</form>
// FormData + fetch 上传(无进度)
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
const res = await fetch('/api/upload', {
    method: 'POST', body: formData,
});
const data = await res.json();

// XMLHttpRequest 上传(带进度条)
function uploadWithProgress(file, onProgress) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        const fd = new FormData();
        fd.append('avatar', file);
        xhr.upload.onprogress = (e) => {
            if (e.lengthComputable) onProgress(e.loaded / e.total);
        };
        xhr.onload = () => resolve(JSON.parse(xhr.responseText));
        xhr.onerror = () => reject(new Error('上传失败'));
        xhr.open('POST', '/api/upload');
        xhr.send(fd);
    });
}

React/Vue 上传组件

带进度条和拖拽上传的前端组件示例

// React 上传组件
function UploadButton() {
    const [progress, setProgress] = useState(0);
    const [dragging, setDragging] = useState(false);

    const handleUpload = async (file) => {
        const fd = new FormData();
        fd.append('avatar', file);
        await fetch('/api/upload', {
            method: 'POST', body: fd,
        });
    };

    return (
        <div
            onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
            onDragLeave={() => setDragging(false)}
            onDrop={(e) => {
                e.preventDefault(); setDragging(false);
                handleUpload(e.dataTransfer.files[0]);
            }}
            style={{ border: dragging ? '2px dashed blue' : '2px dashed #ccc',
                     padding: 40, textAlign: 'center' }}
        >
            拖拽文件到此处上传
            <input type="file" onChange={(e) =>
                handleUpload(e.target.files[0])} />
        </div>
    );
}

拖拽上传通过 dragover/drop 事件实现。注意 fetch API 目前不支持上传进度,如果需要进度条必须用 XMLHttpRequest 或第三方库如 axios。

存储策略全景

从本地磁盘到云存储的三种方案

图:文件上传的三种存储策略架构对比

安全检查清单

文件上传是最常见的攻击入口,六项必检

安全措施说明
限制文件大小防止大文件耗尽磁盘/内存
限制文件类型仅允许安全的 MIME 类型
重命名文件用 UUID 命名,避免路径遍历攻击
独立存储目录上传目录不应在代码目录内
禁止执行权限上传目录不应有执行权限
验证真实类型检查文件头(magic bytes),不只看扩展名
永远不要信任客户端的文件扩展名或 MIME 类型。 它们可以被伪造。建议同时检查扩展名、MIME 和文件头。

文件类型魔法字节检测

为什么扩展名校验不够?用 magic bytes 识别真实文件类型

$ npm install file-type
// src/utils/validate-file-type.ts
import { fileTypeFromBuffer } from 'file-type';

// 常见文件的 magic bytes(文件头)
// JPEG: FF D8 FF       PNG: 89 50 4E 47    GIF: 47 49 46 38
// PDF:  25 50 44 46    ZIP: 50 4B 03 04    WebP: 52 49 46 46

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

export async function validateFileType(
    buffer: Buffer, allowedTypes = ALLOWED_TYPES
): Promise<string> {
    const typeInfo = await fileTypeFromBuffer(buffer);
    if (!typeInfo) {
        throw new Error('无法识别文件类型');
    }
    if (!allowedTypes.includes(typeInfo.mime)) {
        throw new Error(
            `不支持的文件类型: ${typeInfo.mime},` +
            `检测到扩展名 .${typeInfo.ext}`
        );
    }
    return typeInfo.mime;
}

// 在路由中使用
router.post('/upload/secure',
    memoryUpload.single('file'),
    async (req, res) => {
        try {
            const realMime = await validateFileType(req.file!.buffer);
            // realMime 是通过文件内容检测出的真实 MIME
            const result = await uploadToCloud(req.file!.buffer, 'file');
            res.json(result);
        } catch (err) {
            res.status(400).json({ error: (err as Error).message });
        }
    }
);
攻击者可以把 .php 或 .exe 改名为 .jpg 上传。 扩展名和 MIME 类型都可以伪造。只有检查文件头的 magic bytes 才能确认文件的真实类型。这是文件上传安全的关键防线。

Part 2: 静态文件

用 express.static 托管前端资源,配置缓存策略,支持 SPA 路由回退

Express 内置了 express.static 来托管静态文件。开发环境一行代码搞定,生产环境建议加缓存策略或用 Nginx/CDN 分担流量。SPA 应用还需要配置路由回退,让前端路由在刷新时不 404。

express.static

一行代码把目录变成静态文件服务器

import express from 'express';
import path from 'path';

const app = express();

// 基本静态文件服务
app.use('/static', express.static(path.join(__dirname, '../public')));

// 带缓存策略
app.use('/assets', express.static(path.join(__dirname, '../assets'), {
    maxAge: '7d',
    etag: true,
    immutable: true,
    setHeaders: (res, filePath) => {
        if (filePath.endsWith('.html')) {
            res.setHeader('Cache-Control', 'no-cache');
        }
    },
}));

SPA 回退

React/Vue 等单页应用需要把所有路由都指向 index.html

const CLIENT_BUILD_PATH = path.join(__dirname, '../../client/dist');

// 1. 提供静态资源
app.use(express.static(CLIENT_BUILD_PATH, {
    maxAge: '1y',
    index: false, // 不自动发送 index.html
}));

// 2. API 路由
app.use('/api', apiRouter);

// 3. SPA 回退:所有非 API 请求都返回 index.html
app.get('*', (req, res) => {
    res.sendFile(path.join(CLIENT_BUILD_PATH, 'index.html'));
});

SPA 的前端路由(如 /dashboard)在浏览器刷新时,请求会到达服务器。如果不做回退,服务器会返回 404。把所有非 API 请求都指向 index.html,让前端路由来处理。

缓存策略

不同类型的文件需要不同的缓存策略

文件类型缓存策略Cache-Control
HTML不缓存no-cache
JS/CSS(带 hash)长期缓存max-age=31536000, immutable
图片/字体中期缓存max-age=604800
API 响应不缓存no-store

Part 3: 云存储

从本地磁盘迁移到 S3/R2,实现高可用、无限容量的文件存储

生产环境的文件不应该存在应用服务器上。云对象存储(AWS S3、Cloudflare R2)提供高可用、无限容量和 CDN 加速。本部分涵盖直接上传、预签名 URL 直传,以及常见问题的排错。

S3 vs R2

AWS S3 是行业标准,Cloudflare R2 是性价比之选

特性AWS S3Cloudflare R2
API 兼容性原生 S3 APIS3 兼容 API
出口流量费用$0.09/GB免费
存储费用$0.023/GB/月$0.015/GB/月
免费额度5GB(12个月)10GB/月永久

上传到云存储

用 AWS SDK v3 直接上传

$ npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({
    region: process.env.STORAGE_REGION || 'auto',
    endpoint: process.env.STORAGE_ENDPOINT,
    credentials: {
        accessKeyId: process.env.STORAGE_ACCESS_KEY_ID!,
        secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY!,
    },
});

async function uploadToCloud(buffer: Buffer, originalName: string) {
    const key = `uploads/${crypto.randomUUID()}${path.extname(originalName)}`;
    await s3.send(new PutObjectCommand({
        Bucket: process.env.STORAGE_BUCKET,
        Key: key,
        Body: buffer,
    }));
    return { key, url: `${process.env.STORAGE_PUBLIC_URL}/${key}` };
}

预签名 URL 实现

生成带签名的临时 URL,让客户端直接上传/下载,服务器不经手文件

// src/routes/presign.ts
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';

// 生成上传用的预签名 URL
router.post('/presign/upload', async (req, res) => {
    const { contentType, fileName } = req.body;
    const key = `uploads/${crypto.randomUUID()}/${fileName}`;

    const uploadUrl = await getSignedUrl(s3, new PutObjectCommand({
        Bucket: process.env.STORAGE_BUCKET,
        Key: key,
        ContentType: contentType,
    }), { expiresIn: 300 }); // 5 分钟有效

    const publicUrl = `${process.env.STORAGE_PUBLIC_URL}/${key}`;
    res.json({ uploadUrl, publicUrl, key });
});

// 生成下载用的预签名 URL(私有文件)
router.get('/presign/download/:key', async (req, res) => {
    const downloadUrl = await getSignedUrl(s3, new GetObjectCommand({
        Bucket: process.env.STORAGE_BUCKET,
        Key: req.params.key,
    }), { expiresIn: 60 }); // 1 分钟有效

    res.json({ downloadUrl });
});

预签名 URL 把凭证嵌入 URL 参数,客户端拿到后可以直接向 S3 发请求,无需暴露 Access Key。上传 URL 有效期建议 5 分钟,下载 URL 建议 1 分钟。这是大文件上传的最佳实践。

客户端直传 S3 完整流程

前端拿到预签名 URL 后用 fetch 直接 PUT 到 S3

// 前端代码:完整的预签名上传流程
async function uploadWithPresign(file) {
    // 1. 向后端请求预签名 URL
    const { uploadUrl, publicUrl } = await fetch('/api/presign/upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            contentType: file.type,
            fileName: file.name,
        }),
    }).then(r => r.json());

    // 2. 用预签名 URL 直接上传到 S3
    const uploadResult = await fetch(uploadUrl, {
        method: 'PUT',
        headers: { 'Content-Type': file.type },
        body: file,
    });

    if (!uploadResult.ok) throw new Error('上传到云存储失败');

    // 3. 通知后端保存文件记录
    await fetch('/api/files/confirm', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            url: publicUrl,
            name: file.name,
            size: file.size,
        }),
    });

    return publicUrl;
}

前端直传

用预签名 URL 让前端直接上传到云存储,不经过 Node.js

图:前端通过预签名 URL 直传云存储的完整时序流程

前端直传的优势:文件不经过 Node.js 服务器,减少带宽和内存压力,上传速度更快。

常见问题

文件上传出错时,先检查 Content-Type。 文件上传必须用 multipart/form-data,不是 application/json。

问题一:上传报"Unexpected field"

原因:前端字段名和 multer 的参数不匹配。比如前端用 formData.append('file', ...),但后端用的是 upload.single('avatar')。

修复:确保前端的字段名和后端的 single('字段名') 一致。

问题二:静态文件 404

原因:路径拼错了,或文件确实不存在。

修复:用 path.join(__dirname, '../public') 确认绝对路径正确。

问题三:S3 上传报权限错误

原因:IAM 策略不允许该操作,或 Bucket 不存在。

修复:检查 IAM 用户权限,确认 Bucket 名称和 Region 正确。

Multer 错误处理

捕获文件大小超限、文件数量超限等 Multer 专属错误

// src/middleware/upload-error.ts
import { MulterError } from 'multer';

// Multer 错误类型:
// LIMIT_PART_COUNT       - 表单 part 数量超限
// LIMIT_FILE_SIZE        - 文件大小超限
// LIMIT_FILE_COUNT       - 文件数量超限
// LIMIT_FIELD_KEY        - 字段名过长
// LIMIT_FIELD_VALUE      - 字段值过长
// LIMIT_FIELD_COUNT      - 字段数量超限
// LIMIT_UNEXPECTED_FILE  - 未预期的字段名

export function handleUploadError(err: any, req: any, res: any, next: any) {
    if (err instanceof MulterError) {
        const messages: Record<string, string> = {
            LIMIT_FILE_SIZE: '文件大小超出限制',
            LIMIT_FILE_COUNT: '文件数量超出限制',
            LIMIT_UNEXPECTED_FILE: '未预期的上传字段',
        };
        return res.status(400).json({
            error: messages[err.code] ?? `上传错误: ${err.code}`,
        });
    }
    if (err.message?.includes('不支持的文件类型')) {
        return res.status(415).json({ error: err.message });
    }
    next(err);
}

// 在路由中使用
router.post('/avatar',
    upload.single('avatar'),
    handleUploadError, // 放在 multer 中间件之后
    (req, res) => {
        res.json({ filename: req.file!.filename });
    }
);

Multer 抛出的错误是 MulterError 类实例,和普通业务错误不同。需要单独捕获并转换为用户友好的提示信息。建议把错误处理中间件放在 multer 之后、业务 handler 之前。

试一试

练习一

任务:写一个头像上传接口,只接受 jpg/png,大小不超过 2MB。

// 提示:用 avatarUpload.single('avatar')
// 你的代码:

参考答案

const avatarUpload = multer({
    storage,
    fileFilter,
    limits: { fileSize: 2 * 1024 * 1024 },
});

router.post('/avatar', avatarUpload.single('avatar'), (req, res) => {
    if (!req.file) return res.status(400).json({ error: '请选择文件' });
    res.json({ filename: req.file.filename, size: req.file.size });
});

练习二

任务:配置一个静态文件服务,让 /public 路径下的文件可以被访问,HTML 不缓存,其他文件缓存 7 天。

// 提示:express.static + setHeaders
// 你的代码:

参考答案

app.use('/public', express.static('public', {
    setHeaders: (res, filePath) => {
        if (filePath.endsWith('.html')) {
            res.setHeader('Cache-Control', 'no-cache');
        } else {
            res.setHeader('Cache-Control', 'public, max-age=604800');
        }
    },
}));

📝 要点总结

本章核心知识点

multer 上传

磁盘存储适合大文件,内存存储适合需要后续处理的场景

安全检查

文件类型魔法字节检测 + 大小限制 + 文件名安全处理

云存储

预签名 URL 让客户端直传云存储,减轻服务器压力

静态资源

express.static + 缓存策略 + SPA 回退 = 完整的前端资源服务

下一章

文件能存能传了。下一章学习实时通信——WebSocket 和 Socket.io,让服务器能主动"推"数据给客户端。

1 / X