LoginSignup
27
22

More than 5 years have passed since last update.

AngularFire V5 + Firestore 環境で Virtual Scroll を導入する

Last updated at Posted at 2017-12-03

これは Angular Advent Calendar 2017 4日目の記事です。

こんにちは (。・ω・。)
Angular で CGM サービスを運用・構築したり、ng-japan の slack で emoji を追加することを生業としている者です。

今回は、担当しているサービスで Virtual Scroll を導入する機会があったので、その経緯と方法について紹介したいと思います。

今回の動作 demo は コチラ
* この demo は、いずれ落とすことになると思いますのでご了承くださいm(__)m

↓ 動いている様子

demo 用に 100 件のデータを投入しましたが、どんなにデータを読み込んでもページ間の遷移速度が落ちない様子がわかると思います。

なぜ Virtual Scroll が必要だったのか

サービス内で使われるリストは、新着一覧やランキング等様々なページの要となる要素です。
私の担当しているサービスも例に漏れず、各種リスト画面が必要でした。

当初、PWA を構築するにあたり参考にした Twitter Lite が Virtual Scroll を採用していたので、自分も実装するべきか迷っていました。
しかし Virtual Scroll は 「高速でスクロールすると描画が追いつかない」 というデメリット?も併せ持っているため、Twitter のように大量のリストアイテムを読み込むサービスでないと採用する理由は薄いかな?

...と、思っていました

が、とある機能を実装している中で Virtual Scroll が必要不可欠なものだと気づきました。


リスト画面は言うまでもなく各種詳細画面へのリンクを集めた要素で、リスト ←→ 詳細 を行ったり来たりする必要があります。
それらのページを普通に実装すると、詳細画面から戻ってきた際リストアイテムを 1 から読み込み直す必要があります。
そうすると、例え 100 件までアイテムを読み込んでいたとしても また 1 から読み込み直し... という最悪な UX となってしまいます。

ただ、Angular ではこの問題を解決できる RouteReuseStrategy という仕組みが提供されていました。
RouteReuseStrategy は 「どの画面をキャッシュして使いまわすか」 という処理を設定する仕組みで、これを利用することで途中まで読み込んだリストデータを紛失することなく再利用できます。
(使い方については、How to reuse rendered component in Angular 2.3+ with RouteReuseStrategy 等で紹介されています)

これで リスト ←→ 詳細 を瞬時に行き来できるようになりました (●⌒∇⌒●)

ただし、リストのアイテムが少ない場合だけ ですけどね (ノД`)

数十件の程度の場合は すぐに復元されていたのですが、これが 100, 200... と増加するにつれて、リスト画面に戻ってくる際の遅延が無視できないレベルになり始めました。

そりゃ 戻る度に 100 件以上レンダリングしたら重いですよね (´・ω・`)

自分は今まで Virtual Scroll というものは、スクロール時のリソース消費を控えるために存在しているのだと思っていました。
しかし どちらかというとブラウザバックで戻ってきた際、瞬時に描画領域のアイテムとポジションを復元するために利用するパターンな気がしました。

こうして Virtual Scroll の重要性に気づいた私は、導入を進めることにしたのでした。

Virtual Scroll ライブラリの選定

angular virtual scroll 等でググってみると主に使われているであろうライブラリが 2 つ見つかりました。

スター数や機能面では angular2-virtual-scroll が勝っていますが、現在の Angular で扱う非同期処理は Observable なものばかりです。
普通に考えれば、最初から Rx 前提の実装である od-virtualscroll を選びたくなります。
自分も最初、前者を試してみましたが、safari ブラウザでスクロール時の描画が安定しない現象が散見しました。

こういった現象は overflow: scroll + -webkit-overflow-scrolling : touch を使用している場合に発生するようです。
色々試したところ、現在の safari で動作を安定させるためには、overflow: scroll を使ったスクロールではなく、Window で直接スクロールさせる必要がありそうでした。
ただ、これに気づいたのは良いものの od-virtualscroll には Window 自体でスクロールするオプションが存在しません。
一方で angular2-virtual-scrollには parentScroll というひと目で コレだ! とわかるオプションが用意されていました。

ということで、最終的に angular2-virtual-scroll@0.3.0 を選びました。
(もちろん、そのサイトのデザインや要件によると思うので、od-virtualscroll の方が良い場合もあるかもしれません)


日記 が長くなってしまいすみません (ToT)
この記事では、Angularfire2 + angular2-virtual-scroll で無限スクロールを効率良く実現するための カスタムコンポーネント を作成します。これが本題となります。

テンプレート側の実装

最終的な利用イメージを決める

カスタムコンポーネントを作成する際は、「最終的にどうやって使いたいか」 ということを考えると思います。
自分は こんな感じのカスタムコンポーネントが欲しいなぁ と思いながら作り始めました。

  • アイテムの部分は外から注入できる
  • リストが 0 件だった場合のUIが注入できる
  • ローディング中は自動でスピナーが表示される
<app-virtual-scroll [option]="option" [itemNotify]="itemNotify">
  <li class="item">
    <div class="item__name">{{ item.name }}</div>
    <div class="item__body">{{ item.body }}</div>
  </li>

  <div class="no-item">
     xxx が存在しません
     <p>yyy は zzz からできますよ的な文言等</p>
  </div>
</app-virtual-scroll>

リスト要素を外部から注入できるようにする

要件を実現するための機能を調べてみると、ng-template を介してカスタムコンポーネントの値を受け渡せる ということがわかりました。
説明のために簡単な例を示します。

custom-ui.component.ts
@Component({
  selector: 'app-custom-ui',
  template: `
    <ul class="list">
      <template *ngFor="let item of items; template: itemTemplate">
    </ul>`
})
export class CustomUi {
  @ContentChild(TemplateRef) itemTemplate: TemplateRef;
}
app.component.html
<app-custom-ui>
  <template let-item>
    <app-xxx-item [item]="item"></app-xxx-item>
  </template>
</app-custom-ui>

@ContentChild(TemplateRef) itemTemplate: TemplateRef

つまり、カスタムコンポーネントを使用する際、子要素に存在する ng-template を自身の ngFor で使用します。
このパターンを使うことで、カスタムコンポーネントの外側からでも ngFor の値が使えるようになりました。

リスト 0 件 UI を外から注入できるようにする

Angular には ng-content という 子要素を参照する ための要素が存在し、

custom-ui.component.html
<div>
  <p>↓ ここに外からUI注入したい</p>
  <ng-content></ng-content>
</div>
app.component.html
<app-custom-ui>
  <div class="xxx">yyy</div>
</app-custom-ui>
結果
<div>
  <p>↓ ここに外から UI 注入したい</p>
  <div class="xxx">yyy</div>
</div>

このように、外側のテンプレートから簡単に好きな要素を注入できます。
ts 側に何も書かなくて良いのでソースも汚しません。
ただ、今回はアイテム UI の部分も一緒に注入したいので、上記の方法ではアイテム部分が無駄に読み込まれてしまいます。

そのため、ng-content のセレクタ機能を利用しました。
セレクタ機能を使うと、注入される側に ng-content が複数あったとしても、セレクタに一致している部分だけを読み取ってくれます

custom-ui.component.html
<header class="header">
  <ng-content select="[header]"></ng-content>
</header>
<div class="body">
  <ng-content select="[body]"></ng-content>
</div>
app.component.html
<app-custom-ui>
  <div class="xxx__header" header>...</div>
  <div class="xxx__body" body>...</div>
</app-custom-ui>

テンプレート側まとめ

angular2-virtual-scroll を使っている部分は公式ガイドそのままです。
アイテムのストリームを async パイプで subscribe し、取得したアイテムリストの情報を virtual-scroll に設定します。
すると、"現在どの位置まで読み込んでいるか" という情報を end イベントで教えてくれるので、これを利用して次のアイテムを読み込むべきか否かを決定します。
また、アイテムが存在しない場合は noItem が設定されている要素を表示するようにします。

virtual-scroll.component.html
<div class="virtual-scroll" *ngIf="items$ | async as items">

  <virtual-scroll [items]="items" (update)="$event" (end)="fetchMore($event)" [parentScroll]="scroll.window" [childHeight]="childHeight"
        [bufferAmount]="6" #scroll>

    <ng-template *ngFor="let item of scroll.viewPortItems; let index; template: itemTemplate"></ng-template>

  </virtual-scroll>

  <div class="no-item" *ngIf="!items.length">
    <ng-content select="[noItem]"></ng-content>
  </div>

</div>

<app-spinner *ngIf="loadingNotify | async"></app-spinner>

アイテムリストの取得・制御

前項までで、VirtualScrollComponent を使う準備は整いましたが肝心のデータがありません。
ここからは、データの取得や制御についてまとめていきます。

なお、今回扱うアイテムは、Firestore のフィールドタイプである Date型 を持っている想定であり、この情報を元に orderBystartAfter を行います。(new Date() で生成した値が Date型 になります)
RealtimeDB 時代より大分便利になりました (´▽`)

次の読み込みを行う判定

angular2-virtual-scroll は自身が描画しているアイテムが変更された際に下記形式の Object を渡してくれます。

  • {start: 0, end: 13}
  • {start: 0, end: 18}
  • {start: 5, end: 24}
  • ...

次のアイテムを取得するべきか? という判定は、この end と、保持しているアイテムリストのサイズを比較して行います。end の値がアイテムリストのサイズと等しい...つまり、最後まで表示(生成)したら次のページを取得します。

virtual-scroll.component.ts
  fetchMore(event: ChangeEvent) {
    if (event.end === this.itemLength) {
      this.fetchNextChunk();
    }
  }

アイテムリストのストリーム

メインとなるアイテムストリームの生成には scan オペレータを利用します。

scan は過去の値と最新の値から別の値を生成します。
Array でいう reduce ですね (*´ω`)

initialize() {
  this.lastNotify = new BehaviorSubject<Date>(new Date(MaximumTimestamp));

  this.items$ = this.itemNotify.pipe(
    scan((current: any[], future: any[]) => {
      if (future.length > this.limit) {
        future.pop();
        const nextDate = future[future.length - 1][this.orderByDateFieldName];
        this.lastNotify.next(nextDate);
      } else {
        this.lastNotify.next(this.endDate);
      }

      return current.concat(future);
    }, []),
    tap((items: any[]) => this.itemLength = items.length),
    tap(() => this.loadingNotify.next(false)),
  );

  this.fetchNextChunk();
}

ストリーム生成の流れを示すと、

  • 最新の値(アイテムリスト)が 1ページ分の設定件数より多いか?
    • > 多いか
      • 余剰取得分をカットする ( まだ先があるか? という情報を得るため 設定値 + 1 にしています )
      • 次の日付保持用 Subject に最後のアイテムの日付データを渡す
    • > 少ない ( 次のページがない )
      • 次の日付保持用 Subject に終了扱いの値を入れる(0 等)
  • 過去の全アイテムと新たに取得したアイテムリストを合成
  • 生成した新アイテムリストのサイズを保持

DBからデータを取得

実際にDBからデータを取得する処理は、各リストによって様々な状況が考えられるため、無限スクロールを実装する各コンポーネントで用意するべきだと思います。

よって今回の実装では、使用するコンポーネント側から VirtualScrollComponent へアイテムリストの Subject を渡すことにしました。
また、データ取得タイミングは @Output でカスタムイベントを作成し、使用する側に伝えます。

処理の流れとしては、

virtual-scroll.component.ts
private fetchNextChunk() {
  const last = this.lastNotify.getValue();

  if (last <= this.endDate) {
    return;
  }

  const eventParam: FetchEvent = { last, limit: this.limit + 1 };
  this.fetch.emit(eventParam);
}
  • BehaviorSubject に入っている最新の値(次の開始日付)を fetch イベントとして伝えます。
app.component.html
<app-virtual-scroll [itemNotify]="itemNotify" (fetch)="onFetch($event)" #virtualScroll>
  • VirtualScrollComponent のタグ上でイベントのコールバックを設定
app.component.ts
onFetch(event: FetchEvent) {
  this.subscriptions.add(
    this.fetchItems(event).subscribe((items: Item[]) => {
      this.itemNotify.next(items);
    })
  );
}

private fetchItems(e: FetchEvent): Observable<Item[]> {
  return // DB 取得処理
}
  • onFetch がコールされ、fetchItemssubscribe されます
  • fetchItems では FetchEvent の情報を使いDBからデータを取得します
  • fetchItems から取得したデータを itemNotify に渡します
  • 渡した値が、virtual-scroll.component.tsthis.items$ = this.itemNotify.pipe( に伝わり、前述した scan の処理が始まります
  • アイテムリストが生成され、あとは angular2-virtual-scroll がうまくやってくれます

※ 説明のため簡略化した部分もありますが、おおよそこんな感じです

リストのリフレッシュ

上記までの項目で基本的な部分は完成しましたが、実際のアプリケーションでは 新着順ソート → 良いね順ソート などリストの内容が動的に変わることが多々あります。

そういった場合は、リストを管理しているコンポーネントで VirtualScrollComponent に対して @ViewChild しアイテムストリームの初期化を行う initialize をコールします。

this.virtualScroll.initialize();

これにより items$ 等がリセットされ、リストの再構築が可能となります。

実際にリストを再構築している様子

Untitled2.gif

  • empty を on にした場合は、 DB からの値ではなく Observable.of([]) を流しています

完成

上記の過程で、カスタマイズした Virtual Scroll Compon が完成しました。
これで下記使用例のように、各ページで簡単に無限スクロールが設置できるようになりました (≧∀≦)

article-list.component.html
<app-virtual-scroll [itemNotify]="itemNotify" (fetch)="onFetch($event)" #virtualScroll>
  <ng-template let-item>
    <app-article-item [article]="item"></app-article-item>
  </ng-template>

  <mat-card noItem>
    ひとつもないない
  </mat-card>
</app-virtual-scroll>

実際に demo で動いているコードは https://github.com/MasanobuAkiba/angularfirestore-virtual-scroll-demo に置いておきます。

おわりに

自分がまだ Rx に慣れておらず、Angular で Virtual Scroll を実装しようと思った際、どうやって Observable 部分と連結さるのか、 どういう実装が良いのか? ということがわからずに困りました。
また、Virtual Scroll はサイト(アプリ)の根幹を担う非常に重要なUIパターンであるため、新しく Angular を始めた方が結構ハマるポイントかなぁ?と思い今回の記事を書きました。

そういった方々のお役に立つことができれば幸いです |д・)


5日目は @yuhiisk さんです
よろしくお願いしますm(__)m

27
22
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
27
22