Skip to content

10 Angular Dos & Don’ts

Published: at 12:00 AM

Excerpt

提升你的 Angular 应用的开发、模块化和性能。

原文 10 Angular Dos & Don’ts

#1 关注点分离

Angular 架构的美在于分割代码库。

这种分离遵守了单一职责原则,结果是更干净的代码库。

避免在主模块中导入所有的组件、指令和服务。

相反,按模块(功能模块)分割功能。当你只在其中导入与你的功能相关的部分时,模块将保持较小并且代码更清晰。

这可以与 Router 结合使用,以启用 Lazy Loading

const routes: Routes = [
  {
    path: "home",
    component: HomeComponent,
  },
  {
    path: "todos",
    // 懒加载路由
    loadChildren: () => import("./Todos.module").then(m => m.TodosModule),
  },
];

在此示例中,Todos 模块及其依赖项将仅在 landing 到指定路由之后加载。主模块中使用的依赖项越少,你的应用程序将加载得越快。

#2 使用 Angular CLI

Angular CLI 是一个内置工具,通过自动生成应用程序部分和连接依赖项来加速应用程序开发。

开始时,你可以使用以下命令生成一个新的 Angular 应用程序:

ng new my-app

不要手动在你的代码库中创建文件,使用 CLI:

▶ 生成新的组件:

ng g c component-name

▶ 生成服务:

ng g s service-name

▶ 生成管道:

ng g p pipe-name

▶ 预览将要生成的内容

ng g c modules/components/blog --dry-run

CREATE src/app/modules/components/blog/blog.component.html (19 bytes)
CREATE src/app/modules/components/blog/blog.component.spec.ts (545 bytes)
CREATE src/app/modules/components/blog/blog.component.ts (195 bytes)
CREATE src/app/modules/components/blog/blog.component.scss (0 bytes)
UPDATE src/app/app.module.ts (595 bytes)

注意:“--dry-run”选项意味着没有进行任何更改。

类似的操作。 CLI 还允许你安装属于 Angular 标准库的包。例如:

ng add @angular/material

#3 使用框架工具

不要用原生 JavaScript API(如 Document、Fetch)从零开始复制已有的轮子或执行手动表单验证。使用框架内置的工具。

Angular 提供了大量开发功能:

#4 Pipes

假设你需要在模板中转换一个值。自然地,你会在组件的 TypeScript 文件中创建一个函数并执行类似下面的操作:

<h2>{{ doSomething(someFormValue) }}</h2>

这将起作用,但它对性能非常不利,因为每次重新渲染时都会重复调用该函数。

在这些场景中,你应该使用 Pipes。生成管道后,

ng g pipe doSomething
@Pipe({
  name: "doSomething",
})
export class DoSomethingPipe implements PipeTransform {
  transform(value: unknown, ...args: unknown[]): unknown {
    // 执行一些转换
    console.log("inside doSomething Pipe");
    return "transformed-data";
  }
}

使用管道(|)符号在模板中应用它。

<h2>{{ someFormValue | doSomething }}</h2>

现在你有了通过 Pipe 构建的完全相同的功能。两种方法之间的区别立即显而易见。

#5 利用依赖注入

Angular 最强大的特性之一是依赖注入。与其组件自己创建其依赖项,例如:

// 手动创建实例
private service = new PostsService();

它们可以从外部提供,并通过构造函数(或 inject() 函数)注入:

 constructor(private readonly service: PostsService) { }

底层依赖项在模块中注册为提供者:

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule],
  providers: [PostsService], // <-- 注册 PostsService 依赖项
  bootstrap: [AppComponent],
})
export class AppModule {}

服务还可以设置一个 providedIn 标志,显示它在哪里提供。在这种情况下,默认情况下服务是在根模块提供的,这消除了需要在提供者数组中出现服务的必要。

@Injectable({
  providedIn: 'root'
})
export class PostsService

有了依赖注入,Angular 代码变得:

这只是 Angular 中依赖注入的开始。如果你想了解更多,我建议你查阅以下主题:

#6 在非相关组件之间共享数据

常见做法是在组件之间使用 InputOutput 指令来共享数据。这在你有父到子或子到父组件关系时非常棒。

然而,如果你需要向孙子或曾孙子组件或一个兄弟组件发送数据,那么这将是一项艰巨的工作。更不用说如果你必须更新数据,你将不得不在所有地方进行更改。

为了避免这种情况,你可以使用 Rx.js Behavior Subject 来建立一个存储数据的中央位置,以供消费者检索。

一切都是基于 发布者-订阅者 模式进行的。一个组件将数据推送到 Subject,而另一个组件通过订阅 Observable 来消费数据。

创建状态服务

为了看到这个动作中的创建方式,创建一个新的服务和一个带有默认计数(1)的 Behavior Subject。将此主题设为私有是一个好做法,以防止其从外部被修改。

@Injectable({
  providedIn: "root",
})
export class SharedDataService {
  private readonly currentCount$ = new BehaviorSubject<number>(1);

  public setCount(count: number): void {
    const currentValue = this.currentCount$.value;
    this.currentCount$.next(currentValue + count);
  }

  public getCount() {
    return this.currentCount$.asObservable();
  }
}

使用 setCount() 方法,外部组件可以更改当前计数。 使用 getCount() 方法,外部组件可以检索当前计数作为 Observable 来订阅。

生产者组件

注入服务并创建两个按钮,它们在点击时会增加或减少计数 —— 实际上调用相同的方法,只是值不同。

constructor(private readonly service: SharedDataService) { }

increaseCount() {
    this.service.setCount(1);
}

decreaseCount() {
    this.service.setCount(-1);
}
<button (click)="increaseCount()">计数 +1</button>

<button (click)="decreaseCount()">计数 -1</button>

消费者组件

注入相同的服务,只是这次调用 getCount() 方法。

constructor(private readonly service: SharedDataService) { }

ngOnInit() {
    this.service.getCount().subscribe(console.log)
}

现在两个组件可以在彼此不相关的情况下共享数据。

#7 使用 Rx.js 运算符

不要逃避 Rx.js。好好利用它。 它是一个强大的工具,与 Angular 配合得很好。

映射数据

你可以直接在 Observable 管道中根据你的需求映射响应:

titles!: string[];

constructor(private readonly service: PostsService) { }

ngOnInit() {
    this.service.getAllPosts()
    .pipe(
        map((data: Posts[]) => data.map(post => post.title))
    )
    .subscribe(data => this.titles = data)
}
<div *ngFor="let title of titles">{{ title }}</div>

过滤数据

另外,你可以根据标准过滤内容。

ngOnInit() {
  this.service.getAllPosts()
  .pipe(
    map((data: Posts[]) => data.map(post => post.title)),
    filter((data: string[]) => data.length > 0)
  )
  .subscribe(data => this.titles = data)
}

在订阅者之间共享数据

有时你可能想在多个地方消费同一个 Observable。Observables 本质上被设计为 的,意味着对于每个新订阅者,Observable 都会重新创建。

在此示例中,Observable 正在与 API 工作,每个新订阅者都将为其目的调用同一个 API。

const postsData$ = this.service.getAllPosts();

const firstSubscriber$ = postsData$;
const secondSubscriber$ = postsData$;

firstSubscriber$.subscribe(console.log);
secondSubscriber$.subscribe(console.log);

两个网络调用,每个订阅者一个。

通过使用 share() 运算符,将这个 Observable 转换为 Observable 可以解决这个问题。

const postsData$ = this.service.getAllPosts().pipe(share());
const firstSubscriber$ = postsData$;
const secondSubscriber$ = postsData$;
firstSubscriber$.subscribe(console.log);
secondSubscriber$.subscribe(console.log);

现在,多个订阅者将共享相同的 Observable。

多个订阅者的一个网络调用

合并 Observables

为了消费 Observable 数据,你需要订阅它。然而,如果你有一个 API 依赖于另一个 API,那么这是否意味着你需要订阅两次?让我们来找出答案。

getCurrentUser(): Observable<User> {
  const user = {
    id: 1,
    name: 'Mirza',
    profession: 'DEV'
  };
  return of(user);
}

这个函数将返回一个示例用户。要将其与 Posts 服务组合,一个简单的方法可能是这样做:

myPost?: Posts;
private getUserPost() {
  this.getCurrentUser()
  .subscribe((user: User) => {
    this.service.getAllPosts()
    .subscribe((posts: Posts[]) => {
      this.myPost = posts.find(post => post.userId === user.id);
    })
  })
}

这样做是可以的,但这是一个非常糟糕的做法。 在这种情况下,你想使用像 mergeMapswitchMap 这样的运算符将两个流合并成一个。

private getUserPost() {
  this.getCurrentUser()
  .pipe(
    mergeMap((user) => this.service.getAllPosts()
    .pipe(
      map((posts: Posts[]) => posts.find(post => post.userId === user.id))
    ))
  )
  .subscribe((post?: Posts) => {
    this.myPost = post;
  })
}

还有许多其他同样有趣用途的 Rx.js 运算符。我鼓励你浏览 Rx.js 文档 以了解更多。

#8 取消订阅 Observables

许多新的 Angular 开发人员经常忽略的一个重要细节是取消订阅 Observables。

export class AppComponent implements OnInit {
  postsData!: Posts[];

  constructor(private readonly service: PostsService) {}

  ngOnInit() {
    this.service.getAllPosts().subscribe(data => (this.postsData = data));
  }
}

即使 Angular API 与 Rx.js 紧密配合,Rx.js Observables 不知道 Angular 组件何时不再使用。当订阅仍然活跃时,它会导致内存泄漏,以及在尝试更新组件时可能会出现意外行为或错误。

为了解决这个问题,在停止使用组件之后,你需要手动取消订阅所有 Observables。这可以通过各种 take 运算符完成。

export class AppComponent implements OnInit, OnDestroy {
  postsData!: Posts[];
  // 创建一个将用于取消订阅的 subject
  private unsubscribe$ = new Subject<void>();

  constructor(private readonly service: PostsService) {}

  ngOnInit() {
    this.service
      .getAllPosts()
      .pipe(
        // Observable 将保持活跃状态,直到这个 subject 完成
        takeUntil(this.unsubscribe$)
      )
      .subscribe(data => (this.postsData = data));
  }

  ngOnDestroy() {
    this.unsubscribe$.next();
    // 当组件被销毁时,主题将完成
    this.unsubscribe$.complete();
  }
}

#9 Async 管道

Async 管道是内置于 Angular 中的,允许我们直接在模板中订阅 Observables 或解析 Promise。

没有 Async 管道

你需要在组件内订阅,将订阅数据分配给属性,并手动取消订阅。

export class AppComponent implements OnInit, OnDestroy {
  postsData!: Posts[];
  private unsubscribe$ = new Subject<void>();

  constructor(private readonly service: PostsService) {}

  ngOnInit() {
    this.service
      .getAllPosts()
      .pipe(takeUntil(this.unsubscribe$))
      .subscribe(data => (this.postsData = data));
  }

  ngOnDestroy() {
    this.unsubscribe$.next();
    this.unsubscribe$.complete();
  }
}
<div *ngFor="let post of postsData">{{ post.title }}</div>

使用Async管道

你可以直接将Observable赋值给属性,并在模板中订阅。当组件不再被使用时,Async管道还会自动释放(取消订阅)Observable。

export class AppComponent implements OnInit {
  postsData$!: Observable<Posts[]>;

  constructor(private readonly service: PostsService) {}

  ngOnInit() {
    this.postsData$ = this.service.getAllPosts();
  }
}
<div *ngFor="let post of postsData$ | async">{{ post.title }}</div>

#10 OnPush变更检测

当一个组件中发生了变化时,Angular会遍历整个组件树来检查是否有任何组件发生了变化。这是默认的变更检测行为,在有很多组件的项目中极其低效。

为了解决这个问题,Angular团队引入了OnPush变更检测,它只在以下情况下重新渲染组件:

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
  changeDetection: ChangeDetectionStrategy.OnPush, // <-- 应用它
})
export class AppComponent implements OnInit {
  @Input() title: string = "默认";

  updateTitle() {
    this.title = "已更新";
  }
}

当你点击按钮调用updateTitle()方法时,Angular会将这个组件及其所有父组件标记为“脏”。
这不影响子组件。子组件只有在通过输入属性接收新值时才会被标记为脏。

OnPush变更检测更高效,因为它减少了检查的数量,从而带来更好的性能和响应性。

这只是Angular所有提供内容的一小部分。要了解更多,请查看我的其他文章。