当第二个项目要用到同一个组件时,就该考虑把它抽成库了。Angular CLI 对”库”有一等公民支持:ng generate library 生成工程、ng-packagr 负责打包成符合 Angular Package Format(APF)的产物。本文覆盖从创建到发布 npm/私有 registry 的全流程,以及最容易被忽视的版本策略。
创建库
在现有工作区(ng new 创建的应用仓库)中生成:
ng generate library @acme/ui-kit
生成的目录结构:
projects/acme/ui-kit/
├── ng-package.json # ng-packagr 配置
├── package.json # 发布元数据(name/version/peerDependencies)
├── tsconfig.lib.json
├── tsconfig.lib.prod.json
└── src
├── public-api.ts # 库的公开出口
└── lib/
├── ui-kit.component.ts
├── ui-kit.service.ts
└── ...
关键约定:
public-api.ts是唯一的公开面。只有在这里export的符号才对使用者可见,内部实现放src/lib下随意组织tsconfig.json里自动添加了 paths 映射,工作区内的应用可以直接import { Button } from '@acme/ui-kit',无需先构建
// src/public-api.ts
export * from './lib/button/button.component';
export * from './lib/toast/toast.service';
export * from './lib/toast/toast.component';
写一个库组件
库组件就是 Standalone 组件,没有特别魔法。v22 中注意两点:服务用 @Service() 装饰器,组件默认 OnPush:
import { Component, input, output } from '@angular/core';
@Component({
selector: 'acme-button',
template: `
<button type="button" [class.primary]="variant() === 'primary'" (click)="pressed.emit()">
<ng-content />
</button>
`,
})
export class ButtonComponent {
readonly variant = input<'primary' | 'ghost'>('primary');
readonly pressed = output<void>();
}
@Service()
export class ToastService {
// providedIn: 'root' 语义,使用方应用自动获得单例
show(message: string): void { /* ... */ }
}
设计库时多想一步消费方:组件依赖的 Token 通过 input/inject(PROVIDER, { optional: true }) 注入而不是硬编码,给使用方留配置口子。
secondary entry:拆分入口
所有导出都塞进主入口,使用方 import { Button } from '@acme/ui-kit' 时会把整个库拉进构建图——尽管 tree-shaking 最终会裁剪,但类型检查与编译成本仍在。规范做法是按功能拆 secondary entry point:
projects/acme/ui-kit/
├── ng-package.json
├── src/... # 主入口 @acme/ui-kit
├── button/
│ ├── ng-package.json # 声明这是一个 entry
│ └── index.ts # 该入口的 public api
└── toast/
├── ng-package.json
└── index.ts
button/ng-package.json 只需要:
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json"
}
使用方按需导入:
import { ButtonComponent } from '@acme/ui-kit/button';
import { ToastService } from '@acme/ui-kit/toast';
主入口可以 re-export 高频 API 保持便捷,重组件(如富文本编辑器、图表)单独成入口。Angular 官方包(@angular/cdk/overlay、@angular/cdk/drag-drop)就是这个结构。
peerDependencies:声明宿主环境
库与应用最大的区别:库不打包 Angular 自身。Angular 版本由使用方应用决定,库只声明兼容范围:
{
"name": "@acme/ui-kit",
"version": "0.1.0",
"peerDependencies": {
"@angular/core": "^22.0.0",
"@angular/common": "^22.0.0",
"rxjs": "^7.8.0"
}
}
写法要点:
- 用
peerDependencies而不是dependencies——后者会把 Angular 装两份,DI 与静态替换直接崩 - 版本范围写实际测试过的组合。只测过 v22 就写
^22.0.0,别盲目放宽 - 想同时支持多个 Angular 大版本,用
||组合(如^21.0.0 || ^22.0.0),并保证只用这些版本的公共 API 子集
partial compilation:为什么库能跨版本工作
ng-packagr 默认以 partial compilation 模式产出库代码(ng-package.json 中 compilationMode: "partial" 是默认值)。它把组件装饰器编译成”半成品”中间表示,而不是编译到底:
- 不完整 AOT:库不做最终的代码生成,留给消费应用的编译器按应用自己的 Angular 版本与编译选项完成编译
- 跨版本兼容:应用编译器比库编译器新时(官方约定 N 与 N+1 兼容),旧库产物仍能被正确编译
- 装饰器元数据完整保留:AOT、模板类型检查在应用侧照常工作
这是 APF 的核心机制之一,也是”为什么不要用 tsc 直接编译 Angular 库”的答案——手工编译很难产出正确的 partial 产物。
构建与本地验证
ng build @acme/ui-kit
产物输出在 dist/acme/ui-kit,是标准的 npm 包结构(含 fesm2022、类型声明、package.json 的 exports 字段等)。开发期验证有两种:
ng build @acme/ui-kit --watch
方式一:watch 模式,配合应用内的 paths 映射直接调试源码。
cd dist/acme/ui-kit && npm pack
方式二:npm pack 生成 tgz 安装包,在真实消费环境里安装验证。
发布前务必在”干净的应用”里消费一次构建产物(不是 paths 映射到源码),确认 exports、类型、tree-shaking 都符合预期——很多问题只在真实安装后暴露。
发布到 npm 与私有 registry
公开包直接发布:
cd dist/acme/ui-kit
npm publish --access public # scoped 包首次发布需 public
企业内私有包通过 .npmrc 指向内部 registry:
; 项目根 .npmrc(.npmrc 的 ini 注释用分号)
@acme:registry=https://npm.internal.acme.com/
//npm.internal.acme.com/:_authToken=${NPM_TOKEN}
也可以在库的 package.json 里用 publishConfig 固定发布目标,避免每次敲 registry 参数:
{
"publishConfig": {
"registry": "https://npm.internal.acme.com/"
}
}
版本策略
库一旦被多个应用依赖,版本就是 API 承诺:
- 遵循 semver:破坏性改动升 major、新能力升 minor、修复升 patch
- 破坏性改动的判定以使用方视角为准:改组件
input名、改导出函数签名、改 CSS 结构(使用方可能覆写)都算 major - Angular 官方约定库与应用编译器保持 N/N+1 兼容,所以跟随 Angular 大版本升级你的库并升 major,是最清晰的节奏——使用方能从版本号直接推断”需要 Angular 几”
- 变更日志写清楚迁移步骤;如果库带 schematics(
ng update时的自动迁移),重大改动配套迁移脚本会大幅降低使用方升级成本 - 发布前跑一遍库的单元测试(
ng test @acme/ui-kit),CI 里锁定”构建 + 测试 + 干净消费验证”三道闸