パフォーマンスを意識した要素監視について

<!--
![](https://qiita-image-store.s3.amazonaws.com/0/8292/ef5f6cbc-0702-1c98-580f-786429f835e5.gif)

このエントリーは [aratana (Advent Calendar 2017)](https://qiita.com/advent-calendar/2017/aratana) 24日目のエントリーです。

---

はじめまして、BtoB開発グループ フロントエンドエンジニアの菊地[^1]です。

昨日は、新卒の川原くん(@kawahara_hiroyuki)による「[8ヶ月間gitを触った今気づいた発見や学び](https://qiita.com/kawahara_hiroyuki/items/a7208dea0135655ab186)」というエントリーでした。つい数ヶ月前にGitでテンパって相談に来たのを思い出しました。失敗を糧にして、Gitマスターになりつつあるようで安心しました！

さて今日は、 クリスマス・イヴ という事で「ウェブサイト上における**パフォーマンスを意識した要素検出**」について、手法などを紹介したいと思います:sparkles::santa_tone1::christmas_tree::sparkles:
-->

## 背景

今期は、担当案件にてパフォーマンスを意識する機会が多くありました。

色々と行った取り組みの中に、ファーストビュー表示の高速化のため「**ビューポート内に存在しないコンテンツを非同期で読み込みする**」というものがありました。これは、ページスクロールの過程で対象の要素が画面に表示された際に対応したコンテンツに必要な情報をAjaxで取得してコンテンツを非同期的に構築していくというものです。

コンテンツの非同期的な読込例としては、オートページャーやTwitterのタイムラインなどを想像して頂けると理解しやすいかと思います。その他、画像の遅延読み込みなどでこういった手法がよく使われています。

一部、今回の取り組みも交えて、要素監視について手法を紹介したいと思います。


## よくある事例

以下のような要素検知処理はよく見かけるかと思います。

```js:事例1
$(window).on('scroll.target-show', function () {
  var targetTop = $('#target').offset().top;
  var scrollTop = $(window).scrollTop();
  var windowHeight = $(window).height();

  if (scrollTop > (targetTop - windowHeight)) {
    // 処理
    // ...

    // イベント解除
    $(window).off('scroll.target-show');
  }
});
```
※jQueryオブジェクトのキャッシュなどは割愛します

パフォーマンスを意識した場合、上記のコードはどうでしょうか。上記の例では、要素が検知できた際にイベント解除しているとはいえ、それでもスクロールイベント毎に処理が必要以上に走ってしまっており、パフォーマンスはよくありません。

宗教上の理由でどうしてもスクロールイベントを使って実装せざるを得ない場合は、負荷を下げるために後述のように**間引き処理**を行いながら使うと良いです。


### スクロール処理を間引く

色々と手法はありますが、もし既にプロダクトで `lodash`を使用しているのであれば、以下のような使用方法で簡単にスクロール処理を間引くことが出来ます。

```js:lodash.throttle
_.throttle(func, [wait=0], [options={}])
```

```js:事例2
import { throttle } from 'lodash';

$(window).on('scroll.target-show', throttle(function () {
  var targetTop = $('#target').offset().top;
  var scrollTop = $(window).scrollTop();
  var windowHeight = $(window).height();

  if (scrollTop > (targetTop - windowHeight)) {
    // 処理
    // ...

    // イベント解除
    $(window).off('scroll.target-show');
  }
}, 250));
```

スクロールイベントはスクロール時に大量にイベントを発火させますが、上記のように実装するだけで`250ミリ秒`に1回だけしか当該処理が呼び出されなくなるため、多少はパフォーマンスの低下を抑制できます。
また、lodashのような外部スクリプトを使わずに`setTimeout`や`requestAnimationFrame`を使って自前で処理を間引くことも可能です。


## ベストプラクティス

スクロールイベントは実行回数が非常に多いです。多用すると以下のような問題点があります。

- Scroll Jank を引き起こす可能性がある (`preventDefault()`が呼ばれている場合等）
- サイズや位置を取得する場合 `Forced Synchronous Layout` が発生する可能性がある  
[参考 - What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a)

これまで紹介したスクロールイベントを使った要素検知では`offset()`や`scrollTop()`などを実行していました。これらの処理は、対象DOMの位置を取得するため**同期的にLayout計算が発生**します。大量のイベントが発火してしまうスクロールイベント内で、これらの処理を行うと**スクロールを阻害**してしまいますし、その都度計算処理を行わせるためパフォーマンスも良くありません。


### Intersection Observer を使う

`Intersection Observer`というAPIを使えば、スクロールイベントを使用せず指定要素を監視する事ができます。

ここ最近は利用可能なブラウザも増え、`Intersection Observer`の利用も多くなってきていると思います。（IE/Safariは少し忘れておいてください…）

> ![caniuse_intersectionobserver](https://qiita-image-store.s3.amazonaws.com/0/220757/88bc23be-3135-1ab3-6870-16b3737d8263.png)
>
> 引用: https://caniuse.com/#feat=intersectionobserver



### 実装例

これまでの実例を`Intersection Observer`を使って実装すると、ざっくり以下のような形で実装が可能です。

```js
var clientHeight = document.documentElement.clientHeight;

var observer = new IntersectionObserver(function(changes) {
  for (let change of changes) {
    var rect = change.target.getBoundingClientRect();
    var isShow = (0 < rect.top && rect.top < clientHeight) || (0 < rect.bottom && rect.bottom < clientHeight) || (0 > rect.top && rect.bottom > clientHeight);

    if (isShow) {
      // 処理
      // ...

      // イベント解除
      observer.unobserve(change.target);
    }
  }
});

// 対象の要素を監視対象にする（NodeListの場合はループで都度渡す）
var target = document.querySelector('#target');
observer.observe(target);
```

これで指定要素がビューポートに現れたときに処理を実行することができます。
さらにオプションで`threshold`,`rootMargin`など監視条件を細かく設定できますが割愛です。

#### サンプル

- https://codepen.io/callahad/full/YxXpyN/

### Internet Explorer と Safari の対応は？

そろそろ IE と Safari を思い出してください。

現状、これらのブラウザで`Intersection Observer`はサポートされていません。これらのブラウザで`Intersection Observer`を利用する場合は、`Polyfill`が必要となります。

例えば [intersection-observer-polyfill](https://www.npmjs.com/package/intersection-observer-polyfill) を利用した場合は、下記の記述でモダンブラウザと同様に`Intersection Observer`を利用することができます。

```js:es6
import IntersectionObserver from 'intersection-observer-polyfill';
```

```html:global
<script src="intersection-observer-polyfill/dist/IntersectionObserver.js"></script>
<script>
    (function () {
        var observer = new IntersectionObserver(function () {});
    })();
</script>
```

レガシーブラウザでも同じ動きを同じ記述でさせたい場合は`Polyfill`を使うと便利です。


## 担当案件の導入事例

結論から述べると、今回担当案件では、`Intersection Observer`の導入はできませんでした。

理由としては、以下の通りです。

1. プロダクトの Internet Explorer/Safari 利用シェアが全体の7割を占めている (スマホ含む)
1. 影響範囲の少ないようにしたい

案件のシェア率を鑑みると`Polyfill`へ流す割合の方が大きくなってしまいます。また、Internet Explorer と Safari の場合、確認する環境が Windows/macOS/iOS と複数環境にまたがってしまうため、テスト工数が通常よりも多くなります。今回のスケジュールはタイトであり、出来るだけ影響範囲を少なくしたかったので、`Polyfill (Intersection Observer)`の導入は今回は断念しました。


### どうしたのか

`Intersection Observer`が使えないため、代替ライブラリを導入することにしました。

「サイズが小さく、依存関係が少なくて、幅広いブラウザに対応している」ことを条件として、色々と試した結果、[in-view.js](https://www.npmjs.com/package/in-view)というライブラリを導入することにしました。コンテンツの非同期読み込みの足がかりとして、まずは in-view.js を利用することにしました。

この in-view.js は内部的には`Intersection Observer`は使わず、[Mutation Observer](https://caniuse.com/#feat=mutationobserver)を使って要素監視をしているようです。先述で紹介したのと同じように`throttle`で処理を間引きながら、`window`の`load`/`resize`/`scroll`イベントで`100ミリ秒`に1回の頻度で監視しているようでした。

下記のようにシンプルに要素監視のイベントを記述する事ができます。

```js:in-view.jsを利用した場合
inView('#target').once('enter', doSomething);
```

`Intersection Observer`に比べてパフォーマンス面で少々不安は残りましたが、リリース後も大きな問題などは発生していません。今後、パフォーマンスの定期的な監視とブラウザのシェア状況などを考慮して`Intersection Observer`の利用を改めて考えていきたいと思っています。


## おわり

スクロールイベントは慎重に使っていきましょう。もしこれから新規案件などで要素監視を行うのであれば、`Intersection Observer`を使うのがベストプラクティスだと思います。

レガシーなプロダクトでも少しずつモダンな実装を取り込み、プロダクトの新陳代謝を良くする事は重要です。パフォーマンス改善はもちろんですが、保守開発をしやすくする開発環境を構築していきましょう:sparkles::santa_tone1::christmas_tree::sparkles:


## 参考サイト

- [in-view](https://www.npmjs.com/package/in-view)
- [intersection-observer-polyfill](https://www.npmjs.com/package/intersection-observer-polyfill)
- [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a)
- [Intersection Observerを触ってみた](https://qiita.com/tatsuyankmura/items/544827d0a724c2f0c495) 
- https://caniuse.com/#feat=intersectionobserver
- [MDN Web Docs - Intersection Observer](https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API)
- [Intersection Observer comes to Firefox](https://hacks.mozilla.org/2017/08/intersection-observer-comes-to-firefox/)

<!--
---

[aratana (Advent Calendar 2017)](https://qiita.com/advent-calendar/2017/aratana) の大トリは、空前絶後の超絶怒涛のフロントエンドエンジニア、WordPressを愛しWordPressに愛された男、アラタナコーポレートサイトの生みの親、高見さん(@miiitaka)の記事です。

先日、高見さんはコーポレートサイトのリニューアルの実装をお1人で完遂されており、エントリーもそれに合わせた内容（[アラタナコーポレートサイトリニューアル - 技術まとめ
](http://lab.aratana.jp/entry/2017/12/25/120000)）となっているようです。私もどういった技術が使われているのか全く知らないため内容が楽しみです！



[^1]: Uncaught ReferenceError: 菊**池** is not defined
-->