NestJS 图片上传全解:静态资源托管 + 统一响应拦截 + 避坑指南

8 阅读6分钟

从 Multer 配置到 RxJS 拦截器,一文打通文件上传全链路,附新手高频踩坑解决方案

在后端开发中,图片上传是非常基础但又极易踩坑的需求。很多新手在 NestJS 中使用 Multer 做上传时,总会遇到 __dirname 未定义、打包后路径失效、静态资源404、返回格式不统一 等问题。

本文将从零到一实现一套可落地的 NestJS 图片上传方案,同时讲清 ES Module 环境适配、静态资源托管、RxJS 响应拦截器的核心原理,并整理全套避坑清单。

一、前置准备

1. 环境说明

本文基于 ES Module 环境(package.json 中 "type": "module"),这也是目前 NestJS 新项目的主流配置。相比 CommonJS,ES Module 没有内置 __dirname,需要手动构造,这也是新手第一个踩坑点。

2. 安装依赖

Multer 是 Express 生态最成熟的文件上传中间件,NestJS 对其做了封装,开箱即用。

pnpm add multer
pnpm add @types/multer -D

3. 目录结构规划

核心原则:上传目录不要放在 src 或 dist 中,必须放在项目根目录。

demo(项目根)
├─ images          # 图片存储目录,打包不会被清空
├─ src
│  ├─ main.ts      # 入口文件
│  └─ upload
│     ├─ upload.module.ts
│     ├─ upload.controller.ts
│     └─ upload.service.ts
└─ dist            # 编译输出目录,只存放 JS 代码

二、核心实现:Multer 图片上传模块

1. UploadModule 完整配置

模块是 NestJS 的核心,我们在 upload.module.ts 中完成 Multer 的全量配置,包括存储路径、命名规则、大小限制、类型过滤。

import { Module } from '@nestjs/common';
import { UploadService } from './upload.service.js';
import { UploadController } from './upload.controller.js';
import { diskStorage } from 'multer';
import { MulterModule } from '@nestjs/platform-express';
import { extname, join, dirname } from 'path';
import { fileURLToPath } from 'url';

// ========== ES Module 手动构造 __dirname ==========
// CommonJS 自带 __dirname,ES Module 必须通过 import.meta.url 转换得到
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

@Module({
  imports: [
    MulterModule.register({
      // 磁盘存储配置
      storage: diskStorage({
        // 配置文件保存目录
        destination: (req, file, cb) => {
          // 当前文件在 src/upload 下,向上两级到达项目根,拼接 images
          const uploadPath = join(__dirname, '../../images');
          cb(null, uploadPath);
        },
        // 自定义保存文件名,避免重名覆盖
        filename: (_, file, callback) => {
          // 时间戳 + 原始文件后缀
          const filename = `${Date.now()}${extname(file.originalname)}`;
          callback(null, filename);
        }
      }),
      // 限制单文件最大 5MB
      limits: { fileSize: 5 * 1024 * 1024 },
      // 文件类型过滤:只允许图片格式
      fileFilter: (req, file, cb) => {
        const allowTypes = /.(png|jpg|jpeg|gif)$/;
        if (allowTypes.test(file.originalname)) {
          cb(null, true);
        } else {
          cb(new Error('仅支持 png/jpg/jpeg/gif 格式图片'), false);
        }
      }
    })
  ],
  controllers: [UploadController],
  providers: [UploadService],
})
export class UploadModule {}

关键说明

  • 路径计算:upload.module.ts 位于 src/upload 目录下,__dirname 指向当前文件所在文件夹,因此需要 ../../images 向上两级到达项目根目录。
  • Multer 特性:Multer 只负责写入文件,不会自动创建目标文件夹,因此 images 目录需要提前手动创建。
  • 命名规则:使用时间戳作为文件名,避免同名文件覆盖,保留原始后缀保证格式正确。

2. UploadController 接口编写

控制器负责暴露 HTTP 接口,接收前端上传的文件并返回访问地址。

import { Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller('upload')
export class UploadController {
  @Post('img')
  @UseInterceptors(FileInterceptor('file'))
  uploadImg(@UploadedFile() file: Express.Multer.File) {
    return {
      code: 200,
      msg: '上传成功',
      url: `/images/${file.filename}`
    };
  }
}

关键说明

  • FileInterceptor('file'):单文件上传拦截器,参数 'file' 必须和前端 form-data 的字段名完全一致,否则接收不到文件。
  • @UploadedFile():将解析后的文件对象注入到参数中,包含文件名、大小、路径、MIME 类型等信息。
  • 返回 url:直接返回静态资源访问路径,前端拼接域名即可预览图片。

三、静态资源托管:让图片可被浏览器访问

上传后的图片不能直接通过 HTTP 访问,需要在 main.ts 中配置静态资源托管,将 images 目录暴露出去。

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { AppModule } from './app.module.js';

// ES Module 构造 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // 托管项目根目录的 images 文件夹
  app.useStaticAssets(
    join(__dirname, '../images'),
    { prefix: '/images' }
  );

  await app.listen(3000);
  console.log('服务启动:http://localhost:3000');
}
bootstrap();

路径为什么是 ../images?

  • main.ts 位于 src 目录下,__dirname 指向 src;
  • ../images 向上一级到达项目根,拼接 images 目录;
  • 打包后 main.js 位于 dist 目录下,../images 同样指向项目根的 images,开发和生产环境路径一致。

配置完成后,浏览器访问 http://localhost:3000/images/xxx.png 即可直接查看图片。

四、进阶:统一响应拦截器(RxJS 版)

实际项目中,后端接口通常需要统一返回格式,比如 { code, data, msg, success }。NestJS 的拦截器 + RxJS 可以优雅地实现全局统一包装。

1. 响应拦截器实现

新建 response.interceptor.ts:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // next.handle() 触发执行目标控制器方法,返回 RxJS Observable 流
    return next.handle().pipe(
      map(data => {
        return {
          data,
          code: 0,
          msg: '请求成功',
          success: true
        };
      })
    );
  }
}

2. 全局注册拦截器

在 main.ts 中添加全局拦截器,所有接口自动应用统一格式:

import { ResponseInterceptor } from './response.interceptor.js';

// ...
app.useGlobalInterceptors(new ResponseInterceptor());

3. 原理解析

  • next.handle() :拦截器的「放行开关」,执行后才会真正调用控制器的业务方法,返回一个 RxJS 的 Observable 数据流。
  • pipe() + map() :RxJS 的链式操作符,对控制器返回的数据做统一转换,不会侵入业务代码。
  • RxJS 的优势:相比 Promise,Observable 支持多次发射、可取消、丰富的操作符,非常适合处理请求流、事件流。

五、核心概念解惑

1. 为什么上传文件必须用 form-data?

form-data(multipart/form-data)是 HTTP 请求体的一种格式,专门用来传输二进制文件。普通的 application/json 只能传输文本数据,无法直接携带图片、文件等二进制内容。

Multer 中间件的作用就是解析 form-data 格式的请求体,提取出文件和普通字段。

2. images 为什么不能放在 src /dist 里?

  • 放在 src:开发环境正常,但打包后代码运行在 dist 目录,__dirname 路径变化,会找不到 src/images。
  • 放在 dist:每次执行 npm run build 时,dist 目录会被整体清空重建,上传的图片会全部丢失。

最佳实践:业务运行时产生的文件,统一放在项目根目录的独立文件夹中,与编译产物分离。

六、新手高频踩坑清单

1. ReferenceError: __dirname is not defined

  • 原因:项目是 ES Module 环境,没有内置 __dirname 全局变量。
  • 解决:使用 fileURLToPath(import.meta.url) + dirname() 手动构造。

2. ENOENT: no such file or directory

  • 原因:目标文件夹不存在,Multer 不会自动创建目录。
  • 解决:提前手动创建 images 文件夹,或在 destination 回调中用 mkdirSync 自动创建。

3. 打包后图片找不到

  • 原因:路径只适配了开发环境,打包后 __dirname 指向 dist,路径错位。
  • 解决:将图片目录放在项目根,通过 ../ 层级计算,保证开发和生产指向同一个目录。

4. 静态资源访问 404

  • 原因:useStaticAssets 路径配置错误,或 prefix 与访问地址不匹配。
  • 解决:打印 join(__dirname, '../images') 确认路径,保证 prefix 为 /images。

5. 上传接口接收不到文件

  • 原因:前端 form-data 的字段名与 FileInterceptor('file') 中的参数不一致。
  • 解决:前后端字段名严格保持一致,区分大小写。

七、总结

本文完整实现了 NestJS 环境下的图片上传能力,涵盖 Multer 配置、ES Module 适配、静态资源托管、统一响应拦截四个核心部分,同时梳理了新手最容易踩的路径和环境坑。

核心最佳实践:

  1. 上传目录放项目根,与编译产物分离;
  2. ES Module 手动构造 __dirname,路径层级按需计算;
  3. 利用拦截器 + RxJS 统一响应格式,解耦业务与通用逻辑;
  4. 增加文件大小和类型校验,提升接口安全性。

这套方案可以直接用于中小型项目的图片上传场景,也可以扩展至多文件上传、分片上传、OSS 云存储等更复杂的场景。