普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月27日首页

uniapp 多地区、多平台、多环境打包方案

作者 codingWhat
2026年2月27日 12:21

前言:本文基于真实的线上项目,介绍如何用一套代码支持多个地区多种平台(微信/支付宝小程序、H5 等)、多种部署环境(开发/测试/生产)的构建与打包,并给出具体代码与配置说明。

开始之前,大家先想象这样一个场景:同一套业务,要同时给 A 省、B 省、C 省上线微信小程序和支付宝小程序,每个省的标题、首页内容、支付方式、后端网关地址都不一样,还要区分 dev / qa / prod 多套环境。如果每一套都复制一份代码维护,成本会很快失控。那么我这篇文章讲的,就是怎样通过「一套代码 + 一组脚本」把这些组合全部收拢起来,统一维护。


一、需求与目标

维度 说明 示例
地区(DISTRICT) 不同省份/地区,不同应用标题、页面配置、manifest 新疆、安徽、江苏、吉林…
平台(UNI_PLATFORM) 不同运行端 微信小程序 mp-weixin、支付宝 mp-alipay、H5
环境(DEPLOY) 不同 API 与部署目标 dev、qa02、release_anhui、xinjiang_prod…

目标:通过一条 npm 脚本即可确定「地区 + 平台 + 环境」,打出对应产物,无需改代码。


二、整体思路

npm script (cross-env 注入环境变量)
    ↓
process.env.UNI_PLATFORM / DISTRICT / DEPLOY / NODE_ENV
    ↓
vue.config.js 加载时先执行 preBuild.js(类似于webpack自定义plugin的效果)
    ↓
preBuild.js 根据上述变量生成 CLIENT_TYPE、APP_TITLE、API_BASE_URL 等
    ↓
动态生成 pages.json、manifest.json(按地区合并)
    ↓
Webpack 通过 EnvironmentPlugin 将变量注入业务代码
    ↓
构建产物中 process.env.XXX 被替换为常量

核心有以下三点:

  1. 环境变量驱动:用 UNI_PLATFORMDISTRICTDEPLOY 等控制整条构建链。
  2. 构建前预处理:在 Webpack 之前跑 preBuild.js,统一把「平台/地区/环境」转成业务需要的 CLIENT_TYPEAPI_BASE_URLAPP_TITLE 等。
  3. 配置与代码注入:用 Webpack 官方 EnvironmentPluginprocess.env 中的键注入到前端代码,保证运行时代码能拿到当前构建的「地区/平台/环境」。

三、具体实现步骤与代码说明

3.1 用 cross-env 在 npm script 中传参

不同操作系统下设置环境变量方式不同,使用 cross-env 可统一写法。

安装:

npm i -D cross-env

package.json 中的脚本示例:

{
  "scripts": {
    "build:mp-weixin:anhui:release_anhui": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin DISTRICT=anhui DEPLOY=release_anhui vue-cli-service uni-build",
    "dev:mp-weixin:anhui:release_anhui": "cross-env NODE_ENV=development UNI_PLATFORM=mp-weixin DISTRICT=anhui DEPLOY=dev_anhui vue-cli-service uni-build --watch"
  }
}

含义简述:

  • cross-env KEY=value 会在当前 Node 进程中设置 process.env.KEY = value
  • 后面执行的 vue-cli-service uni-build 与它加载的 vue.config.jspreBuild.js 都运行在同一进程,因此能直接读到这些环境变量。
  • 命名约定:dev/build + 平台 + 地区 + 环境,便于一眼看出打的是哪一套。

关于 --watch

  • 开发脚本(如 dev:mp-weixin:...)末尾加上 --watch,表示 开启监听模式:当代码或配置文件变更时,会自动重新编译对应平台/地区/环境的产物,无需每次手动重新执行命令,适合本地联调和反复修改。

常见变量:

变量 含义 示例
NODE_ENV 开发/生产 development / production
UNI_PLATFORM 平台 mp-weixin、mp-alipay、h5
DISTRICT 地区 anhui、xinjiang、jiangsu
DEPLOY 部署环境 dev、qa、release_anhui
CUSTOM_TAB 可选,自定义 tab 配置 如 pay、order_search

3.2 统一配置:preBuild.config.js

把「平台 → 客户端类型」「地区 → 应用标题」「部署环境 → API 地址」等做成映射表,便于维护和扩展。

config/preBuild.config.js:

// 平台与客户端类型(用于接口等)
const CLIENT_TYPE_MAP = {
  h5: 1,
  'mp-weixin':2,
  'mp-alipay':3,
  'mp-baidu': 4,
  'mp-toutiao': 5,
  'mp-qq': 6
}

// 地区与应用标题等展示配置
const DISTRICT_CONFIG_MAP = {
  jilin: { APP_TITLE: 'xxx' },
  xinjiang: { APP_TITLE: 'xxx' },
  // ... 其他地区
}

// 部署环境与 API 根地址(示例域名为占位)
const API_BASE_URL_MAP = {
  dev: 'https://api-dev.example.com/mobile-app/api',
  qa: 'https://api-qa02.example.com/mobile-app/api',
  release_regionA: 'https://api-release-regionA.example.com/mobile-app/api',
  // ... 其他环境
}

module.exports = {
  CLIENT_TYPE_MAP,
  DISTRICT_CONFIG_MAP,
  API_BASE_URL_MAP
}

后续新增地区或环境时,只需在此处增补配置,无需改构建脚本逻辑。


3.3 构建前预处理:preBuild.js

在 Webpack 读取 vue.config.js 之前,需要把「平台/地区/环境」转成业务和配置生成器使用的变量。因此把 preBuild.js 放在 vue.config.js 最前面执行。

build/preBuild.js:

/**
 * 设置自定义的 process.env.X 需同时在 vue.config.js 的
 * configureWebpack.plugins 里加入 EnvironmentPlugin 对应 key
 */
const { CLIENT_TYPE_MAP, DISTRICT_CONFIG_MAP, API_BASE_URL_MAP } = require('../config/preBuild.config')

// 版本号等固定值(示例)
process.env.VERSION = '1.0.0'

// 由「平台」得到客户端类型
process.env.CLIENT_TYPE = CLIENT_TYPE_MAP[process.env.UNI_PLATFORM]

// 由「地区」得到应用标题(缺省用 jilin)
process.env.APP_TITLE = DISTRICT_CONFIG_MAP[process.env.DISTRICT || 'xuzhou'].APP_TITLE

// 由「部署环境」得到 API 根地址(缺省用 dev)
process.env.API_BASE_URL = API_BASE_URL_MAP[process.env.DEPLOY || 'dev']

// 可选:自定义 tab 等,未传则空字符串
process.env.CUSTOM_TAB = process.env.CUSTOM_TAB || ''

// 便于排查:构建时打印当前维度
console.log('------------------------------')
console.log('NODE_ENV:', process.env.NODE_ENV)
console.log('DEPLOY:', process.env.DEPLOY)
console.log('UNI_PLATFORM:', process.env.UNI_PLATFORM)
console.log('DISTRICT:', process.env.DISTRICT)
console.log('CLIENT_TYPE:', process.env.CLIENT_TYPE)
console.log('API_BASE_URL:', process.env.API_BASE_URL)
console.log('------------------------------')

// 根据 DISTRICT 等动态生成 pages.json、manifest.json
require('./pages.json.js')
require('./manifest.json.js')

要点:

  • 只读不写:从 process.env 读取由 cross-env 注入的 UNI_PLATFORMDISTRICTDEPLOY
  • 派生变量:写入 CLIENT_TYPEAPP_TITLEAPI_BASE_URLCUSTOM_TAB 等,供后续 Webpack 和动态配置使用。
  • 执行顺序:本文件在 vue.config.js 的顶部被 require,因此先于 Webpack 配置执行;下面的 pages.json.jsmanifest.json.js 会用到当前的 process.env.DISTRICT 等。

3.4 在 vue.config.js 中接入 preBuild 与 EnvironmentPlugin

vue.config.js:

const webpack = require('webpack')
const path = require('path')

// 必须最先执行:注入 CLIENT_TYPE、API_BASE_URL 等,并生成 pages.json / manifest.json
require('./build/preBuild.js')

module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        // 按地区做目录别名,业务里 import 来自 @district 即当前地区配置
        '@district': path.join(__dirname, 'src/district', process.env.DISTRICT)
      }
    },
    plugins: [
      // 将 process.env 中列出的 key 在编译时注入到业务代码中
      // 业务代码中 process.env.UNI_PLATFORM 等会被替换为构建时的常量
      new webpack.EnvironmentPlugin([
        'UNI_PLATFORM',
        'CLIENT_TYPE',
        'VERSION',
        'DISTRICT',
        'API_BASE_URL',
        'CUSTOM_TAB'
      ])
    ]
  }
}

说明:

  • require('./build/preBuild.js'):保证在任何 webpack 配置使用 process.env.DISTRICTprocess.env.API_BASE_URL 等之前,这些变量就已经就绪。
  • @district 别名:指向 src/district/${DISTRICT},便于按地区维护配置(如 src/district/anhui/config.js),业务侧统一从 @district 引用。
  • EnvironmentPlugin:Webpack 内置插件,会把数组中列出的 process.env.XXX编译时替换为当前构建时的值,因此业务里写 process.env.UNI_PLATFORMprocess.env.DISTRICTprocess.env.API_BASE_URL 等即可,无需再传参。

3.5 按地区动态生成 pages.json(build/pages.json.js)

不同地区可能需要不同的页面列表、tabBar 等,因此把「基础配置」与「地区配置」合并后再写入 src/pages.json

build/pages.json.js:

const fs = require('fs')
const path = require('path')
const lodash = require('lodash')
const standardPages = require('../config/pages.js')

let districtPage = {}
let customPages = []
const district = process.env.DISTRICT

// 若存在该地区的 pages 配置则合并
try {
  districtPage = require(`../config/districts/${district}/pages.js`)
} catch (e) {
  console.log(`不存在【${district}】地区差异化 page.js,略过`)
}

// 若有 CUSTOM_TAB,可再合并 custom.xxx.pages.js
process.env.CUSTOM_TAB.split(',').forEach((type) => {
  // ... 按 type 加载 config/custom.${type}.pages.js 与 config/districts/${district}/ 下同名文件
})

const pageJSON = lodash.mergeWith({}, standardPages, districtPage, ...customPages, (objValue, srcValue, key) => {
  if (Array.isArray(objValue) && key === 'list') {
    return srcValue  // 如 tabBar.list 用地区配置覆盖,而不是数组合并
  }
})

fs.writeFileSync(path.join(__dirname, '../src/pages.json'), JSON.stringify(pageJSON, null, 2), { encoding: 'utf8' })
console.log('page.json 构建完成')

思路:对基础 pages + 地区 pages + 自定义 tab 配置 做 merge,对 list 类数组采用覆盖策略,避免 tab 等被意外合并。


3.6 按地区动态生成 manifest.json(build/manifest.json.js)

小程序与 H5 的 appid、描述等可能按地区不同,同样采用「标准 manifest + 地区 manifest」合并。

build/manifest.json.js:

const fs = require('fs')
const path = require('path')
const lodash = require('lodash')

const standardManifest = {
  name: 'app',
  appid: 'xxx',
  'mp-weixin': { /* ... */ },
  'mp-alipay': { /* ... */ }
  // ...
}

let districtManifest = {}
try {
  districtManifest = require(`../config/districts/${process.env.DISTRICT}/manifest.json.js`)
} catch (e) {
  console.log('该地区不存在差异化 manifest.json.js')
}

const merged = lodash.merge({}, standardManifest, districtManifest)
fs.writeFileSync(path.join(__dirname, '../src/manifest.json'), JSON.stringify(merged, null, 4), { encoding: 'utf8' })
console.log('manifest.json 生成完成')

地区目录示例:config/districts/anhui/manifest.json.jsconfig/districts/xinjiang/pages.js 等,按需添加。


3.7 业务代码中如何使用

构建时环境变量已被注入,业务中直接读 process.env 即可。

按平台分支:

// 例如仅微信小程序展示某模块
if (process.env.UNI_PLATFORM === 'mp-weixin') {
  // 微信逻辑
}

// 计算属性中
moreModuleGroups: vm => process.env.UNI_PLATFORM === 'mp-weixin'
  ? xxxx
  : xxxx

按地区使用配置:

// 使用别名 @district,实际指向 src/district/${DISTRICT}
import banner from '@district/banner.jpg'

// 或直接使用注入的常量
const district = process.env.DISTRICT
const apiBase = process.env.API_BASE_URL

请求 API:

// 封装请求时用 process.env.API_BASE_URL 作为 baseURL
axios.create({ baseURL: process.env.API_BASE_URL })

四、目录与脚本约定小结

角色 路径/命令 作用
环境变量注入 cross-env UNI_PLATFORM=... DISTRICT=... DEPLOY=... 在 npm script 中传入维度
映射配置 config/preBuild.config.js 平台/地区/环境 → 客户端类型、标题、API
构建前逻辑 build/preBuild.js 设置 CLIENT_TYPE、API_BASE_URL 等并生成 pages/manifest
Webpack 入口 vue.config.js require preBuild、配置 @district、EnvironmentPlugin
动态页面配置 build/pages.json.js 合并基础 + 地区 pages,写回 src/pages.json
动态 manifest build/manifest.json.js 合并基础 + 地区 manifest,写回 src/manifest.json
地区前端配置 src/district/<DISTRICT>/config.js 业务通过 @district 引用
地区构建配置 config/districts/<DISTRICT>/pages.jsmanifest.json.js 仅该地区生效的页面与 manifest

五、新增地区/环境/平台时的操作清单

  1. 新增地区

    • config/preBuild.config.jsDISTRICT_CONFIG_MAP 中增加 APP_TITLE 等。
    • 如需差异化页面:在 config/districts/<新地区>/ 下增加 pages.js
    • 如需差异化 manifest:在 config/districts/<新地区>/ 下增加 manifest.json.js
    • src/district/ 下新增同名目录及 config.js(可参考现有地区)。
  2. 新增部署环境

    • config/preBuild.config.jsAPI_BASE_URL_MAP 中增加 DEPLOY → API 地址。
    • package.json 的 scripts 中增加对应 dev/build:平台:地区:环境 命令。
  3. 新增平台

    • CLIENT_TYPE_MAP 中增加平台与客户端类型。
    • 若 uniapp 支持该平台,只需在 script 中增加 UNI_PLATFORM=新平台 的 dev/build 脚本即可。

六、注意事项

  • EnvironmentPlugin 与 preBuild 一致:凡在 preBuild.js 里新加的 process.env.XXX,若要在业务代码中使用,需在 vue.config.jsEnvironmentPlugin 数组中增加 'XXX'
  • DISTRICT / DEPLOY 默认值:preBuild 中使用了 process.env.DISTRICT || 'xuzhou'process.env.DEPLOY || 'dev',未传时会有默认地区与环境,可按需修改。
  • 跨平台兼容:使用 cross-env 可避免在 Windows 与 Mac/Linux 下环境变量写法不一致的问题。

按上述方案,即可用「地区 + 平台 + 环境」三维度通过一条命令完成打包,并实现配置集中、扩展清晰,欢迎大家学习指正!

昨天以前首页

100s 带你了解 Bun 为什么这么火

作者 冴羽
2026年2月26日 17:14

1995 年, JavaScript 诞生,主要用于广告弹窗。

2009 年,Node.js 诞生,JS 可以写后端了。

然而这是罪恶的开始,之后 JS 发展出了世界上最复杂的工具链。

于是写一个 Web 项目,你需要 Node.js 作为运行环境,Npm 作为包管理器,Webpack 作为打包工具,Jest 作为测试,还要用 Babel 转译,还要写一大堆没人看懂的配置文件。

这样的痛苦想必你已经体会到了。

2021 年,Bun 说:“为什么不能在运行时就完成所有得事情呢?”

于是它火了。

Bun 是什么?

Bun 本质上是一个 JavaScript 运行时,类似于 Node.js,但极其注重性能。

为了实现高性能,Bun 的核心策略是将:

  1. Node.js 的 C++ 替换成 Zig

  2. Node.js 的 V8 引擎替换成 Safari 使用的 JavaScript Core

这确实让 Bun 取得了不错的性能测试成绩。

image.png

但 Bun 真正革命性的地方在于它不仅仅是一个运行时。

它取代了你的打包工具,于是你可以直接写 TypeScript 或 JavaScript,而不用做任何配置。

它取代了你的测试框架和包管理器,甚至内置数据库驱动程序,同时又保持了与 Node.js 生态的兼容性。

从此以后,你只用一个工具就可以完成所有任务。

当然直接说还是有些抽象,我们直接看代码吧。

Bun 的使用

安装 Bun:

curl -fsSL https://bun.sh/install | bash

创建新项目:

bun init

现在你已经可以编写 TypeScript 代码了。

现在我们搭建一个 Web 服务器,不需要 express,只需要:

const server = Bun.serve({
  port: 3000,
  routes: {
    "/": () => new Response("Bun!"),
  },
});

console.log(`Listening on ${server.url}`);

运行 bun run index.ts 你就可以直接看到效果。

如果你想操作数据库,直接写:

import { Database } from "bun:sqlite";
const db = new Database("./app.sqlite");

如果你想使用 Redis,直接写:

import { redis } from "bun";

// 设置 Key
await redis.set("greeting", "Hello from Bun!");

// 读取数据
const cachedDate = await redis.exists("greeting");

如果你需要安装包,直接运行:

# 安装速度比 npm 快 25 倍
bun install

如果你想写测试,直接写:

// 内置测试工具
import { test, expect } from "bun:test";

test("2 + 2 = 4", () => {
  expect(2 + 2).toBe(4);
});

为什么要关注 Bun?

Bun 本身其实已经很火了。

2025 年底,Anthropic 收购 Bun,更是为 Bun 的发展添了一把柴。

Bun 现在已经普遍被用于 Claude Code 等工具、云平台上的 Serverless Functions 等,这预示着它正在成为 JavaScript 生态系统中的重要力量。

所以如果你正在学 JavaScript,或者想尝试新工具,Bun 值得一看。

即使现在不用,了解这个“未来趋势”也会让你对前端生态有更深的理解。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

一文吃透 Nestjs 动态模块之 register、forRoot、forFeature

作者 Mr_li
2026年2月25日 17:33

读完此文,你能更准确的理解动态模块以及清楚register、forRoot、forFeature三者作用,何时使用它们

什么是动态模块

Dynamic modules 官方文档(英文)

Dynamic modules 官方文档(中文镜像)

在 Nest 里,普通(静态)模块可以理解为“写死的一份模块定义”,你只能在 imports: [] 里直接引入模块类;而动态模块允许你在“导入模块时传参”,由模块的静态方法(如 register / forRoot / forFeature返回一个 DynamicModule 对象,Nest 会把这个对象当成模块元数据来编译,从而按你的参数动态生成 providers / exports / imports 等配置。

一句话总结:动态模块 = 可传参的模块工厂,返回 DynamicModule 来决定模块最终提供什么能力。

动态模块作用

动态模块的核心价值是:把“可配置性”变成模块 API 的一部分。也就是说,你不再只能 imports: [SomeModule] 这样“死引入”,而是可以通过 SomeModule.forRoot(...) / forFeature(...) / register(...) 传入参数,让模块在“被导入时”就完成:

  • 根据不同环境/业务场景生成不同的 Provider(例如不同的连接串、不同的开关、不同的策略实现)
  • 导出一组“已经配置好”的能力(让使用方只管注入,不用关心怎么组装)
  • 把模块的配置约束收口到一个入口(避免到处散落 process.env 或重复 new 客户端)

从官方定义看,动态模块本质上就是:一个模块提供一个静态方法,返回 DynamicModule 对象(包含 module / imports / providers / exports / global 等元数据),Nest 会把它当成“模块定义”来编译。

你可以把动态模块理解为:“返回模块定义的工厂函数”,只不过它被约定写成模块类上的静态方法。


什么时候适合用动态模块

动态模块并不是“写 Nest 就必须用”的东西,通常在下面这些场景才值得上:

  • 需要配置且配置会变:例如 JWT、缓存、HTTP 客户端、消息队列、数据库连接等。
  • 需要多实例:同一种能力要按不同名字/用途创建多个实例(例如两个 Redis、两个第三方 API client)。
  • 需要隐藏复杂装配:使用方只想 imports: [...],不想了解内部 provider 如何拼装、如何选择实现。
  • 希望统一约束与默认值:集中处理 option 校验、默认值合并、token 命名、导出策略等。

不适合的情况:

  • 没有任何配置差异,普通静态模块就够了。
  • 配置只在单个业务模块里用且很简单,直接在该模块里写 providers 可能更清晰。

register、forRoot、forFeature:它们是什么关系

先说结论:它们不是语法关键字,只是 Nest 生态里长期形成的命名约定,目的是让人一眼看懂“这个动态模块方法在语义上做什么”。

  • register(...):更通用的命名,表示“注册/配置一次模块”。常见于需要传入 options 的模块(例如 ClientsModule.register(...)JwtModule.register(...)MulterModule.register(...))。
  • forRoot(...):强调“根级别(应用级/全局级)配置”,通常只需要做一次,影响整个应用的默认行为或单例资源(例如 ConfigModule.forRoot(...)TypeOrmModule.forRoot(...))。
  • forFeature(...):强调“按功能域(feature)扩展/注册一小部分能力”,往往会被多次调用,每个业务模块各取所需(例如 TypeOrmModule.forFeature([Entity...])MongooseModule.forFeature([{ name, schema }...]))。

一个很好理解的类比是:

  • forRoot:建“基础设施”(连接、全局配置、默认客户端)
  • forFeature:在某个业务域里“挂载资源”(实体仓库、某些模型、某些订阅)
  • register:没有明确 root/feature 分层时的“通用注册入口”

使用方法(从 DynamicModule 结构看懂一切)

动态模块方法最终都要返回一个 DynamicModule 对象(官方文档的核心点之一)。你需要理解这些字段各自解决什么问题:

  • module:必须指向当前模块类本身(Nest 用它做标识与元数据合并)。
  • imports:该动态模块额外依赖的模块(例如需要先导入 ConfigModule 才能注入 ConfigService)。
  • providers:根据 options 生成/选择出来的 provider(通常包含 options provider、核心服务、工厂 provider 等)。
  • exports:允许外部模块使用的 provider(不导出就无法在外部注入)。
  • global(可选):设为 true 后,该模块导出的 provider 在整个应用可见(减少重复 imports,但要谨慎使用)。

下面用“伪代码”把三类方法串起来看。


register(...):通用注册入口

概念

register 通常用于:模块需要 options 才能工作,并且这个模块既可能全局用一次,也可能按需在少数地方导入,但作者不想强行区分 root/feature。

作用

  • 把调用方传入的 options 固化为一个可注入的 provider(常用做法是 useValue
  • 用这些 options 组装出真正的客户端/服务 provider(常用做法是 useFactory
  • 决定导出哪些 token 给外部模块使用

伪代码示例

// 伪代码:不依赖具体业务库,展示结构与思路
type FooModuleOptions = { baseUrl: string; timeoutMs?: number };

const FOO_OPTIONS = Symbol('FOO_OPTIONS');
const FOO_CLIENT = Symbol('FOO_CLIENT');

@Module({})
export class FooModule {
  static register(options: FooModuleOptions): DynamicModule {
    const optionsProvider = {
      provide: FOO_OPTIONS,
      useValue: { timeoutMs: 3000, ...options },
    };

    const clientProvider = {
      provide: FOO_CLIENT,
      useFactory: (opts: FooModuleOptions) => {
        // 这里可以 new 一个 HTTP client / SDK client
        return createFooClient(opts.baseUrl, opts.timeoutMs);
      },
      inject: [FOO_OPTIONS],
    };

    return {
      module: FooModule,
      providers: [optionsProvider, clientProvider],
      exports: [clientProvider],
    };
  }
}

何时使用

  • 你在写一个可复用模块:需要 options,但不想强制区分“全局/feature”两套 API。
  • 你希望调用方语义简单:FooModule.register({ ... }) 一眼看懂“我在配置这个模块”。

forRoot(...):应用级(根级别)初始化

概念

forRoot 的关键词是“根”。它通常承担两类职责:

  • 初始化一次:创建单例连接/单例客户端/全局默认配置。
  • 定义默认行为:例如全局中间件、全局拦截器/管道依赖的配置,或某个模块的“默认实例”。

在 Nest 的常见用法里:你会在 AppModule(或根模块)里调用 forRoot,其他业务模块不再重复调用,而是通过注入来使用它导出的 provider。

作用

  • 建立“全局共享的底座”:连接池、客户端单例、全局配置 provider 等。
  • 明确生命周期:避免每个 feature 模块都 new 一个连接或重复注册同一份全局配置。

伪代码示例

type FooRootOptions = { url: string };
const FOO_CONNECTION = Symbol('FOO_CONNECTION');

@Module({})
export class FooModule {
  static forRoot(options: FooRootOptions): DynamicModule {
    const connectionProvider = {
      provide: FOO_CONNECTION,
      useFactory: async () => {
        // 连接通常是 async 初始化
        return await connectFoo(options.url);
      },
    };

    return {
      module: FooModule,
      providers: [connectionProvider],
      exports: [connectionProvider],
      // 可选:如果你希望全局可见(谨慎)
      // global: true,
    };
  }
}

// AppModule 里只做一次 root 初始化
@Module({
  imports: [FooModule.forRoot({ url: '...' })],
})
export class AppModule {}

何时适合用 forRoot

  • 需要“只初始化一次”的资源:数据库连接、MQ 连接、缓存连接、全局配置加载等。
  • 你希望模块 API 语义明确:forRoot 让读代码的人直接知道“这是应用级初始化”。

forFeature(...):按业务域挂载/扩展能力

概念

forFeature 的关键词是“feature”。它解决的问题通常是:某个模块已经通过 forRoot 建好了底座,但不同业务模块只需要其中一部分资源,或者需要在该模块下再注册一批与业务相关的 provider。

经典例子(官方生态里最常见的理解方式):

  • ORM/ODM 模块在 root 初始化连接后,feature 模块再声明“我需要这些实体/模型”,框架据此生成仓库/模型 provider 并导出给当前业务模块使用。

作用

  • 把“业务域的声明”放在业务模块里:可读性强、边界清晰。
  • 支持多次调用:每个业务模块可以传不同的 feature 元数据。
  • 避免全量导出:只为当前 feature 生成它需要的 providers。

伪代码示例

type FooFeature = { name: string };
const fooFeatureToken = (name: string) => `FOO_FEATURE_${name}`;

@Module({})
export class FooModule {
  static forRoot(options: { url: string }): DynamicModule {
    // 省略:创建连接并导出
    return { module: FooModule, providers: [...], exports: [...] };
  }

  static forFeature(features: FooFeature[]): DynamicModule {
    const featureProviders = features.map((f) => ({
      provide: fooFeatureToken(f.name),
      useFactory: (conn: unknown) => {
        // conn 来自 forRoot 导出的连接 token
        return connCreateFeatureHandle(conn, f.name);
      },
      inject: [/* FOO_CONNECTION */],
    }));

    return {
      module: FooModule,
      providers: featureProviders,
      exports: featureProviders,
    };
  }
}

// 某个业务模块按需声明它要哪些 feature
@Module({
  imports: [FooModule.forFeature([{ name: 'User' }, { name: 'Order' }])],
})
export class UserDomainModule {}

何时适合用 forFeature

  • 你已经有一个“root 级底座”,但需要在不同业务模块里分别声明不同资源集合。
  • 你希望业务模块的依赖可读:打开模块文件就能看到它依赖了哪些实体/模型/功能片段。

三者在真实项目里的组合方式(推荐理解)

常见的组合模式是:

  • 基础设施模块XxxModule.forRoot(...)(只在根模块调用一次)
  • 业务域模块XxxModule.forFeature(...)(每个业务域各自声明所需)
  • 不分层或轻量模块XxxModule.register(...)(直接配置即可用)

如果你在某个三方库里同时看到 registerforRoot/forFeature

  • 通常意味着作者提供了多种入口,方便不同使用习惯;
  • 但底层本质仍然是返回 DynamicModule,差异更多在“语义分层”和“推荐调用位置”。

注意事项(容易踩坑但官方语义允许你避免)

  • 不要把 forRoot 到处调用:如果它创建的是连接/单例资源,多次调用往往意味着多份实例(开销大、难排查)。更稳妥的模式是 root 初始化一次,feature 按需挂载。
  • 导出策略要克制exports 只导出真正需要给外部用的 provider。导出太多会让依赖边界变模糊,也会增加误用概率。
  • token 设计要稳定:options provider、客户端 provider、feature provider 的 token 一旦对外暴露,后续变更会影响大量模块。推荐用常量/Symbol/统一工厂函数生成 token,避免字符串散落。
  • global: true 谨慎使用:全局模块能减少 imports,但也会让依赖变“隐式”。团队协作里,显式 imports 往往更可维护。
  • 考虑异步配置:如果 options 依赖配置中心/远程拉取/ConfigService,一般会需要 registerAsync / forRootAsync 这一类异步变体(很多官方生态模块也提供同名 Async 方法)。

小结

动态模块的本质是:用一个静态方法返回 DynamicModule,把“模块如何被配置、生成哪些 provider、导出哪些能力”收敛为一个清晰入口register / forRoot / forFeature 是社区约定的命名语义:

  • forRoot:做应用级初始化(通常一次),建立底座与默认能力
  • forFeature:按业务域扩展/声明所需资源(可多次),只生成当前 feature 需要的 providers
  • register:通用注册入口(语义不分层),把 options 转成可注入能力即可用

掌握这三者,你读三方模块源码时会更快看懂“哪里初始化一次、哪里按需扩展、哪些 provider 会被导出”,写自己的可复用模块时也能把配置与依赖边界做得更清楚。

Nestjs 中 Provider 的注入方式扫盲,解决你的选择困难症

作者 Mr_li
2026年2月25日 16:36

Providers 是 Nest 中的一个核心概念。许多基础的 Nest 类,如服务、仓库、工厂和辅助工具,都可以被视为提供者。提供者的核心思想是它可以作为依赖被注入,从而允许对象之间形成各种关系。“连接”这些对象的责任在很大程度上由 Nest 运行时系统处理。

但是 Providers 的注入方式有很多种,对此不了解的同学在开发中遇到时,可能难以选择该用哪一种方式,这篇文章就针对这一点做一个详细的阐述


0. 先把话说清楚:你纠结的其实是两件事

在 Nest 里,“我想用一个 Provider”通常包含两步:

  • 注册(registration):把某个 token 和“怎么得到这个值/实例”的规则,交给 Nest 的 IoC 容器管理(通常写在 @Module({ providers: [...] }) 里)。
  • 注入(injection):在需要它的地方声明依赖,让 Nest 在创建类实例时把它“塞进来”(最常见是构造函数注入)。

另外要记住一个关键词:token

  • 最常用的 token:类本身(例如 CatsService)。
  • 也可以用:字符串、Symbol、TypeScript enum(官方明确提到可以用这些)。
  • 不建议/不能直接用:TypeScript interface(运行时不存在,容器没法拿它当 token 匹配)。

接下来所有“方式”,本质都是围绕 token 在做文章:要么变更“这个 token 对应哪个实现/值”,要么变更“这个实例的创建时机与生命周期”。


1. 方式一(默认首选):按类名 token 的构造器注入(Standard provider)

何时使用

  • 绝大多数业务场景的默认选择:Service / Repository / Helper 这类“可复用、可测试”的逻辑单元。
  • 当你不需要动态切换实现、不需要注入常量/第三方实例时,用它最省心。

典型场景

  • Controller 调 Service,Service 调 Repository。
  • 业务逻辑都在 class 里,依赖关系清晰。

注意事项

  • 别忘了注册:类写了 @Injectable() 只是“允许被容器管理”,但你仍要把它放进某个模块的 providers(或被某个模块导入后可见)。
  • 跨模块要 export/import:Provider 默认只在声明它的模块内部可见;要给别的模块用,需要 exports

伪代码

// cats.module.ts
@Module({
  providers: [CatsService], // 这是最常见的“短写”
  exports: [CatsService],   // 需要给别的模块用就导出
})
class CatsModule {}

// cats.controller.ts
@Controller('cats')
class CatsController {
  constructor(private readonly catsService: CatsService) {}
}

小知识:providers: [CatsService] 其实是下面这种“长写”的语法糖:{ provide: CatsService, useClass: CatsService }。理解这个等价关系,会让你更容易看懂后面的自定义 Provider。


2. 方式二:自定义 token + @Inject(token) 注入(字符串 / Symbol / enum)

何时使用

  • 你要注入的东西不是一个 class:常量、配置对象、第三方库实例(DB 连接、Redis client、SDK)。
  • 你想用一个“抽象 token”来隔离实现:例如用 CONFIG/CONNECTION 这类 token,让依赖方不直接 import 具体实现文件。

典型场景

  • 数据库连接、消息队列 client、第三方 SDK 实例。
  • 为了避免“魔法字符串”到处飘,集中管理 token。

注意事项

  • 尽量别直接散落字符串 token:官方建议把 token 放到独立文件(如 constants.ts)统一导出,避免冲突和拼写错误。
  • 更推荐 Symbol:字符串容易撞名;Symbol('CONNECTION') 更不容易冲突。

伪代码

// constants.ts
export const CONNECTION = Symbol('CONNECTION');

// db.module.ts
@Module({
  providers: [
    { provide: CONNECTION, useValue: connectionInstance },
  ],
  exports: [CONNECTION],
})
class DbModule {}

// cats.repository.ts
@Injectable()
class CatsRepository {
  constructor(@Inject(CONNECTION) private readonly conn: Connection) {}
}

3. 方式三:useValue(值提供者 Value provider)

何时使用

  • 注入常量值、配置对象、已经创建好的实例。
  • 测试/本地调试时,用 mock 替换真实实现(官方也拿它举例)。

典型场景

  • useValue: mockService 做单元测试替身。
  • 注入某个第三方库的“现成对象”(例如 logger、连接句柄)。

注意事项

  • useValue 直接把一个值交给容器:不会由 Nest new,也不会帮你管理它的内部依赖。
  • 如果你用它替换一个 class provider,确保这个值的“形状”能满足调用方需要(在 TS 里通常靠结构化类型兼容)。

伪代码

const mockCatsService = { findAll: () => [] };

@Module({
  providers: [
    { provide: CatsService, useValue: mockCatsService },
  ],
})
class TestModule {}

4. 方式四:useClass(类提供者 Class provider)

何时使用

  • 你想让一个 token 在不同环境/条件下解析到不同的实现类
  • 例如开发环境用 DevConfigService,生产环境用 ProdConfigService

典型场景

  • 多套实现按环境切换(dev/prod)。
  • 同一抽象能力的多实现(例如不同供应商的短信服务)。

注意事项

  • 依赖方注入的是 token(通常是一个“抽象入口”),不要在依赖方写 if/else 去挑实现,把选择逻辑放在 provider 注册处。

伪代码

const configProvider = {
  provide: ConfigService,
  useClass: isDev ? DevConfigService : ProdConfigService,
};

@Module({ providers: [configProvider] })
class AppModule {}

5. 方式五:useFactory(工厂提供者 Factory provider)

何时使用

  • 你需要“动态创建”一个实例:创建过程要读配置、组合参数、甚至依赖别的 Provider。
  • 你需要“异步初始化”后才允许系统启动(比如先连上数据库再接请求)。

典型场景

  • DB 连接创建、缓存 client 创建、按配置生成 SDK 实例。
  • 一部分依赖可选:没有就用默认行为。

注意事项

  • inject 数组的顺序要和工厂函数参数一一对应(官方明确说明会按顺序传参)。
  • inject 里可以声明可选依赖:{ token: XXX, optional: true },工厂函数就要能处理 undefined
  • 异步 provider:工厂返回 Promise 时,Nest 会等待它 resolve 后,才会实例化依赖它的类(官方在“Async providers”章节强调这一点)。

伪代码(同步 + 可选依赖)

const connectionProvider = {
  provide: CONNECTION,
  useFactory: (options: OptionsProvider, maybePrefix?: string) => {
    const opts = options.get();
    return new DatabaseConnection({ ...opts, prefix: maybePrefix });
  },
  inject: [
    OptionsProvider,
    { token: 'SOME_OPTIONAL', optional: true },
  ],
};

伪代码(异步初始化)

const asyncConnectionProvider = {
  provide: 'ASYNC_CONNECTION',
  useFactory: async () => {
    const conn = await createConnection(options);
    return conn;
  },
};

注入时和普通 provider 一样,只是 token 不同:

constructor(@Inject('ASYNC_CONNECTION') conn: Connection) {}

6. 方式六:useExisting(别名提供者 Alias provider)

何时使用

  • 你想让两个 token 指向同一个 Provider 实例(官方称之为 alias)。
  • 常见于迁移期:旧代码用旧 token,新代码用新 token,但底层实现先共用一份。

典型场景

  • 日志服务从 LoggerService 迁到 'LOGGER',但一段时间内两种写法都得兼容。

注意事项

  • useExisting 不是创建新实例,而是“多一个入口指向同一个实例”。
  • 在默认单例(DEFAULT)下,两边拿到的是同一对象;如果你用了请求级/瞬态作用域,要更小心理解生命周期(见后文“作用域”)。

伪代码

const loggerAliasProvider = {
  provide: 'AliasedLoggerService',
  useExisting: LoggerService,
};

@Module({ providers: [LoggerService, loggerAliasProvider] })
class AppModule {}

7. 跨模块使用:导出(export)自定义 Provider

何时使用

  • 你的 Provider 定义在 DbModuleConfigModule 里,但别的模块要注入它。

典型场景

  • DbModule 里创建连接 provider,在 UserModule / OrderModule 注入使用。

注意事项

  • 自定义 Provider 默认只在本模块可见,要给别人用必须导出。
  • 官方给了两种导出方式:
    • exports: [TOKEN](导出 token)
    • exports: [providerObject](导出整个 provider 定义)

伪代码

const connectionFactory = { provide: 'CONNECTION', useFactory: ..., inject: [...] };

@Module({
  providers: [connectionFactory],
  exports: ['CONNECTION'], // 或 exports: [connectionFactory]
})
class DbModule {}

8. “可选依赖”到底怎么写?

官方文档里最直接、最可控的一种可选依赖写法,是在 useFactoryinject 里声明 optional: true

inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }]

这会让工厂函数对应参数可能为 undefined使用场景通常是“有则增强、无则降级”的依赖,比如可选的前缀、可选的扩展配置、可选的监控上报器等。

注意事项很朴素:你必须把 undefined 当成合法输入处理掉,否则等同于把问题从“容器解析阶段”推迟到“运行时崩溃阶段”。


9. 属性注入(Property-based injection)要不要用?

Nest 支持用 @Inject(token) 在属性上注入,但官方长期更强调构造器注入这条主路径。实际工程里,一般建议把属性注入当成“应急方案”:

何时使用

  • 你在做一些元编程/基类封装,构造器签名不方便改动。
  • 你非常明确这不会让依赖关系变得隐蔽(例如只在框架层封装里用)。

不太建议的原因

  • 依赖不在构造器里显式声明,阅读类定义时更难一眼看出“需要哪些东西”。
  • 测试替换与重构成本更高,容易留下隐性依赖。

伪代码

@Injectable()
class CatsRepository {
  @Inject(CONNECTION)
  private readonly conn: Connection;
}

10. 循环依赖:forwardRef()ModuleRef 的取舍

循环依赖指 A 依赖 B、B 也依赖 A。Nest 官方给了两条路:

方式 A:forwardRef()(最常用)

何时使用

  • 两个 Provider 真的是互相需要,而且短期内不好拆。

注意事项(官方强调的坑)

  • 实例化顺序不确定,代码不要依赖“谁先构造”。
  • 如果循环依赖链上出现 Scope.REQUEST 的 provider,可能导致依赖变成 undefined(官方给了明确 warning)。
  • 还有一种“看似 DI 的循环依赖”,其实是 barrel file(index.ts 聚合导出)导致的 import 循环;官方建议在模块/Provider 类上尽量避免 barrel file。

伪代码:

@Injectable()
class AService {
  constructor(@Inject(forwardRef(() => BService)) private b: BService) {}
}

@Injectable()
class BService {
  constructor(@Inject(forwardRef(() => AService)) private a: AService) {}
}

模块之间循环 import 也同理:

@Module({ imports: [forwardRef(() => BModule)] })
class AModule {}

@Module({ imports: [forwardRef(() => AModule)] })
class BModule {}

方式 B:ModuleRef(重构友好)

何时使用

  • 你想把循环依赖“断开一边”,让其中一方在运行时按需从容器取实例(而不是在构造器里硬绑死)。

注意事项

  • 这通常意味着你在改设计:把“必须在构造器里就拿到依赖”变成“需要时再取”,要保证调用路径上能接受这种变化。

11. 作用域(Injection scopes):默认单例、请求级、瞬态

Nest 官方把 Provider 生命周期分为三类:

  • DEFAULT(默认):全局单例,应用生命周期内共享一份实例。官方也明确说:大多数场景推荐单例
  • REQUEST:每个请求一份实例,请求结束后释放。适合“按请求隔离状态”的边界场景。
  • TRANSIENT:每个注入点(每个消费者)都会拿到一份新实例。

何时使用 REQUEST

  • GraphQL 的按请求缓存、请求链路追踪、多租户(根据请求头选择租户上下文)等官方列出的典型例子。

注意事项

  • 性能影响:请求级 provider 会让 DI 子树在每个请求都创建实例,官方建议除非必须,否则优先单例。
  • 作用域会沿依赖链“向上冒泡”:Controller 依赖了 request-scoped provider,那么 Controller 自己也会变成 request-scoped。
  • WebSocket Gateway 不应使用 request-scoped:官方明确指出它们必须是单例;Passport strategy、Cron 等也有类似限制。

伪代码

@Injectable({ scope: Scope.REQUEST })
class RequestCacheService {}

// 或者在自定义 provider 上设置 scope
{ provide: 'CACHE_MANAGER', useClass: CacheManager, scope: Scope.TRANSIENT }

小结

  • 能用构造器注入 + 类 token 就别复杂化providers: [MyService] + constructor(private my: MyService) 是默认正确答案。
  • 要注入“不是 class 的东西”:用自定义 token(优先 Symbol)+ @Inject(token)
  • 要替换实现 / mock / 常量useValue
  • 要按环境/条件切换实现useClass
  • 要动态创建/组合依赖/异步初始化useFactory(需要 async 就直接返回 Promise)。
  • 要做兼容/迁移/多入口同实例useExisting
  • 遇到循环依赖:优先重构拆分;确实拆不开再用 forwardRef(),并避开 request-scoped 组合的坑。
  • 作用域:默认单例最香;REQUEST/TRANSIENT 是为边界问题准备的“手术刀”,别当“菜刀”乱用。

参考(官方文档)

一次讲透 NestJS 里“绑定”(全局 vs 局部)

作者 Mr_li
2026年2月25日 15:53

你在 NestJS 里看到的 @UseGuards()@UsePipes()app.useGlobalInterceptors() 这些,本质上都在做一件事:

  • 把一段“横切逻辑”挂到请求处理链上
    比如:鉴权、参数校验、日志、统一返回体、统一异常格式……

这篇就用“人话”把三个问题讲清楚:

  • NestJS 里可绑定的【元素】有哪些
  • 全局绑定 vs 局部绑定:作用与区别
  • 全局绑定的多种形式:各自原理/传参/差异/注意点,以及怎么选

本文所有结论都以 NestJS 官方文档为准(会在对应小节标注链接)。

目录

  1. NestJS 里能“绑定”的【元素】有哪些?
  2. 全局绑定 vs 局部绑定:作用与区别
  3. 全局绑定的多种形式:到底差在哪?
  4. 五类元素分别怎么绑、怎么传参、有哪些坑?
  5. 选型:什么时候用哪种绑定方式?
  6. 总结

1. NestJS 里能“绑定”的【元素】有哪些?

日常开发里,最常说的“绑定”,基本就这五类(也是官方文档重点讲的五条链路):

  • Middleware(中间件):在路由处理前跑的一段函数/类,能拿到 req/res/next
    参考:Middleware
  • Guard(守卫):决定“这次请求到底能不能进到 handler”。典型用来做鉴权/权限。
    参考:Guards
  • Pipe(管道):对入参做校验转换(字符串转数字、DTO 校验等),发生在方法调用前。
    参考:Pipes
  • Interceptor(拦截器):更像 AOP,能在 handler 前后插逻辑、改返回值、做缓存、把异常映射成别的异常等。
    参考:Interceptors
  • Exception Filter(异常过滤器):专门兜异常,统一格式、打日志、屏蔽敏感信息等。
    参考:Exception filters

如果你要一个“背诵版”的链路顺序,官方明确写过的一句是:

  • Guard 在所有 Middleware 之后执行,并且在任何 Interceptor 或 Pipe 之前执行
    参考:Guards - Hint

2. 全局绑定 vs 局部绑定:作用与区别(别背概念,直接按场景理解)

先把“范围”说清楚,后面选型才不容易绕晕。

  • 局部绑定(Local / Scoped):只影响“某个控制器 / 某个路由方法 / 某个参数”。
    典型写法:@UseGuards()@UsePipes()@UseInterceptors()@UseFilters(),以及 Pipe 还能绑到参数上。
  • 全局绑定(Global):影响“整个应用里所有 controller + 所有 route handler”。
    典型写法:app.useGlobalXxx(...)、模块 providers 里用 APP_XXX、Middleware 的 app.use(...) / forRoutes('*')

一句话区分:

  • 局部绑定:像“给某个接口/模块单独加一条规则”
  • 全局绑定:像“把规则写进公司制度,所有人默认都得遵守”

3. 全局绑定不止一种写法:到底差在哪?

这个是很多人纠结的核心:为什么全局还能写出两三种形式?我该用哪个?

3.1 main.tsapp.useGlobalXxx(new ...):直给、简单,但 DI 有坑

以 Pipe 为例,官方给过最直观的全局写法:

// main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());

参考:Pipes - Global scoped pipes

这种写法的本质是:你自己把实例 new 出来,挂到应用上

还有个容易被忽略的“覆盖范围”问题:在混合应用(HTTP + WS/微服务)里,useGlobalPipes() / useGlobalGuards() 默认不一定覆盖网关/微服务。官方在 pipes/guards 里都有提醒。
参考:

3.2 模块里用 APP_PIPE / APP_GUARD / APP_INTERCEPTOR / APP_FILTER:更“框架化”,DI 友好

官方给的“解决 DI 问题”的标准姿势,就是把它注册成 provider:

// app.module.ts(示例:全局 Pipe)
import { APP_PIPE } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_PIPE, useClass: ValidationPipe },
  ],
})
export class AppModule {}

参考:Pipes - Global scoped pipes(APP_PIPE)

Guard / Interceptor / Filter 的写法完全一样,只是 token 变了:

这种写法的本质是:交给 Nest DI 容器来创建实例

  • 优点:能注入依赖;更容易做可测试的设计;在复杂业务里更推荐
  • 注意:官方也强调——不管你在哪个 module 里写,它都是“真的全局”,建议放在“该类定义所在的 module”
    参考同上各章的 Hint(都提到了“choose the module where X is defined”)

3.3 装饰器里传“类” vs 传“new 出来的实例”:你其实是在选“谁来创建对象”

官方在多个章节都写过:装饰器里你可以传,也可以传实例

以 Guard 为例:

@UseGuards(RolesGuard)       // 传类:Nest 来实例化,可 DI
@UseGuards(new RolesGuard()) // 传实例:你来实例化,一般就别指望 DI 了

参考:Guards - Binding guards

Pipe/Interceptor/Filter 也是同理(官方都写了“pass class enables dependency injection / pass in-place instance for customization”那套逻辑)。

简单粗暴的结论:

  • 想要 DI:尽量传类(或用 APP_XXX
  • 想要按接口定制参数(比如某个 ParseIntPipe 想改 errorHttpStatusCode):就传 new Xxx(options)

3.4 Middleware 的全局绑定更“特别”:app.use() 很香,但它根本进不了 DI

官方对 middleware 的说明更直白:

  • app.use(logger) 能一次绑到所有路由,但无法访问 DI 容器
    参考:Middleware - Global middleware
  • 如果你需要 DI,就别用 app.use();改用 class middleware + .forRoutes('*')(它运行在 module 里,能注入)
    参考同上(官方也给了替代方案)

4. 逐个元素讲清楚:怎么绑、怎么传参、有哪些坑

下面每个元素我都给你:能绑在哪些层级 + 全局的几种写法 + 需要注意的点 + 伪代码

4.1 Middleware(中间件)

能绑在哪些层级

  • 模块/路由级consumer.apply(...).forRoutes(...)(最常用)
  • 全局app.use(...)(但不走 DI)

绑定伪代码

// 1) 模块内绑定(推荐:可 DI)
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('cats'); // 只绑 /cats

    consumer
      .apply(LoggerMiddleware)
      .forRoutes({ path: 'cats', method: RequestMethod.GET }); // 只绑 GET /cats

    consumer
      .apply(LoggerMiddleware)
      .exclude(
        { path: 'cats', method: RequestMethod.GET },
        'cats/{*splat}',
      )
      .forRoutes(CatsController); // 除了排除的,其他都绑
  }
}

// 2) 全局绑定(简单,但 DI 不可用)
const app = await NestFactory.create(AppModule);
app.use(logger); // logger 是 functional middleware

参考:Middleware - Applying middleware / Excluding routes / Global middleware

特别注意

  • 不调用 next() 请求会挂住(官方原话就是“request will be left hanging”)
    参考:Middleware
  • app.use() 的全局 middleware 拿不到 DI(要 DI 就用 .forRoutes('*') 那套)
    参考:Middleware - Global middleware
  • Express 与 Fastify 的 middleware 签名不一样(官方有 warning)
    参考同上:Middleware - Warning

4.2 Guard(守卫)

能绑在哪些层级

  • Controller 级@UseGuards() 写在类上
  • Method 级@UseGuards() 写在方法上
  • 全局app.useGlobalGuards(...)APP_GUARD

绑定伪代码

// 局部:controller 级
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

// 局部:method 级
@Post()
@UseGuards(RolesGuard)
create() {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());

// 全局:APP_GUARD(走 DI,推荐)
@Module({
  providers: [{ provide: APP_GUARD, useClass: RolesGuard }],
})
export class AppModule {}

参考:Guards - Binding guards

特别注意

  • 执行顺序:Guard 在 middleware 之后,在 interceptor/pipe 之前
    参考:Guards - Hint
  • 混合应用覆盖范围useGlobalGuards() 在 hybrid app 默认不覆盖网关/微服务(官方 Notice)
    参考:Guards - Binding guards
  • 全局 + DI:要 DI 就别在 main.tsnew,用 APP_GUARD
    参考同上(官方写得很明确)

4.3 Pipe(管道)

Pipe 这块“绑的层级”最多,也是最容易写出花的。

能绑在哪些层级

  • 参数级@Param('id', ParseIntPipe) / @Body(new ValidationPipe())
  • 方法级@UsePipes(...)
  • Controller 级@UsePipes(...) 写在类上
  • 全局useGlobalPipes()APP_PIPE

绑定伪代码

// 参数级:把 id 转成 number,不行就直接 400
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {}

// 参数级:定制 options,就 new 一个实例
@Get(':id')
findOne(
  @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
  id: number,
) {}

// 方法级:按接口传 schema(典型“每个接口一套校验规则”)
@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
create(@Body() dto: CreateCatDto) {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());

// 全局:APP_PIPE(走 DI,推荐)
@Module({
  providers: [{ provide: APP_PIPE, useClass: ValidationPipe }],
})
export class AppModule {}

参考:Pipes - Binding pipes / Global scoped pipes

特别注意

  • Pipe 抛异常会进入异常层处理(官方叫 exceptions zone),抛了异常 handler 就不会执行
    参考:Pipes - Hint
  • 混合应用覆盖范围useGlobalPipes() 在 hybrid app 下默认不覆盖网关/微服务(官方 Notice)
    参考:Pipes - Global scoped pipes
  • 全局 + DI:同样要用 APP_PIPE(官方直接点名)
    参考同上

4.4 Interceptor(拦截器)

能绑在哪些层级

  • Controller / Method 级@UseInterceptors(...)
  • 全局useGlobalInterceptors()APP_INTERCEPTOR

绑定伪代码

// 局部:controller 级
@UseInterceptors(LoggingInterceptor)
export class CatsController {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());

// 全局:APP_INTERCEPTOR(走 DI,推荐)
@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],
})
export class AppModule {}

参考:Interceptors - Binding interceptors

特别注意

4.5 Exception Filter(异常过滤器)

能绑在哪些层级

  • Method / Controller 级@UseFilters(...)
  • 全局useGlobalFilters()APP_FILTER

绑定伪代码

// 局部:method 级
@Post()
@UseFilters(HttpExceptionFilter) // 推荐传类,让 Nest 复用实例
create() {}

// 全局:main.ts(不走 DI)
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());

// 全局:APP_FILTER(走 DI,推荐)
@Module({
  providers: [{ provide: APP_FILTER, useClass: HttpExceptionFilter }],
})
export class AppModule {}

参考:Exception filters - Binding filters

特别注意


5. 到底怎么选?给你一套“能直接落地”的决策规则

你可以按这几个问题来选,基本不踩坑。

5.1 这段逻辑是不是“所有接口都必须有”?

  • (比如统一校验/统一返回体/统一异常格式/全局鉴权):倾向全局
  • 不是(只对某几个接口/某个模块生效):局部绑定,别污染全局

5.2 这段逻辑要不要注入 Service / Config / DB / Cache?

  • 要 DI
    • Guard/Pipe/Interceptor/Filter:优先用 APP_GUARD / APP_PIPE / APP_INTERCEPTOR / APP_FILTER
    • Middleware:优先用 class middleware + consumer.apply(...).forRoutes('*')
  • 不要 DI
    • 你就可以用 main.tsuseGlobalXxx(new ...)app.use(...),写起来最快

(这些 DI 限制和替代方案,官方都写在对应章节里了:
PipesGuardsInterceptorsException filtersMiddleware

5.3 你需要“每个接口参数不一样”吗?

  • 需要(比如某个 Pipe 要带不同 options、或者每个接口校验 schema 不一样):局部 new Xxx(options) 更合适
  • 不需要(全站统一同一套配置):全局注册一次,别每个方法都写一遍

5.4 你项目是不是 hybrid(HTTP + WS/微服务)?

如果是,别默认以为 useGlobalXxx() 就全覆盖。官方在 pipes/guards/filters 里都写了“hybrid app”的注意点,建议你在项目里明确验证一下覆盖范围:


6. 总结

  • 能绑定的核心元素就 5 个:Middleware、Guard、Pipe、Interceptor、Exception Filter。
  • 局部绑定解决“精准控制”:只对某个 controller / method / param 生效,最不容易“误伤”别的模块。
  • 全局绑定解决“统一规则”:所有接口默认生效,但你要对“DI 能不能用、hybrid 覆盖范围”保持敏感。
  • 全局绑定最重要的分水岭是 DI
    • main.tsuseGlobalXxx(new ...):快,但基本不走 DI
    • module 里的 APP_XXX:更工程化,DI 友好,复杂项目更推荐
    • middleware 的 app.use():最简单,但拿不到 DI;要 DI 就 .forRoutes('*')
  • 装饰器传“类”还是“实例”:你其实是在决定“让 Nest 创建对象(可 DI、可复用)”还是“自己 new(方便定制参数)”。

一文讲清 NestJS 中 IoC、DI、AOP、DTO、Entity 等名词

作者 Mr_li
2026年2月25日 15:18

上一篇《构建一个 NestJS 应用程序需要具备哪些基础元素?》里,我们把 Module / Controller / Provider / Guard / Pipe… 这些“组件角色”捋了一遍。

但很多人(包括我一开始)真正卡住的,其实是另一层:这些角色里面提到的名词到底表示啥?

这篇就专门把 NestJS 里常见的英文缩写/名词一次性讲清楚

先给一张速查表(用来对号入座)

缩写/名词 中文名 一句话记住 在 NestJS 里常落到哪
IoC (Inversion of Control) 控制反转 “对象怎么创建/怎么组合”交给框架 @nestjs/core 的容器/加载流程
DI (Dependency Injection) 依赖注入 “依赖不要自己 new,框架帮你注入” Provider 构造函数注入、@Inject()
AOP (Aspect-Oriented Programming) 面向切面编程 把日志/鉴权/校验这类横切逻辑“抽出去” Middleware / Guard / Pipe / Interceptor / Filter
DTO (Data Transfer Object) 数据传输对象 传输用的数据结构(尤其是入参) @Body() + DTO class + ValidationPipe
Entity 实体(实体类) 持久化模型(数据库表/集合的映射) TypeORM/Prisma/Mongoose 各自的实体/模型
ORM 对象关系映射 “对象 ↔ 表”映射(更广义:把数据库访问抽象成模型/接口) TypeORM / Prisma(Prisma 更像 ORM-like 的类型安全 DB Client)
ODM 对象文档映射 “对象 ↔ 文档”映射(Mongo) Mongoose
CRUD 增删改查 最常见的接口形态(创建/查询/更新/删除) Controller + Service + Repository
REST 表述性状态转移(常说 RESTful) 用“资源 + HTTP 方法”来组织 API Controller 的路由设计
HTTP 超文本传输协议 Web 接口最常见的传输方式 @Controller() / @Get() / @Post()
RPC 远程过程调用 更像“调用方法”,常见于服务间通信 @nestjs/microservices
RxJS 响应式扩展 响应式编程库(NestJS 部分链路会用到) Interceptor 里的 pipe(map(...))
Observable 可观察对象/流 RxJS 的核心类型(可订阅的异步流) CallHandler.handle() 返回值
JWT JSON Web Token(JSON Web 令牌) 常见的无状态 token 方案 Guard + Passport Strategy
RBAC 基于角色的访问控制 “角色 → 权限”的经典权限模型 Guard(配合装饰器/元数据)
CQRS 命令查询职责分离 复杂业务下的读写分离组织方式 @nestjs/cqrs(可选,不是必需品)
CLI 命令行接口(脚手架) 生成模板代码、统一项目结构 @nestjs/cli
CORS 跨域资源共享 浏览器跨域访问控制 app.enableCors()
CSRF 跨站请求伪造 利用 Cookie 自动携带发起伪造请求 结合鉴权方式 + 中间件/策略设计

IoC:Inversion of Control(控制反转)

是什么

在软件工程里,IoC 指把“控制权”(对象创建、生命周期管理、依赖装配、调用时机等)从业务代码转移给框架/容器来统一管理。
它被提出的目的,是降低耦合集中扩展点、让项目变大后依赖关系仍然可控(而不是到处 new、到处传参)。

一句话:对象的创建与组装不由你手写流程控制,而由框架在启动时统一完成。

适合干啥

  • 让项目变大后还能“有秩序地装配依赖”(否则满世界 import/new/传参)
  • 让模块边界更清晰:你用“声明”代替“到处调用”

何时使用

在 NestJS 里你基本“自动就在用 IoC”了,因为 NestJS 本身就是基于容器的框架。你需要做的更多是:别破坏它(比如绕过容器到处 new)。

原理(抓重点就行)

  • 启动时,NestJS 会扫描模块(Module)、收集 Provider、构建依赖图(谁依赖谁)
  • 需要实例化某个类时,由容器按依赖图“自底向上”创建并缓存(默认单例)

伪代码示例

// 你只声明“我需要什么”
@Injectable()
class UserService {
  constructor(private repo: UserRepo) {}
}

// 你只声明“这个模块包含哪些”
@Module({ providers: [UserRepo, UserService] })
class UserModule {}

// 剩下的“怎么创建 repo 再创建 service”,框架搞定(IoC)

DI:Dependency Injection(依赖注入)

是什么

DI 是 IoC 的一种常见实现方式,它强调“依赖从外部注入”,而不是在类内部主动创建依赖。
目的很直接:让代码更解耦、依赖更可替换(方便测试/替身实现)、也更容易在大型项目里统一管理。

一句话:把“依赖关系”从代码里的 new,变成声明式注入。

适合干啥

  • 让 service 更容易测试(可以替换依赖、注入 mock)
  • 避免强耦合(service 不需要知道 repo 如何创建)

何时使用

你写 NestJS 业务时,绝大多数依赖都推荐走 DI:

  • service 依赖 repo / http client / config / logger
  • guard 依赖 auth service
  • interceptor 依赖 cache service

原理

DI 的核心是“token → provider”的映射:

  • 最常见 token 就是 class 本身(UserService
  • 容器根据构造函数参数的类型/注入 token,找到对应 provider,注入实例

伪代码示例

@Injectable()
class UserService {
  constructor(private readonly repo: UserRepo) {}
}

// 自定义 token(比如注入第三方库实例)
const REDIS = Symbol('REDIS');

@Module({
  providers: [
    { provide: REDIS, useValue: /* redisClient */ {} },
    { provide: UserRepo, useClass: UserRepo },
  ],
})
class InfraModule {}

@Injectable()
class CacheService {
  constructor(@Inject(REDIS) private redis: any) {}
}

AOP:Aspect-Oriented Programming(面向切面)

是什么

AOP 是一种把“横切关注点”(logging、auth、validation、metrics 等)从核心业务逻辑中分离出来的思想,常见手段是拦截/代理,在函数执行前后“织入”通用逻辑。
提出它的目的,是减少重复代码、统一策略,让业务代码更专注在“做业务”。

一句话:把“到处都要做”的横切逻辑(日志/鉴权/校验/统一返回)从业务代码里抽出来。

适合干啥

  • 统一做日志、耗时统计、异常格式、权限校验
  • 让 controller/service 更“干净”,专注业务

何时使用(别上来就 AOP 过度)

当你发现同一段逻辑在 N 个接口里重复出现时,AOP 才开始值钱。
如果只是一个接口的特殊处理,直接写在 handler 里往往更直观。

原理(用 NestJS 的话来讲)

NestJS 不是“只有一种 AOP”,它把 AOP 拆成几类工具,各司其职:

  • Middleware:请求刚进门(路由前)
  • Guard:能不能进(鉴权/权限)
  • Pipe:入参校验/转换
  • Interceptor:前后包一层(统一返回/缓存/耗时)
  • Exception Filter:统一错误输出

它们共同点是:不改业务函数签名,也能在请求链路上插入逻辑。

伪代码示例

// 统一返回结构:{ code, data, traceId }
@Injectable()
class WrapResponseInterceptor implements NestInterceptor {
  intercept(ctx, next) {
    const traceId = /* get from request */ 'xxx';
    return next.handle().pipe(map(data => ({ code: 0, data, traceId })));
  }
}

// 鉴权:没 token 就不让进
@Injectable()
class AuthGuard implements CanActivate {
  canActivate(ctx) {
    const req = ctx.switchToHttp().getRequest();
    return Boolean(req.headers.authorization);
  }
}

DTO:Data Transfer Object(数据传输对象)

是什么

DTO 常用于分层架构/分布式系统的“边界处”,用来描述数据在层与层(或服务与服务)之间传输的结构。它强调“只承载数据”,不强调业务行为。
提出它的目的,是把外部输入/输出与内部模型隔离开:API 契约清晰不把内部 Entity/领域对象直接暴露出去

一句话:专门用来“接收/传输”的数据结构(尤其是“请求入参”)。

适合干啥

  • 把“接口入参长啥样”讲清楚(更容易维护)
  • 配合校验:让脏数据在进业务之前就被拦住

何时使用

只要是对外接口(HTTP/RPC/GraphQL)基本都建议用 DTO:

  • CreateUserDtoUpdateUserDtoQueryUserDto

原理(别纠结细节,抓住链路)

典型链路是:@Body() 拿到原始对象 → ValidationPipe 校验/转换 → 传入 controller 方法参数。
常见组合是 class-validator + class-transformer(你会在项目里看到它们)。

伪代码示例

class CreateUserDto {
  // @IsEmail()
  email: string;

  // @MinLength(8)
  password: string;
}

@Controller('users')
class UserController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
}

// main.ts 里常见开启方式
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

Entity:实体(通常指持久化实体)

是什么

在不同语境里 Entity 有两层含义:

  • 在 DDD 里,Entity 是“有持续身份(identity)的对象”,即便属性变了,它仍然是同一个实体
  • 在 ORM 里,Entity 往往指“表/集合的映射模型”(实体类/模型定义)
    提出它的目的,是用更结构化的方式表达持久化数据(字段/关系/约束),让数据访问与演进更可维护。

一句话:“数据库里的数据结构”在代码里的对应物
注意:Entity 本身不是 NestJS 独有,它更多来自你选的 ORM/ODM。

适合干啥

  • 在数据库层做结构化建模(表字段、索引、关系)
  • 让查询/写入更可维护(至少比字符串拼 SQL 好维护一些)

何时使用

取决于你选型:

  • 用 TypeORM:你会写 @Entity() 这类实体类
  • 用 Prisma:你写的是 schema.prisma 的模型(更像“模型定义 + 类型安全访问层”,不一定叫 Entity,但在项目里的角色类似)
  • 用 Mongoose:你会写 Schema/Model(更偏 ODM)

原理(大方向)

Entity/Model 提供“结构 + 映射 + 生命周期(可选)”,最终目标是:把 DB 操作封装成更可控的接口。

伪代码示例(以 TypeORM 思路举例)

@Entity('users')
class UserEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;
}

@Injectable()
class UserRepo {
  constructor(/* inject repository */) {}
}

ORM(对象关系映射)/ ODM(对象文档映射):到底差在哪?

ORM(Object-Relational Mapping,对象关系映射)

ORM 通过把“关系型数据库的表/行/关系”映射为代码里的对象/模型,让你用更高层的接口去读写数据。
提出它的目的,是减少手写 SQL 的重复劳动,让数据访问更结构化、更易演进(当然也会带来一定抽象成本)。

  • 面向关系型数据库(MySQL/PostgreSQL…)
  • 关键词:表、行、关联、事务

ODM(Object-Document Mapping,对象文档映射)

ODM 把“文档数据库的文档/集合/嵌套结构”映射为代码里的对象/模型,帮助你更一致地读写 MongoDB 等文档型存储。
目的类似 ORM:让数据结构和访问方式更可控,只是底层存储模型不同。

  • 面向文档型数据库(MongoDB…)
  • 关键词:文档、集合、嵌套结构

何时用哪一个

别纠结名词,按存储选型走:你用 MySQL/PG 就看 ORM,你用 Mongo 就看 ODM。


CRUD(增删改查)/ REST(表述性状态转移):为什么老看到它俩一起出现?

CRUD 是什么

CRUD 是持久化系统里最常见的一组基础操作集合(Create / Read / Update / Delete)。
提出它的目的,是用一套通用词汇把“增删改查”这类需求说清楚,方便分工、设计接口与评审。

一句话:Create / Read / Update / Delete,增删改查。

REST 是什么

REST(Representational State Transfer)最初是 Roy Fielding 在博士论文里提出的一种架构风格,用“资源 + 表述 + 约束”来组织系统交互。
它的目的,是让 API 的语义更统一、可被 HTTP 的缓存/状态码等机制更好地利用。

一句话:用“资源 + HTTP 方法”来组织 API(比如 /users/:id + GET/POST/PUT/DELETE)。

在 NestJS 里怎么体现

CRUD 更像“你要做的事”,REST 更像“你怎么设计接口”。
NestJS 的 Controller 很适合写 REST 风格的 CRUD(但你也可以写 RPC 风格,不强制)。

@Controller('users')
class UserController {
  @Post() create(@Body() dto) {}
  @Get(':id') findOne(@Param('id') id) {}
  @Put(':id') update(@Param('id') id, @Body() dto) {}
  @Delete(':id') remove(@Param('id') id) {}
}

JWT(JSON Web Token)/ RBAC(基于角色的访问控制):鉴权和权限常见两套缩写

JWT(JSON Web Token,JSON Web 令牌)

JWT 是一种开放标准(RFC 7519)定义的令牌格式,用 JSON 结构承载声明(claims),常见形式是 header.payload.signature
它被广泛使用的目的,是让身份信息在服务之间可传递、可验证(尤其适合前后端分离/多服务场景)。

一句话:一种无状态 token(常用来承载登录态/身份声明)。

什么时候用:前后端分离、移动端、需要跨服务验证身份时很常见。
什么时候别硬上:你如果只是内部小系统、单体应用,Session 也完全可以。

伪代码(NestJS 常见落点):

// 认证:验证 token 的逻辑一般放 Guard / Strategy
@UseGuards(JwtAuthGuard)
@Get('me')
me(@Req() req) {
  return req.user;
}

RBAC(Role-Based Access Control,基于角色的访问控制)

RBAC 是经典的访问控制模型,用“角色(Role)”作为权限分配与授权的中间层(用户 ↔ 角色 ↔ 权限)。
目的,是让权限管理更可维护:你通常给用户分配角色,而不是给每个用户逐条配置权限。

一句话:按角色控制权限(admin/user…)。

落点通常是 Guard + 装饰器元数据:

@SetMetadata('roles', ['admin'])
@UseGuards(AuthGuard, RolesGuard)
@Delete(':id')
remove() {}

CQRS:Command Query Responsibility Segregation(命令查询职责分离,常被简化成“读写分离”)

是什么

CQRS 提倡把“改变系统状态的命令(Command)”与“读取系统状态的查询(Query)”分离成不同的模型/路径。
提出它的目的,是在复杂业务里隔离读写关注点:写侧强调规则与一致性,读侧强调展示与查询效率(但也会增加架构复杂度)。

一句话:把“写操作(Command)”和“读操作(Query)”拆成不同模型/流程

适合干啥

  • 复杂业务、读写模型差异巨大时,能让代码更清晰
  • 事件驱动/审计需求强时更常见

何时使用

别上来就 CQRS。通常是项目复杂到:

  • service 越写越像一坨“超级函数”
  • 写操作需要强规则/强审计
  • 读操作需要高度定制的视图模型 再考虑引入(NestJS 有 @nestjs/cqrs 可选)。

HTTP(超文本传输协议)/ RPC(远程过程调用):请求进来到底是哪条路?

是什么

HTTP 是应用层网络协议,用于客户端与服务端之间的请求/响应通信;RPC 是一种远程调用模型,强调“像调用本地函数一样调用远端能力”。
把它们放在一起讲的目的,是让你明确 NestJS 不只做 HTTP:它既能做 Web API,也能做微服务通信。

  • HTTP:最常见的 Web 接口方式(REST/JSON 都算在这里面)
  • RPC:更像“调用一个方法”,不强制资源风格(常见于微服务通信)

适合干啥

  • 你做 BFF / Web API:基本就是 HTTP
  • 你做服务拆分、服务间通信:RPC(或者消息队列)会更常见

何时使用(说人话)

小中型系统,先把 HTTP 写顺就行;
只有当你确实遇到“服务间调用多、边界清晰、协议要统一”时,再去考虑 RPC/Microservices 那套。

原理(在 NestJS 里怎么体现)

  • HTTP:@Controller() + @Get()/@Post() 这一套装饰器,走的是平台适配层(Express/Fastify)
  • RPC:通常用 @nestjs/microservices,通过 transport(TCP/Redis/NATS/Kafka…)收发消息

伪代码示例

// HTTP(你已经很熟了)
@Controller('users')
class UserController {
  @Get(':id')
  findOne(@Param('id') id: string) {}
}

// RPC(示意):message pattern 触发一个 handler
// @MessagePattern({ cmd: 'user.findOne' })
// findOne(payload) {}

RxJS(响应式扩展)/ Observable(可观察对象):为啥 NestJS 老出现 rxjs?

是什么

RxJS 是基于 Observable 的响应式编程库,用来表达“时间维度上的数据流”;Observable 则是可订阅的异步流抽象。
它们出现的目的,是更自然地处理“流式、可组合、可取消/重试”的异步场景(NestJS 的部分执行链路也选择了它作为抽象)。

  • RxJS:响应式编程库
  • Observable:RxJS 的核心数据类型(可以理解成“可订阅的异步流”)

适合干啥

你不用为了 NestJS 去“强行学响应式编程”。它主要在两块很常见:

  • 拦截器/管道链路next.handle().pipe(map(...)) 这种写法(你在 Interceptor 里已经见过)
  • 流式/事件式场景:SSE、WebSocket、某些需要持续推送的接口

何时使用

  • 你只写普通 CRUD:Promise/async-await 够用,别硬上 Observable
  • 你需要“组合多个异步来源”“做流式处理/取消/重试”:Observable 就很香

原理(够用版)

NestJS 允许 controller 返回:

  • primitive / object(直接返回)
  • Promise<T>(等 promise resolve)
  • Observable<T>(内部订阅,取最终值/流)

Interceptor 里之所以经常用 RxJS,是因为 CallHandler.handle() 返回的就是 Observable。

伪代码示例

// 典型 interceptor:对返回值做 map
intercept(ctx, next) {
  return next.handle().pipe(
    map(data => ({ code: 0, data })),
  );
}

// controller 返回 Observable(示意)
@Get()
list() {
  // return from([1, 2, 3]);
}

CLI:Command Line Interface(命令行接口/脚手架)

是什么

CLI(Command Line Interface)是通过命令行与工具交互的一种方式;在 NestJS 语境下通常特指官方脚手架,用于生成模板代码与维护约定结构。
它的目的,是减少重复劳动、统一项目结构,让团队协作更顺。

一句话:帮你生成模板代码、少手敲一些重复文件

适合干啥

  • 新建 module/controller/service
  • 一键生成资源(CRUD 模板),并把文件结构按约定摆好

何时使用

你如果是团队项目,建议统一用 CLI 生成骨架,代码风格更一致;
个人练手也可以不用,但熟悉一下常用命令挺省事。

原理(不深究)

CLI 本质是代码生成器(schematics),按模板生成文件 + 更新模块引用。

伪代码示例(命令示意)

nest g module user
nest g controller user
nest g service user
# nest g resource user  # 想要 CRUD 模板时再用

CORS(跨域资源共享)/ CSRF(跨站请求伪造):Web 安全里最常被混淆的两个缩写

它们是什么

CORS 是浏览器同源策略下的一套“跨域放行机制”(通过响应头协商);CSRF 是一种利用浏览器自动携带 Cookie 的攻击方式。
把它俩放一起的目的,是提醒你:跨域(CORS)和伪造请求(CSRF)是两件事,不要混着处理。

  • CORS(Cross-Origin Resource Sharing):浏览器的跨域访问控制(“能不能从别的域来调用我”)
  • CSRF(Cross-Site Request Forgery):利用浏览器自动带 Cookie 的特性发起伪造请求(“我是不是被借刀了”)

适合干啥

  • CORS:前后端分离、不同域名端口开发时必须处理
  • CSRF:当你用 Cookie 维持登录态 且接口会产生副作用(转账/下单/改资料)时,需要重点考虑

何时使用(简化判断)

  • 你用 Authorization: Bearer <token>(JWT)这类 header 携带 token:CSRF 风险通常更低(但不代表“完全不用管安全”)
  • 你用 Cookie + Session:CSRF 基本要纳入设计(同站策略、token、双重提交等)

原理(够用版)

  • CORS:是浏览器限制,你服务器得返回合适的响应头
  • CSRF:是攻击方式,你得让“跨站伪造请求”失效

伪代码示例

// CORS:NestJS 常见开启方式(示意)
app.enableCors({
  origin: ['https://your-frontend.example'],
  credentials: true,
});

最后

如果你现在只想快速上手 NestJS,我建议按这个顺序消化:

  1. 先把 IoC / DI 吃透(这决定你后面写代码是不是舒服)
  2. 再把 AOP 在 NestJS 的落点(Guard/Pipe/Interceptor/Filter)对上请求链路
  3. 然后用 DTO + ValidationPipe 把“入参质量”先稳住
  4. 至于 Entity/ORM/ODM、JWT/RBAC、CQRS,就按项目需要逐步加,不用一次性把工具箱搬回家

以上是我学习 NestJS 过程中的一些整理与理解,欢迎在评论区补充/讨论;如果哪里有偏差,也欢迎直接指出,我会及时修正。

CommonJS 与 ES Modules的区别

作者 NEXT06
2026年2月21日 12:12

在前端工程化的演进长河中,模块化规范的变迁是理解 JavaScript 运行机制的关键一环。对于资深开发者而言,CommonJS(简称 CJS)与 ES Modules(简称 ESM)不仅仅是语法的区别,更代表了 JavaScript 在服务端与浏览器端不同运行环境下的架构哲学。

本文将从底层原理出发,剖析这两大规范的核心差异,并结合 Node.js 的最新特性,探讨工程化场景下的互操作性方案。

一、模块化的前世今生

在 ES6 之前,JavaScript 语言层面并没有内置的模块体系。这导致早期的大型项目开发极易陷入全局作用域污染、依赖关系混乱(Dependency Hell)的泥潭。为了解决这一痛点,社区涌现出了多种解决方案。

CommonJS 应运而生,它主要面向服务器端(Node.js)。由于服务器端的文件存储在本地硬盘,读取速度极快,因此 CommonJS 采用了同步加载的设计。这一规范迅速确立了 Node.js 生态的统治地位。

然而,随着前端应用的日益复杂,浏览器端急需一种标准化的模块体系。ES6(ECMAScript 2015)正式推出了 ES Modules。作为官方标准,ESM 旨在统一浏览器和服务器的模块规范,凭借其静态编译和异步加载的特性,逐渐成为现代前端构建工具(如 Vite, Webpack, Rollup)的首选。

二、两大规范的运行机制与特点

1. CommonJS (CJS)

定位:服务器端模块规范,Node.js 的默认模块系统。

核心特点

  • 运行时加载:模块在代码执行阶段才被加载。
  • 同步加载:代码按照编写顺序同步执行,阻塞后续代码直至模块加载完成。
  • 值的拷贝:导出的是值的副本(对于基本数据类型)。

代码示例

JavaScript

// 导出:module.exports
const obj = { a: 1 };
module.exports = obj;

// 引入:require
const obj = require('./test.js');

2. ES Modules (ESM)

定位:ECMAScript 官方标准,旨在实现浏览器与服务端的通用。

核心特点

  • 编译时输出接口:在代码解析阶段(编译时)即可确定依赖关系。
  • 异步加载:支持异步加载机制,适应网络请求环境。
  • 值的引用:导出的是值的动态映射(Live Binding)。

代码示例

JavaScript

// 导出:export
export const obj = { name: 'ESM' };
export default { name: 'Default' };

// 引入:import
import { obj } from './test.js';
import defaultObj from './test.js';

三、深度解析——核心差异

如果要深入理解两者的区别,必须从输出机制、加载时机和加载方式三个维度进行剖析。

1. 输出值的机制:值的拷贝 vs 值的引用

这是 CJS 与 ESM 最本质的区别,也是面试中最高频的考察点。

  • CommonJS:值的拷贝
    CJS 模块输出的是一个对象,该对象在脚本运行完后生成。一旦输出,模块内部的变化就无法影响到这个值(除非导出的是引用类型对象且修改了其属性,这里特指基本数据类型或引用的替换)。
  • ES Modules:值的引用
    ESM 模块通过 export 导出的是一个静态接口。import 导入的变量仅仅是一个指向被导出模块内部变量的“指针”。如果模块内部修改了该变量,外部导入的地方也会感知到变化。

代码演示:

场景:我们定义一个 age 变量和一个自增函数 addAge。

CommonJS 实现:

JavaScript

// lib.js
let age = 18;
module.exports = {
  age,
  addAge: function () {
    age++;
  },
};

// main.js
const { age, addAge } = require('./lib.js');
console.log(age); // 18
addAge();
console.log(age); // 18 (注意:这里依然是 18,因为导出的是 age 变量在导出时刻的拷贝)

ES Modules 实现:

JavaScript

// lib.mjs
export let age = 18;
export function addAge() {
  age++;
}

// main.mjs
import { age, addAge } from './lib.mjs';
console.log(age); // 18
addAge();
console.log(age); // 19 (注意:这里变成了 19,因为 import 获取的是实时的绑定)

技术延伸
由于 ESM 是实时引用,它能更好地处理循环依赖问题。在 ESM 中,只要引用存在,代码就能执行(尽管可能在暂时性死区 TDZ 中);而在 CJS 中,循环依赖可能导致导出一个不完整的对象(空对象),因为模块可能尚未执行完毕。此外,ESM 导入的变量是只读的(Read-only),尝试在 main.mjs 中直接执行 age = 20 会抛出 TypeError。

2. 加载时机:运行时 vs 编译时

  • CommonJS (运行时)
    require 本质上是一个函数。你可以将它放在 if 语句中,或者根据变量动态生成路径。只有当代码执行到这一行时,Node.js 才会去加载模块。

    JavaScript

    if (condition) {
      const lib = require('./lib.js'); // 条件加载
    }
    
  • ES Modules (编译时)
    import 语句(静态导入)必须位于模块顶层,不能嵌套在代码块中。JavaScript 引擎在编译阶段(解析 AST 时)就能确定模块的依赖关系。
    工程化价值:这使得 Tree Shaking(摇树优化)  成为可能。构建工具可以在打包时静态分析出哪些 export 没有被使用,从而安全地删除这些死代码,减小包体积。

3. 加载方式:同步 vs 异步

  • CommonJS (同步)
    主要用于服务器端。文件都在本地磁盘,读取时间通常在毫秒级,同步加载不会造成明显的性能瓶颈。
  • ES Modules (异步)
    设计之初就考虑了浏览器环境。在浏览器中,模块需要通过网络请求加载,网络延迟不可控。如果采用同步加载,会阻塞主线程,导致页面“假死”无法交互。因此,ESM 规范规定模块解析阶段是异步的。

四、工程化实践与互操作性

在 Node.js 环境逐步过渡到 ESM 的过程中,两者共存的情况十分常见。

1. 文件后缀与配置

在 Node.js 中,为了区分模块类型:

  • CommonJS:通常使用 .cjs 后缀,或者在 package.json 中未设置 type 字段(默认为 CJS)。
  • ES Modules:强制使用 .mjs 后缀,或者在 package.json 中设置 "type": "module"。

2. 相互引用(Interoperability)

这是开发中最容易踩坑的地方。

场景 A:CommonJS 引用 ES Modules

由于 CJS 是同步的 require,而 ESM 是异步加载的,因此原生 CJS 无法直接 require ESM 文件

  • 常规方案:使用异步的动态导入 import() 配合 IIFE。

    JavaScript

    // index.cjs
    (async () => {
      const { default: foo } = await import('./foo.mjs');
    })();
    
  • 新特性(Node.js v22+ / Experimental)
    Node.js 在 2024 年推出了 --experimental-require-module 标志。开启后,支持同步 require 加载 ESM(前提是该 ESM 模块内部没有顶级 await)。

    Bash

    node --experimental-require-module index.cjs
    

场景 B:ES Modules 引用 CommonJS

ESM 的兼容性较好,可以导入 CJS 模块。

  • 机制:Node.js 会将 CJS 的 module.exports 整体作为一个默认导出(Default Export)处理。

  • 注意事项不支持具名导入(Named Imports)的直接解构。虽然部分构建工具(如 Webpack)支持混用,但在原生 Node.js 环境下,以下写法通常会报错或表现不符合预期:

    JavaScript

    // 错误示范 (原生 Node.js)
    import { someMethod } from './lib.cjs'; // 可能会失败,因为 CJS 只有 default 导出
    

    正确写法

    JavaScript

    import lib from './lib.cjs';
    const { someMethod } = lib;
    

五、面试场景复盘

面试官提问:“请聊聊 CommonJS 和 ESM 的区别。”

高分回答策略

1. 一句话定性(宏观视角)
“CommonJS 是 Node.js 社区提出的服务器端运行时模块规范,主要特点是同步加载值的拷贝;而 ES Modules 是 ECMAScript 的官方标准,实现了浏览器和服务端的统一,主要特点是编译时静态分析异步加载值的引用。”

2. 核心差异展开(技术深度)
“两者最本质的区别在于输出值的机制
CommonJS 输出的是值的拷贝。一旦模块输出,内部变量的变化不会影响导出值,类似于基本类型的赋值。
ES Modules 输出的是值的引用(Live Binding) 。导入的变量实际上是指向模块内部内存地址的指针,模块内部变化会实时反映到外部,这使得 ESM 能更好地处理循环依赖问题。”

3. 工程化价值(架构视角)
“在工程实践中,ESM 的静态编译特性非常关键。因为它允许构建工具在代码运行前分析依赖关系,从而实现 Tree Shaking,去除无用代码,优化包体积。这是 CommonJS 这种动态加载规范无法做到的。”

4. 兼容性补充(实战经验)
“在 Node.js 环境中,两者互操作需要注意。ESM 可以较容易地导入 CJS(作为默认导出),但 CJS 导入 ESM 通常需要异步的 import()。不过,Node.js 最近引入了 --experimental-require-module 标志,正尝试打破这一同步加载的壁垒。”

❌
❌