angular-modularize

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Angular 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:
  1. Read
    package.json
    → detect
    @angular/core
    version
  2. Scan structure
    Glob("**/*.component.ts")
    ,
    Glob("**/*.module.ts")
    ,
    Glob("**/*.routes.ts")
  3. Detect standalone
    Grep("standalone", glob="**/*.component.ts")
  4. Identify large components → read
    .ts
    and
    .html
    files, count lines, list responsibilities
  5. Map dependencies → which components use which services, check imports
  6. Present findings to the user and ask confirmation before proceeding

在进行任何变更之前:
  1. 查看
    package.json
    → 检测
    @angular/core
    版本
  2. 扫描项目结构 → 使用
    Glob("**/*.component.ts")
    Glob("**/*.module.ts")
    Glob("**/*.routes.ts")
    工具
  3. 检测独立组件 → 使用
    Grep("standalone", glob="**/*.component.ts")
    工具
  4. 识别大型组件 → 读取
    .ts
    .html
    文件,统计代码行数,列出组件职责
  5. 梳理依赖关系 → 明确哪些组件使用了哪些服务,检查导入情况
  6. 向用户呈现分析结果,并在执行前征得用户确认

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

代码异味指标——组件必须拆分的情况

IndicatorThresholdAction
Template (HTML) lines> 100 linesExtract child components
Component class lines> 200 linesExtract logic to services
File total lines> 400 linesSplit responsibilities (angular.dev style guide)
inject()
calls or constructor params
> 5Too many concerns, split
Distinct
@if
blocks (or
*ngIf
in legacy)
> 3 unrelated sectionsEach section → child component
Distinct
@for
loops (or
*ngFor
in legacy)
> 2 different data sourcesEach list → child component
Component fetches data AND rendersMixed smart/presentationalSplit into container + presentational
Repeated HTML blocksSame block 2+ timesExtract to shared component
Multiple form groups> 2 in same componentEach form section → child component
Generic component name
MainComponent
,
PageComponent
Rename to reflect single responsibility
指标阈值操作
模板(HTML)行数> 100行提取子组件
组件类(TS)行数> 200行将逻辑提取至服务
文件总行数> 400行拆分职责(遵循angular.dev风格指南)
inject()
调用或构造函数参数数量
> 5个职责过多,需要拆分
独立的
@if
块(旧版本为
*ngIf
> 3个不相关区块每个区块提取为子组件
独立的
@for
循环(旧版本为
*ngFor
> 2个不同数据源每个列表提取为子组件
组件同时负责数据获取和渲染混合了智能/展示组件职责拆分为容器组件+展示组件
重复的HTML区块同一区块出现2次以上提取为共享组件
多个表单组同一组件内存在>2个表单组每个表单区块提取为子组件
通用组件名称
MainComponent
PageComponent
重命名以体现单一职责

When a Component Should NOT Be Split

组件无需拆分的情况

CriteriaReason
Template < 50 lines with single responsibilityAlready small enough
Used only once, no childrenNo reuse benefit
Simple wrapper (< 3 inputs, no logic)Already granular
Splitting needs > 5 inputs passed downCreates prop drilling (worse)
Tightly coupled to parent lifecycleSplitting breaks interaction
Feature has < 3 files totalModule overhead exceeds benefit
App has < 10 components totalOver-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 展示组件

TypeCharacteristicsRules
Smart (Container)Injects services via
inject()
, fetches data, manages state with signals, coordinates children
One per route/feature. Knows WHERE data comes from.
Presentational (Dumb)Receives data via
input()
/
input.required()
, emits events via
output()
, renders UI only
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.
类型特征规则
智能组件(容器组件)通过
inject()
注入服务、获取数据、使用信号管理状态、协调子组件
每个路由/特性模块对应一个。知道数据来自哪里
展示组件(纯UI组件)通过
input()
/
input.required()
接收数据、通过
output()
触发事件、仅负责渲染UI
不注入服务。无业务逻辑。可在任意上下文复用。不知道数据来自哪里。
关键测试: 如果组件在不修改的情况下无法在其他应用中使用 → 属于智能组件。如果可以 → 属于展示组件。

3.3 Where Each Responsibility Goes

3.3 各职责的归属

ResponsibilityDestinationRule
API calls, data fetchingService with
httpResource()
or
HttpClient
Never in presentational components
Business logic, calculationsService or utility fileComponents should not contain business rules
Reusable UI block
shared/components/
If used in 2+ features
Feature-specific UI blockChild component in same feature dirIf used only here
Form validation rulesValidators file or serviceTestable and reusable
State managementService with
signal()
,
computed()
,
linkedSignal()
Components are NOT the source of truth
职责目标位置规则
API调用、数据获取使用
httpResource()
HttpClient
的服务
永远不要放在展示组件中
业务逻辑、计算逻辑服务或工具文件组件不应包含业务规则
可复用UI区块
shared/components/
如果被2个以上特性模块使用
特性专属UI区块同一特性目录下的子组件如果仅在此处使用
表单验证规则验证器文件或服务便于测试和复用
状态管理使用
signal()
computed()
linkedSignal()
的服务
组件不是状态的唯一可信源

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 处理拆分后组件间的共享状态

PatternWhen to Use
Parent passes data via
input()
Simple flow, < 3 levels deep
Shared service with
signal()
/
computed()
Multiple siblings need same reactive state
output()
events up +
input()
down
Parent orchestrates child interactions
model()
two-way binding
Parent and child need to sync a value
linkedSignal()
Dependent state that resets when source changes but remains writable
NgRx / signal storeComplex 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.

模式使用场景
父组件通过
input()
传递数据
简单数据流,层级<3层
使用
signal()
/
computed()
的共享服务
多个子组件需要同一响应式状态
output()
事件向上传递 +
input()
向下传递
父组件协调子组件交互
model()
双向绑定
父组件与子组件需要同步某个值
linkedSignal()
依赖于源状态的可写状态,源状态变更时会重置
NgRx / 信号存储复杂状态,跨特性模块被多个消费者使用
规则: 不要仅仅为了避免传递一个输入属性而创建服务。当3个以上组件需要同一状态,或者属性透传超过3层时,再使用服务。

Step 4: Project-Level Structure

步骤4:项目级结构

Angular v19+ (Standalone by default)

Angular v19+(默认使用独立组件)

Components are standalone by default. No need for
standalone: true
explicitly.
src/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: true
src/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.ts

Angular 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

模块边界规则

DirectoryContainsRules
core/Auth, interceptors, guards, app-wide services
providedIn: 'root'
. Imported ONCE at root. Never in features.
shared/Reusable UI: buttons, tables, pipes, directivesStateless only. No services with state. Barrel exports for public API.
features/Business domains with routingSelf-contained. Lazy-loaded. No cross-feature imports. Smart + presentational.
目录包含内容规则
core/认证、拦截器、守卫、应用级服务
providedIn: 'root'
。仅在根模块导入一次。永远不要在特性模块中导入。
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
的延迟加载(v17+)

For heavy components within a page. Component inside
@defer
MUST be standalone.
html
@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:
on idle
(default),
on viewport
,
on interaction
,
on hover
,
on timer(500ms)
,
when condition
,
on immediate
.
Prefetching:
@defer (on interaction; prefetch on idle)
— loads JS in background before user triggers it.
用于页面内的重型组件。
@defer
内的组件必须是独立组件。
html
@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 idle
(默认)、
on viewport
on interaction
on hover
on timer(500ms)
when condition
on immediate
预加载:
@defer (on interaction; prefetch on idle)
—— 在用户触发前,后台加载JS文件。

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:
  • ChangeDetectionStrategy.OnPush
    on ALL components
  • inject()
    function
    for dependency injection (not constructor params)
  • input()
    /
    input.required()
    for inputs (not
    @Input()
    decorator)
  • output()
    for events (not
    @Output()
    decorator)
  • model()
    for two-way binding
  • Signals (
    signal
    ,
    computed
    ,
    linkedSignal
    ) for reactive state
  • @if
    ,
    @for
    ,
    @switch
    control flow (not
    *ngIf
    ,
    *ngFor
    )
  • readonly
    on properties initialized by Angular (
    input
    ,
    output
    , queries)
  • protected
    for members only used in templates
  • Co-locate
    .ts
    ,
    .html
    ,
    .css
    ,
    .spec.ts
    in same directory
  • 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.ts
    ,
    date-format.pipe.ts
  • Classes:
    UserListComponent
    ,
    AuthService
    ,
    DateFormatPipe
  • Selectors:
    app-user-list
    (kebab-case with prefix)
  • Event handlers: name by action performed, not trigger →
    saveUser()
    not
    handleClick()
  • Avoid:
    utils.ts
    ,
    helpers.ts
    ,
    common.ts
    — be specific
  • Barrel exports (
    index.ts
    ) only for public APIs, never for internals

  • 文件:
    <name>.<type>.ts
    user-list.component.ts
    auth.guard.ts
    date-format.pipe.ts
  • 类:
    UserListComponent
    AuthService
    DateFormatPipe
  • 选择器:
    app-user-list
    (带前缀的短横线命名法)
  • 事件处理函数: 按执行的操作命名,而非触发方式 →
    saveUser()
    而非
    handleClick()
  • 避免:
    utils.ts
    helpers.ts
    common.ts
    —— 名称要具体
  • **桶文件导出(
    index.ts
    )**仅用于公共API,不要用于内部模块

Step 9: NgModule → Standalone Migration

步骤9:NgModule → 独立组件迁移

When project uses NgModules and Angular v14+:
  1. Leaf components first (no children) → add
    standalone: true
    +
    imports
    array
  2. Shared components next → convert and update all consumers
  3. Feature modules one at a time → replace
    .module.ts
    with
    .routes.ts
  4. Update routing
    loadChildren
    with module →
    loadComponent
    /
    loadChildren
    with routes
  5. Remove empty modules → delete
    .module.ts
    when all declarations are standalone
  6. Hybrid is OK → not everything must be migrated at once
当项目使用NgModules且Angular版本为v14+时:
  1. 先迁移叶子组件(无子组件)→ 添加
    standalone: true
    +
    imports
    数组
  2. 再迁移共享组件 → 转换后更新所有使用该组件的地方
  3. 逐个迁移特性模块 → 用
    .routes.ts
    替代
    .module.ts
  4. 更新路由配置 → 从加载模块的
    loadChildren
    改为加载路由的
    loadComponent
    /
    loadChildren
  5. 删除空模块 → 当所有声明的组件都变为独立组件后,删除
    .module.ts
  6. 混合模式是允许的 → 无需一次性完成所有迁移

Do NOT Migrate

无需迁移的情况

  • Third-party modules without standalone support
  • Modules with complex providers → migrate providers to
    app.config.ts
    first
  • Library entry point modules

  • 不支持独立组件的第三方模块
  • 包含复杂提供者的模块 → 先将提供者迁移至
    app.config.ts
  • 库的入口模块

Step 10: Anti-Patterns

步骤10:反模式

Anti-PatternFix
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 importsCommunicate via shared services
Services in
shared/
Move stateful services to
core/
Business logic in templatesMove to
computed()
or methods
Fat templates (> 100 lines)Extract child components
Generic file namesName by specific responsibility
Circular dependenciesRestructure module boundaries
@Input
/
@Output
decorators
Use
input()
/
output()
signal APIs
*ngIf
/
*ngFor
directives
Use
@if
/
@for
control flow
Constructor injectionUse
inject()
function
Default change detectionUse
ChangeDetectionStrategy.OnPush
subscribe()
for GET requests
Use
httpResource()
or
resource()
Manual
ngOnDestroy
cleanup
Use
DestroyRef.onDestroy()

反模式修复方案
上帝组件(类代码>200行)拆分为智能组件+展示组件
上帝模块(声明>50个)拆分为特性模块
属性透传(>3层)使用带信号的共享服务
跨特性模块导入通过共享服务通信
服务放在
shared/
目录
将有状态服务移至
core/
业务逻辑写在模板中移至
computed()
或方法中
臃肿模板(>100行)提取子组件
通用文件名改为具体名称
循环依赖重构模块边界
使用
@Input
/
@Output
装饰器
改用
input()
/
output()
信号API
使用
*ngIf
/
*ngFor
指令
改用
@if
/
@for
控制流
构造函数注入改用
inject()
函数
默认变更检测策略使用
ChangeDetectionStrategy.OnPush
GET请求使用
subscribe()
改用
httpResource()
resource()
手动在
ngOnDestroy
中清理
使用
DestroyRef.onDestroy()

Step 11: Refactoring Checklist

步骤11:重构检查清单

Execute in this order:
  1. Identify large components → read
    .html
    and
    .ts
    files, list candidates by thresholds
  2. Split monolithic components → apply Step 3 for each candidate
  3. Create directory structure
    core/
    ,
    shared/
    ,
    features/
  4. Extract core services → auth, interceptors, guards →
    core/
  5. Extract shared components → reusable UI →
    shared/
  6. Group into features → related components →
    features/<name>/
  7. Set up lazy loading → configure routes +
    @defer
    blocks
  8. Update all imports → fix paths across the application
  9. Modernize APIs → replace deprecated patterns (see Anti-Patterns table)
  10. Build the project
    npx ng build
    to detect errors and circular dependencies
  11. Run ALL tests
    npx ng test --watch=false
  12. Serve and verify
    npx ng serve
    , test navigation and lazy loading

按以下顺序执行:
  1. 识别大型组件 → 读取
    .html
    .ts
    文件,按阈值列出候选组件
  2. 拆分单体组件 → 对每个候选组件应用步骤3的操作
  3. 创建目录结构 → 建立
    core/
    shared/
    features/
    目录
  4. 提取核心服务 → 认证、拦截器、守卫 → 移至
    core/
  5. 提取共享组件 → 可复用UI → 移至
    shared/
  6. 按特性分组 → 相关组件 → 移至
    features/<name>/
  7. 配置懒加载 → 配置路由 +
    @defer
  8. 更新所有导入路径 → 修复应用中的路径引用
  9. 升级API → 替换已废弃的模式(参考反模式表格)
  10. 构建项目
    npx ng build
    检测错误和循环依赖
  11. 运行所有测试
    npx ng test --watch=false
  12. 启动并验证
    npx ng serve
    ,测试导航和懒加载功能

Step 12: Unit Testing

步骤12:单元测试

Every file must have a
.spec.ts
:
  • Presentational: test rendering with different
    input()
    values,
    output()
    emissions, user interactions
  • 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()
    事件触发、用户交互
  • 智能组件:测试服务调用、信号状态管理、子组件协调逻辑
  • 服务:测试公共方法、使用
    HttpTestingController
    测试HTTP调用
  • 守卫:测试访问条件和重定向逻辑
  • 管道:测试转换逻辑及边界情况
  • 重构后,运行所有现有测试以确保功能未被破坏

Important Reminders

重要提醒

  • Always present before/after structure to the user
  • Ask confirmation before moving files or splitting components
  • Preserve git history → use
    git mv
    for file moves
  • Update
    tsconfig.json
    path aliases after restructuring
  • Verify
    angular.json
    references after file moves
  • 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",