Angular - async pipe 踩过的坑

1,966 阅读2分钟

大家好,这里分享一下平时Angular开发中遇到的坑,希望大家能完美避过^^

前提

了解

对于async pipe的官方解释:async 管道会订阅一个 Observable 或 Promise,并返回它发出的最近一个值。 当新值到来时,async 管道就会把该组件标记为需要进行变更检测。当组件被销毁时,async 管道就会自动取消订阅,以消除潜在的内存泄露问题。

在Jasmine中使用fakeAsync + tick测试:fakeAsync将创建一个伪zonetickfakeAsync中使用可以模拟异步时间流逝计时器。

先说结论

尽量在template对同一个 Observable 或 Promise只使用一次async管道,否则若多次使用,会使得Angular创建多个相同的监听事件Subscription,消耗内存,降低性能。

如果需要多次使用该 Observable 或 Promise中订阅的值,可以使用*ngIf="someObservable | async as someValue方法将其发出的最近的一个值转换为一个临时变量someValue,在子节点中可以直接使用该临时变量。

例如: 原来的代码希望实时监听someObservable中的prop1prop2的值并且展示在页面上:

<div> 
    {{ (someObservable | async).prop1 }} - {{ (someObservable | async).prop2 }}
</div>

以上代码会创建2个SubscriptionsomeObservable进行监听。

使用*ngIf重构后的代码:

<div *ngIf="someObservable | async as someValue"> 
    {{ someValue.prop1 }} - {{ someValue.prop2 }}
</div>

以上修改后的代码将创建1个Subscription,减少内存消耗。但是若someObservable中一直不发出值或者发出的值为null的话,div不会被创建。

当然如果希望someObservable中的值不影响div的创建,可以使用ng-container作为该文本节点的父级。

修改后使用ng-container的代码:

<div>
    <ng-container *ngIf="someObservable | async as someValue">
        {{ someValue.prop1 }} - {{ someValue.prop2 }}
    </ng-container>
</div>

对于一个Observable使用多次async管道遇到的坑

这里介绍对一个带有debounceTime的Observable并且多次在template中使用async pipe导致jasmine测试时遇到的问题

Template 代码:

<div *ngIf="(book$|async)?.author && (book$|async)?.title">
  whatever..
</div>

Component代码:

import { Component } from '@angular/core';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { debounceTime } from 'rxjs/operators';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  // 模拟映射到template的Observable,注意debounceTime!
  book$ = new BehaviorSubject({ title: 'titleTest', author: 'authorTest' })
          .asObservable().pipe(debounceTime(200));
}

jasmine单元测试代码:

import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { AppComponent } from './app.component';

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  });

  it('should render whatever', fakeAsync(() => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    // 模拟200毫秒流逝
    tick(200);
    // 手动更新视图
    fixture.detectChanges();
    /*这里会加上修正的代码*/
    const compiled = fixture.nativeElement;
    // 希望页面展示的文字中包括'whatever',这里fail
    expect(compiled.querySelector('div').textContent).toContain('whatever');
  }));
});

命令行允许ng serve后,页面会正常显示'whatever..'

image.png

但是运行测试(ng test)时,Jasmine报错div未被创建:

image.png 但是当我们在测试代码中再加入以下注释中的两行(tick(200) + fixture.detectChanges())时,测试通过:

  it('should render whatever', fakeAsync(() => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    tick(200);
    // 第一次detectChanges时计算(book$|async)?.author
    fixture.detectChanges();
    // !!!以下两行为修正代码:
    // 再次模拟200ms流逝
    tick(200);
    // 第二次计算(book$|async)?.title
    fixture.detectChanges();
    // !!!修正代码结束
    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('div').textContent).toContain('whatever');
  }));

image.png

这是因为在template的表达式中,使用了与&&操作符,这个表达式计算是从左至右的,也就是说操作符左边的表达式成立为真的时候才会继续计算操作符右边的。

测试中第一次tick(200)时,因为debounceTime(200)book$这时接收到了值 { title: 'titleTest', author: 'authorTest' },之后的第一次fixture.detectChanges()成功将template中的第一个表达式(book$|async)?.author计算为真;第二次tick(200)+fixture.detectChanges()时,才将&&右边的(book$|async)?.title值计算为真,并且成功创建div。

重构后的template:

<ng-container *ngIf="(book$|async) as book">
  <div *ngIf="book.author && book.title">
    whatever.. 
  </div>
</ng-container>

测试保持没有额外的tick,detectChanges的版本,即:

  it('should render whatever', fakeAsync(() => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    tick(200);
    // 第一次detectChanges时计算(book$|async)?.author
    fixture.detectChanges();
    /* 不再需要以下代码
    tick(200);
    // 第二次计算(book$|async)?.title
    fixture.detectChanges();
    */
    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('div').textContent).toContain('whatever');
  }));

image.png