添加依赖和第三方模块
这一页讲怎么给项目加一个 npm 包,以及怎么把第三方的 Nest 模块接进服务端。
项目对依赖管得比较严:版本要发布满一天、安装脚本要登记、许可证要检查。规则都写在配置文件和检查脚本里,违反时安装或 pnpm verify 会直接失败。照着下面的步骤做,一般一次就能通过。
1. 装到哪个包
仓库是一个 pnpm workspace(工作区:一个仓库里放多个包,一起安装、互相引用)。依赖要装到用它的那个包里,而不是仓库根目录:
| 包 | 目录 | 装什么 |
|---|---|---|
@qiwu/server | apps/server | 服务端用的包 |
@qiwu/web | apps/web | 电脑端前端用的包 |
@qiwu/shared | packages/shared | 只有 zod,一般不加(原因见下文) |
在仓库根目录运行,用 --filter 指定包名。下面以服务端加 @nestjs/event-emitter 为例,换成你要装的包即可:
# 运行时要用的包(写进 dependencies)
pnpm --filter @qiwu/server add @nestjs/event-emitter
# 只在开发、测试、构建时用的包,加 -D(写进 devDependencies),例如类型声明
pnpm --filter @qiwu/server add -D @types/某个包前端把 @qiwu/server 换成 @qiwu/web。这几条命令在 macOS 和 Windows(PowerShell)里写法一样。
几点注意:
- 不要在根目录直接
pnpm add。 不加--filter时,包会被写进根目录的package.json,而那里只放全仓库共用的工具(lint、格式化、提交检查)。这样服务端或前端的package.json里没有这个依赖,构建、部署和许可证检查都会漏掉它。 - 前端代码
import的包必须放dependencies,不能加-D。 前端最后会打包进浏览器代码,而许可证检查只看dependencies。pnpm verify的架构检查会找出放错的包。 - 共享包只能引用
zod。@qiwu/shared同时被移动端(小程序)构建,所以它的源码只允许引用zod和自己的文件,架构检查会拦住其他包。需要的功能请放到服务端或前端。 - 移动端是单独的工程。
mobile/不在 workspace 里,有自己的package.json、锁定文件和 pnpm 配置。给它加依赖用pnpm -C mobile add 包名(-C mobile表示在mobile目录里运行),检查用pnpm mobile:verify。它的规则和版本限制见移动端开发 · 一个独立的工程。
装完之后,package.json 和根目录的 pnpm-lock.yaml(锁定文件,记录每个包的确切版本)都会变化,两者要一起提交。
2. 版本要发布满一天
pnpm-workspace.yaml 里有这三行:
# supply chain: packages must be ≥ 1 day old; strict = fail instead of silently falling back
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude: []意思是:发布不满 1440 分钟(一天)的版本不能安装。这是为了防供应链投毒:有人盗用作者账号发布带恶意代码的新版本,通常几小时内就会被发现并撤下。等一天,可以避开大部分这类事件。
不写版本号时,不会出错。 比如 pnpm --filter @qiwu/server add 包名,pnpm 会自动选择发布满一天的最新版本。
指定了一个太新的版本时,安装失败,报错类似这样:
[ERR_PNPM_NO_MATURE_MATCHING_VERSION] 1 version does not meet the minimumReleaseAge constraint:
@aws-sdk/[email protected] was published at 2026-10-14T18:53:45.126Z, within the minimumReleaseAge cutoff (2026-10-14T01:59:45.390Z)strict(严格)表示不会悄悄换成旧版本,而是直接报错。你有两种做法:
- 等一天,到报错里的发布时间过了 24 小时再装;
- 换一个早一点的版本,或者不写版本号,让 pnpm 自己挑。
依赖的依赖也受这条规则管。如果某个包要求的子依赖版本都还太新,同样会报这个错,做法也一样。
minimumReleaseAgeExclude 是例外名单,模板里是空的,也就是不给任何包开例外。报错信息会提示可以把包名加进这个名单,请不要这样做:这等于关掉了这层保护。
3. 安装脚本:allowBuilds
有些包在安装时要执行一段脚本(安装脚本),比如下载或编译二进制文件。安装脚本能在你的电脑上运行任何代码,所以项目默认不执行,每个这样的包都要在 pnpm-workspace.yaml 的 allowBuilds 里登记:
| 写法 | 意思 |
|---|---|
包名: true | 允许执行安装脚本 |
包名: false | 不执行,也不再报错 |
没有登记时,pnpm i 会报错:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: [email protected]
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.报错里列出的就是需要登记的包。新的依赖可能自己没有安装脚本,但它的子依赖有,这时报错列出的是子依赖的名字。
**怎么选 true 还是 false:**先看这个脚本做什么(包的 package.json 里的 preinstall、install、postinstall)。
- 包离开它就不能用,比如要下载自己的二进制文件,才设
true; - 只是统计、广告、可选的加速,或者包没有它也能正常工作,设
false。
模板里现有的登记都是 false,每一项上面都用注释写了原因,例如:
allowBuilds:
# optional native msgpack speed-up (via bullmq -> msgpackr); the install script only builds it when no
# prebuilt binary fits, msgpackr falls back to pure JS without it; never allowed to run
msgpackr-extract: false新加的登记也照这样写一行注释:经由哪个包引入、脚本做什么、为什么允许或不允许。登记之后再运行一次 pnpm i,不能再出现 ERR_PNPM_IGNORED_BUILDS。
快速开始的常见问题里也有这一条。
4. 许可证检查
这是模板自己的规则
栖梧要以 MIT 许可证开源发布,所以模板仓库对自己的依赖做许可证检查。你用栖梧开发自己的项目时,用什么许可证的依赖由你决定:可以修改 scripts/license-check.mjs 里允许的许可证,或者把根目录 package.json 的 verify 脚本里最后的 && pnpm license:check 去掉(保留手机端时,mobile/package.json 的 verify 里也有同样一步)。只是要自己评估所选依赖的许可证义务,比如 GPL 类许可证对你的项目分发方式的要求。
注意:整个去掉检查,也会去掉 scripts/license-check.mjs 里的禁用包名单。名单里有的包不只是许可证原因,比如 wangeditor 是因为存在未修复的 XSS(跨站脚本)漏洞被禁用。所以更稳妥的做法是只放宽允许的许可证,保留禁用名单。保留 BPMN 设计器时,bpmn-js 的 bpmn.io 标志义务照样适用,见仓库的 THIRD-PARTY-NOTICES.md。
下面说明模板默认的检查规则。用了许可证不兼容的包,模板就不能按 MIT 分发,所以模板仓库的每个依赖都要检查:
pnpm license:checkpnpm verify 里已经包含它,平时运行 pnpm verify 就会检查。它检查三个包的生产依赖(dependencies,包括它们的子依赖);mobile/ 已安装时也一起检查。devDependencies 不打包进产品,不在检查范围里。
**接受的许可证:**MIT、ISC、BSD-2-Clause、BSD-3-Clause、Apache-2.0、0BSD、MIT-0、BlueOak-1.0.0、CC0-1.0、Python-2.0、Unicode-3.0、Unicode-DFS-2016、Zlib、Unlicense。
会失败的情况:
- GPL、AGPL、LGPL 等传染性许可证(要求使用它的项目也用同样的许可证开源);
- 不在上面名单里的其他许可证;
- 没有写许可证、写成
SEE LICENSE IN …(自定义条款)或UNLICENSED(私有); - 双许可证(比如
MIT OR GPL-3.0)没有登记选用哪一个; - 明确禁止的包:几个许可证或使用条款不适合本项目的富文本编辑器、表单设计器、Excel 解析库、进程管理器、表达式求值库和验证码库,以及新版本改了许可证的包。完整名单见 CONTRIBUTING.md 和检查脚本
scripts/license-check.mjs。
失败时会逐行打印原因,格式大致如下(包名和数量只是示意):
FAIL 某个包@1.0.0 GPL-3.0 GPL-3.0 is copyleft (GPL/AGPL/LGPL) [@qiwu/server]
license-check: <包的总数> packages, 1 violation(s)以 note 开头的行只是提示(比如已经登记过的例外),不算失败;最后一行的 0 violation(s) 才表示通过。
**遇到失败怎么办:**如果你还想保持和模板一样的规则,首选换一个许可证合适的包。确实需要、而且许可证本身没有问题(比如只是写法不规范,MIT/X11 实际就是 MIT),可以在根目录的 license-exceptions.json 里登记:
| 字段 | 用途 |
|---|---|
aliases | 许可证写法不规范,按版本把它对应到标准名称 |
elections | 双许可证,登记选用哪一个(如 jszip 选 MIT) |
exceptions / binaryExceptions | 经过审查、确认可以使用的特殊许可证 |
iconSets | 允许使用的 @iconify-json/* 图标集 |
每一条都要写 reason(原因)。登记例外等于你替项目做了一次许可证判断,拿不准时不要登记。移动端还有自己的 mobile/license-exceptions.json,只对移动端生效。
另外,在提交说明或 PR 里写清楚新依赖的名称、版本、许可证和引入理由。
5. 共用版本:catalog
有些包被好几个包同时使用,版本必须一致,比如 zod(服务端、前端、共享包都用)。这类包的版本统一写在 pnpm-workspace.yaml 的 catalog(版本目录)里:
catalog:
typescript: ~6.0.3
zod: ^4.6.5
vitest: 5.0.1
'@vitest/coverage-v8': 5.0.1
vite: ^8.3.1
'@vue/test-utils': ^2.5.1
happy-dom: ^20.14.5
'@types/node': ^22.18.0各包的 package.json 里不写版本号,而是写 catalog::
"dependencies": {
"zod": "catalog:"
}什么时候用:
- 你要装的包已经在
catalog里:在package.json里写"包名": "catalog:",然后运行pnpm i; - 一个包会被两个以上的包使用,并且版本必须相同:先把它加进
catalog,各处都写catalog:; - 只有一个包用:照常
pnpm --filter … add,不用进catalog。
升级时只改 catalog 一处,所有包一起升级。pnpm verify 会检查 typescript、vite、vitest 等关键工具在整个仓库只有一个版本。
pnpm-workspace.yaml 里的 overrides 是另一回事:它强制替换某些子依赖的版本,用来修安全漏洞或许可证问题。每一项都有注释说明原因,平时不用动。
6. 装配第三方 Nest 模块
很多 Nest 生态的包会提供一个模块,比如 XxxModule.forRoot(...),要放进某个模块的 imports 里才能用。
放在哪里
按项目的分层规则(见模块结构与注册 · 分层):
- 业务用的:放在你的项目模块里,即
apps/server/src/modules/<领域>/<业务>/下的xxx.module.ts,再在modules/project.module.ts里注册这个项目模块(见注册一个新模块)。绝大多数情况都属于这一种。 - 框架级的:整个应用的基础设施(日志、数据库、缓存、队列这一类)才放进
apps/server/src/core/。core不能引用任何业务模块。
读取配置:forRootAsync
第三方模块常常需要地址、密钥等配置。不要在代码里写死,也不要直接读 process.env,而是用 forRootAsync(有的包叫 registerAsync),在 useFactory 里从配置服务读取。
项目里的配置服务是 AppConfigService。它包装了 @nestjs/config 的 ConfigService,读出来的值已经校验过、转好了类型(数字是数字,开关是布尔值),而且全局提供,不用导入模块就能注入。
模板的日志模块就是这样接入第三方模块 nestjs-pino 的:
// apps/server/src/core/logger/logger.module.ts(节选)
import { LoggerModule } from 'nestjs-pino'
import { AppConfigService } from '../config/config.module.js'
export const CoreLoggerModule = LoggerModule.forRootAsync({
inject: [AppConfigService],
useFactory: (cfg: AppConfigService) => ({ pinoHttp: pinoHttpOptions(cfg) }),
})inject 列出要注入的服务,它们会按顺序传给 useFactory;useFactory 返回这个模块的配置。任务队列用同样的写法接入 BullMQ,注入的是 Redis 客户端(BullModule.forRootAsync({ inject: [REDIS], useFactory: … }),见 core/queue/queue.module.ts)。
在项目模块里写法相同,只是导入路径多几层。下面是一个示意(SomeSdkModule 和 SEARCH_URL 都是假设的名字):
// apps/server/src/modules/biz/search/search.module.ts
import { Module } from '@nestjs/common'
import { SomeSdkModule } from 'some-nest-sdk'
import { AppConfigService } from '../../../core/config/config.module.js'
import { SearchService } from './search.service.js'
@Module({
imports: [
SomeSdkModule.forRootAsync({
inject: [AppConfigService],
useFactory: (cfg: AppConfigService) => ({ url: cfg.get('SEARCH_URL') }),
}),
],
providers: [SearchService],
})
export class SearchModule {}新增环境变量
cfg.get('SEARCH_URL') 只接受已经声明过的变量名,没声明会类型检查失败。新增一个环境变量要改三处:
① 在校验规则里声明。 文件是 apps/server/src/core/config/env.schema.ts,在 z.object({ … }) 里加一行:
/** search service address; unset → search is off */
SEARCH_URL: z.url().optional(),服务启动时会用这里的规则校验全部环境变量,缺少必填项或者值不合法,就拒绝启动。
尽量写成可选(.optional())或者带默认值(.default(…))。如果写成必填,所有环境(开发、测试、端到端测试)都要配上它,否则服务起不来。可选的变量没配时,像微信小程序登录那样把功能关掉就好。
② 写进 apps/server/.env.example。 这是 .env 的模板,新同事照着它配置。普通配置直接写一行 SEARCH_URL= 加注释说明。
③ 密钥只放 .env.local。 账号、密码、密钥这类值不能写进 .env,在 .env.example 里也只用注释说明,真实的值写进不提交的 apps/server/.env.local。原因和加载顺序见环境变量。
改完之后
pnpm verify它会依次运行 lint、架构检查、类型检查、翻译检查、原创性检查和许可证检查。然后运行你改动模块相关的测试,再用 pnpm dev 启动看一下效果。