この投稿の要約
- Angularを採用したからには、「フレームワークのカバーする範囲が広い」という特徴を活かし、いい意味で「誰が書いても同じ」になるようにしたい
- RxJS周りに関しては、習熟度やスタンスによって「書く人によって違う」状態になりやすい
- 頻出するデータの持ち方のパターンを洗い出し、チーム内で標準パターンを決めておくことで、生産性が上がることを期待
- この投稿では、Heroアプリを題材に、よくある「リスト表示→詳細ページ (list-detail)」の画面遷移をステート管理の考え方・実装例を提供する
- 画面間で共有するデータは、共通のサービスに
heroes$
のように持ち、async
パイプを活用する、のが基本パターン
参考コード
-
GitHubレポジトリ
- チュートリアルとの差分のみを見る
- ご存知「ツアー・オブ・ヒーロー」をfolkして、リファクタしたものです
想定読者
- Angular開発者
- RxJS周りの実装で悩んでいる
- 小規模なチームであり、NgRxを導入するほどではない、と考えている
この記事を書くきっかけ - RxJSの「普通の使い方」が見つけにくい
私は本格的にAngularを書くようになってからは、およそ8ヶ月程度です。
それまでは、Vue.jsをそこそこ、Reactをほんのちょっとかじった、というタイプです。
この記事では、フレームワークの選定のお話はしません。
記事を読む方が、好んでAngularを選んだのであれ、押し付けられたのであれ、使うからにはそのフレームワークの特徴を理解し、メリットを活かす方向に頭を巡らす方がハッピーになると思っています。
私自身は、尊敬している先輩エンジニアにAngularをおすすめされたから、というのがきっかけです。
触っていく過程で見えてきたAngularのメリットは、フレームワークのカバー範囲の広さです。
例えば、「ログイン有無やユーザーが特定のグループに属しているかで画面を開けるか制御したい、というパターンでは、guardを使うべし」。
このような「ふつーのウェブアプリ作るならしょっちゅう出てくるし、毎回車輪の再発明したくないよね」となるところを、Angularのドキュメントを読んだメンバーならみんな同じやり方を共有している。
これはメンバーの入れ替わりを前提としたチームでは、大きなメリットです。
いい意味で「誰が書いても同じ」になる、というのは、開発者個人というよりは、チーム全体の生産性を考える立場として非常に重要です。
(もちろん、キャッチアップコストが高かったり、ReactやVueより人数が少ないのでエンジニア採用しにくいというデメリットもあります)
しかし、ことRxJS周りに関しては 「誰が書いても同じ」となりにくい と感じるようになりました。
きっかけは、一通り、簡単なアプリならAngularで作れるな、と思うようになってから、スキルアップのために、YouTubeで ng-conf の講演動画を見まくったこと。
Angularコミュニティの中でも「RxJSでみんな悩むよね」という話が多いのに共感しつつ、紹介されている実装パターンを見ていくことで「全然RxJSの使い方わかっていなかった」と気づくことになりました。
一方で、Heroチュートリアルでは、Promiseに近いパターンの実装しか紹介されておらず、またRxJSのドキュメントでもほんの簡単な導入の話の後は「APIドキュメント」見て、という構成になっていて、およそAngularと組み合わせたベストプラクティスを学ぶには、難しい環境です。
ng-confの動画も大変勉強になるのですが、英語なのもあり(自動英語字幕はなかなかの精度なのでオススメ)、チームメンバー全員に見ろというのも現実的ではありません。
そのため、
- 日本語で読める
- よくある画面のパターンのためのコピペできる実装例
- 実装例に至る試行錯誤を通して、RxJSのメリットや使いすぎのデメリットを理解するきっかけになる
ような記事を書きたいと思い、アドベントカレンダーに手を挙げたもののズルズル延ばして担当日でもないクリスマス当日にアップするという趣旨に沿ってねーだろ状態になりましたあ
list型
前置きが長くなりました。
この記事ではよくある「リスト表示→詳細ページ」の画面遷移のパターンをlist-detail型と呼び、RxJSの使い方の標準パターンを考えていきます。まず、listの方から。
チュートリアル完了時のHeroアプリの概要(抜粋)
list-detail型という視点でHeroアプリのコンポーネントを眺めると以下のように整理できます。
- dashboardコンポーネント
- listタイプ
- 4件のみHeroを表示する
- 検索については別パターンとみなして割愛
- データ取得のタイミング
-
ngOnInit
内でGETリクエストを行い、結果をheroes: Hero[]
フィールドに格納 link
-
- heroesコンポーネント
- hero-detailコンポーネント
また関連するサービスクラスが一つあります
- hero.service.ts
- REST APIの呼び出しを担当
- Heroオブジェクトをキャッシュするなどのstateは持っていない
このままの実装でなんかダメなの? → 要件がこれだけなら実装変更不要!
ここまで若干くどく、Heroアプリの実装・構造を改めて整理しました。
「標準パターンの追求」というタイトルを出しておいてあれなのですが、現状の仕様のままなら、実装を変える必要はないと思います。
この先、より「RxJSっぽい書き方」を紹介していきます。が、あくまで手段の紹介です。
シンプルな仕様であれば、現状のHeroアプリの実装 (検索部分は入力語句をStreamとみなしてReactiveな実装にしつつ、単純なデータ取得はPromiseと同じようにStreamを意識しない実装) の方がAngular初見の新規メンバーにも触りやすいと思います。
ng-confの動画のどれかで(いっぱい見たんで忘れました、すいません・・)、RxJSのコアチームの @BenLesh も言っていましたが、**「全てをRxJSにしようとするな」**というのは大事な方針です。
RxJSは、学べば学ぶほど、いろんなパターンを知れるし、画面ごとに個別最適した実装をするのは、パズルを解くような楽しさ・快感があります。
しかし、一人チームならまだしも、複数人開発においては、単純な画面に置かれたパズルは、ただの障害物です。
以上の理由からここから先は、「Heroアプリに新たな要件の追加があった」という設定で話を進めていきます。
新たな要件: APIの呼び出しを減らして欲しい
前述したHeroアプリの仕様では、DashboardとHeroesの画面を開くたびに、毎回Heroを全件取得するGETが呼ばれます。
今はMockでフロントのロジック内で解決されていますが、実際にサーバーサイドを呼びに行ってる場合、呼び出し回数を減らしてくれ、というのは、あり得そうな追加要件なので、そうなったとします。
コンポーネント間でのデータ共有とasyncパイプ
さて、この要件をどのように実装するか?
- コンポーネント間で呼び出しデータを共有する
- 最初に表示されたコンポーネントがAPIをキックする
- それ以降は、最初に呼び出されたデータを使い回す
- 追加や削除がされたら、リストを更新する
RxJSかどうかに関わらず、おおよそこのような方針になると思います。
コンポーネント間で呼び出しデータを共有する方法
Angularではコンポーネント間でのデータ共有にいくつか方法があります。
-
@Input()
を使って親が持っているデータを子コンポーネントに渡す - ルーティングの時のパラメータとして受け渡す
- データを共有するためのサービスクラスを用意し、
Observable
フィールドを参照することで共有
本項では、3の実装方法とそれを採用するメリットを深掘りしていきますが、簡単に1の方法についても見ます。
今回の要件にて、1の方法を用いるのは、あまり向いていません。
- コンポーネント間の親子関係を作らないといけないが、DashboardとHeroesコンポーネントは、横並びの関係性である
- やるとしたら、無理矢理DashboardとHeroesの親となるコンポーネントを作る必要がある
- また、データのライフサイクルがコンポーネントのライフサイクルに引っ張られるため、取り回しがしにくくなる
2の方法についても、できなくはないけれど、ルーティングのロジックが複雑になり、相互依存性が高まってしまいます。
Viewのためのstateを持つサービスを導入する
今回のリファクタでは、最終的に3つの層に分けています。
- 同じstateを参照するコンポーネント群
- Heroのmodelのstateを持つサービス
- stateを持たず、REST APIの呼び出しに徹するサービス (元々あったhero.service.ts)
list部分の実装
まず、2の hero-view.service.ts
の実装を見てみましょう。
private refreshHeroes$ = new BehaviorSubject(null);
readonly heroes$: Observable<Hero[]> = this.refreshHeroes$.pipe(
exhaustMap(() => this.heroApi.getHeroes()),
tap((heroes) => {
console.log(`${heroes.length} heroes are loaded.`)
}),
shareReplay(),
);
/* 中略 */
refreshHeroes() {
this.refreshHeroes$.next(null);
}
大きく二つのstateを管理しているうち、Heroの配列のstateを持つ部分の実装です。
heroes$
はコンポーネント間で Hero[]
データを共有するための Observable
です。
refreshHeroes$
から新たな値が流れてくると、 REST APIのGETを呼び出し、ストリームに取得したHeroの配列を流します。
そして、最後に shareReplay()
により、再度更新が走るまでは、新たにsubscribeしたコンポーネントにも一度取得されたデータが共有されるようになります。
要はメモリ上にキャッシュしているということですが、そのような実装がpipeのなかで、1つのoperatorsを書き込むだけで完結する、というのがメリットの一つです。
では、コンポーネント側は、このObservable
をどのように使うのか
Dashboardコンポーネントの実装
dashboard.component.ts
export class DashboardComponent implements OnInit {
heroes$ = this.heroViewService.heroes$.pipe(
map((heroes) => heroes.slice(1, 4))
);
constructor(
private router: Router,
private heroViewService: HeroViewService,
) {
}
ngOnInit(): void {
// 初期化処理を呼ばない!
}
dashboard.component.html
<div *ngFor="let hero of (heroes$ | async)" (click)="gotoDetail(hero)" class="col-1-4">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
見るべきポイントが3つあります。
- pipe処理のなかで4件取得する処理(
heroes.slice(1, 4)
)を行なっている -
ngOnInit
の中では、何もしない。ngOnDestroy
でunsubscribe
することもない - HTML内で
async
パイプを使うことで、ストリームにアクセスしている
このなかで一番重要なのは、3のasync
パイプです。
前述の動画のなかで「subscribeしないということは? unsubscribeもいらない!」という話がありました。
asyncパイプを用いることで、tsファイル内で定義したストリームをそのままストリームとしてHTML内で活用することができます。
ストリームのmap
オペレーターをHTML内でも使える、というような解釈ができるかと思います。
逆にいうと、これができないと、ts内で作ったストリームをHTMLテンプレートのために、普通のオブジェクトに直したり、subscribe/unsubscribeの処理が出てきてしまい、複雑化していきます。
async
パイプは、コンポーネントのライフサイクルに合わせて、subscribe/unsubscribeを自動で行ってくれるので、ts内で明示的に記述する必要がありません。
heroes$
の定義をフィールドの初期化時に行っているので、一見すると、そのタイミングでデータが流れてくるように見えるかもしれません。
しかし、この部分ではsubscribeを行なっていないので、このフィールドの評価時にデータは流れません。
HTML内のheroes$ | async
が評価された時に、async
パイプ内の処理でsubscribeが行われ、その時初めて、Heroes[]
が流れてきます。
このように、見通しがよいコードになっていれば、宣言的な記述のメリットが得られていると言えるでしょう。
Heroesコンポーネントの実装
続いて、同じくHeroes[]
のデータを参照したいHeroesコンポーネントの実装を見ていきます。
heroes.component.ts
export class HeroesComponent implements OnInit {
/* 中略 */
heroes$ = this.heroViewService.heroes$;
/* 中略 */
ngOnInit(): void {
// 初期化処理を呼ばない!
}
Dashboardと同様にngOnInit
では処理が行われません。
一方、全件表示するため、heroes$
フィールドはサービスのストリームをそのまま使っています。
heroes.component.html
<ng-container *ngIf="heroes$ | async as heroes">
Count {{ heroes.length }} heroes
<ul class="heroes">
<li *ngFor="let hero of heroes" (click)="onSelect(hero)"
[class.selected]="hero.id === (selectedHeroId$ | async)">
HTML側では同様にasync
を利用しています。
1点async as heroes
という記法になっていますが、これはasで定義したheroes
という変数をそれ以下のelementのなかで単純なArrayとして参照できるという機能です。
(この機能の説明のためにCount {{ heroes.length }} heroes
という行を追加しました。)
「ループ回してliを描画するのに加え、件数も表示したい」という時に、何度もasync
を書く必要がなくなります。
(正確には、単に記載が完結になるだけでなく、subscribeの回数が減るという根本的で重要な違いがありますが、一旦説明略)
いつデータ取得のREST APIが呼び出されるのか?
- DashboardかHeroesの画面を最初に開いて、
heroes$ | async
が評価された時(ざっくり表現) - そこから、もう片方のページに遷移した時は、APIは呼び出されず、前の画面と同じ値が流れてきます
- これが
shareReplay()
の効果です - 同様にそこから前の画面に戻っても同様です
- これが
これにより、当初の仕様を満たすことができます
明示的にリロードしたい時は?
- 追加や削除がされたら、リストを更新する
前述した仕様を満たすため、Heroのリストを任意のタイミングでリロードできる必要があります。
viewサービスには、refreshHeroes()
が用意してあり、呼び出すと、BehaviorSubjectに新しい値を流します。
(.next(null)
のnullは意味のない値です。単にストリームに新しい値が流すことだけが目的だからです)
hero-view.service.ts
(再掲)
refreshHeroes() {
this.refreshHeroes$.next(null);
}
heroes.component.ts
内の更新・削除のcallbackで、上記のメソッドを呼び出すことで、リストが更新されます。
closeDetail(savedHero: Hero): void {
this.addingHero = false;
if (savedHero) {
this.heroViewService.refreshHeroes();
}
}
deleteHero(hero: Hero, event: any): void {
console.log('deleteHero', hero)
event.stopPropagation();
this.heroApi.delete(hero)
.subscribe(
() => this.heroViewService.refreshHeroes(),
(error) => this.error = error,
)
}
callback内の記述で興味深いのは、 this.heroViewService.refreshHeroes()
の呼び出しに対するcallbackが定義されていないことです。
単に「更新してね」とメソッドを呼び出すだけで役割は終了。
この簡潔さがメリットです。
(デバッグしにくいというデメリットでもありますが)
detail型
ちょっと長すぎるので、また別の記事で・・
終わりに
今回の記事で特に参考にしたのは動画はこの二つです。
特にBen LeshはRxJSの開発者なので、彼の動画をディグると、開発の意図まで含めて勉強になるのでオススメです
- Complex features made easy with RxJS by Ben Lesh, Lead, RxJS & Angular Team @ Google.
- Data Composition with RxJS by Deborah Kurata
RxJSのメリットは、データの関係性が複雑になるほど、力を発揮すると思うので、記事で簡単に紹介するってなかなか難しいですよね。
ほんとはプロダクションコードをお見せしながらだといいのですが、そういうわけにもいかず。。
次回は、detailの話もしつつ、リアクティブな実装方法の使い所をより抽象化してパターン化したいと思っています。