11
8

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 3 years have passed since last update.

チーム開発を加速するためのAngular + RxJS標準パターンの追求: list-detail型編

Posted at

この投稿の要約

  • Angularを採用したからには、「フレームワークのカバーする範囲が広い」という特徴を活かし、いい意味で「誰が書いても同じ」になるようにしたい
  • RxJS周りに関しては、習熟度やスタンスによって「書く人によって違う」状態になりやすい
  • 頻出するデータの持ち方のパターンを洗い出し、チーム内で標準パターンを決めておくことで、生産性が上がることを期待
  • この投稿では、Heroアプリを題材に、よくある「リスト表示→詳細ページ (list-detail)」の画面遷移をステート管理の考え方・実装例を提供する
  • 画面間で共有するデータは、共通のサービスに heroes$ のように持ち、 async パイプを活用する、のが基本パターン

参考コード

想定読者

  • 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コンポーネント
    • listタイプとdetailタイプが混在
    • データ取得のタイミング
      • ngOnInit 内でGETリクエストを行い、結果を heroes: Hero[] フィールドに格納 link
      • hero-detailコンポーネント内で新しいHeroが保存された時、 heroes を取得し直す link
  • hero-detailコンポーネント
    • detailタイプ
      • ngOnInit 内で routerに id パラメータがが指定されていれば、GETリクエストを行い、結果を hero: Hero フィールに格納 link
      • id パラメータが指定されていなければ、空のHeroインスタンスを作成し、フィールドにセットする link

また関連するサービスクラスが一つあります

  • 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ではコンポーネント間でのデータ共有にいくつか方法があります。

  1. @Input() を使って親が持っているデータを子コンポーネントに渡す
  2. ルーティングの時のパラメータとして受け渡す
  3. データを共有するためのサービスクラスを用意し、 Observable フィールドを参照することで共有

本項では、3の実装方法とそれを採用するメリットを深掘りしていきますが、簡単に1の方法についても見ます。

今回の要件にて、1の方法を用いるのは、あまり向いていません。

  • コンポーネント間の親子関係を作らないといけないが、DashboardとHeroesコンポーネントは、横並びの関係性である
  • やるとしたら、無理矢理DashboardとHeroesの親となるコンポーネントを作る必要がある
  • また、データのライフサイクルがコンポーネントのライフサイクルに引っ張られるため、取り回しがしにくくなる

2の方法についても、できなくはないけれど、ルーティングのロジックが複雑になり、相互依存性が高まってしまいます。

Viewのためのstateを持つサービスを導入する

image.png

今回のリファクタでは、最終的に3つの層に分けています。

  1. 同じstateを参照するコンポーネント群
  2. Heroのmodelのstateを持つサービス
  3. 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つあります。

  1. pipe処理のなかで4件取得する処理(heroes.slice(1, 4))を行なっている
  2. ngOnInit の中では、何もしない。 ngOnDestroyunsubscribe することもない
  3. 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の開発者なので、彼の動画をディグると、開発の意図まで含めて勉強になるのでオススメです

RxJSのメリットは、データの関係性が複雑になるほど、力を発揮すると思うので、記事で簡単に紹介するってなかなか難しいですよね。
ほんとはプロダクションコードをお見せしながらだといいのですが、そういうわけにもいかず。。

次回は、detailの話もしつつ、リアクティブな実装方法の使い所をより抽象化してパターン化したいと思っています。

11
8
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
11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?