3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

AngularAdvent Calendar 2022

Day 20

AngularのAsyncPipeを学ぶ

Last updated at Posted at 2022-12-19

この記事はAngular Advent Calendar 2022 20日目の記事です。
AsyncPipeについての学習記録です。

AsyncPipeとは

まずは公式ページを確認します。

The async pipe subscribes to an Observable or Promise and returns the latest value it has emitted. When a new value is emitted, the async pipe marks the component to be checked for changes. When the component gets destroyed, the async pipe unsubscribes automatically to avoid potential memory leaks. When the reference of the expression changes, the async pipe automatically unsubscribes from the old Observable or Promise and subscribes to the new one.

  1. Observable(やPromise)を購読して、最新の値を返す。
  2. Componentが破棄された際に、AsyncPipeは自動的に購読を解除する。
  3. 式の参照が変わった際に、AsyncPipeは古いObservavleの購読を自動的に解除し、新しいObservavleを購読する。

AsyncPipeを使うと何が嬉しいかというと、

  • コンポーネントでの購読解除に関するコードを書かなくて良い
    • 購読解除忘れを心配をしなくて良い(購読解除忘れはメモリリークを引き起こす)
    • コンポーネントのコード量が減りスッキリする
  • Observableの初期化をコンストラクタで完結させることができる
  • readonlyにできる

2.3個目については私のような初心者レベルではあまり旨味を感じたことがないので、良い例が見つかったらまた書きます。
1個目については、AsyncPipeの導入前・後のコードを比べれば一目瞭然です。

user-list.component.ts
import { Component, OnDestroy, OnInit } from '@angular/core';
import { UserListService } from '../user-list.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Component({
  selector: 'user-list',
  template: `
    <ul>
        <li *ngFor="let user of users">
    		{{ user.id }} {{ user.first_name }} {{ user.last_name }}
    	</li>
    </ul>
  `,
})
export class UserListComponent implements OnDestroy, OnInit {
  private _onDestroy = new Subject<void>();
  users: User[];

  constructor(private userListService: UserListService) { }

  ngOnInit() {
    this.userListService.getUsers()
      .pipe(takeUntil(this.onDestroy))
      .subscribe(users => {
        this.users = users;
      });
  }

  ngOnDestroy() {
    this._onDestroy.next(null);
  }

}
user-list.component.ts
import { Component, OnInit } from '@angular/core';
import { UserListService } from '../user-list.service';

@Component({
  selector: 'user-list',
  template: `
    <ul>
        <li *ngFor="let user of users$ | async">
    		{{ user.id }} {{ user.first_name }} {{ user.last_name }}
    	</li>
    </ul>
  `,
})
export class UserListComponent implements OnInit { // だいぶスッキリした
  readonly users$: Observavle<User[]>;

  constructor(private userListService: UserListService) {
    this.users$ = this.userListService.getUsers();
  }

  ngOnInit() {
  }
}

コンポーネント内で購読解除に関する記述がまるまる消えてだいぶスッキリしましたね。

しかしそんなAsyncPipeにも辛い点があります。

初期値null問題

ストリームの時点では絶対nullは入ってないのに、AsyncPipeを使うとその子供ではnullを気にしないといけなくなる。

例えば下記のような場合、コンパイル時点で怒られます。

app.component.ts
import { Component, OnInit } from '@angular/core';
import { UserListService } from '../user-list.service';

@Component({
  selector: 'app-root',
  template: `<user-list [users]="users$ | async"></user-list>`,
})
export class AppComponent implements OnInit {
  readonly users$: Observavle<User[]>;

  constructor(private userListService: UserListService) {
    this.users$ = this.userListService.getUsers();
  }

  ngOnInit() {
  }
}
user-list.component.ts
@Component({
  selector: 'user-list',
  template: `
    <ul>
        <li *ngFor="let user of users">
    		{{ user.id }} {{ user.first_name }} {{ user.last_name }}
    	</li>
    </ul>
  `,
})
export class UserListComponent implements OnInit {
  @Input() users: User[];

  constructor() { }

  ngOnInit() {
  }
}
Type 'null' is not assignable to type 'User[]'.

1 <user-list [users]="users$ | async"></user-list>
              ~~~~~

User[]型のusersにnull入れたらアカン!!と・・・
下記の通りUserListServicegetUsers()nullを返すことはありません。

user-list.service.ts
@Injectable({ providedIn: 'root' })
export class UserListService {

  constructor(private http: HttpClient) { }

  getUsers(): Observable<User[]> {
    return this.http
      .get<{ data: User[] }>("https://reqres.in/api/users")
      .pipe(
        map(resp => resp.data)
      );
  }
}

にも関わらずusersnullが入ってしまうの原因は、AsyncPipeの実装にあります。

こちらの記事で詳しく解説されていますが、この記事が書かれた当時(2020年)からコードが少し変わっているため、改めて確認してみます。
AsyncPipeをコピーしたMyAsyncPipeを作り、console.log()をブチ込みまくりました。🤣)

transform()が肝となるコードです。

async_pipe.ts
@Pipe({
  name: 'async',
  pure: false,
  standalone: true,
})
export class AsyncPipe implements OnDestroy, PipeTransform {
  private _latestValue: any = null; //<- コイツが問題
                              ~~~~ 
  private _obj: Subscribable<any>|Promise<any>|EventEmitter<any>|null = null;
  // 略

  transform<T>(obj: Observable<T>|Subscribable<T>|Promise<T>|null|undefined): T|null {
    // ①AsyncPipeに初めてObservableが渡されたとき
    if (!this._obj) {
      if (obj) {
        this._subscribe(obj);
      }
      return this._latestValue; 
    }
    // (objの参照が変わった時(別のObservableが渡された時、今回の話をする上では重要ではない))
    if (obj !== this._obj) {
      this._dispose();
      return this.transform(obj);
    }

    return this._latestValue;
  }
  // 略
}

transform()内で呼び出される重要なコードです。

class SubscribableStrategy implements SubscriptionStrategy {
  createSubscription(async: Subscribable<any>, updateLatestValue: any): Unsubscribable {
    return async.subscribe({
      next: updateLatestValue,
      error: (e: any) => {
        throw e;
      }
    });
  }
  // 略
}

private _subscribe(obj: Subscribable<any>|Promise<any>|EventEmitter<any>): void {
  this._obj = obj;
  this._strategy = this._selectStrategy(obj);
  this._subscription = this._strategy.createSubscription(
    obj, (value: Object) => this._updateLatestValue(obj, value));
}

private _updateLatestValue(async: any, value: Object): void {
  if (async === this._obj) {
    this._latestValue = value;
    this._ref!.markForCheck();
  }
}

getUsers()が返すObservableを🐧とすると、
ざっくりの流れとしては以下のようになります。

No 処理 _obj _latestValue
1 AsyncPipeに🐧(=obj)が渡される null null
2 transform()が呼ばれる null null
3 _obj===null なので①のif文の内部へ null null
4 _subscribe()が呼ばれる null null
5 this._objを上書き 🐧 null
6 createSubscriptionで🐧の購読を開始 🐧 null
7 return this._latestValue 🐧 null

今のところ、_latestValueは初期値のnullのまま上書きされていませんので、return this._latestValueではnullが返されることになります。これが「AsyncPipe初期値null問題」です。

どう倒すか

動画でも記事でも示されている通り、必ず*ngIf*ngForといったnullを無視するディレクティブを通すことで倒すことができます。

app.component.ts
import { Component, OnInit } from '@angular/core';
import { UserListService } from '../user-list.service';

@Component({
  selector: 'app-root',
  template: `
    <ng-container *ngIf="users$ | async as users">
        <user-list [users]="users"></user-list>
    </ng-container>
  `,
})
export class AppComponent implements OnInit {
  readonly users$: Observavle<User[]>;

  constructor(private userListService: UserListService) {
    this.users$ = this.userListService.getUsers();
  }

  ngOnInit() {
  }
}

AsyncPipeは常にNgIfやNgForによるnull安全なガードを通して利用すべきである。

というのが結論です。
また動画や下記記事では、テンプレート内で使う複数の非同期的な値を単一のストリームにまとめてスナップショット化するSingle State Streamという手法も紹介されているので、ぜひ見てみて下さい。


ちなみに、動画内で

BehaviorSubjectのような、購読と同時に値が流れることが確定しているストリームでさえもnullが入る

とおっしゃっていましたのでこちらも調べてみました。

app.component.ts
import { Component, OnInit } from '@angular/core';
import { UserListService } from '../user-list.service';

@Component({
  selector: 'app-root',
  template: `<user-list [users]="users$ | async"></user-list>`,
})
export class AppComponent implements OnInit {
  readonly users$: Observavle<User[]>;

  constructor(private userListService: UserListService) {
-   this.users$ = this.userListService.getUsers();
+   this.users$ = this.userListService.users$;
  }

  ngOnInit() {
  }
}
user-list.service.ts
@Injectable({ providedIn: 'root' })
export class UserListService {

+ private _usersSubject = new BehaviorSubject<User[]>([]); // <-購読と同時に[]が流れる

+ get users$() {
+   return this._usersSubject.asObservable() 
+ }

  constructor(private http: HttpClient) { }

-  getUsers(): Observable<User[]> {
-    return this.http
-      .get<{ data: User[] }>("https://reqres.in/api/users")
-      .pipe(
-        map(resp => resp.data)
-      );
-  }
+  getUsers(): void {
+    this.http
+      .get<{ data: User[] }>("https://reqres.in/api/users")
+      .pipe(map(resp => resp.data))
+      .subscribe(users => this._usersSubject.next(users));
+  }
}

すると、nullは返ってきません。

No 処理 _obj _latestValue
6 createSubscriptionで🐧の購読を開始 🐧 null
_updateLatestValue()が呼び出される 🐧 null
this._latestValueを上書き 🐧 []
markForCheck()が呼ばれる 🐧 []
7 return this._latestValue 🐧 []

markForCheck()が呼ばれると何が起きるのか分かっていないので色々と間違っているかもしれませんが、とりあえず最新のAsyncPipeでは、BehaviorSubjectなど購読と同時に値が流れる場合はnullが返ってこないようです。

間違いがあればご指摘いただけますと幸いです🙇‍♂️

明日は hkt100rtkn さんです!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?