この記事は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.
-
Observable
(やPromise
)を購読して、最新の値を返す。 -
Component
が破棄された際に、AsyncPipeは自動的に購読を解除する。 - 式の参照が変わった際に、AsyncPipeは古い
Observavle
の購読を自動的に解除し、新しいObservavle
を購読する。
AsyncPipe
を使うと何が嬉しいかというと、
- コンポーネントでの購読解除に関するコードを書かなくて良い
- 購読解除忘れを心配をしなくて良い(購読解除忘れはメモリリークを引き起こす)
- コンポーネントのコード量が減りスッキリする
-
Observable
の初期化をコンストラクタで完結させることができる - readonlyにできる
2.3個目については私のような初心者レベルではあまり旨味を感じたことがないので、良い例が見つかったらまた書きます。
1個目については、AsyncPipeの導入前・後のコードを比べれば一目瞭然です。
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);
}
}
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を気にしないといけなくなる。
例えば下記のような場合、コンパイル時点で怒られます。
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() {
}
}
@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入れたらアカン!!と・・・
下記の通りUserListService
のgetUsers()
はnull
を返すことはありません。
@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)
);
}
}
にも関わらずusers
にnull
が入ってしまうの原因は、AsyncPipe
の実装にあります。
こちらの記事で詳しく解説されていますが、この記事が書かれた当時(2020年)からコードが少し変わっているため、改めて確認してみます。
(AsyncPipe
をコピーしたMyAsyncPipe
を作り、console.log()
をブチ込みまくりました。🤣)
transform()
が肝となるコードです。
@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
を無視するディレクティブを通すことで倒すことができます。
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が入る
とおっしゃっていましたのでこちらも調べてみました。
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() {
}
}
@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 さんです!