JavaScript
AdventCalendar
angular
RxJS
IntersectionObserver

ユーザーが画面内のコンテンツを見たかどうかを判定する一歩上の仕組み、サービスでの活用事例

JavaScript2 Advent Calendar 2018 18 日目の記事です。

ビジネスSNSサービス Goalousの開発で、投稿一覧ページにおいてユーザーが厳密に他人の投稿を見た場合のみ既読にしたいという要件があったので、どのように実装したのかと負荷を抑える為に考慮したことをお話します。

ここで言う「厳密に」とは、具体的にはAPIでデータを読み込んだ時ではなく、画面(View Port)内に投稿の要素が表示された時です。

サンプルを用意しましたので、まずはこちらをご覧ください。
https://stackblitz.com/edit/view-in-port-sample
view-import-sample.gif

例えば↓はページロードしてから少しスクロールした状態です。
サンプルでは画面内に表示されたか判定の対象要素を赤の枠線に設定しています。
なので、画面内に5個目の投稿は見えてはいますが赤の枠線まで辿り着いていないので、読んだ投稿数は4個となります。
image.png

ここからもうちょっとだけスクロールしてみましょう。
赤の枠線が画面内に見えたので、読んだ投稿数は5個になりました。
image.png

このようにユーザーがコンテンツを見たかどうかを厳密に判定し特定の処理を行う為の実装方法を次から説明します。
※後半からこのAngularアプリケーションのサンプルに沿っての説明となりますが、ReactやVue.jsでも基本的な考え方は同じです。

要素が画面内に表示されたかを判定するには

従来は大変だったんだ・・・そう、offsetの嵐だった

まず僕が思いついたのはloadとscrollイベントをトリガーとし、対象要素とwindowの位置を比較して画面内に要素が含まれているかどうか判定する方法です。
ただ思いついた瞬間に「あ、これ実装しないでも分かるあかんやつやん汗」と悟りました:expressionless:

なぜダメなのか、これも冒頭のサンプルと同じ動きをする別のサンプル(jQuery)を作りましたのでご覧ください。
https://codepen.io/endam-the-sasster/pen/qLOQVw

サンプルのコードに沿って説明すると、offsetを利用した方法で一番問題なのは、最悪の場合スクロールするごとに全ての要素を取得し、1個ずつ要素位置を比較して画面内に要素があるかどかを判定しなければいけないことです。
もし判定の対象となる要素が100個、スクロールイベントが100回走ったら計10000回ループしなければいけません。
またインフィニットローディング等で対象の要素が増えれば増えるほど、負荷は比例して増大します。

$(window).on('load scroll', function() {
  check_in_viewport();
});
function check_in_viewport() {
  const win_scroll_top    = $(window).scrollTop();
  const win_scroll_bottom = win_scroll_top + $(window).height();
  let read_count = 0;
  const $targets = $('.content').find('.read-line');
  // わざわざ対象要素全てを取得して、一個ずつ要素が画面内にあるか判定しなくちゃいけないよ\(^o^)/ ガッデム
  // e.g. 投稿が100個あったらループを100回実行
  $targets.each(function(i){
    const $target = $targets.eq(i);
    const read_flg = $target.attr('data-read');
    // もう読んだ投稿はoffsetの判定を行わない
    if (read_flg === '1') {
      read_count++;
      return true;
    }

    // offset取得
    const target_pos = $target.offset().top;
    // 要素がwindowの中にあるかチェック
    if(win_scroll_top < target_pos && target_pos < win_scroll_bottom) {
      $target.attr('data-read', 1);
      read_count++;
    } 
  });

  // 既読個数の表示を更新
  $('.read-count').text(read_count);
}

上記はあくまで何の工夫もしないバッドパターンですので、もちろん工夫をすれば比較回数を極力抑えることは可能です。
しかしそもそもそんな工夫をしなければならない方法自体を見直すべきですし、
業務で従来のoffsetを利用する方法は現実的ではありません。

じゃあどうすれば良いのか。
この問題を解決するのがIntersection Observer APIです。

Intersection Observer APIを使ってみよう

Intersectionは Observer APIは要素と要素の交差を監視する為のAPIです。
viewport内に何か要素が入ったり出たりするのも検知出来ます。

詳細はMozillaのドキュメントを参照してください。

ここではIntersection Observer APIを使ったらどうなるのかをデモを交えて説明します。

例のごとくサンプルコードはこちら
https://codepen.io/endam-the-sasster/pen/oJbdrZ
intersection-observer-api-sample.gif

まずIntersection observer を作成し、要素が交差する度に実行されるコールバック関数を渡します。
コンストラクタの第二引数に何も指定しなければ対象の要素が見えるかどうかを確認するためのビューポートとして使用される要素はデフォルトでブラウザーのビューポートが使用されます。

let visible_flg = false;

const io = new IntersectionObserver(entries => {

  // Available data when an intersection happens
  console.log(entries);
  // Element enters the viewport
  if(entries[0].intersectionRatio !== 0) {
    visible_flg = true;
  } else {
    visible_flg = false;
  }
  updateStatus(visible_flg);
});

intersectionRatioは交差している領域の割合です。
要素が交差した時のconsole.logでentriesの中身を出力するとこのようになります。
image.png

これが0でなければ対象要素がviewport内に入ってきたと判定します。
おっと、何の要素を監視するか登録するのを忘れないようにしましょう。

const target = document.querySelector('.target');
// Start observing target element
io.observe(target);

後はコールバックでその要素がviewport内に入ったのか出たのかによって処理を振り分けるだけです。

// Just necessary for displaying the current status
function updateStatus(visible_flg) {
  console.log(visible_flg);
  const status = document.querySelector('.status');
  if (visible_flg) {
    status.textContent = '見えたっ!';
    status.className = 'status visible';
  } else {
    status.textContent = '見えない。。奴はどこだ';
    status.className = 'status invisible';
  }
}

こうしてコードで比較するとoffset判定から、いかにシンプルになったかが分かりますね。。。

Angularではどう実装するのがベターか

現場ではAngularで実装したので、冒頭のサンプルコードに沿ってAngularではどう実装するのが良いかを説明します。
※もし興味ない方は読み飛ばして構いません。

Angularで手軽にIntersection Observer APIを利用したい場合はng-in-viewportパッケージをオススメします。

まずはパッケージをインストールしましょう。

npm install --save ng-in-viewport@next intersection-observer

Intersection Observer APIはIE11やSafariのブラウザでは未対応です。
Can I use intersectionobserver

こうした未対応のブラウザにはPolyfillで対応します。
polyfills.tsintersection-observerパッケージをimportしてください。

polyfills.ts
import 'intersection-observer';

次にng generate componentコマンドで監視対象要素となるPostComponentを作成します。

post.component.html
<p>
Post {{postId}}
</p>
<div 
  class="read-line"
  inViewport
  [inViewportOptions]="{ threshold: [0] }"
  (inViewportAction)="onIntersection($event)"
>↑read line</div>

サンプルではread-lineクラスの要素を監視対象としますので、inViewportのディレクティブを追加します。
inViewportOptionsはIntersection Observer APIで利用出来るオプションそのままです。
[inViewportOptions]="{ threshold: [0] }"thresholdはは対象要素がどのくらい見えていれば検出するかという割合なので、0を指定すると、1ピクセルでも表示されるとコールバックが実行されます。
inViewportActionには要素が交差した時のコールバック関数を設定します。

続いてtsファイルでコールバック関数の中身を書いていきます。

post.component.ts
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';


@Component({
  selector: 'post',
  templateUrl: './post.component.html',
  styleUrls: ['./post.component.css']
})
export class PostComponent implements OnInit {
  @Output() onView = new EventEmitter<number>();
  @Input() postId: number;
  readFlg = false;
  constructor() { }

  ngOnInit() { }

  onIntersection({ visible }: { visible: boolean }) {
    if (this.readFlg || !visible) {
      return;
    }
    this.readFlg = true;
    this.onView.emit(this.postId);
  }
}

visibleはtrueであれば要素がviewport内に入ってきた、falseであれば逆に要素がviewportから出たことを意味します。
すでに読んだ投稿もしくは要素がviewportから出たのであれば処理不要なので、イベント発火はしないようにします。

今度はAppComponentの修正に移ります。

app.component.html
<div class="wrapper">
    <div class="header">
        <p><span class="num">{{readCount}}</span> 個の投稿を読んだ</p>
  </div>
  <div class="content">
    <post 
      *ngFor='let in of counter(100) ;let i = index' 
      [postId]="i+1"
      (onView)="onReadPost($event)"
    ></post>
  </div>
</div>
app.component.ts
import { Component } from '@angular/core';
import { bufferTime } from 'rxjs/operators';
import { Subject } from 'rxjs';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  readCount = 0;

  onReadPost(postId: number) {
    console.log({postId});
    this.readCount++;
  }

  counter(i: number) {
    return new Array(i);
  }
}

大事なのは先ほど作成した子コンポーネントでonViewイベントが発火した際のコールバックです。
ここではonReadPostというメソッドで既読投稿数をカウントアップする処理を行なっています。

    <post 
      *ngFor='let in of counter(100) ;let i = index' 
      [postId]="i+1"
      (onView)="onReadPost($event)"
    ></post>
  onReadPost(postId: number) {
    console.log({postId});
    this.readCount++;
  }

最後にapp.module.tsimportsInViewportModuleを、declarationsに対象要素のコンポーネントとなるPostComponentを追加します。

app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { PostComponent } from './post/post.component';
import { InViewportModule } from 'ng-in-viewport';

@NgModule({
  imports:      [ BrowserModule, FormsModule, InViewportModule ],
  declarations: [ AppComponent, PostComponent ],
  bootstrap:    [ AppComponent ]
})
export class AppModule { }

これで完成です :thumbsup:

APIリクエスト増大による負荷が心配・・そんな時こそRxJsっしょ!

でもちょっと待って!!:hand_splayed_tone2:
もし監視する要素が本当に少なければこれでも良いかもしれません

しかしサンプルのような、サービスでよくある一覧ページではもし要素がviewport内に入ってくる度に何かしらのAPIを呼ぶとすれば、バックエンドの相当な負荷が予想されます。
一気にスクロールすれば数十のリクエストが一瞬で飛ぶことになるからです。
そんな事態は避けなければいけません。

実際の現場で求められたのは投稿を既読にするAPIのリクエストを極力減らすことです。
投稿ごとにAPIを呼ぶのではなく、一つのAPIで複数の投稿を一括して既読にしたい。
もっと具体的に言うと、ある一定間隔で画面内に表示された複数の投稿をまとめて一つのAPIリクエストによって既読のステータスに変更するです。

そしてこれを実現するのがRxJsのbufferTimeです。

bufferTimeは最初のイベントをから指定した時間が過ぎるまでの間に流したであろうイベントの値をまとめてonNextに渡します。

以下の公式のサンプルを見てもらうと分かるようにnextに渡されるのは、2秒間の間に起こったイベントでの値をまとめた配列です。
bufferTime.gif

このbufferTimeを用いて先ほどのAngularサンプルを改善します。
修正するファイルはapp.component.tsで、↓は修正後のコードです。

app.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { bufferTime } from 'rxjs/operators';
import { Subject } from 'rxjs';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent implements OnInit, OnDestroy  {
  subject: Subject<number>;
  readCount = 0;

  ngOnInit() {
    this.subject = new Subject<number>();
    this.subject.pipe(bufferTime(1000)).subscribe(postIds => {
      if (postIds.length > 0) {
        this.readCount += postIds.length;
      }
    });
  }

  onReadPost(postId: number) {
    console.log(`postId:${postId}`);
    this.subject.next(postId);
  }

  counter(i: number) {
    return new Array(i);
  }

  ngOnDestroy() {
    this.subject.unsubscribe();
  }
}

まずngOnInitでSubjectを生成し、bufferTimeをpipeで設定した上でonNextでの処理を記述します。
Subjectって何ぞという方はRxJS を学ぼう #5 - Subject について学ぶ / Observable × Observerの記事が分かりやすいですのでご参考ください。

onNextに流す値は複数の投稿IDの配列で、もし配列が空でなければ既読数をカウントアップします。

  ngOnInit() {
    this.subject = new Subject<number>();
    this.subject.pipe(bufferTime(1000)).subscribe(postIds => {
      if (postIds.length > 0) {
        // サンプルでは既読数を更新するだけだが、実際はAPIを呼ぶ
        this.readCount += postIds.length;
      }
    });
  }

次にonReadPostメソッドの中身を変更しましょう。
ここでは既読数更新を直接行うのはやめて、Subjectに対して既読にする対象の投稿IDを引数としてイベントを流します。

  onReadPost(postId: number) {
    console.log(`postId:${postId}`);
    this.subject.next(postId);
  }

最後にngOnDestroyでunsubscribeするのをお忘れなく。

  ngOnDestroy() {
    this.subject.unsubscribe();
  }

以上で改良版の完成です!!

改良版のサンプルコードはこちら
https://stackblitz.com/edit/view-in-port-sample-v2

1秒ごとに処理をまとめて実行しているので、その分表示反映は遅れていますが負荷は大分下がっています。
view-import-sample-v2.gif

このbufferTimeの時間をどれぐらいにするかはビジネス要件によります。
僕が開発しているサービスでは即表示の反映はしないのでAPIリクエスト数を極力減らすことに重きを置いて設定は1秒としています。

最後に

offsetの嵐から卒業し、Intersection Observer APIとRxJsを活用して、シンプルにかつ負荷をかけることない実装が出来ました。
正直RxJsを知った最初の頃は「理屈は分かるがメリットがよくわからん」と思っていましたが、本記事のような案件も体験して利便性を少しずつ実感している今日この頃です。

もし「こういう時どうしているの」「ここのコードについて聞きたいんだけど」などなど質問がある方は気軽にコメントを下さい。