🚀 NestJS 完全入门指南: Module/Controller/Service讲解 + 实战 CRUD

1 阅读23分钟

📌 写在最前面:为什么你要读这篇?

被 Next.js 全栈能力「宠坏」的前端,突然接手 NestJS 项目——看着 @Module()、@Controller() 一脸懵?又或者被 ERR_PNPM_IGNORED_BUILDS 卡了一下午,怀疑人生?

这篇 2 万字的保姆级教程,用大量表格 + 餐厅比喻 + 真实踩坑,把 NestJS 核心概念拆解得明明白白。全文覆盖:框架定位、环境搭建、装饰器/DI/MVC 三大件、完整 CRUD 实战、面试速查表。一篇顶十篇,建议先收藏。


📚 本文目录

章节内容概要重要度
1️⃣ NestJS 是什么框架定位 + 与 Next.js 对比(小白版)⭐⭐⭐⭐⭐
2️⃣ 后端开发是做些什么API、集成、并发、微服务,给前端「喂饭」⭐⭐⭐⭐
3️⃣ 安装与环境搭建脚手架安装 + 新建项目 + 启动 + 踩坑实录⭐⭐⭐⭐⭐
4️⃣ 常见报错速查EADDRINUSE、Cannot GET、TS1261 实战解决⭐⭐⭐⭐⭐
5️⃣ 目录架构文件结构 + 命名规范⭐⭐⭐⭐
6️⃣ 工厂模式为什么不用 new?餐厅比喻秒懂⭐⭐⭐⭐
7️⃣ 高度模块化Module 是啥?怎么装配?⭐⭐⭐⭐⭐
8️⃣ 装饰器模式三层装饰器 + Express 对比 + 参数速查表⭐⭐⭐⭐⭐
9️⃣ MVC 三大件Module / Controller / Service 各司其职⭐⭐⭐⭐⭐
🔟 依赖注入(DI)三个角色 + 语法糖 + 手动 new 对比⭐⭐⭐⭐⭐
1️⃣1️⃣ 完整 CRUD 实战Todos 项目 7 个文件全解析 + 数据流图⭐⭐⭐⭐⭐
1️⃣2️⃣ TypeScript 关键概念interface、throw vs return、Partial、展开运算符等⭐⭐⭐⭐
1️⃣3️⃣ 数组方法速查find / findIndex / splice / Object.assign⭐⭐⭐⭐
1️⃣4️⃣ 开发流程 & 错误处理5 步走 + 内置异常类 + 面试常考⭐⭐⭐⭐
1️⃣5️⃣ 面试常考速查表框架 / RESTful / TS 基础 / 综合实战 4 大类⭐⭐⭐⭐⭐

1️⃣ NestJS 是什么?

相比 Next.js 全栈,NestJS 就是 Node 的纯后端企业级开发框架。

默认使用 TypeScript,全面模块化思想,适合构建企业级服务。相比自由松散的 Express,NestJS 强约束(按 Module / Controller / Service 组织)、默认 TS、适合大型项目。

🆚 与 Next.js 的区别(小白版)

维度Next.jsNestJS
定位全栈框架(前后端都管)纯后端框架(只管服务端)
主要产物网页(HTML + 交互)API 接口(JSON 数据)
路由方式app/ 目录即路由装饰器 @Controller() 声明路由
渲染方式SSR/SSG/CSR 都支持不渲染页面,只返回数据
用在啥场景用户直接访问的网站给前端/手机/小程序提供数据
类比🍽️ 餐厅(提供完整用餐体验)🏭 中央厨房(只做菜送出去)

📌 一句话记:Next.js 是给「用户看」的,NestJS 是给「程序调用」的。


2️⃣ 后端开发是做些什么?

2.1 提供 API 接口(Web 开发)

  • 干啥:给前端提供数据接口
  • 例子:
    • GET /api/users —— 获取用户列表
    • POST /api/login —— 处理登录请求
    • GET /api/products/:id —— 获取某商品详情
  • 用户能感知到吗:看不到,但每次刷新页面看到的数据都来自后端

2.2 系统集成、并发、底层服务、AI Infra

  • 干啥:处理重活累活,承接前端的复杂需求
  • 例子:
    • 系统集成:把多个第三方服务打通(支付 + 物流 + 短信)
    • 并发:同时处理大量用户请求(抢购、秒杀)
    • 底层服务:消息队列、定时任务、日志收集
    • AI Infra(AI 基础设施):把 AI 模型封装成 API、对接云服务、做模型推理调度
  • 用户能感知到吗:感知到的是"流畅",看不到的是后端扛下的压力

2.3 微服务框架

  • 干啥:把一个大后端拆成多个小服务,每个独立部署
  • 例子:
    • 用户服务(专门管注册登录)
    • 订单服务(专门管下单)
    • 商品服务(专门管商品)
  • 好处:单个服务挂了不影响整体,可以独立扩容
  • NestJS 的支持:内置 @nestjs/microservices 模块,原生支持微服务架构

📌 一句话总结:后端开发就是「给前端喂饭」——前端要数据后端给数据,前端要算啥后端帮算,前端要存啥后端帮存。


3️⃣ 安装与环境搭建

整个安装流程分 3 步:装脚手架 → 新建项目 → 启动项目。

第 1 步:全局安装 NestJS CLI 脚手架

npm i -g @nestjs/cli

命令拆解:

部分含义
npmNode.js 的包管理工具(装 Node 时自带)
iinstall 的缩写,意思是"安装"
-g--global 的缩写,"全局安装"——装完之后电脑任何目录都能用
@nestjs/cli要安装的包名(@nestjs 是组织名,cli 是命令行工具包)

装完能干啥:能在终端里直接敲 nest 开头的命令,比如 nest new(新建项目)、nest g controller(生成控制器)、nest start(启动项目)等。

💡 这条命令只需在电脑上执行一次,以后所有 NestJS 项目都能复用。

📌 小白比喻:-g 就像装桌面版微信,全局都能双击打开;不加 -g 就像临时用网页版微信,只在当前目录可用。

第 2 步:创建新的 NestJS 项目

nest new hello
部分含义
nest第 1 步全局安装的 CLI 工具
new创建新项目的子命令
hello项目名,也是项目文件夹名(可自定义)

执行后选 pnpm(推荐,更快更省空间),之后会自动执行 pnpm install 装依赖。

⚠️ 这一步可能踩到 ERR_PNPM_IGNORED_BUILDS 这个坑(pnpm 10+ 安全策略导致)。如果踩了别慌,跳到下面的「安装踩坑记录」按步骤解决。

第 3 步:启动项目

cd hello        # 进入项目目录
pnpm run start  # 启动

启动成功的标志:终端出现最后一行日志:

[Nest] LOG [NestApplication] Nest application successfully started

验证:浏览器访问 http://localhost:3000,能看到 Hello World! 就完事了。

📌 端口默认 3000,如果被占用会报 EADDRINUSE :3000,详见「常见报错速查」。

🕳️ 安装踩坑记录(新手必看)

主坑:ERR_PNPM_IGNORED_BUILDS(pnpm 拒绝执行构建脚本)

触发场景:用 nest new hello + pnpm 装依赖时

报错现象:

[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: unrs-resolver@1.12.2
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.

根本原因(3 个角色撞车):

  1. pnpm 10+ 当保安 🛡️:出于安全考虑,默认禁止任何第三方包在安装时跑"构建脚本"(防恶意包挖矿/盗密),必须用户明确批准
  2. unrs-resolver 被迫营业 🔧:NestJS 底层依赖(用于解析模块路径),必须跑构建脚本才能编译原生代码,不跑就半残
  3. nest CLI 偷懒了 😅:生成项目时,往 pnpm-workspace.yaml 写的是占位符 set this to true or false,没帮你写死成 true

解决方案(改一个字符就过) ✅:

打开项目根目录的 pnpm-workspace.yaml,把里面的:

allowBuilds:
  unrs-resolver: set this to true or false

改成:

allowBuilds:
  unrs-resolver: true

保存后重跑:

pnpm install
pnpm run start

⚠️ 这个坑还藏着两个小陷阱(坑中坑)

陷阱一:nest new 把真实错误吞了,让你看不到真相

现象:nest new 自动调 pnpm 装依赖时,只显示一行模模糊糊的:

Failed to execute command: pnpm install --strict-peer-dependencies=false --reporter=silent
🙀  Packages installation failed!

原因:注意命令里的 --reporter=silent —— 它是 pnpm 的"静默模式",会把详细输出屏蔽掉,所以你看不到真正的错误。

应对:手动跑一遍去掉 silent 的命令,错误就现身了:

pnpm install --strict-peer-dependencies=false

看到真实报错后(多半就是上面那个 ERR_PNPM_IGNORED_BUILDS)再对症下药。

陷阱二:pnpm approve-builds 交互界面别直接回车

场景:你按主坑的报错提示去跑 pnpm approve-builds

正确姿势 ✅:

  1. 终端出现一个交互列表
  2. 按空格 勾选 unrs-resolver(一定要勾选!)
  3. 再回车 确认

错误姿势 ❌:直接按回车 → 所有包被默认标记为 false(不允许构建)→ 下次再跑 pnpm approve-builds 时直接告诉你"无包待批准",连勾选机会都不给了。

误操作后的修复:手动打开 pnpm-workspace.yaml,把对应包值从 false 改成 true,再 pnpm install。


📌 一句话总结

pnpm 10+ 想保护你 → nest CLI 没帮你签字 → unrs-resolver 不能跑脚本 → install 失败。改 pnpm-workspace.yaml 里一个 true 就过。


4️⃣ 常见报错速查

报错 1:EADDRINUSE: address already in use :::3000

含义:3000 端口被占用了(多半是上一次启动的 NestJS 还没退干净)。

排查 + 解决(PowerShell):

# 1. 看谁占了 3000 端口
netstat -ano | findstr :3000

# 2. 拿到最后一列的 PID(一串数字),强杀进程
taskkill /PID <PID号> /F

如果不想杀进程:改端口跑,编辑 src/main.ts:

await app.listen(3001);  // 换个端口

或者命令行临时指定:

# Windows PowerShell
$env:PORT=3001; pnpm run start

📌 一句话记:EADDRINUSE 不是代码错,是端口被占。改端口或杀进程二选一。

报错 2:Cannot GET /todos(404 找不到路由)

含义:路由没注册成功,多半是改了代码但服务没重启。

排查清单(按顺序查):

  1. AppModule 是否 import 了 TodosModule —— 看 app.module.ts 的 imports 数组里有没有 TodosModule
  2. TodosModule 是否注册了 controller —— 看 todos.module.ts 的 controllers: [TodosController]
  3. controller 装饰器是否正确 —— @Controller('todos') + @Get() 拼起来才等于 /todos
  4. 服务有没有重启 —— 默认 pnpm run start 不热重载,改完代码必须重启

强烈建议:开发时用 watch 模式,自动重启:

pnpm run start:dev

启动成功的标志日志:

[Nest] LOG [RoutesResolver] TodosController { /todos }:

看到 [RoutesResolver] TodosController { /todos } 才说明路由真的注册上了。

报错 3:TS1261: ... resolved to X while ... resolved to Y

含义:文件名大小写和 import 路径大小写不一致。TypeScript 大小写敏感,Windows 文件系统不敏感,撞车就报错。

典型场景:文件叫 todos.controller.ts(小写 t),但 import 写成 from './Todos.controller'(大写 T)。

解决方案(推荐方案):把文件名统一改成全小写,符合 NestJS CLI 命名规范,且 import 路径必须与文件名完全一致。

错误命名正确命名(全小写)
Todos.controller.ts / Todos.Controller.tstodos.controller.ts
Todos.service.ts / Todos.Service.tstodos.service.ts
Todos.module.ts / Todos.Module.tstodos.module.ts

辅助手段:改完后在 IDE 里执行 TypeScript: Restart TS Server,清掉缓存的大小写不一致。这步很关键——即使文件已经改对了,TS Server 还可能缓存着旧路径。

📌 一句话记:NestJS 文件名一律全小写,跨平台兼容,且和 nest CLI 生成的文件保持一致。import 路径必须与文件名完全一致(大小写敏感)。


5️⃣ 目录架构

hello/
├── src/
│   ├── main.ts                 # 入口文件:启动 Nest 应用
│   ├── app.module.ts           # 根模块:组装所有子模块
│   ├── app.controller.ts       # 根控制器:处理 / 路由
│   ├── app.service.ts          # 根服务:返回 Hello World
│   └── todos/                  # 业务模块(手写)
│       ├── todos.module.ts     # 模块定义:组装 controller + service
│       ├── todos.controller.ts # 控制器:接收 HTTP 请求
│       └── todos.service.ts    # 服务:业务逻辑 + 数据
├── test/
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.json               # TS 配置(含 forceConsistentCasingInFileNames: true)
└── nest-cli.json

命名规范:业务文件全部小写,例如 todos.controller.ts、todos.service.ts。文件名大小写敏感问题参考「报错 3:TS1261」。


6️⃣ 工厂模式

什么是工厂模式?

一句话:不直接 new,把"创建对象"这件事交给一个专门的工厂去做,你只管要、不管怎么造。

为什么实例化不用 new?

对比看:

// 写法 A:直接 new(传统)
const app = new NestApplication();
app.init();
app.listen(3000);
//           ↑ 一堆初始化细节,调用者得知道

// 写法 B:工厂模式(NestJS 实际用法)
const app = await NestFactory.create(AppModule);
await app.listen(3000);
//           ↑ 调用者啥都不用管,工厂内部全包了

工厂的优势:

  1. 封装复杂创建过程:用户不用关心初始化顺序、依赖关系
  2. 统一入口:所有应用都用 NestFactory.create,写法一致
  3. 支持多场景:同一套代码可以创建 HTTP / WebSocket / 微服务 / GraphQL 等不同类型应用

跟 OOP(面向对象)什么关系?

工厂模式是 OOP 设计模式的经典成员,体现了 OOP 三大特性:

OOP 特性工厂模式如何体现
封装把复杂的"new + 初始化"塞进工厂内部,调用者只看到 create(...)
继承不同子类工厂可以创建不同类型的应用
多态同一个 NestFactory.create 入口,能创建多种应用

📌 比喻:直接 new = 自己造蛋糕(要准备原料、控温、烤多久),工厂 = 蛋糕店(你说"要一个巧克力蛋糕",店员后台搞定所有事)。

NestJS 里的工厂入口

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);   // 工厂模式核心:通过工厂创建应用,不直接 new
  await app.listen(3000);
}
bootstrap();

NestFactory.create(AppModule) 内部会解析依赖树、实例化所有 Module / Controller / Service、注入依赖、启动 HTTP 服务——这一套复杂流程用户完全不用知道。


7️⃣ 高度模块化

Module 是什么?

NestJS 把"一组功能"打包成一个 Module(模块),每个模块独立、可复用、可组合。约定如下:

AppModule(根模块)
    └── imports: [TodosModule, UserModule, ...]   ← 装配子模块

一个 Module 文件包括:

xx.module.ts        # 定义 + 装配(controller + service 在此登记)
xx.controller.ts    # 控制器:接 HTTP 请求
xx.service.ts       # 服务:业务逻辑、数据访问

@nestjs/common 提供 Module 类,用 @Module({...}) 装饰一个类,告诉框架这个类是个模块:

@Module({
  imports: [...],            // 依赖的其他模块
  controllers: [XxxController],  // 注册控制器(框架会扫描它的路由方法)
  providers: [XxxService],       // 注册服务(供依赖注入使用)
  exports: [XxxService],         // 导出服务,供其他模块使用(可选)
})
export class XxxModule {}

关于 exports:如果其他模块需要用到当前模块的 Service,必须在 exports 数组中声明。例如,如果 UserModule 需要使用 TodosService,则 TodosModule 需要:

@Module({
  controllers: [TodosController],
  providers: [TodosService],
  exports: [TodosService],   // ← 允许其他模块使用 TodosService
})
export class TodosModule {}

然后在 UserModule 中 imports: [TodosModule] 即可。这是 NestJS 模块封装性的体现——默认不暴露内部实现,只有显式 exports 的内容才对外可见。

模块化的好处

好处例子
独立todos 模块挂了不影响 users 模块
复用把 todos 模块整体搬到别的项目就能用
团队协作不同人负责不同模块,互不干扰

8️⃣ 装饰器模式

什么是装饰器模式?

一句话:不修改原有对象,动态给对象/方法/参数添加功能的设计模式。需要 TypeScript 支持(experimentalDecorators: true),原生 JS 不支持。

📌 比喻:给物体贴标签——@Controller() 贴个标签,NestJS 就认出这是控制器;@Get() 贴个标签,NestJS 就知道这是个 GET 路由。原类没动一行代码,只是多了个"标签"。

装饰器三个层级(NestJS 核心)

NestJS 几乎所有功能都靠装饰器,分为三个层级:

层级装饰器例子贴在哪干啥
类级别@Module() @Controller() @Injectable()类的上方标识这个类的身份
方法级别@Get() @Post() @Put() @Delete() @Patch()方法上方标识这是处理什么请求的路由
参数级别@Param() @Body() @Query()方法参数前从请求里抽取参数

三层装饰器完整示例

@Controller('todos')          // ← 类级别:这个类是 todos 控制器
export class TodosController {
  
  @Get()                       // ← 方法级别:处理 GET /todos
  findAll() { ... }

  @Get(':id')                  // ← 方法级别:处理 GET /todos/:id
  findOne(@Param('id') id: string) { ... }
  //          ↑ 参数级别:从 URL 取 id

  @Post()                      // ← 方法级别:处理 POST /todos
  create(@Body('title') title: string) { ... }
  //         ↑ 参数级别:从请求体取 title

  @Delete(':id')               // ← 方法级别:处理 DELETE /todos/:id
  remove(@Param('id') id: string) { ... }

  @Patch(':id')                // ← 方法级别:处理 PATCH /todos/:id
  update(
    @Param('id') id: string,    // ← 参数级别
    @Body() patch: Partial<Todo>,  // ← 参数级别:取整个 body
  ) { ... }
}

Express vs NestJS 装饰器对比

Express 写法(没有装饰器,手动注册路由):

const express = require('express');
const app = express();

app.get('/todos', (req, res) => { /* 查全部 */ });
app.post('/todos', (req, res) => { /* 新增 */ });
app.delete('/todos/:id', (req, res) => { /* 删除 */ });

NestJS 写法(装饰器声明式):

@Controller('todos')
class TodosController {
  @Get()       findAll() { ... }
  @Post()      create()  { ... }
  @Delete(':id') remove() { ... }
}
项ExpressNestJS
路由注册命令式(手动 app.get)声明式(装饰器贴标签)
组织方式函数+回调类 + 方法
类型安全弱(运行时才报错)强(编译期就报错)
可读性路由散落各处同一 controller 集中管理

装饰器速查表(参数装饰器详解)

装饰器用在从哪取例子后端拿到的值
@Module()类—标识模块—
@Controller('todos')类—标识控制器(路径前缀)—
@Injectable()类—标识可注入服务—
@Get() @Post() @Put() @Patch() @Delete()方法—声明路由 + HTTP 方法—
@Param('id')参数URL 路径/todos/1"1"(string)
@Body()参数请求体{title:'a'}{title:'a'}(对象)
@Body('title')参数请求体某字段{title:'a'}"a"(string)
@Query('page')参数URL 查询串/todos?page=2"2"(string)
@Headers('auth')参数请求头Authorization: xxx"xxx"(string)
@HttpCode(204)方法—自定义 HTTP 状态码—

参数装饰器关键点:

  • 路由里写 :id 是占位符,@Param('id') 的 'id' 必须和 :id 同名
  • URL 取出来的所有值都是 string(/todos/1 里的 1 是文本"1",不是数字 1),需要 Number(id) 转换
  • @Body() 不带 key = 取整个 body 对象;带 key(如 @Body('title'))= 只取该字段

前端调用对比:

// @Param:URL 直接带
fetch('/todos/1');

// @Body:放在请求体里
fetch('/todos', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({ title: '吃饭' })
});

9️⃣ MVC 三大件详解

Module、Controller、Service 各自干啥

NestJS 遵循经典 MVC 思想(在框架里被重新组织成 Module/Controller/Service 三件套):

角色干啥比喻
Module容器,登记 controller 和 service,让框架认识📄 营业执照
Controller接 HTTP 请求、抽参数、调 Service、返结果🧑‍💼 服务员
Service业务逻辑、数据 CRUD👨‍🍳 后厨

📌 餐厅比喻一句话记牢:客人(前端请求)→ 服务员(Controller)→ 后厨(Service)→ 菜(数据)。服务员不做饭、后厨不见客。

AppModule(根模块)

import { Module } from '@nestjs/common'; 
import { AppController } from './app.controller';  
import { AppService } from './app.service';
import { TodosModule } from './todos/todos.module';

@Module({ 
  imports: [TodosModule],            // 依赖的其他模块(把业务模块挂进来)
  controllers: [AppController],       // 注册根控制器
  providers: [AppService],            // 注册根服务
})
export class AppModule {}

关键点:

  • imports 数组装的是其他 Module——你在哪写业务模块,就要在这里 import 进来
  • 业务模块(TodosModule)一旦 import,它的所有 controller 路由才会生效
  • 没在 imports 里登记的模块 = 没接入系统,访问对应路由会 404

Controller(控制器)

@Controller('todos')   // 类级别装饰器:路径前缀 /todos
export class TodosController {
  constructor(private readonly todosService: TodosService) {} 
  //           ↑ 依赖注入:声明要 TodosService,NestJS 自动实例化并塞进来

  @Get()
  findAll(): Todo[] {
    return this.todosService.findAll();   // 转发给 Service
  }
}

Controller 的责任:

  1. 接 HTTP 请求(路由匹配)
  2. 抽参数(@Param、@Body 等)
  3. 调 Service 处理
  4. 把 Service 返回的数据转给前端

不该干的:写业务逻辑、直接操作数据库——这些归 Service。

Service(服务)

@Injectable()                          // ← 标签:可以被 NestJS 创建实例并注入到别处
export class TodosService {
  findAll(): Todo[] {
    return todos;                       // 直接返假数据
  }

  findOne(id: number): Todo {
    const todo = todos.find(t => t.id === id);
    if (!todo) throw new Error(`Todo${id}不存在`);
    return todo;
  }
}

Service 的责任:

  1. 业务逻辑(校验、计算、转换)
  2. 数据访问(CRUD 假数据 / 调数据库 / 调第三方 API)
  3. 抛业务异常(找不到、不合法等)

🔟 依赖注入(DI)

什么是依赖注入?

一句话:你不用 new,NestJS 自动帮你创建 Service 实例并塞进 Controller。

三个角色

角色干啥例子
提供者声明"我可以被注入"@Injectable() 标记的 Service
注册者把 Provider 登记到系统@Module({ providers: [TodosService] })
消费者在构造函数里"要"依赖constructor(private readonly todosService: TodosService)

注入语法糖

constructor(private readonly todosService: TodosService) {}

这是 TypeScript 的语法糖,等价于:

private readonly todosService: TodosService;

constructor(todosService: TodosService) {
  this.todosService = todosService;  // 手动赋值
}
关键字作用
private让参数变成类的私有属性
readonly防止后续代码误改这个属性
TodosService(参数类型)告诉 NestJS "我要一个 TodosService 实例"

NestJS 在运行时会看构造函数的参数类型,自动从已注册的 providers 里找到对应实例,new 出来塞进去。

DI vs 手动 new

写法优劣
const service = new TodosService()紧耦合,难替换、难测试
DI 自动注入松耦合,易替换(比如换成 MockService 测试)

📌 比喻:手动 new = 自己买菜做饭(得知道去哪买、买啥),DI = 让 NestJS 当采购员(你只要在菜单上写"我要鸡蛋",它就把鸡蛋送来)。


1️⃣1️⃣ 完整 CRUD 实战(Todos 项目)

RESTful 风格

REST 是前后端约定的一套"动词规范":用同一个 URL /todos,搭配不同 HTTP 方法做不同的事。

HTTP 方法装饰器语义路由示例成功状态码
GET@Get()查GET /todos 查全部200 OK
GET@Get(':id')查一个GET /todos/1200 OK / 404 没找到
POST@Post()增POST /todos 新增201 Created
PUT@Put(':id')全量替换PUT /todos/1200 OK
PATCH@Patch(':id')局部修改PATCH /todos/1200 OK
DELETE@Delete(':id')删DELETE /todos/1200 OK / 204

核心思想:URL 标识资源(/todos/1 表示"那一条 todo"),HTTP 方法标识动作(GET 看、POST 增、DELETE 删)。

路由拼接规则

控制器类装饰器 @Controller('todos') 给所有方法加一个路径前缀,方法装饰器 @Get(':id') 定义子路径,最终路由 = 前缀 + 子路径。

类装饰器方法装饰器最终路由
@Controller('todos')@Get()GET /todos
@Controller('todos')@Get(':id')GET /todos/:id
@Controller('todos')@Post()POST /todos
@Controller('todos')@Delete(':id')DELETE /todos/:id
@Controller('todos')@Patch(':id')PATCH /todos/:id

项目文件结构(7 个核心文件)

src/
├── main.ts                       # ① 入口:启动应用
├── app.module.ts                 # ② 根模块:挂载子模块
├── app.controller.ts             # ③ 根控制器:处理 / 路由(返 Hello World)
├── app.service.ts                # ④ 根服务:Hello World 业务逻辑
└── todos/
    ├── todos.module.ts           # ⑤ 业务模块:装配 controller + service
    ├── todos.service.ts          # ⑥ 业务服务:CRUD + 数据
    └── todos.controller.ts       # ⑦ 业务控制器:接 HTTP 请求

执行顺序:main.ts 启动 → 加载 AppModule → 加载 TodosModule → 注册 AppController/AppService + TodosController/TodosService → 路由生效。

① main.ts(入口文件)

// 入口文件:NestJS 应用从这里启动
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  // NestFactory.create 就是工厂模式(详见「工厂模式」章节)
  // 传入根模块 AppModule,工厂内部解析依赖、实例化所有 Module/Controller/Service、注入依赖
  const app = await NestFactory.create(AppModule);
  
  // 启动 HTTP 服务,端口优先取环境变量 PORT,没有就用 3000
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

关键点:

  • 工厂模式入口:NestFactory.create(AppModule)
  • 端口可被环境变量覆盖:process.env.PORT ?? 3000
  • 启动后浏览器访问 http://localhost:3000

② app.module.ts(根模块)

import { Module } from '@nestjs/common'; 
import { AppController } from './app.controller';  
import { AppService } from './app.service';
import { TodosModule } from './todos/todos.module';

@Module({ 
  imports: [TodosModule],          // ← 把业务模块挂进来,路由才会生效
  controllers: [AppController],    // 根控制器(处理 / 路由,返 Hello World)
  providers: [AppService],         // 根服务
})
export class AppModule {}

关键点:

  • imports 数组里必须挂上 TodosModule,否则 todos 的所有路由都访问不到(404)
  • 根模块本身也有自己的 controller 和 service(默认的 Hello World 接口)
  • 没在 imports 里登记的模块 = 没接入系统

③ app.controller.ts(根控制器)

// 【控制器】NestJS 的"接待员",专门负责接收前端 HTTP 请求
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()          // 类级别装饰器:声明这是个控制器(不带参数 → 路径前缀为空)
export class AppController {
  
  // 依赖注入:NestJS 自动创建 AppService 实例并塞进来
  // private → 变成类私有属性;readonly → 防止误改
  constructor(private readonly appService: AppService) {}

  @Get()                // 方法级别装饰器:处理 GET / 请求(根路径)
  getHello(): string {
    console.log('/ 的控制器');   // 调试日志,方便排查
    // this.appService → 访问注入的 Service 实例
    return this.appService.getHello();   // 转发给 Service
  }
}

关键点:

  • @Controller() 不带参数 = 路径前缀为空,匹配根路径 /
  • @Get() 不带参数 = 匹配根路径,所以拼起来就是 GET /
  • 访问 http://localhost:3000/ 就会执行 getHello(),返回 Hello World!
  • 这是 NestJS 自动生成的"演示控制器",让你看到框架跑起来了

④ app.service.ts(根服务)

import { Injectable } from '@nestjs/common';

@Injectable()           // 标签:声明这个类可以被 NestJS 创建实例并注入到别处
export class AppService {
  
  getHello(): string {   // 类型约束:返回值必须是 string
    // 返回数据给 Controller,Controller 再返回给前端
    return 'Hello World!';
    // return 1;          // 会报错:number 不能赋给 string(演示类型约束的作用)
  }
}

关键点:

  • @Injectable() 让这个类成为可注入服务,才能被 Controller 通过 DI 拿到
  • : string 类型约束:承诺返回 string,如果写成 return 1 会在编译期报错(演示类型约束的作用)
  • 真正"干活"的地方——返回 'Hello World!' 这个字符串
  • 跟 TodosService 是同一套机制,只是业务更简单(啥也不查,直接返字符串)

AppService vs TodosService 对比:

对比项AppService(根服务)TodosService(业务服务)
职责返回简单的 Hello World 字符串完整的 Todo CRUD 业务逻辑
数据硬编码字符串数组模拟数据库 + 自增 id
复杂度⭐ 极简(演示用)⭐⭐⭐⭐⭐ 完整业务
依赖注入被 AppController 注入被 TodosController 注入
异常处理无(不会出错)有(找不到抛 NotFoundException)

共同点:都使用 @Injectable() 装饰器,都是可注入的服务,都遵循"Controller 调 Service"的 MVC 分层思想。

⑤ todos.module.ts(业务模块装配)

// Todos Module 的定义文件
import { Module } from '@nestjs/common';
import { TodosController } from './todos.controller';
import { TodosService } from './todos.service';

@Module({
  controllers: [TodosController],   // 注册控制器,NestJS 会扫描其路由方法
  providers: [TodosService],        // 注册服务,供依赖注入(DI)使用
  // exports: [TodosService],       // 如其他模块需要,可取消注释
})
export class TodosModule {}

关键点:

  • 一个 Module 文件干的事就是"装配":登记 controller 和 service
  • controllers 数组:告诉 NestJS "这个模块有哪些控制器"
  • providers 数组:告诉 NestJS "这个模块提供哪些可注入的服务"
  • exports 数组:告诉 NestJS "哪些 Provider 可以暴露给其他模块使用"(默认不暴露)

⑥ todos.service.ts(业务核心 + 数据)

import { Injectable } from '@nestjs/common';

// 接口:定义一个待办事项的属性(前后端共享的类型约定)
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// 模拟数据库(学习用,生产环境由数据库替代)
let todos: Todo[] = [
  {id: 1, title: '学习 NestJS', completed: false},
  {id: 2, title: '完成项目', completed: true},
  {id: 3, title: '与客户沟通', completed: false},
  {id: 4, title: '测试代码', completed: true},
  {id: 5, title: '部署应用', completed: false},
]; 
let nextId = 6;   // 自增 id 计数器(重启会重置,生产环境由数据库自增)

@Injectable()    // 标签:声明这个类可以被 NestJS 创建实例并注入到别处
export class TodosService {
  
  // ① 查全部
  findAll(): Todo[] {
    return todos;
  }

  // ② 查一个
  findOne(id: number): Todo {
    const todo = todos.find(t => t.id === id);
    if (!todo) throw new Error(`Todo${id}不存在`);  // 容错:找不到抛错
    return todo;
  }

  // ③ 新增
  create(title: string): Todo {
    // id 由后端生成(前端不能传),completed 默认 false
    const todo: Todo = { id: nextId++, title, completed: false };
    todos.push(todo);          // 加到数组末尾
    return todo;               // 返回完整对象(含后端生成的 id)
  }

  // ④ 删除
  remove(id: number): void {   // void 表示不返回值
    const index = todos.findIndex(t => t.id === id);
    if (index === -1) throw new Error(`Todo${id}不存在`);  // 必须用 === -1
    todos.splice(index, 1);    // 从 index 位置删 1 个
  }

  // ⑤ 局部修改(PATCH)
  update(id: number, patch: Partial<Todo>): Todo {
    const todo = this.findOne(id);          // 复用 findOne 找原对象(含容错)
    Object.assign(todo, patch);            // 合并:patch 里有字段覆盖 todo
    return todo;
  }
}

5 个 CRUD 方法核心思路表:

方法干啥关键代码注意点
findAll()查全部return todos直接返整个数组
findOne(id)查一个todos.find(t => t.id === id)找不到用 if (!todo) throw
create(title)新增{id: nextId++, title, completed: false}id 由后端生成(前端不能传 id),completed 默认 false
remove(id)删除findIndex + splice(index, 1)必须用 index === -1 判断找不到,不能用 !index
update(id, patch)局部修改findOne(id) + Object.assign(todo, patch)复用 findOne 找原对象,原地合并 patch 字段

⑦ todos.controller.ts(路由 + 转发)

import {
  Controller,    // 类装饰器:声明控制器身份 + 路径前缀
  Get,           // 方法装饰器:声明 GET 路由
  Post,          // 方法装饰器:声明 POST 路由
  Patch,         // 方法装饰器:声明 PATCH 路由
  Delete,        // 方法装饰器:声明 DELETE 路由
  Param,         // 参数装饰器:从 URL 路径取参数
  Body,          // 参数装饰器:从请求体取参数
} from '@nestjs/common';

import { TodosService } from './todos.service';
import { type Todo } from './todos.service';   // 只导入类型,不导入值

@Controller('todos')        // 类级别装饰器:路径前缀 /todos
export class TodosController {
  
  // 依赖注入:在构造函数声明要 TodosService,NestJS 自动实例化并塞进来
  // private → 变成类私有属性;readonly → 防止误改
  constructor(private readonly todosService: TodosService) {} 

  // ① 查全部   GET /todos
  @Get() 
  findAll(): Todo[] {
    return this.todosService.findAll();    // 转发给 Service
  }

  // ② 查一个   GET /todos/:id
  @Get(':id') 
  findOne(@Param('id') id: string): Todo {
    //    ↑ @Param('id') 从 URL 取路径参数(取出来都是 string)
    return this.todosService.findOne(Number(id));   // Number() 显式转 number
  }

  // ③ 新增    POST /todos
  @Post()
  create(@Body('title') title: string): Todo {
    //    ↑ @Body('title') 只取请求体里的 title 字段(id/completed 由后端生成)
    return this.todosService.create(title);
  }

  // ④ 删除    DELETE /todos/:id
  @Delete(':id')
  remove(@Param('id') id: string): { message: string } {
    this.todosService.remove(Number(id));
    return { message: `Todo${id}删除成功` };   // 返确认信息,方便前端提示
  }

  // ⑤ 局部修改  PATCH /todos/:id
  // 修改有两个:Put(全量替换)和 Patch(局部修改),此处用 patch 更合适
  @Patch(':id')
  update(
    @Param('id') id: string,           // 从 URL 取 id
    @Body() patch: Partial<Todo>,      // 从请求体取要改的字段(部分字段,用 Partial)
  ): Todo {
    return this.todosService.update(Number(id), patch);
  }
}

Controller 的核心职责:

  • 接 HTTP 请求 → 抽参数 → 调 Service → 返结果
  • 不写业务逻辑,只做"接请求、转格式、调 Service、返结果"
  • URL 取出的都是 string,调 Service 前要用 Number(id) 转成数字
  • @Body('title') 只取一个字段;@Body() 取整个 body 对象
  • Controller 不需要 try/catch:NestJS 会捕获 Service 层抛出的异常,自动转成对应的 HTTP 错误响应(如 NotFoundException → 404)

关于 HTTP 状态码的补充:

在 RESTful 规范中,不同操作应该返回不同的状态码:

操作成功状态码NestJS 默认建议
GET(查全部)200 OK✅ 200保持默认
GET(查一个)200 OK / 404✅ 200(找不到需手动抛 404)用 NotFoundException
POST(新增)201 Created❌ 默认 200需手动改
PUT/PATCH(改)200 OK✅ 200保持默认
DELETE(删)204 No Content / 200 OK✅ 200可保持 200 并返回消息

如何让 POST 返回 201:

import { Controller, Post, Body, HttpCode } from '@nestjs/common';
//                                ↑ 需要额外引入 HttpCode

@Controller('todos')
export class TodosController {
  @Post()
  @HttpCode(201)  // ← 加这一行,状态码变 201
  create(@Body('title') title: string): Todo {
    return this.todosService.create(title);
  }
}

📌 虽然默认 200 也能用,但严格 RESTful 规范要求 POST 返回 201,面试时可能会被问到。

完整数据流示例(前端访问 GET /todos/1)

浏览器请求 GET /todos/1
  ↓
NestJS 路由匹配:@Controller('todos') + @Get(':id')
  ↓
Controller.findOne(@Param('id') id = "1")
  ↓ Number("1") = 1
Service.findOne(1)
  ↓ todos.find(t => t.id === 1)
  ↓ 找到 {id:1, title:'学习 NestJS', completed:false}
  ↓ return todo
Controller 拿到 todo
  ↓ return 给 NestJS
NestJS 序列化成 JSON
  ↓
HTTP 200 OK + {"id":1,"title":"学习 NestJS","completed":false}
  ↓
浏览器收到响应 ✅

1️⃣2️⃣ TypeScript 关键概念(小白版)

📐 类型约束(接口 interface)

interface Todo {
  id: number;          // 必须是 number
  title: string;       // 必须是 string
  completed: boolean;  // 必须是 boolean
}

作用:给编译器看,编译期检查你的数据对不对。运行时(浏览器收到的)类型已经丢了,类型不能保证运行时不出错。

const todo: Todo = { id: 1, title: 'a', completed: false };  // ✅
const bad: Todo = { id: '1', title: 'a' };                   // ❌ TS 报错

📌 类型管"代码不出错",运行时容错还得靠代码(如 if + throw)。

🎯 throw vs return(核心区别)

关键字行为HTTP 响应比喻
return正常返回值,方法结束200 OK🍽️ 服务员端菜
throw异常中断,逃出整个调用链500/404/...🚨 喊"着火了!"

关键差异:return 走"成功通道",throw 走"异常通道"。

findOne(id: number): Todo {
  const todo = todos.find(t => t.id === id);
  if (!todo) throw new Error('不存在');   // ← 失败通道:HTTP 500
  return todo;                              // ← 成功通道:HTTP 200
}

口诀:成功 return,失败 throw。

类型签名只约束 return:findOne(): Todo 承诺返回 Todo,但 throw new Error(...) 不违反签名。

📦 void 返回类型

remove(id: number): void {
  //  ↑ void 表示"不打算返回任何值"
  todos.splice(index, 1);
}

含义:方法只干副作用(删数据),不返东西给调用者。

注意:返回 undefined 也算 void,但 NestJS 会把空响应转成 HTTP 200 + 空 body,前端可能解析失败。建议改成返回确认信息:

remove(id: number): { message: string } {
  todos.splice(index, 1);
  return { message: `已删除 ${id}` };   // ← 前端能拿到 JSON
}

🔧 Partial 工具类型

作用:把一个类型的所有字段变成可选。

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

type PartialTodo = Partial<Todo>;
// 等价于:
// {
//   id?: number;          // ← 都加了 ?
//   title?: string;
//   completed?: boolean;
// }

PATCH 场景必用:前端只传要改的字段,其他字段可以缺。

update(id: number, patch: Partial<Todo>): Todo { ... }
//                       ↑ 接收 {completed:true} 或 {title:'a'} 或空对象都合法

🔄 显式 vs 隐式类型转换

URL 里的所有值都是字符串,需要转 number 给 Service:

写法类型例子
Number(id)✅ 显式转换Number('1') → 1
parseInt(id)✅ 显式转换parseInt('1') → 1
+id隐式转换+'1' → 1(不推荐,可读性差)
id * 1隐式转换'1' * 1 → 1(不推荐)

推荐用 Number(id):意图明确,不易出 bug。

✍️ 对象简写语法

const title = '吃饭';
const todo = { title };        // ← 简写
// 等价于
const todo = { title: title }; // ← 完整写法

规则:当对象的 key 名跟变量名一样时,可以省略 : 变量名,只写一个名字。这是 ES6 简写语法。

🌊 展开运算符 ...

const old = { a: 1, b: 2 };
const patch = { b: 99, c: 3 };

const merged = { ...old, ...patch };
// 结果:{ a: 1, b: 99, c: 3 }
//             ↑ 没动 ↑ 被覆盖 ↑ 新增

规则:后面覆盖前面,同名 key 取后面的值。

➕ 后置自增 nextId++

let nextId = 6;
const todo = { id: nextId++ };
//                  ↑ 取当前值 6 给 id,然后 nextId 变 7
写法行为返回
nextId++(后置)先用再加 1旧值(6)
++nextId(前置)先加 1再用新值(7)

1️⃣3️⃣ 数组方法速查

🔍 find vs findIndex(关键区别)

方法找到时返回找不到时返回适合干啥
find(cb)元素本身undefined(falsy)取值
findIndex(cb)索引(0,1,2...)-1定位(为删改做准备)

判断找不到的写法对比:

// find(用 ! 判断)
const todo = todos.find(t => t.id === id);
if (!todo) throw ...;        // ← undefined 是 falsy,!undefined === true

// findIndex(必须用 === -1)
const index = todos.findIndex(t => t.id === id);
if (index === -1) throw ...; // ← 必须显式比较!
// ❌ 不能用 !index,因为 index=0 时 !0===true,会把"找到了第一条"误判为"找不到"

✂️ splice:删除/插入/替换

array.splice(起始位置, 删除个数, ...插入的元素)
//                       ↑ 可选       ↑ 可选
写法作用
todos.splice(1, 1)从位置 1 删 1 个
todos.splice(1, 2)从位置 1 删 2 个
todos.splice(1, 0, item)在位置 1 插入 item(删 0 个)
todos.splice(1, 1, item)在位置 1 替换(删 1 插 1)

📋 Object.assign:合并对象

Object.assign(目标, 源1, 源2, ...);
// 把所有源的属性复制到目标,后面覆盖前面
const todo = { id:1, title:'a', completed:false };
const patch = { completed:true };

Object.assign(todo, patch);
// 结果:todo = { id:1, title:'a', completed:true }
//                            ↑ 被覆盖     ↑ 没动

注意:

  • Object.assign 是浅拷贝,嵌套对象只覆盖引用
  • 原地修改目标对象,不创建新对象(适合 PATCH 直接改原数据)

1️⃣4️⃣ 开发流程 & 错误处理

🚀 开发流程

完整开发一个业务模块的步骤:

  1. 写 Service(xx.service.ts):定义接口、假数据、CRUD 方法
  2. 写 Controller(xx.controller.ts):声明路由、抽参数、转发给 Service
  3. 写 Module(xx.module.ts):装配 controller 和 service
  4. 挂到根模块(app.module.ts):在 imports 数组里 import 业务模块
  5. 启动开发服务器:pnpm run start:dev(自动重启)

⚙️ 开发模式 vs 生产模式启动

命令模式特点适用场景
pnpm run start普通一次编译,不热重载生产环境(配合 PM2)
pnpm run start:dev开发热重载(改代码自动重启)开发阶段(强烈推荐)
pnpm run start:debug调试热重载 + 开启调试端口需要打断点调试时
pnpm run start:prod生产先 build 再启动编译后代码生产部署(更规范)

开发时的最佳实践:

# ✅ 开发时用这个,改完代码自动重启,不用手动 Ctrl+C
pnpm run start:dev

# ❌ 开发时别用这个,改代码不重启,访问会 404
pnpm run start

🛑 错误处理

NotFoundException(NestJS 内置错误类)

import { NotFoundException } from '@nestjs/common';

findOne(id: number): Todo {
  const todo = todos.find(t => t.id === id);
  if (!todo) throw new NotFoundException(`Todo ${id} 不存在`);
  //              ↑ 抛这个会让 HTTP 返回 404,语义更准
  return todo;
}

对比 throw new Error() 和 throw new NotFoundException():

写法HTTP 状态码语义
throw new Error(...)500 Internal Server Error"服务器出问题了"(不合适,找不到数据不是服务器崩了)
throw new NotFoundException(...)404 Not Found"没找到"(语义正确)

NestJS 异常处理机制说明:Controller 层不需要手动 try/catch Service 层抛出的异常。NestJS 框架层会自动捕获所有异常,并根据异常类型(如 NotFoundException)转换成对应的 HTTP 状态码和错误响应体。这体现了「让框架处理横切关注点」的设计理念。

NestJS 内置了一系列 HTTP 异常类:

异常类状态码场景
BadRequestException400参数不合法
UnauthorizedException401没登录
ForbiddenException403没权限
NotFoundException404资源不存在
ConflictException409冲突(如重复创建)
InternalServerErrorException500服务器内部错误

🔧 tsconfig.json 中的关键配置

{
  "compilerOptions": {
    "experimentalDecorators": true,   // ← 必须开启,否则装饰器不生效
    "emitDecoratorMetadata": true,    // ← 必须开启,否则依赖注入不工作
    "forceConsistentCasingInFileNames": true  // ← 强制文件名大小写一致(防 TS1261)
  }
}
配置项作用不开启的后果
experimentalDecorators支持装饰器语法@Controller() 等全部报错
emitDecoratorMetadata生成装饰器元数据(类型信息)依赖注入失效(NestJS 不知道要注入啥类型)
forceConsistentCasingInFileNames强制文件名大小写一致Windows 上容易出现 TS1261 错误

📌 用 nest new 生成的项目默认都配置好了,不需要手动改。但如果你从零手写 tsconfig,这三个配置必须加上。


🎯 面试常考:你是如何处理后端报错的?

  • TypeScript 是单线程,不处理异常会挂掉整个进程
  • 使用 try / catch / finally 兜底
  • NestJS 提供标准化错误类(如 NotFoundException),抛出后框架自动转成对应 HTTP 状态码
  • 标准化错误输出包含:
    • statusCode(状态码)
    • message(错误消息)
    • 可选 error(错误名称)

1️⃣5️⃣ 🎯 面试常考知识点速查表

下面这些点都常出现在面试题里,对应章节已经讲过。复习时按这张表查漏补缺。

NestJS 框架相关

知识点面试常见问法所在章节
工厂模式"为什么 NestJS 实例化不用 new?"工厂模式 → 为什么实例化不用 new
装饰器模式"装饰器分哪几层?跟 OOP 什么关系?"装饰器模式 → 装饰器三个层级
MVC 三大件"Controller 能直接操作数据库吗?为什么?"MVC 三大件详解
依赖注入(DI)"什么是依赖注入?跟手动 new 有啥区别?"依赖注入(DI)
异常处理"你是如何处理后端报错的?"错误处理 → 面试常考

RESTful & API 设计

知识点面试常见问法所在章节
RESTful 风格"GET/POST/PUT/PATCH/DELETE 各对应什么操作?"完整 CRUD 实战 → RESTful 风格
路由拼接规则"@Controller + @Get 怎么拼出最终路由?"路由拼接规则
PUT vs PATCH"PUT 和 PATCH 有啥区别?什么时候用 PATCH?"完整 CRUD 实战 → RESTful 风格表

TypeScript / JavaScript 基础

知识点面试常见问法所在章节
throw vs return"为什么找不到数据要 throw 而不是 return undefined?"throw vs return
find vs findIndex"find 和 findIndex 返回值有啥区别?判断找不到怎么写?"find vs findIndex
Partial<T>"Partial、Pick、Omit 区别?什么时候用?"Partial<T> 工具类型
显式 vs 隐式转换"Number(id) 和 +id 有啥不同?哪个推荐?"显式 vs 隐式类型转换
后置自增"i++ 和 ++i 区别?返回啥?"后置自增 nextId++
展开运算符"合并两个对象,相同字段如何处理?"展开运算符 ...
Object.assign"Object.assign 是深拷贝还是浅拷贝?"Object.assign:合并对象
类型约束"interface 和 type 有啥区别?类型能保证运行时不出错吗?"类型约束(接口 interface)

综合实战题

知识点面试常见问法所在章节
完整 CRUD 设计"让你设计一个 Todo API,路由怎么定?"完整 CRUD 实战
参数装饰器选择"什么场景用 @Param、@Body、@Query?"装饰器速查表
启动报错排查"NestJS 跑不起来 / 404,怎么排查?"常见报错速查

💡 复习建议:先把每章对应知识点看一遍,再合上文档用自己的话讲一遍——讲不出来就是没懂。


🎓 最终总结(本篇回顾)

如果你读到了这里,恭喜你 🎉——你已经系统地走完了 NestJS 入门的第一阶段!

这篇你学到了什么?

知识板块核心收获
🧭 框架定位NestJS 是纯后端框架,和 Next.js 各司其职
🔧 环境搭建CLI 安装 + 项目创建 + 3 个常见报错实战解决
🏗️ 架构模式工厂模式、装饰器模式、模块化设计
🧩 核心三件套Module(登记)+ Controller(接客)+ Service(干活)
💉 依赖注入不用 new,NestJS 自动送实例上门
📡 RESTful API7 个文件手写完整 CRUD,含数据流图解
📘 TS 基础interface、throw/return、Partial、数组方法等
🎯 面试准备4 大类常考点 + 标准回答思路

🔜 下一篇继续