它把 Express 的自由换成了约定
Nest 的所有设计都在做同一件事:用一套可分层的约定,把"什么都自己做主"的 Express 变成"结构固定、边界清晰"的工程框架。模块是边界,DI 是装配, 五个可插拔组件(中间件、守卫、拦截器、管道、过滤器)是横切关注点的落点。
01先猜再看看到代码先在心里跑一遍,再看下面的结果。
02抓一个主线如果只记一件事,记请求生命周期那条链,其他都是它的补充。
03连上 NodeDI、装饰器、元数据这些机制,底层原理在 Node 那份。
01
三个角色,一条直线
module wires, controller routes, service works
Controller 只负责"接请求、调方法、返回结果",一行业务逻辑都不该有。
业务在 Service,装配在 Module。这个边界守住了,Nest 的价值才成立。
为什么 Controller 要"薄"
- 路由和业务解耦,同一份业务能被 HTTP、微服务、定时任务复用
- 单元测试时不用起 HTTP 服务,直接测 Service
- 横切逻辑(日志、鉴权、缓存)交给守卫和拦截器,不用污染每个方法
常见的写法错误
- 在 Controller 里直接
this.repo.save() - 在 Controller 里手写 try/catch 拼错误响应
- Service 里直接读
req(它不该知道 HTTP)
面试这样答
Nest 把应用拆成 Module、Controller、Provider 三层。Module 负责组织,声明这个模块有哪些控制器、提供者和导出;Controller 只负责路由映射和参数解析,把请求交给 Service;Provider(通常是 Service)承载业务逻辑。这样分层的好处是业务逻辑不依赖 HTTP,可以被不同入口复用,也更容易做单元测试。横切关注点则通过中间件、守卫、拦截器、管道、过滤器这五个可插拔组件来处理,避免写在每个方法里。
02
DI 容器凭什么知道你缺什么★
decorator + emitDecoratorMetadata
容器不是"猜"的。TypeScript 的
emitDecoratorMetadata 会把
构造函数参数的类型写进元数据,容器读出来就知道该注入哪个类。
但interface 会被 TypeScript 抹掉,运行时不存在。
所以如果你依赖的是接口而不是类,就必须手动指定 token:
四种写法的使用场景
useClass:测试时换成 Mock 实现,或者按环境切换 | 最常用
useValue:配置对象、常量、已经创建好的第三方客户端
useFactory:需要异步初始化(连数据库、拉配置),依赖别的东西
useExisting:给同一个实例取别名,避免重复创建
useValue:配置对象、常量、已经创建好的第三方客户端
useFactory:需要异步初始化(连数据库、拉配置),依赖别的东西
useExisting:给同一个实例取别名,避免重复创建
- 为什么需要 token:运行时的 DI 靠"标识"找实例,而 TS 接口在编译后不存在,只能用类、字符串或 Symbol 当标识。
- 依赖链是递归的:如果 DatabaseService 又依赖 ConfigService,容器会一直往深处解析。任何一个环节找不到 provider 就会启动失败——在启动时炸,不是运行时炸,这是 DI 很大的好处。
- 循环依赖会报错:A 依赖 B、B 依赖 A,容器解析时会死循环,必须用
forwardRef(第 12 节)。
面试这样答
Nest 的 DI 依赖两个东西:装饰器和反射元数据。开启 emitDecoratorMetadata 后,TypeScript 会把构造函数参数的类型写进 design:paramtypes 元数据,IoC 容器在实例化时读这个元数据,就知道要注入哪个类,然后递归地先把依赖创建好再 new 出来。因为接口在运行时会被擦除,所以依赖接口时要自定义 provider 并用字符串或 Symbol 作为 token,注入时用 @Inject 指定。自定义 provider 有 useClass、useValue、useFactory、useExisting 四种形式,分别对应替换实现、直接给值、异步初始化、取别名。
03
作用域:单例是默认,也是最优★
and why REQUEST bubbles up
默认是单例,整个应用只有一个实例。
改成
REQUEST 会让每个请求都新建一个,
而且这个作用域会沿着依赖链向上传染——依赖它的组件也会被迫变成 request 作用域。
作用域的传染(最容易踩)
→ UserService 被迫也变成 REQUEST 作用域
→ 依赖 UserService 的 Controller 也变成 REQUEST
→ 整条链上每个请求都要重新实例化,性能明显下降
UserService 是单例,但它注入了 RequestContextService(REQUEST)→ UserService 被迫也变成 REQUEST 作用域
→ 依赖 UserService 的 Controller 也变成 REQUEST
→ 整条链上每个请求都要重新实例化,性能明显下降
想拿请求上下文,更好的做法
用 AsyncLocalStorage(nestjs-cls 就是基于它)在中间件里存上下文,Service 仍然是单例,随时能取到当前请求的 id。
不要为了拿 req 就开 REQUEST
很多人只想要 req.user.id,就把整个 Service 设成 REQUEST,结果拖慢了整条依赖链。
- 单例为什么好:实例只创建一次,没有每请求开销,也能安全地持有连接池这类资源。
- 单例的风险:不能在里面存请求相关的状态(比如
this.currentUser),会串数据。这是最隐蔽的 bug。 - TRANSIENT 用在哪:需要每次注入都是"干净副本"的工具类,比如带内部状态的 Builder。
面试这样答
Nest 的 provider 默认是单例,整个应用生命周期只创建一个实例,这是性能和资源管理最优的选择。另外还有 REQUEST 和 TRANSIENT 两种:REQUEST 是每个请求一个实例,适合需要请求级状态的场景;TRANSIENT 是每个注入点一个实例。要注意 REQUEST 作用域会向上传染——如果一个单例 service 注入了 request 作用域的 provider,它自己也会变成 request 作用域,整条依赖链上的组件都变成每请求重新实例化,性能影响很大。所以如果只是想拿请求上下文,更推荐用 AsyncLocalStorage 这类方案,保持 service 是单例。
04
模块是边界,不是文件夹
exports is the only door
模块划的是可注入范围。默认情况下,一个模块里的 provider
对其他模块完全不可见——想让别人用,必须在
exports 里明确写出来。
- 为什么
forRoot只在根模块调一次:它是"全局配置"性质的,重复调用会重复注册连接池。 forFeature可以多次调:TypeORM 的forFeature([User])是在每个业务模块里注册自己的 Repository。@Global()别滥用:全局模块会让依赖关系变得不可见,前中期方便,后期难维护。配置、日志这类真正全局的才用。
面试这样答
Nest 的模块是依赖注入的边界。一个模块里的 provider 默认只在本模块内可见,其他模块要用必须先 import 这个模块,而且对方还得在 exports 里明确导出。exports 里的 provider 才能被外部注入,没导出的即使 provide 了也是私有的。动态模块是通过静态方法返回 DynamicModule,把配置在 import 时传进去,常见的是 forRoot 做全局配置、forFeature 注册局部资源,还有 forRootAsync 用于依赖其他 provider 的异步初始化。
05
请求生命周期:一条链走完★
the single most-asked question
顺序是:Middleware → Guard → Interceptor(前置)→ Pipe → Controller → Interceptor(后置)→ Exception Filter。
注意 Pipe 在 Interceptor 之后——这个顺序很多人记错。
△ 可交互:跟着一个请求走一遍
1
Middleware
Express 层。能改 req / res,但拿不到 Nest 的执行上下文
2
Guard
决定"能不能进"。返回 false 直接 403,后面的全都不执行
3
Interceptor(前置)
进入方法之前。可以改参数、记录开始时间
4
Pipe
转换 + 校验参数。校验失败抛 400,方法根本不会被调用
5
Controller → Service
真正执行业务逻辑
6
Interceptor(后置)
拿返回值做二次加工:包装格式、算耗时、写日志
7
Exception Filter
只在抛异常时介入。统一错误响应格式
点下面的按钮,看每一步发生什么。
- Guard 在 Pipe 之前:所以鉴权拿不到校验后的 DTO。想基于业务数据鉴权,只能从
req.body里自己取(此时还没转换类型)。 - Pipe 校验失败时,拦截器的后置逻辑不会执行:因为异常直接跳到 Filter 了。但拦截器的
catchError能捕获到。 - Middleware 只能拿 req/res:它运行在 Express 的中间件栈里,那时候 Nest 的路由还没匹配到。
- 注册位置决定生效范围:全局(app.use / APP_GUARD)→ 控制器级(@UseGuards)→ 方法级,越靠近方法优先级越高。
面试这样答
请求进来先经过 Middleware,它是 Express 层的,只能操作 req 和 res。然后是 Guard,用 canActivate 决定这个请求有没有权限继续,返回 false 直接 403。接着是 Interceptor 的前置逻辑,然后才是 Pipe 做参数转换和校验,校验不过抛 400,控制器方法不会被调用。方法执行完,Interceptor 的后置逻辑处理返回值。整个过程中任何一步抛异常,都会跳到 Exception Filter 统一处理。要特别注意 Pipe 是在 Interceptor 之后执行的,所以 Guard 里拿不到校验后的 DTO。
06
Middleware:唯一还在 Express 层的
the odd one out
五个组件里,只有中间件是在 Nest 之外的。
它跑在 Express 的中间件栈上,拿不到
ExecutionContext,
也就无法知道"接下来要执行哪个控制器的哪个方法"。
适合放在中间件
- 请求日志、请求 ID 注入
- CORS、body parser、cookie parser
- 限流(rate limit)
- 挂载
AsyncLocalStorage存上下文
不适合
- 鉴权(用 Guard,它能拿到路由和 handler 元数据)
- 参数校验(用 Pipe)
- 响应格式统一(用 Interceptor)
- 功能中间件 vs 类中间件:简单的用
function (req, res, next),需要依赖注入的用类。 - 全局中间件:
app.use()直接挂,不受模块约束,但也不能用 DI。 - 执行顺序:先全局中间件,再模块中间件,按注册顺序。
面试这样答
Nest 的中间件实际上就是 Express 中间件,它运行在 Nest 路由处理之前,所以只能拿到 req、res、next,拿不到 Nest 的 ExecutionContext,也就不知道最终会命中哪个控制器方法。适合做请求日志、请求 ID 注入、CORS、限流这些不需要路由信息的处理。需要用模块的 configure 方法配合 MiddlewareConsumer 注册,可以排除特定路由。鉴权这类需要知道"要访问哪个 handler"的逻辑应该用 Guard 而不是中间件。
07
Guard:只回答"能不能"
boolean, not transformation
守卫的职责非常单一:能不能继续。返回 false 或抛异常就中断。
不要在里面做参数处理或响应加工,那是 Pipe 和 Interceptor 的事。
- 返回 false 是 403,抛
UnauthorizedException是 401:语义要分清——没登录是 401,登录了但没权限是 403。 - 可以返回 Promise 或 Observable:需要查数据库确认权限时,返回 Promise<boolean> 即可。
- 全局守卫 + 白名单:用
@Public()装饰器设置元数据,守卫里检查到就放行,比在每个控制器上写 @UseGuards 清爽。 - 执行顺序:全局 → 控制器 → 方法,同级从左到右。
面试这样答
Guard 的职责是决定请求能不能继续,通过 canActivate 返回布尔值或 Promise、Observable,也可以直接抛异常。它比中间件强的地方是能拿到 ExecutionContext,可以访问当前要执行的 handler 和 controller 类,配合 Reflector 读出装饰器上写好的元数据,所以适合做基于角色的鉴权。要注意语义区分:未登录返回 401,已登录但权限不够返回 403。因为 Guard 在 Pipe 之前执行,所以拿不到经过校验和转换的 DTO。
08
Interceptor:方法前后的切面
rxjs pipelines around the handler
拦截器能同时插手"进入前"和"返回后",因为它拿到的是
next.handle() 这个 Observable——你可以在它之前做事,
也可以 pipe 它来改结果。
map 和 tap 的区别(常被追问)
map:转换数据,返回值会被当作新的响应
tap:只做副作用(日志、埋点),不影响数据流
如果只想要副作用却用了 map 又忘了 return,响应会变成 undefined
tap:只做副作用(日志、埋点),不影响数据流
如果只想要副作用却用了 map 又忘了 return,响应会变成 undefined
典型用途
- 统一响应包装
{ code, data, message } - 接口耗时统计、慢接口告警
- 简单缓存(配合 Reflector 读缓存 key)
- 超时控制
timeout(5000)
别做的事
- 在这里做鉴权(用 Guard,语义更清楚)
- 在这里校验参数(用 Pipe,它拿不到解析后的 DTO)
- 写复杂的业务逻辑(切面里藏业务最难查)
- 为什么是 RxJS:因为它天然支持"包裹一个异步过程"——
next.handle()是一个流,你可以任意组合前置、后置、错误处理。 - 全局拦截器要注册成 provider:用
APP_INTERCEPTORtoken,这样能用 DI;直接app.useGlobalInterceptors(new X())会拿不到依赖。 - 流式响应要小心:如果方法里返回的是
StreamableFile,拦截器里不要随便 map,会破坏流。
面试这样答
拦截器基于 RxJS,它拿到 next.handle() 这个 Observable,所以能在处理方法执行前后都插手:在返回它之前可以做前置处理,pipe 上去可以改返回值、做副作用或捕获异常。典型用途是统一响应格式、耗时统计、超时控制、简单缓存。用 map 会改变返回值,tap 只做副作用不影响数据流。要注意全局拦截器应该用 APP_INTERCEPTOR 这个 token 注册,直接用 useGlobalInterceptors 实例化的话就拿不到依赖注入。
09
Pipe 和 DTO:把校验挪出业务代码
transform + validate
管道做两件事:转换(字符串 → 数字、JSON → DTO 实例)和
校验(不合法直接抛 400)。
好处是控制器方法拿到的一定是干净、类型正确的数据。
whitelist 为什么重要
请求体:
开了 whitelist 后,
如果直接把
{ "email": "a@b.com", "role": "user", "isAdmin": true }开了 whitelist 后,
isAdmin 会被剥掉如果直接把
req.body 传给 ORM 的 create,这个字段就能越权写入
- 内置管道:
ParseIntPipe、ParseUUIDPipe、ParseBoolPipe、ParseArrayPipe。简单类型转换不用自己写。 - 自定义管道:实现
PipeTransform,在transform里return转换后的值或抛BadRequestException。 - 校验失败的错误结构:默认返回
{ statusCode: 400, message: string[], error: 'Bad Request' },想改格式就自定义exceptionFactory。 - 为什么用 class 而不是 interface:装饰器和运行时元数据都需要"真实存在的类",interface 编译后消失了。
面试这样答
管道负责两件事:转换和校验。转换是把请求里的字符串或普通对象变成目标类型或 DTO 实例,校验是用 class-validator 检查字段是否合法,不合法直接抛 400,控制器方法根本不会执行。DTO 必须写成 class 而不是 interface,因为装饰器和运行时反射需要真实存在的类。配置 ValidationPipe 时建议开 whitelist 剥离未声明字段、transform 转成类实例;开了 whitelist 能防止用户提交额外字段越权写入,这是个常见的安全点。全局管道用 APP_PIPE 注册,比 useGlobalPipes 更好,因为能使用依赖注入。
10
Exception Filter:最后一道处理
one shape for all errors
任何阶段抛出的异常都会汇到过滤器。它的作用是
把各种异常统一成一种响应格式,顺便保证不把堆栈泄露给用户。
内置异常(记住这几个状态码对应关系)
400 BadRequestException ·
401 UnauthorizedException ·
403 ForbiddenException
404 NotFoundException · 409 ConflictException · 500 InternalServerErrorException
404 NotFoundException · 409 ConflictException · 500 InternalServerErrorException
- 作用域优先级:方法级 → 控制器级 → 全局。越近的优先。
- 全局注册用
APP_FILTER:这样能注入 Logger、Config 这些依赖。 - 不要吞掉异常:捕获后要么转成合适的响应,要么重新抛。静默 catch 后返回 200 是最坏的做法,会让监控彻底失效。
- WebSocket / gRPC 也有过滤器:
host.switchToWs()、switchToRpc(),同一个异常处理逻辑可以适配不同传输层。
面试这样答
异常过滤器是 Nest 异常处理的最后一环,请求链路上任何位置抛出的异常都会汇到这里。用 @Catch() 可以捕获所有异常,也可以指定只捕获某几类。它的核心作用是把不同来源的异常统一成一致的响应格式,同时做安全隔离——业务异常可以返回具体信息,非预期的错误只对外返回通用的 500,完整堆栈写进日志。注册时用 APP_FILTER 这个 token 能保留依赖注入。要避免的是捕获后静默返回成功,那样会掩盖真实故障。
11
装饰器:Nest 的语法基础
decorator writes metadata, reflector reads it
Nest 的所有"声明式"能力都是一个套路:
装饰器往类或方法上写元数据,运行时用
Reflector 读出来。
@Get()、@Roles()、@Public() 全是这个模式。
这套模式的三个组成部分
- 写入:
@SetMetadata(key, value) - 读取:
Reflector.get / getAllAndOverride - 消费:在 Guard / Interceptor / Pipe 里用读到的值做判断
常见误区
- 以为装饰器有"执行顺序"——它只在类定义时执行一次,不是每次请求
- 在装饰器里直接写业务判断(那时没有请求上下文)
- 忘了
emitDecoratorMetadata,导致 DI 和参数装饰器失效
getAllAndOverridevsgetAllAndMerge:前者"就近覆盖",后者会把方法级和类级的元数据合并。角色权限通常用前者。- 自定义装饰器不要覆盖内置语义:比如自己造一个叫
@Get的,会让人极度困惑。 - 参数装饰器的返回值就是注入给参数的值:可以返回对象、Promise,甚至抛异常。
面试这样答
Nest 的声明式能力都基于装饰器加反射元数据这个模式:装饰器在类定义时执行,往类或方法上写元数据;运行时守卫、拦截器通过 Reflector 把元数据读出来决定行为。比如 @Roles('admin') 本质是 SetMetadata,RolesGuard 里用 reflector.getAllAndOverride 读,方法级优先于类级。自定义参数装饰器用 createParamDecorator,返回值会作为参数注入。可以用 applyDecorators 把多个装饰器组合成一个语义更清楚的,比如把鉴权和角色检查合成 @Auth('admin')。
12
循环依赖:forwardRef 与它背后的设计问题
a symptom, not a feature
forwardRef 能解决循环依赖,但它只是让双向引用延迟求值。
真正该做的是问一句:这两个模块是不是职责划错了。
为什么需要 forwardRef
模块加载是自上而下的。UsersModule 加载时要用 OrdersModule,但 OrdersModule 还没定义完
→ 直接引用拿到 undefined
→ forwardRef 包成箭头函数,把"取值"推迟到两个模块都加载完之后
→ 直接引用拿到 undefined
→ forwardRef 包成箭头函数,把"取值"推迟到两个模块都加载完之后
- 模块级和 provider 级要分别处理:只写了模块的 forwardRef、忘了 provider 的,照样报错。
- 循环依赖通常是设计信号:A 需要 B、B 又需要 A,往往说明有一块共享逻辑该抽成第三个模块。
- 拆解思路:把共用的部分抽成
SharedModule;或者用事件解耦(发事件、另一方监听),避免直接调用。
面试这样答
循环依赖的产生是因为模块或 provider 互相引用,而 Node 的模块加载是自上而下的,先加载的那个会拿到 undefined。forwardRef 的作用是把引用包成箭头函数,延迟到两个模块都加载完成后再求值,从而打破这个循环。要注意模块级和 provider 级都要分别加 forwardRef,只加一处还是会报错。但在工程上,循环依赖往往说明职责划分有问题,更好的做法是抽出共享模块,或者用事件机制解耦,而不是一味地加 forwardRef 把问题压下去。
13
配置、事务、那些容易写错的地方
config validation and transaction scope
配置要在启动时校验,缺环境变量就起不来,而不是跑到某个接口才炸。
事务要覆盖完整的业务边界,并且事务内所有操作必须用同一个 manager。
为什么要在启动时校验
启动失败:部署时立刻发现,回滚成本低
运行时才失败:某个低频接口调用时才 500,可能几天后才发现
这是"fail fast"原则最典型的应用
运行时才失败:某个低频接口调用时才 500,可能几天后才发现
这是"fail fast"原则最典型的应用
- 事务里用错 manager 是最常见的 bug:代码看着没问题,回滚时发现另一半数据已经提交了。
- 不要在事务里做 HTTP 请求:事务持有连接和锁,外部请求一慢,锁一直不放,容易拖垮数据库。
- 长事务要警惕:批量处理要分批提交,不要一个事务跑几十万行。
release()必须放在 finally:否则异常时连接不归还,连接池会被慢慢耗光。
面试这样答
配置用 ConfigModule 加载,并且一定要配 validationSchema,在启动时校验必需的环境变量,缺失就直接启动失败,这比运行时才暴露问题好得多。事务方面,TypeORM 里可以用 dataSource.transaction 或者手动用 QueryRunner,关键是事务内的所有数据库操作必须使用事务提供的 manager,如果混用了普通的 repository,那部分操作会跑在事务之外,回滚时不会生效。QueryRunner 要在 finally 里 release 归还连接。另外不要在事务里发起外部 HTTP 请求,事务持有锁的时间会被拉长。
14
微服务:换传输层,不换代码
transport is a config detail
Nest 的微服务本质是"把 Controller 的入口从 HTTP 换成别的传输层"。
业务代码完全不动,只换装饰器和启动方式。
可选的 Transport
Transport.TCP—— 默认,简单直连Transport.REDIS—— 用 Redis 做消息中转Transport.RABBITMQ/KAFKA—— 需要可靠投递、削峰Transport.GRPC—— 强类型、跨语言Transport.NATS—— 轻量、云原生
两个必须分清的概念
MessagePattern 是请求-响应,调用方 send() 并等结果,本质是 RPC。
EventPattern 是事件广播,调用方 emit() 后立刻返回,没有返回值。
选错了会导致"以为发出去了其实没人收"或者"等一个永远不会来的响应"。
- 微服务里也能用 Guard / Pipe / Interceptor:只是上下文要用
switchToRpc()而不是switchToHttp()。 - 不要用它替代消息队列:需要重试、持久化、死信队列这些能力时,应该直接用 Kafka / RabbitMQ 的客户端,而不是指望框架封装。
- 服务发现:多实例部署时可以用内置的
Transport.KAFKA的消费者组,或者接 Consul / etcd。
面试这样答
Nest 的微服务模块把入口从 HTTP 换成了其他传输层,但 Controller 和 Service 的代码结构不变,只是把 @Get/@Post 换成 @MessagePattern 或 @EventPattern,启动时用 connectMicroservice 指定 transport。它支持 TCP、Redis、RabbitMQ、Kafka、gRPC、NATS 等。要分清两种模式:MessagePattern 是请求-响应,调用方 send 并等待结果;EventPattern 是事件广播,emit 之后立即返回。一个应用可以同时开 HTTP 和微服务两个入口,共享同一套业务逻辑,这也是 Nest 分层做得好的体现。
15
Nest 里的 SSR:两种完全不同的路子★
mvc templates, or be the backend of one
Nest 本身不是 Next 那种元框架。它的 SSR 能力是
MVC 模板渲染——服务端拼 HTML,浏览器直接显示,没有 hydration。
要做 React/Vue 的 SSR,正确姿势是让 Nest 当 API 层,SSR 交给 Next/Nuxt。
路子一:MVC 模板渲染(Nest 自带)
这条路的特点
优点:实现简单,首屏直出、SEO 友好,不需要维护两套构建
限制:没有 hydration,交互要靠整页刷新或自己写少量 JS;不适合交互复杂的前端应用
所以它适合:后台管理、内容站、邮件模板、内部工具
限制:没有 hydration,交互要靠整页刷新或自己写少量 JS;不适合交互复杂的前端应用
所以它适合:后台管理、内容站、邮件模板、内部工具
路子二:Nest 做 API,SSR 交给前端框架(推荐)
浏览器请求访问 /posts
→
Next / Nuxt服务端发起数据请求
→
Nest API返回 JSON(业务、鉴权、数据都在这)
→
前端渲染 HTML返回带内容的页面
→
hydrate客户端接管交互
为什么这个分工更好?因为 Nest 的全部优势(模块化、DI、守卫、管道、微服务) 都在"业务和 API 层",而 SSR 需要的是"路由、数据预取、水合"这些前端框架的专长。 硬要让 Nest 干前端的活,等于放弃两边的长处。
- SSR 的渲染压力在这条路上由前端服务承担,Nest 只负责数据,可以独立扩容——这是分离部署的最大好处。
- 数据预取要避免二次请求:Next 在服务端拿到的数据要通过 props 或序列化的 state 传给客户端,否则客户端会再请求一次,还可能渲染不一致。
- 注意 Cookie 透传:SSR 阶段服务端发的请求默认没有用户 Cookie,需要手动从原始请求里取出来带上。
- 缓存放在哪:页面级缓存放前端 SSR 层(或 CDN),数据级缓存放 Nest,两层职责不同不要混。
选 MVC 模板渲染
页面交互少、要 SEO、团队只有后端、想快速上线。一个 Nest 服务全包,部署最简单。
别用它做复杂前端
需要大量客户端状态、路由动画、表单联动的场景。模板渲染会让交互逻辑变得极其难维护,这时候该换 Next/Nuxt。
面试这样答
Nest 的 SSR 有两条路。一是内置的 MVC 模式,通过 setViewEngine 配模板引擎,控制器用 @Render 指定模板并返回数据对象,框架把它渲染成完整 HTML 返回,适合后台管理、内容站这类交互不复杂的场景,但它没有 hydration,交互还是靠整页刷新。二是把 Nest 定位成 API 层或 BFF,SSR 交给 Next、Nuxt 这类前端框架,Nest 只负责业务、鉴权和数据。对需要 React 或 Vue 的服务端渲染的项目,第二种更合适,因为路由、数据预取、水合这些是元框架的专长,硬塞进 Nest 会两边都做不好。分离部署还带来一个好处:SSR 层吃 CPU,可以独立扩容。
16
速答清单
last 30 minutes before the interview
考前扫一遍。如果只有五分钟,优先看第 5 题。
QNest 和 Express 的本质区别?
Express 是"给你一个中间件管道,怎么组织随你";Nest 在它之上加了一层架构约定——模块划分、依赖注入、可插拔组件。所以 Nest 更适合多人协作的中大型项目,Express 更适合小服务或需要极致自由度的场景。
Q依赖注入是怎么实现的?
靠装饰器加反射元数据。开启 emitDecoratorMetadata 后,构造函数参数类型被写入 design:paramtypes,容器读出来递归解析依赖树,全部创建好再 new。接口因为运行时不存在,要用字符串或 Symbol 作为 token 加 @Inject。
Q四种自定义 provider 分别用在哪?
useClass 替换实现(测试 Mock、按环境切换);useValue 给现成的值(配置、常量、客户端实例);useFactory 需要异步初始化或依赖别的 provider;useExisting 给同一实例取别名,避免重复创建。
Qprovider 作用域怎么选?
默认单例,性能和资源管理最优,能用就用。REQUEST 是每请求一个实例,但会沿着依赖链向上传染,导致整条链都变成每请求实例化,性能影响大。要拿请求上下文优先用 AsyncLocalStorage 而不是开 REQUEST。
Q请求生命周期的完整顺序?
Middleware → Guard → Interceptor 前置 → Pipe → Controller/Service → Interceptor 后置 → Exception Filter。任何一步抛异常都直接跳到 Filter。特别注意 Pipe 在 Interceptor 之后,所以 Guard 拿不到校验后的 DTO。
Q中间件和守卫的区别?
中间件跑在 Express 层,只有 req/res/next,不知道最终命中哪个 handler,适合日志、CORS、限流。守卫能拿到 ExecutionContext,可以访问 handler 和 controller 类,配合 Reflector 读元数据,适合做鉴权。鉴权放中间件是常见错误。
Q拦截器里 map 和 tap 有什么区别?
map 会转换数据,它的返回值成为新的响应;tap 只做副作用,不影响数据流。只想打日志却用了 map 又忘了 return,响应会变成 undefined——这是很常见的手误。
QDTO 为什么必须是 class 不能是 interface?
interface 在编译后会被完全擦除,运行时不存在,没法挂 class-validator 的装饰器元数据,也没法被 class-transformer 转成实例。而 ValidationPipe 的校验和转换都依赖运行时的类信息。
QValidationPipe 的 whitelist 有什么用?
剥离 DTO 里没声明的字段。如果没有它,用户提交多余字段(比如 isAdmin: true)可能被直接传给 ORM 写入,造成越权。加上 forbidNonWhitelisted 更严格,发现多余字段直接报错。
Q模块的 exports 是干什么的?
控制 provider 的可见性。默认 provider 只在本模块内可注入,其他模块必须先 import 这个模块,且对方在 exports 里明确导出才行。这是 Nest 做依赖边界的方式。
Q循环依赖怎么处理?
用 forwardRef 把引用包成箭头函数延迟求值,模块级和 provider 级都要加。但循环依赖通常说明职责划分有问题,更好的做法是抽共享模块或者用事件解耦。
QMessagePattern 和 EventPattern 的区别?
前者是请求-响应,调用方 send 并等待返回结果,本质是 RPC;后者是事件广播,调用方 emit 后立即返回,没有返回值,可能有多个消费者。
QNest 怎么做 SSR?
两种。一是内置 MVC:setViewEngine 配模板引擎,控制器用 @Render 返回数据对象,服务端直接渲染成完整 HTML,但没有 hydration。二是 Nest 只做 API/BFF,SSR 交给 Next/Nuxt。要 React 或 Vue 的 SSR,第二种更合适。