angular-modularize
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAngular Modularization — Universal Best Practices
Angular模块化——通用最佳实践
You are an expert Angular architect. These guidelines are universal for any
Angular project. Always detect the Angular version first and adapt accordingly.
Sources: angular.dev style guide, angular.dev component/signals/DI guides,
Smart/Presentational pattern, SOLID principles.
您是一位Angular架构专家。这些指南适用于任何Angular项目。请始终先检测Angular版本,再据此调整操作。
参考来源: angular.dev风格指南、angular.dev组件/信号/依赖注入指南、Smart/Presentational模式、SOLID原则。
Step 1: Analyze Before Changing
步骤1:变更前先分析
Before making ANY change:
- Read → detect
package.jsonversion@angular/core - Scan structure → ,
Glob("**/*.component.ts"),Glob("**/*.module.ts")Glob("**/*.routes.ts") - Detect standalone →
Grep("standalone", glob="**/*.component.ts") - Identify large components → read and
.tsfiles, count lines, list responsibilities.html - Map dependencies → which components use which services, check imports
- Present findings to the user and ask confirmation before proceeding
在进行任何变更之前:
- 查看→ 检测
package.json版本@angular/core - 扫描项目结构 → 使用、
Glob("**/*.component.ts")、Glob("**/*.module.ts")工具Glob("**/*.routes.ts") - 检测独立组件 → 使用工具
Grep("standalone", glob="**/*.component.ts") - 识别大型组件 → 读取和
.ts文件,统计代码行数,列出组件职责.html - 梳理依赖关系 → 明确哪些组件使用了哪些服务,检查导入情况
- 向用户呈现分析结果,并在执行前征得用户确认
Step 2: Identify Components That Need Modularization
步骤2:识别需要模块化的组件
Single Responsibility Principle (SRP)
单一职责原则(SRP)
"Each class should have only ONE reason to change." — SOLID
Ask: what responsibilities does this component have? If you describe it with
more than one "AND", it violates SRP and needs refactoring.
Violation example: "This component fetches users AND renders the list AND
handles the create form AND validates input AND manages pagination."
“每个类应该只有一个修改的理由。”——SOLID原则
思考:这个组件承担了哪些职责? 如果您用多个“并且”来描述它,说明它违反了单一职责原则,需要重构。
违反示例: “该组件获取用户数据并且渲染用户列表并且处理创建表单并且验证输入并且管理分页。”
Smell Indicators — When a Component MUST Be Split
代码异味指标——组件必须拆分的情况
| Indicator | Threshold | Action |
|---|---|---|
| Template (HTML) lines | > 100 lines | Extract child components |
| Component class lines | > 200 lines | Extract logic to services |
| File total lines | > 400 lines | Split responsibilities (angular.dev style guide) |
| > 5 | Too many concerns, split |
Distinct | > 3 unrelated sections | Each section → child component |
Distinct | > 2 different data sources | Each list → child component |
| Component fetches data AND renders | Mixed smart/presentational | Split into container + presentational |
| Repeated HTML blocks | Same block 2+ times | Extract to shared component |
| Multiple form groups | > 2 in same component | Each form section → child component |
| Generic component name | | Rename to reflect single responsibility |
| 指标 | 阈值 | 操作 |
|---|---|---|
| 模板(HTML)行数 | > 100行 | 提取子组件 |
| 组件类(TS)行数 | > 200行 | 将逻辑提取至服务 |
| 文件总行数 | > 400行 | 拆分职责(遵循angular.dev风格指南) |
| > 5个 | 职责过多,需要拆分 |
独立的 | > 3个不相关区块 | 每个区块提取为子组件 |
独立的 | > 2个不同数据源 | 每个列表提取为子组件 |
| 组件同时负责数据获取和渲染 | 混合了智能/展示组件职责 | 拆分为容器组件+展示组件 |
| 重复的HTML区块 | 同一区块出现2次以上 | 提取为共享组件 |
| 多个表单组 | 同一组件内存在>2个表单组 | 每个表单区块提取为子组件 |
| 通用组件名称 | | 重命名以体现单一职责 |
When a Component Should NOT Be Split
组件无需拆分的情况
| Criteria | Reason |
|---|---|
| Template < 50 lines with single responsibility | Already small enough |
| Used only once, no children | No reuse benefit |
| Simple wrapper (< 3 inputs, no logic) | Already granular |
| Splitting needs > 5 inputs passed down | Creates prop drilling (worse) |
| Tightly coupled to parent lifecycle | Splitting breaks interaction |
| Feature has < 3 files total | Module overhead exceeds benefit |
| App has < 10 components total | Over-engineering |
| 判定标准 | 原因 |
|---|---|
| 模板行数<50且职责单一 | 已经足够精简 |
| 仅被使用一次,无嵌套子组件 | 没有复用价值 |
| 简单包装组件(<3个输入属性,无业务逻辑) | 粒度已经足够细 |
| 拆分后需要传递>5个输入属性 | 会导致属性透传,反而更糟 |
| 与父组件生命周期强耦合 | 拆分后会破坏交互逻辑 |
| 特性模块总文件数<3个 | 模块化带来的开销大于收益 |
| 应用总组件数<10个 | 属于过度设计 |
Step 3: Decompose a Monolithic Component
步骤3:分解单体组件
3.1 List Every Responsibility
3.1 列出所有职责
Read the component and enumerate what it does:
Analysis of a monolithic OrderPageComponent:
1. Fetches order data from API → Extract to OrderService
2. Displays order header info → OrderHeaderComponent (presentational)
3. Renders line items table → OrderItemsTableComponent (presentational)
4. Handles payment form → PaymentFormComponent (presentational)
5. Manages form validation → Validators file or form service
6. Handles error/loading states → Keep in smart component通读组件,枚举其所有功能:
单体组件OrderPageComponent的分析:
1. 从API获取订单数据 → 提取至OrderService
2. 展示订单头部信息 → OrderHeaderComponent(展示组件)
3. 渲染订单项表格 → OrderItemsTableComponent(展示组件)
4. 处理支付表单 → PaymentFormComponent(展示组件)
5. 管理表单验证 → 提取至验证器文件或表单服务
6. 处理错误/加载状态 → 保留在智能组件中3.2 Classify: Smart vs Presentational
3.2 分类:智能组件 vs 展示组件
| Type | Characteristics | Rules |
|---|---|---|
| Smart (Container) | Injects services via | One per route/feature. Knows WHERE data comes from. |
| Presentational (Dumb) | Receives data via | No service injection. No business logic. Reusable in any context. Does NOT know where data comes from. |
Key test: If the component wouldn't work in a different app without modification → smart.
If it would → presentational.
| 类型 | 特征 | 规则 |
|---|---|---|
| 智能组件(容器组件) | 通过 | 每个路由/特性模块对应一个。知道数据来自哪里。 |
| 展示组件(纯UI组件) | 通过 | 不注入服务。无业务逻辑。可在任意上下文复用。不知道数据来自哪里。 |
关键测试: 如果组件在不修改的情况下无法在其他应用中使用 → 属于智能组件。如果可以 → 属于展示组件。
3.3 Where Each Responsibility Goes
3.3 各职责的归属
| Responsibility | Destination | Rule |
|---|---|---|
| API calls, data fetching | Service with | Never in presentational components |
| Business logic, calculations | Service or utility file | Components should not contain business rules |
| Reusable UI block | | If used in 2+ features |
| Feature-specific UI block | Child component in same feature dir | If used only here |
| Form validation rules | Validators file or service | Testable and reusable |
| State management | Service with | Components are NOT the source of truth |
| 职责 | 目标位置 | 规则 |
|---|---|---|
| API调用、数据获取 | 使用 | 永远不要放在展示组件中 |
| 业务逻辑、计算逻辑 | 服务或工具文件 | 组件不应包含业务规则 |
| 可复用UI区块 | | 如果被2个以上特性模块使用 |
| 特性专属UI区块 | 同一特性目录下的子组件 | 如果仅在此处使用 |
| 表单验证规则 | 验证器文件或服务 | 便于测试和复用 |
| 状态管理 | 使用 | 组件不是状态的唯一可信源 |
3.4 Extract — Before and After
3.4 提取前后对比
BEFORE — Monolithic (violates SRP, uses legacy patterns to show what to refactor):
typescript
// BEFORE: legacy patterns — *ngFor, *ngIf, constructor injection, no OnPush, no signals
@Component({
selector: 'app-order-page',
template: `
<div class="header">
<h1>{{ order?.title }}</h1>
<span>{{ order?.status }}</span>
</div>
<table>
<tr *ngFor="let item of order?.items">
<td>{{ item.name }}</td>
<td>{{ item.price | currency }}</td>
</tr>
</table>
<form [formGroup]="paymentForm" (ngSubmit)="submitPayment()">
<input formControlName="cardNumber">
<div *ngIf="paymentForm.get('cardNumber')?.errors?.['required']">
Card number is required
</div>
<button type="submit" [disabled]="paymentForm.invalid">Pay</button>
</form>
`
})
export class OrderPageComponent implements OnInit {
order: Order | null = null;
paymentForm: FormGroup;
constructor(
private http: HttpClient,
private route: ActivatedRoute,
private fb: FormBuilder,
private router: Router,
private notificationService: NotificationService
) {
this.paymentForm = this.fb.group({
cardNumber: ['', [Validators.required, Validators.minLength(16)]],
expDate: ['', Validators.required],
cvv: ['', [Validators.required, Validators.minLength(3)]]
});
}
ngOnInit(): void {
const id = this.route.snapshot.params['id'];
this.http.get<Order>(`/api/orders/${id}`).subscribe(order => this.order = order);
}
submitPayment(): void {
this.http.post('/api/payments', {
orderId: this.order?.id,
...this.paymentForm.value
}).subscribe({
next: () => this.router.navigate(['/orders']),
error: () => this.notificationService.error('Payment failed')
});
}
}AFTER — Modularized with modern Angular (v19+):
typescript
// order.service.ts — Data fetching (uses httpResource for reads, HttpClient for mutations)
@Injectable({ providedIn: 'root' })
export class OrderService {
private readonly http = inject(HttpClient);
readonly orderResource = httpResource<Order>(() => ({
url: `/api/orders/${this.currentOrderId()}`
}));
readonly currentOrderId = signal<string>('');
submitPayment(orderId: string, payment: PaymentData): Observable<PaymentResult> {
return this.http.post<PaymentResult>('/api/payments', { orderId, ...payment });
}
}typescript
// order-header.component.ts — Presentational (display only, no services)
@Component({
selector: 'app-order-header',
template: `
<div class="header">
<h1>{{ order().title }}</h1>
<span class="status">{{ order().status }}</span>
<p>{{ order().date | date:'longDate' }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DatePipe]
})
export class OrderHeaderComponent {
readonly order = input.required<Order>();
}typescript
// order-items-table.component.ts — Presentational
@Component({
selector: 'app-order-items-table',
template: `
<table>
@for (item of items(); track item.id) {
<tr>
<td>{{ item.name }}</td>
<td>{{ item.price | currency }}</td>
<td>{{ item.quantity }}</td>
</tr>
} @empty {
<tr><td colspan="3">No items</td></tr>
}
<tr class="total">
<td colspan="2">Total</td>
<td>{{ total() | currency }}</td>
</tr>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe]
})
export class OrderItemsTableComponent {
readonly items = input.required<OrderItem[]>();
protected readonly total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
}typescript
// payment-form.component.ts — Presentational (form UI, no API calls)
@Component({
selector: 'app-payment-form',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="cardNumber" placeholder="Card number">
<input formControlName="expDate" placeholder="MM/YY">
<input formControlName="cvv" placeholder="CVV">
@if (form.get('cardNumber')?.hasError('required')) {
<div class="error">Card number is required</div>
}
<button type="submit" [disabled]="form.invalid || submitting()">Pay</button>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule]
})
export class PaymentFormComponent {
private readonly fb = inject(FormBuilder);
readonly submitting = input(false);
readonly paymentSubmitted = output<PaymentData>();
protected readonly form = this.fb.group({
cardNumber: ['', [Validators.required, Validators.minLength(16)]],
expDate: ['', Validators.required],
cvv: ['', [Validators.required, Validators.minLength(3)]]
});
protected onSubmit(): void {
if (this.form.valid) {
this.paymentSubmitted.emit(this.form.value as PaymentData);
}
}
}typescript
// order-page.component.ts — Smart/Container (orchestrates children, manages state)
@Component({
selector: 'app-order-page',
template: `
@if (orderService.orderResource.hasValue()) {
<app-order-header [order]="orderService.orderResource.value()!" />
<app-order-items-table [items]="orderService.orderResource.value()!.items" />
<app-payment-form
[submitting]="submitting()"
(paymentSubmitted)="onPaymentSubmit($event)" />
} @else if (orderService.orderResource.isLoading()) {
<p>Loading order...</p>
} @else if (orderService.orderResource.error()) {
<p>Error loading order</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [OrderHeaderComponent, OrderItemsTableComponent, PaymentFormComponent]
})
export class OrderPageComponent {
protected readonly orderService = inject(OrderService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly notificationService = inject(NotificationService);
protected readonly submitting = signal(false);
constructor() {
this.orderService.currentOrderId.set(this.route.snapshot.params['id']);
}
protected onPaymentSubmit(payment: PaymentData): void {
this.submitting.set(true);
const orderId = this.orderService.orderResource.value()!.id;
this.orderService.submitPayment(orderId, payment).subscribe({
next: () => {
this.notificationService.success('Payment completed');
this.router.navigate(['/orders']);
},
error: () => {
this.notificationService.error('Payment failed');
this.submitting.set(false);
}
});
}
}提取前——单体组件(违反SRP,使用旧版模式展示需要重构的内容):
typescript
// BEFORE: legacy patterns — *ngFor, *ngIf, constructor injection, no OnPush, no signals
@Component({
selector: 'app-order-page',
template: `
<div class="header">
<h1>{{ order?.title }}</h1>
<span>{{ order?.status }}</span>
</div>
<table>
<tr *ngFor="let item of order?.items">
<td>{{ item.name }}</td>
<td>{{ item.price | currency }}</td>
</tr>
</table>
<form [formGroup]="paymentForm" (ngSubmit)="submitPayment()">
<input formControlName="cardNumber">
<div *ngIf="paymentForm.get('cardNumber')?.errors?.['required']">
Card number is required
</div>
<button type="submit" [disabled]="paymentForm.invalid">Pay</button>
</form>
`
})
export class OrderPageComponent implements OnInit {
order: Order | null = null;
paymentForm: FormGroup;
constructor(
private http: HttpClient,
private route: ActivatedRoute,
private fb: FormBuilder,
private router: Router,
private notificationService: NotificationService
) {
this.paymentForm = this.fb.group({
cardNumber: ['', [Validators.required, Validators.minLength(16)]],
expDate: ['', Validators.required],
cvv: ['', [Validators.required, Validators.minLength(3)]]
});
}
ngOnInit(): void {
const id = this.route.snapshot.params['id'];
this.http.get<Order>(`/api/orders/${id}`).subscribe(order => this.order = order);
}
submitPayment(): void {
this.http.post('/api/payments', {
orderId: this.order?.id,
...this.paymentForm.value
}).subscribe({
next: () => this.router.navigate(['/orders']),
error: () => this.notificationService.error('Payment failed')
});
}
}提取后——使用现代Angular(v19+)模块化:
typescript
// order.service.ts — Data fetching (uses httpResource for reads, HttpClient for mutations)
@Injectable({ providedIn: 'root' })
export class OrderService {
private readonly http = inject(HttpClient);
readonly orderResource = httpResource<Order>(() => ({
url: `/api/orders/${this.currentOrderId()}`
}));
readonly currentOrderId = signal<string>('');
submitPayment(orderId: string, payment: PaymentData): Observable<PaymentResult> {
return this.http.post<PaymentResult>('/api/payments', { orderId, ...payment });
}
}typescript
// order-header.component.ts — Presentational (display only, no services)
@Component({
selector: 'app-order-header',
template: `
<div class="header">
<h1>{{ order().title }}</h1>
<span class="status">{{ order().status }}</span>
<p>{{ order().date | date:'longDate' }}</p>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DatePipe]
})
export class OrderHeaderComponent {
readonly order = input.required<Order>();
}typescript
// order-items-table.component.ts — Presentational
@Component({
selector: 'app-order-items-table',
template: `
<table>
@for (item of items(); track item.id) {
<tr>
<td>{{ item.name }}</td>
<td>{{ item.price | currency }}</td>
<td>{{ item.quantity }}</td>
</tr>
} @empty {
<tr><td colspan="3">No items</td></tr>
}
<tr class="total">
<td colspan="2">Total</td>
<td>{{ total() | currency }}</td>
</tr>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe]
})
export class OrderItemsTableComponent {
readonly items = input.required<OrderItem[]>();
protected readonly total = computed(() =>
this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
}typescript
// payment-form.component.ts — Presentational (form UI, no API calls)
@Component({
selector: 'app-payment-form',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="cardNumber" placeholder="Card number">
<input formControlName="expDate" placeholder="MM/YY">
<input formControlName="cvv" placeholder="CVV">
@if (form.get('cardNumber')?.hasError('required')) {
<div class="error">Card number is required</div>
}
<button type="submit" [disabled]="form.invalid || submitting()">Pay</button>
</form>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule]
})
export class PaymentFormComponent {
private readonly fb = inject(FormBuilder);
readonly submitting = input(false);
readonly paymentSubmitted = output<PaymentData>();
protected readonly form = this.fb.group({
cardNumber: ['', [Validators.required, Validators.minLength(16)]],
expDate: ['', Validators.required],
cvv: ['', [Validators.required, Validators.minLength(3)]]
});
protected onSubmit(): void {
if (this.form.valid) {
this.paymentSubmitted.emit(this.form.value as PaymentData);
}
}
}typescript
// order-page.component.ts — Smart/Container (orchestrates children, manages state)
@Component({
selector: 'app-order-page',
template: `
@if (orderService.orderResource.hasValue()) {
<app-order-header [order]="orderService.orderResource.value()!" />
<app-order-items-table [items]="orderService.orderResource.value()!.items" />
<app-payment-form
[submitting]="submitting()"
(paymentSubmitted)="onPaymentSubmit($event)" />
} @else if (orderService.orderResource.isLoading()) {
<p>Loading order...</p>
} @else if (orderService.orderResource.error()) {
<p>Error loading order</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [OrderHeaderComponent, OrderItemsTableComponent, PaymentFormComponent]
})
export class OrderPageComponent {
protected readonly orderService = inject(OrderService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly notificationService = inject(NotificationService);
protected readonly submitting = signal(false);
constructor() {
this.orderService.currentOrderId.set(this.route.snapshot.params['id']);
}
protected onPaymentSubmit(payment: PaymentData): void {
this.submitting.set(true);
const orderId = this.orderService.orderResource.value()!.id;
this.orderService.submitPayment(orderId, payment).subscribe({
next: () => {
this.notificationService.success('Payment completed');
this.router.navigate(['/orders']);
},
error: () => {
this.notificationService.error('Payment failed');
this.submitting.set(false);
}
});
}
}3.5 Handle Shared State Between Split Components
3.5 处理拆分后组件间的共享状态
| Pattern | When to Use |
|---|---|
Parent passes data via | Simple flow, < 3 levels deep |
Shared service with | Multiple siblings need same reactive state |
| Parent orchestrates child interactions |
| Parent and child need to sync a value |
| Dependent state that resets when source changes but remains writable |
| NgRx / signal store | Complex state with many consumers across features |
Rule: Never create a service just to avoid passing one input. Use services when
3+ components need the same state or prop drilling exceeds 3 levels.
| 模式 | 使用场景 |
|---|---|
父组件通过 | 简单数据流,层级<3层 |
使用 | 多个子组件需要同一响应式状态 |
| 父组件协调子组件交互 |
| 父组件与子组件需要同步某个值 |
| 依赖于源状态的可写状态,源状态变更时会重置 |
| NgRx / 信号存储 | 复杂状态,跨特性模块被多个消费者使用 |
规则: 不要仅仅为了避免传递一个输入属性而创建服务。当3个以上组件需要同一状态,或者属性透传超过3层时,再使用服务。
Step 4: Project-Level Structure
步骤4:项目级结构
Angular v19+ (Standalone by default)
Angular v19+(默认使用独立组件)
Components are standalone by default. No need for explicitly.
standalone: truesrc/app/
├── app.component.ts
├── app.config.ts # provideRouter, provideHttpClient, etc.
├── app.routes.ts
├── core/ # Singletons — imported ONCE at root
│ ├── interceptors/
│ │ └── auth.interceptor.ts # functional interceptor
│ ├── guards/
│ │ └── auth.guard.ts # functional guard (CanActivateFn)
│ └── services/
│ └── auth.service.ts # providedIn: 'root'
├── shared/ # Reusable, STATELESS
│ ├── components/
│ │ └── data-table/
│ │ ├── data-table.component.ts
│ │ ├── data-table.component.html
│ │ ├── data-table.component.css
│ │ └── data-table.component.spec.ts
│ ├── directives/
│ ├── pipes/
│ └── models/
└── features/ # Lazy-loaded business domains
└── users/
├── user-list/
│ ├── user-list.component.ts # smart/container
│ ├── user-list.component.html
│ ├── user-list.component.spec.ts
│ └── components/ # presentational children
│ └── user-card/
│ ├── user-card.component.ts
│ └── user-card.component.spec.ts
├── user-detail/
├── services/
│ └── user.service.ts
├── models/
│ └── user.model.ts
└── users.routes.ts组件默认是独立的,无需显式添加。
standalone: truesrc/app/
├── app.component.ts
├── app.config.ts # provideRouter, provideHttpClient等配置
├── app.routes.ts
├── core/ # 单例服务 —— 仅在根模块导入一次
│ ├── interceptors/
│ │ └── auth.interceptor.ts # 函数式拦截器
│ ├── guards/
│ │ └── auth.guard.ts # 函数式守卫(CanActivateFn)
│ └── services/
│ └── auth.service.ts # providedIn: 'root'
├── shared/ # 可复用、无状态的内容
│ ├── components/
│ │ └── data-table/
│ │ ├── data-table.component.ts
│ │ ├── data-table.component.html
│ │ ├── data-table.component.css
│ │ └── data-table.component.spec.ts
│ ├── directives/
│ ├── pipes/
│ └── models/
└── features/ # 懒加载的业务域
└── users/
├── user-list/
│ ├── user-list.component.ts # 智能/容器组件
│ ├── user-list.component.html
│ ├── user-list.component.spec.ts
│ └── components/ # 展示性子组件
│ └── user-card/
│ ├── user-card.component.ts
│ └── user-card.component.spec.ts
├── user-detail/
├── services/
│ └── user.service.ts
├── models/
│ └── user.model.ts
└── users.routes.tsAngular v14-v16 (NgModules)
Angular v14-v16(使用NgModules)
src/app/
├── app.module.ts
├── app-routing.module.ts
├── core/
│ └── core.module.ts
├── shared/
│ └── shared.module.ts
└── features/
└── users/
├── users.module.ts
├── users-routing.module.ts
├── components/
├── services/
└── models/src/app/
├── app.module.ts
├── app-routing.module.ts
├── core/
│ └── core.module.ts
├── shared/
│ └── shared.module.ts
└── features/
└── users/
├── users.module.ts
├── users-routing.module.ts
├── components/
├── services/
└── models/Module Boundary Rules
模块边界规则
| Directory | Contains | Rules |
|---|---|---|
| core/ | Auth, interceptors, guards, app-wide services | |
| shared/ | Reusable UI: buttons, tables, pipes, directives | Stateless only. No services with state. Barrel exports for public API. |
| features/ | Business domains with routing | Self-contained. Lazy-loaded. No cross-feature imports. Smart + presentational. |
| 目录 | 包含内容 | 规则 |
|---|---|---|
| core/ | 认证、拦截器、守卫、应用级服务 | |
| shared/ | 可复用UI:按钮、表格、管道、指令 | 仅包含无状态内容。无带状态的服务。使用桶文件(index.ts)暴露公共API。 |
| features/ | 带路由的业务域 | 自包含。懒加载。禁止跨特性模块导入。包含智能组件+展示组件。 |
When TO Create a Feature Directory
何时创建特性目录
- Feature has its own route
- 3+ related components
- Benefits from lazy loading
- Distinct business domain
- 特性拥有独立路由
- 包含3个以上相关组件
- 懒加载可带来收益
- 属于独立的业务领域
When NOT to Create a Feature Directory
何时不创建特性目录
- App < 10 components total
- Single component with no subroutes
- Would create circular dependencies
- Always loads at startup anyway
- 应用总组件数<10个
- 单个组件,无子路由
- 会导致循环依赖
- 无论如何都会在启动时加载
Step 5: Lazy Loading
步骤5:懒加载
Route-based (v19+)
基于路由的懒加载(v19+)
typescript
// app.routes.ts
export const routes: Routes = [
{
path: 'users',
loadChildren: () => import('./features/users/users.routes')
.then(m => m.USERS_ROUTES)
},
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard.component')
.then(c => c.DashboardComponent)
}
];typescript
// app.routes.ts
export const routes: Routes = [
{
path: 'users',
loadChildren: () => import('./features/users/users.routes')
.then(m => m.USERS_ROUTES)
},
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard.component')
.then(c => c.DashboardComponent)
}
];Deferred Loading with @defer
(v17+)
@defer使用@defer
的延迟加载(v17+)
@deferFor heavy components within a page. Component inside MUST be standalone.
@deferhtml
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder (minimum 200ms) {
<div class="skeleton">Chart loading...</div>
} @loading (after 100ms; minimum 500ms) {
<app-spinner />
} @error {
<p>Failed to load chart</p>
}Triggers: (default), , , ,
, , .
on idleon viewporton interactionon hoveron timer(500ms)when conditionon immediatePrefetching: — loads JS in background
before user triggers it.
@defer (on interaction; prefetch on idle)用于页面内的重型组件。内的组件必须是独立组件。
@deferhtml
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder (minimum 200ms) {
<div class="skeleton">Chart loading...</div>
} @loading (after 100ms; minimum 500ms) {
<app-spinner />
} @error {
<p>Failed to load chart</p>
}触发条件: (默认)、、、、、、。
on idleon viewporton interactionon hoveron timer(500ms)when conditionon immediate预加载: —— 在用户触发前,后台加载JS文件。
@defer (on interaction; prefetch on idle)NgModules (v14-v16)
NgModules版本的懒加载(v14-v16)
typescript
const routes: Routes = [
{
path: 'users',
loadChildren: () => import('./features/users/users.module')
.then(m => m.UsersModule)
}
];typescript
const routes: Routes = [
{
path: 'users',
loadChildren: () => import('./features/users/users.module')
.then(m => m.UsersModule)
}
];Step 6: Modern Angular APIs (v19+)
步骤6:现代Angular API(v19+)
Signals — Reactive State
信号——响应式状态
typescript
// Writable signal
protected readonly count = signal(0);
// Read-only computed
protected readonly doubled = computed(() => this.count() * 2);
// Dependent writable state (resets when source changes, but still writable)
protected readonly selectedOption = linkedSignal(() => this.options()[0]);
// Side effects
effect(() => {
console.log('Count changed:', this.count());
});typescript
// 可写信号
protected readonly count = signal(0);
// 只读计算信号
protected readonly doubled = computed(() => this.count() * 2);
// 依赖可写状态(源状态变更时重置,但自身仍可写)
protected readonly selectedOption = linkedSignal(() => this.options()[0]);
// 副作用
effect(() => {
console.log('Count changed:', this.count());
});Component Communication
组件通信
typescript
// Inputs (signal-based, read-only)
readonly name = input<string>(); // optional
readonly id = input.required<number>(); // required
readonly disabled = input(false); // with default
readonly width = input(0, { transform: numberAttribute }); // with transform
// Two-way binding (writable input + automatic output)
readonly value = model(0); // parent uses [(value)]="signal"
// Outputs (event emitter)
readonly closed = output<void>();
readonly selected = output<User>();typescript
// 输入属性(基于信号,只读)
readonly name = input<string>(); // 可选
readonly id = input.required<number>(); // 必填
readonly disabled = input(false); // 带默认值
readonly width = input(0, { transform: numberAttribute }); // 带转换函数
// 双向绑定(可写输入+自动输出)
readonly value = model(0); // 父组件使用[(value)]="signal"
// 输出属性(事件发射器)
readonly closed = output<void>();
readonly selected = output<User>();Data Fetching
数据获取
typescript
// httpResource for GET requests (reactive, signal-based)
readonly usersResource = httpResource<User[]>(() => '/api/users');
// resource() for custom async operations
readonly dataResource = resource({
params: () => ({ id: this.userId() }),
loader: ({ params, abortSignal }) =>
fetch(`/api/data/${params.id}`, { signal: abortSignal }).then(r => r.json())
});
// HttpClient for mutations (POST, PUT, DELETE)
this.http.post('/api/users', userData).subscribe();typescript
// httpResource用于GET请求(响应式、基于信号)
readonly usersResource = httpResource<User[]>(() => '/api/users');
// resource()用于自定义异步操作
readonly dataResource = resource({
params: () => ({ id: this.userId() }),
loader: ({ params, abortSignal }) =>
fetch(`/api/data/${params.id}`, { signal: abortSignal }).then(r => r.json())
});
// HttpClient用于变更操作(POST、PUT、DELETE)
this.http.post('/api/users', userData).subscribe();Template Control Flow
模板控制流
html
@if (user(); as u) {
<h1>{{ u.name }}</h1>
} @else {
<p>No user</p>
}
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<p>No items found</p>
}
@switch (status()) {
@case ('active') { <app-active-badge /> }
@case ('inactive') { <app-inactive-badge /> }
@default { <span>Unknown</span> }
}html
@if (user(); as u) {
<h1>{{ u.name }}</h1>
} @else {
<p>No user</p>
}
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<p>No items found</p>
}
@switch (status()) {
@case ('active') { <app-active-badge /> }
@case ('inactive') { <app-inactive-badge /> }
@default { <span>Unknown</span> }
}Dependency Injection
依赖注入
typescript
// inject() function — preferred over constructor injection
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
// Cleanup with DestroyRef (replaces ngOnDestroy)
constructor() {
this.destroyRef.onDestroy(() => {
// cleanup logic
});
}typescript
// inject()函数 —— 优先于构造函数注入
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
// 使用DestroyRef清理资源(替代ngOnDestroy)
constructor() {
this.destroyRef.onDestroy(() => {
// 清理逻辑
});
}Lifecycle
生命周期
typescript
// Still valid: ngOnInit, ngOnChanges, ngOnDestroy, etc.
// Modern additions:
afterNextRender(() => {
// DOM manipulation after first render (SSR-safe)
});
afterEveryRender(() => {
// runs after every render cycle
});typescript
// 仍可使用:ngOnInit、ngOnChanges、ngOnDestroy等
// 新增的现代API:
afterNextRender(() => {
// 首次渲染后的DOM操作(支持SSR)
});
afterEveryRender(() => {
// 每次渲染周期后执行
});Step 7: Component Best Practices Summary
步骤7:组件最佳实践总结
Every component MUST follow these rules:
- on ALL components
ChangeDetectionStrategy.OnPush - function for dependency injection (not constructor params)
inject() - /
input()for inputs (notinput.required()decorator)@Input() - for events (not
output()decorator)@Output() - for two-way binding
model() - Signals (,
signal,computed) for reactive statelinkedSignal - ,
@if,@forcontrol flow (not@switch,*ngIf)*ngFor - on properties initialized by Angular (
readonly,input, queries)output - for members only used in templates
protected - Co-locate ,
.ts,.html,.cssin same directory.spec.ts - One concept per file — one component/service/directive per file
- kebab-case file names:
user-profile.component.ts
所有组件必须遵循以下规则:
- 所有组件都启用
ChangeDetectionStrategy.OnPush - 使用函数进行依赖注入(而非构造函数参数)
inject() - **使用/
input()**定义输入属性(而非input.required()装饰器)@Input() - **使用**定义事件(而非
output()装饰器)@Output() - **使用**实现双向绑定
model() - 使用信号(、
signal、computed)管理响应式状态linkedSignal - **使用、
@if、@for**控制流(而非@switch、*ngIf)*ngFor - Angular初始化的属性(input、output、查询)添加修饰符
readonly - 模板中使用的成员添加修饰符
protected - 将、
.ts、.html、.css放在同一目录下.spec.ts - 每个文件对应一个概念 —— 一个文件仅包含一个组件/服务/指令
- 文件名使用短横线命名法:
user-profile.component.ts
Step 8: Naming Conventions
步骤8:命名规范
- Files: →
<name>.<type>.ts,user-list.component.ts,auth.guard.tsdate-format.pipe.ts - Classes: ,
UserListComponent,AuthServiceDateFormatPipe - Selectors: (kebab-case with prefix)
app-user-list - Event handlers: name by action performed, not trigger → not
saveUser()handleClick() - Avoid: ,
utils.ts,helpers.ts— be specificcommon.ts - Barrel exports () only for public APIs, never for internals
index.ts
- 文件: →
<name>.<type>.ts、user-list.component.ts、auth.guard.tsdate-format.pipe.ts - 类: 、
UserListComponent、AuthServiceDateFormatPipe - 选择器: (带前缀的短横线命名法)
app-user-list - 事件处理函数: 按执行的操作命名,而非触发方式 → 而非
saveUser()handleClick() - 避免: 、
utils.ts、helpers.ts—— 名称要具体common.ts - **桶文件导出()**仅用于公共API,不要用于内部模块
index.ts
Step 9: NgModule → Standalone Migration
步骤9:NgModule → 独立组件迁移
When project uses NgModules and Angular v14+:
- Leaf components first (no children) → add +
standalone: truearrayimports - Shared components next → convert and update all consumers
- Feature modules one at a time → replace with
.module.ts.routes.ts - Update routing → with module →
loadChildren/loadComponentwith routesloadChildren - Remove empty modules → delete when all declarations are standalone
.module.ts - Hybrid is OK → not everything must be migrated at once
当项目使用NgModules且Angular版本为v14+时:
- 先迁移叶子组件(无子组件)→ 添加+
standalone: true数组imports - 再迁移共享组件 → 转换后更新所有使用该组件的地方
- 逐个迁移特性模块 → 用替代
.routes.ts.module.ts - 更新路由配置 → 从加载模块的改为加载路由的
loadChildren/loadComponentloadChildren - 删除空模块 → 当所有声明的组件都变为独立组件后,删除
.module.ts - 混合模式是允许的 → 无需一次性完成所有迁移
Do NOT Migrate
无需迁移的情况
- Third-party modules without standalone support
- Modules with complex providers → migrate providers to first
app.config.ts - Library entry point modules
- 不支持独立组件的第三方模块
- 包含复杂提供者的模块 → 先将提供者迁移至
app.config.ts - 库的入口模块
Step 10: Anti-Patterns
步骤10:反模式
| Anti-Pattern | Fix |
|---|---|
| God component (> 200 lines class) | Split into smart + presentational |
| God module (50+ declarations) | Split into feature modules |
| Prop drilling (> 3 levels) | Shared service with signals |
| Cross-feature imports | Communicate via shared services |
Services in | Move stateful services to |
| Business logic in templates | Move to |
| Fat templates (> 100 lines) | Extract child components |
| Generic file names | Name by specific responsibility |
| Circular dependencies | Restructure module boundaries |
| Use |
| Use |
| Constructor injection | Use |
| Default change detection | Use |
| Use |
Manual | Use |
| 反模式 | 修复方案 |
|---|---|
| 上帝组件(类代码>200行) | 拆分为智能组件+展示组件 |
| 上帝模块(声明>50个) | 拆分为特性模块 |
| 属性透传(>3层) | 使用带信号的共享服务 |
| 跨特性模块导入 | 通过共享服务通信 |
服务放在 | 将有状态服务移至 |
| 业务逻辑写在模板中 | 移至 |
| 臃肿模板(>100行) | 提取子组件 |
| 通用文件名 | 改为具体名称 |
| 循环依赖 | 重构模块边界 |
使用 | 改用 |
使用 | 改用 |
| 构造函数注入 | 改用 |
| 默认变更检测策略 | 使用 |
GET请求使用 | 改用 |
手动在 | 使用 |
Step 11: Refactoring Checklist
步骤11:重构检查清单
Execute in this order:
- Identify large components → read and
.htmlfiles, list candidates by thresholds.ts - Split monolithic components → apply Step 3 for each candidate
- Create directory structure → ,
core/,shared/features/ - Extract core services → auth, interceptors, guards →
core/ - Extract shared components → reusable UI →
shared/ - Group into features → related components →
features/<name>/ - Set up lazy loading → configure routes + blocks
@defer - Update all imports → fix paths across the application
- Modernize APIs → replace deprecated patterns (see Anti-Patterns table)
- Build the project → to detect errors and circular dependencies
npx ng build - Run ALL tests →
npx ng test --watch=false - Serve and verify → , test navigation and lazy loading
npx ng serve
按以下顺序执行:
- 识别大型组件 → 读取和
.html文件,按阈值列出候选组件.ts - 拆分单体组件 → 对每个候选组件应用步骤3的操作
- 创建目录结构 → 建立、
core/、shared/目录features/ - 提取核心服务 → 认证、拦截器、守卫 → 移至
core/ - 提取共享组件 → 可复用UI → 移至
shared/ - 按特性分组 → 相关组件 → 移至
features/<name>/ - 配置懒加载 → 配置路由 + 块
@defer - 更新所有导入路径 → 修复应用中的路径引用
- 升级API → 替换已废弃的模式(参考反模式表格)
- 构建项目 → 检测错误和循环依赖
npx ng build - 运行所有测试 →
npx ng test --watch=false - 启动并验证 → ,测试导航和懒加载功能
npx ng serve
Step 12: Unit Testing
步骤12:单元测试
Every file must have a :
.spec.ts- Presentational: test rendering with different values,
input()emissions, user interactionsoutput() - Smart: test service calls, signal state management, child coordination
- Services: test public methods, HTTP calls with
HttpTestingController - Guards: test access conditions and redirects
- Pipes: test transformations with edge cases
- After restructuring, run ALL existing tests to verify nothing broke
每个文件都必须有对应的测试文件:
.spec.ts- 展示组件:测试不同值的渲染效果、
input()事件触发、用户交互output() - 智能组件:测试服务调用、信号状态管理、子组件协调逻辑
- 服务:测试公共方法、使用测试HTTP调用
HttpTestingController - 守卫:测试访问条件和重定向逻辑
- 管道:测试转换逻辑及边界情况
- 重构后,运行所有现有测试以确保功能未被破坏
Important Reminders
重要提醒
- Always present before/after structure to the user
- Ask confirmation before moving files or splitting components
- Preserve git history → use for file moves
git mv - Update path aliases after restructuring
tsconfig.json - Verify references after file moves
angular.json - Do not over-modularize → if splitting adds more complexity than it solves, don't split
- Adapt to version → use legacy APIs only if the project's Angular version requires it
- 始终向用户呈现变更前后的结构
- 移动文件或拆分组件前,务必征得用户确认
- 保留Git历史 → 使用命令移动文件
git mv - **更新**中的路径别名(重构后)
tsconfig.json - **验证**中的引用(移动文件后)
angular.json - 不要过度模块化 → 如果拆分带来的复杂度超过其解决的问题,就不要拆分
- 适配版本 → 仅当项目的Angular版本要求时,才使用旧版API",