Bitwarden Clients Angular 现代化迁移模式全指南:Standalone、Signals 与 ADR 规范实战
【免费下载链接】clientsBitwarden client apps (web, browser extension, desktop, and cli).项目地址: https://gitcode.com/GitHub_Trending/cl/clients
本指南以 Bitwarden Clients 仓库(Web、浏览器扩展、桌面端与 CLI 客户端)的 Angular 现代化迁移模式参考 为核心骨架,系统梳理从旧式 NgModule 组件迁移到现代 Angular 架构的完整模式:Standalone 组件、inject()依赖注入、Signals 与 Observables 的分工、新模板控制流以及类型安全约束。读完本文,你将掌握一套可直接落地到本仓库任意 Angular 模块的迁移清单与代码范式,并理解每条模式背后的 ADR 决策依据与真实源码印证。
迁移总览:两条路径的配合
本仓库的 Angular 现代化采用"自动化优先 + 手工补充"的两阶段策略(见 .claude/skills/angular-modernization/SKILL.md):
- Angular CLI 自动迁移:凡是有官方 schematic 的场景,一律使用
npx ng generate系列命令,禁止手工重构。官方迁移器能处理边界情况、同步更新测试并保证正确性。 - Bitwarden 自定义模式:对于 CLI 工具未覆盖的模式(OnPush 变更检测、可见性修饰符、组件状态 Signal 化、服务层保持 Observable、业务逻辑下沉到服务、类成员组织、测试更新),按 migration-patterns.md 手工落实。
CLI 迁移命令需按依赖顺序执行,且一律作用于目录而非单个文件(使用--path=<directory>定位目标目录):
| 迁移目标 | 命令 |
|---|---|
| Standalone 组件 | npx ng generate @angular/core:standalone --path=<directory> --mode=convert-to-standalone |
| 新控制流语法 | npx ng generate @angular/core:control-flow |
| Signal 输入 | npx ng generate @angular/core:signal-input-migration |
| Signal 输出 | npx ng generate @angular/core:output-migration |
| Signal 查询 | npx ng generate @angular/core:signal-queries-migration |
inject()函数 | npx ng generate @angular/core:inject-migration |
| 自闭合标签 | npx ng generate @angular/core:self-closing-tag |
| 无用导入清理 | npx ng generate @angular/core:unused-imports |
迁移完成后需执行npm run lint:fix修复格式,再以npm run test验证测试通过。
组件架构模式
Standalone 组件为默认
仓库默认采用 Standalone 组件架构,组件定义中省略standalone: true(默认即为 true);任何仍显式声明standalone: false的组件都必须迁移为 Standalone。标准范式如下:
@Component({ selector: "app-user-profile", imports: [CommonModule, ReactiveFormsModule, AsyncPipe], templateUrl: "./user-profile.component.html", changeDetection: ChangeDetectionStrategy.OnPush, }) export class UserProfileComponent {}该范式在真实组件中已大量落地,例如 banner 组件:声明selector: "bit-banner"、直接imports依赖模块、显式changeDetection: ChangeDetectionStrategy.OnPush,libs/components下绝大多数组件(avatar、badge、accordion、breadcrumb 等)都遵循同一写法。
类成员组织规范
组件类成员必须按固定顺序组织,从公开到私密、从声明到生命周期再到方法,模板可访问的成员用protected而非private(private成员在 Angular 模板中访问会编译报错):
@Component({...}) export class MyComponent { // 1. Inputs (public) @Input() data: string; // 2. Outputs (public) @Output() valueChange = new EventEmitter<string>(); // 3. ViewChild/ContentChild @ViewChild('template') template: TemplateRef<any>; // 4. Injected dependencies (private/protected) private userService = inject(UserService); protected dialogService = inject(DialogService); // 5. Public properties public formGroup: FormGroup; // 6. Protected properties (template-accessible) protected isLoading = signal(false); protected items$ = this.itemService.items$; // 7. Private properties private cache = new Map(); // 8. Lifecycle hooks ngOnInit() {} // 9. Public methods public save() {} // 10. Protected methods (template-accessible) protected handleClick() {} // 11. Private methods private processData() {} }在 banner 组件 中可以观察到完全一致的编排:input()/outputFromObservable()声明在前,inject()依赖、signal/computed状态随后,ngOnInit生命周期钩子与onDismiss()方法收尾。
依赖注入:统一使用 inject() 函数
构造函数注入仅在旧代码中存在,一律迁移为inject()函数:
迁移前:
constructor( private userService: UserService, private route: ActivatedRoute ) {}迁移后:
private userService = inject(UserService); private route = inject(ActivatedRoute);仓库中的真实用法示例,如 banner 组件 通过inject(ConfigService, { optional: true })获取可选依赖。需要注意:禁止混用构造函数注入与inject()(属于反模式),同一组件内应统一风格。
响应式模式:Signals 与 Observables 的分工
组件局部状态用 Signals(ADR-0027)
组件内部状态应使用signal(),派生状态使用computed():
// Local state protected selectedFolder = signal<Folder | null>(null); protected isLoading = signal(false); // Derived state protected hasSelection = computed(() => this.selectedFolder() !== null);派生状态优先用 computed() 而非 effect()
computed()用于派生值;effect()仅限副作用(日志、埋点、DOM 同步)。用effect()写派生逻辑不仅啰嗦,还会引入显式的读写依赖:
❌ 反模式:
constructor() { effect(() => { const id = this.selectedId(); this.selectedItem.set(this.items().find(i => i.id === id) ?? null); }); }✅ 推荐写法:
selectedItem = computed(() => this.items().find((i) => i.id === this.selectedId()) ?? null);服务间通信保持 Observables(ADR-0003)
服务层状态与跨组件通信继续使用 Observable,不要转成 Signal:
// In component protected folders$ = this.folderService.folders$; // Template // <div *ngFor="let folder of folders$ | async"> // For explicit subscriptions constructor() { this.userService.user$ .pipe(takeUntilDestroyed()) .subscribe(user => this.handleUser(user)); }显式订阅必须使用takeUntilDestroyed()运算符自动管理生命周期,替代手工维护Subject+takeUntil+ngOnDestroy的样板代码。该方法在 vault-items 组件、SSO 组件 等大量组件中均有实际应用。
用 toSignal() 桥接 Observable 与 Signal
组件中需要把服务 Observable 接入 Signal 世界时,用toSignal()转换,同时保持服务状态仍是 Observable:
迁移前(手工订阅样板):
private destroy$ = new Subject<void>(); users: User[] = []; ngOnInit() { this.userService.users$.pipe(takeUntil(this.destroy$)) .subscribe(users => this.users = users); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }迁移后(一行搞定):
protected users = toSignal(this.userService.users$, { initialValue: [] });banner 组件 正是该模式的真实范例:toSignal(this.configService?.getFeatureFlag$(FeatureFlag.VFO1Foundation) ?? of(false), { initialValue: false }),为 Signal 化组件安全地桥接 Feature Flag Observable。
模板语法模式
新控制流语法
用@if/@for/@switch替代*ngIf/*ngFor/*ngSwitch。@for必须提供track键以优化变更检测:
迁移前:
<div *ngIf="user$ | async as user; else loading"> <p *ngFor="let item of user.items">{{ item.name }}</p> </div> <ng-template #loading>Loading...</ng-template>迁移后:
@if (user$ | async; as user) { @for (item of user.items; track item.id) { <p>{{ item.name }}</p> } } @else { <p>Loading...</p> }用 [class.*] / [style.*] 绑定替代 ngClass / ngStyle
类与样式绑定直接使用属性绑定语法,配合 Signal 读取更自然、类型更安全:
❌ 反模式:
<div [ngClass]="{ 'active': isActive(), 'disabled': isDisabled() }"> <div [ngStyle]="{ 'width.px': width(), 'height.px': height() }"></div> </div>✅ 推荐写法:
<div [class.active]="isActive()" [class.disabled]="isDisabled()"> <div [style.width.px]="width()" [style.height.px]="height()"></div> </div>banner 组件 的host绑定"[class]": "class()"即体现了 Signal 驱动的类绑定风格。
类型安全模式
禁止 TypeScript 枚举(ADR-0025)
TSenum会产生运行时对象且类型行为存在坑,统一改用Object.freeze常量对象 + 类型别名:
迁移前:
enum CipherType { Login = 1, SecureNote = 2, }迁移后:
export const CipherType = Object.freeze({ Login: 1, SecureNote: 2, } as const); export type CipherType = (typeof CipherType)[keyof typeof CipherType];仓库中 IntegrationType 常量 是完整范例:Object.freeze({...} as const)定义值,(typeof IntegrationType)[keyof typeof IntegrationType]导出联合类型;libs/common下的two-factor-provider-type、discount-tier-type等枚举文件均采用此模式,替代了历史上的真实enum写法。
响应式表单
FormGroup 构造时对必填字段使用nonNullable: true,并对控件做显式泛型标注:
protected formGroup = new FormGroup({ name: new FormControl('', { nonNullable: true }), email: new FormControl<string>('', { validators: [Validators.email] }), });反模式清单(迁移时逐条自查)
- ❌ CLI 已有迁移命令时仍手工重构
- ❌ 未使用
takeUntilDestroyed()的手工订阅 - ❌ TypeScript 枚举(应使用 const 对象,见 ADR-0025)
- ❌ 混用构造函数注入与
inject() - ❌ 在与非 Angular 代码共享的服务中使用 Signals(服务状态保持 Observable,见 ADR-0003)
- ❌ 组件内写业务逻辑(应下沉到服务,保持 Thin Components)
- ❌ 使用代码区域(Code Regions,应重构拆分)
- ❌ 把服务 Observable 转成 Signal(组件内可
toSignal()桥接,服务层不转) - ❌ 用
effect()计算派生状态(应使用computed()) - ❌ 使用
ngClass/ngStyle(应使用[class.*]/[style.*])
迁移验证清单
完成迁移后,按 SKILL.md 的清单逐项核验:
- 已添加 OnPush 变更检测
- 已应用可见性修饰符(模板访问用
protected,内部实现用private) - 组件状态用 Signals,服务状态保持 Observables
- 类成员按规范顺序组织
- 测试已更新且全部通过
- 未引入新的 TypeScript 枚举
- 无代码区域残留
这套模式已在libs/components、libs/angular、libs/vault、apps/web等模块中广泛落地:libs/components下几乎全部组件使用ChangeDetectionStrategy.OnPush与 Standalone 写法,libs/common的枚举类全部完成 const 对象化改造,toSignal桥接模式则贯穿 libs/angular、libs/vault 等多个库。迁移者可将本指南作为日常开发与评审的对照基准,确保新增与存量代码长期保持一致的现代化水准。
【免费下载链接】clientsBitwarden client apps (web, browser extension, desktop, and cli).项目地址: https://gitcode.com/GitHub_Trending/cl/clients
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考