IntersectionObserveの遅延読み込みライブラリのLozad.jsを紹介する

この記事は
「DeNA IPプラットフォーム事業部 Advent Calendar 2017」
13日目の記事です。

こんにちは。 @yukagil です。
マンガボックスという、スマホ向け電子書籍サービスのサーバーサイド/フロントエンドの担当をしています。

前回は[「Perlの行列演算モジュールを用いた推薦システムの実装」](https://qiita.com/kaneU/items/1c85d50dea52433673f1)の紹介をしたのですが、
今回は「要素の画面内判定をどのようにして行うか」という話を中心に、個人的にもっと知られてほしいと思っている`Lozad.js`の紹介をします。

# はじめに
Webサイトの実装(特にスマホ)をする際に、「ある要素が画面内にあるかどうか」の判定が必要になる場面って結構あるかと思います。

ユースケースとしては、

- LoadMore(無限スクロール)
- LazyLoad(遅延読み込み)
- Log(全体の何％読まれたか等)
- 画面内に入ったタイミングでアニメーションを実行

などが挙げられます。

さて、この「画面内に対象となる要素があるか否か」という判定ですが、

1. `scroll`/`resize`イベントを監視し、イベント毎で要素の位置が取得画面内に存在するか判定する
2. `Intersection Observer`で要素が画面内にあるか判定する

と、大きく2つの手法に分けられます。

今回はこの2つの手法それぞれの特徴をおさらいしつつ、`Intersection Observer`をベースにより使い勝手のよいAPIを提供してくれる`Lozad.js`というライブラリの紹介をしようと思います。

# 「画面内に対象となる要素があるか否か」の実装あれこれ

## 1.scroll / resize イベントを監視し、イベント毎で要素の位置が取得画面内に存在するか判定する
古より伝わる画面内判定の王道ともいえる実装方法です。
もしかすると、現行の多くのサイトはこの実装を用いているのでは無いでしょうか。

ただし、この実装方法は以下のような理由で、パフォーマンス面の問題を抱えていることが知られています。

1. `scroll`/`resize`イベントは断続的に発生するため、イベントが大量に発火してしまう
    - eventの発生回数を間引く`throttling`で緩和は可能
2. `getBoundingClientRect()`等のサイズや位置を計算する処理が、JavaScriptの処理と同期的に実行されるため、パフォーマンスの劣化が生じる
  - 詳細は`Forced Synchronous Layout`で検索！！

→ パフォーマンスが劣化する処理がメインスレッド上で断続的に実行されてしまうので、特にモバイル端末で**スクロールの詰まり(`Scroll Jank`)が発生**し気持ち悪い

このようなパフォーマンス上の問題がありつつも、こういった判定処理が必要となるケースが後を絶たないという状況が続いていました。

そんなある日、ブラウザ自体の内部実装としてこの判定機能を搭載することで、パフォーマンス問題を解消させてやろうじゃないかと用意されたのが、この後紹介する`Intersection Observer`です。

## Intersection Observerで要素が画面内にあるか判定する
### Intersection Observerってそもそもなに？
`Intersection Observe`は**要素同士の交差検出**を行うために用意されたAPIです。
例えば、画面内に要素があるかどうかは、「viewportと対象要素が交差したか」で判定することができます。

この`Intersection Observer`は、パフォーマンスも良く使い勝手のよいAPIなのですが、まだ新しい仕様かつ[対応ブラウザが少ない](https://caniuse.com/#feat=intersectionobserver)ので、認知度も実績もさほど多くないような印象です。

じゃあ使えないじゃないか！!と思ったそこのあなた。

未対応ブラウザでも実行できるよう、[polyfill](https://github.com/w3c/IntersectionObserver/tree/master/polyfill)は既に用意されているのでこれを使えば大抵のサイトは問題なく使用することができます。

なので、ブラウザサポートの問題がない限りは`Intersection Observer`を用いた実装に積極的に移行すべきだと思います。

### :warning: 注意事項
polyfillは同じように動作をするよう実装されているだけなので、パフォーマンス上のメリットは得られません。[ここらへん](https://github.com/w3c/IntersectionObserver/blob/47343633bad89c1fe4b079b66c2070cfa8574498/polyfill/intersection-observer.js#L262-L292)の実装を見てもらえらえば分かるのですが、古の実装を`Intersection Observe`ライクに作られていることがわかると思います。

### 実装例
さて、LazyLoadの実装をしてみましょう。

```html:index.html
<div id="images">
  <img src="" data-src="image_1.jpg" class="lazy-load" />
  <img src="" data-src="image_2.jpg" class="lazy-load" />
  <img src="" data-src="image_3.jpg" class="lazy-load" />
</div>
```
```js:intersectionObserver.js
// options
const options = {
  rootMargin: '5%', // 画面内に入る直前にイベントが起こるように,画面領域にマージンを設ける
}

// Observerを用意
const io = new IntersectionObserver((entries) => {
  entries.forEach((e) => {

    /* 画面内に入った際に実行する処理を定義 */
  
    // data-srcの値をsrcにセットする
    e.target.src = e.target.dataset.src
    
    // 監視対象から外す
    io.unobserve(e.target)
  })
}, options)

// Elementを指定し、Observeを開始
const targetImages = [...document.querySelectorAll('.lazy-load')]
targetImages.forEach((image) => {
  io.observe(image)
});
```

これでおしまいです。
記述の単調さも感じつつ、考え方としてはシンプルなので理解しやすいのではないでしょうか。

では上記の処理後、以下のように動的にDOMが追加される場合はどうでしょう。
(Ajaxでデータ取得し、DOMを追加する処理って結構ありますよね。)

```js
const el = document.getElementById('images')
let div = document.createElement('img');
div.src = "";
div.dataset.src = "image_4.jpg";
div.class = "lazy-load";
el.appendChild(div);
```

動的に追加したDOMは当然監視対象にはなっていないので、このままでは画像のLazyLoadができないどころか、srcが空っぽなので永遠に画像が表示されません。

この場合、LazyLoadの対象となる要素を再取得し、まだ画像読み込みが行われていない要素だけを取り出してObserveしなおす必要があります。
そして、そのためには画像の読み込みが完了しているか否かのフラグを別途用意する必要がありそうですね。

ただLazyLoadしたいだけにしては、なんだかめんどくさくなってきました。。

そんなときに使ってほしいのが、これから紹介する`Lozad.js`です。

## Lozad.jsでLazyLoadを実装する
Github: https://github.com/ApoorvSaxena/lozad.js
デモページ: https://apoorv.pro/lozad.js/demo/
### `Lozad.js`ってどんなライブラリ？

- `Intersection Observer`をうす〜く、うす〜くラップすることで利便性を高めてくれている
    - 当然[polyfill](https://github.com/w3c/IntersectionObserver/tree/master/polyfill)を使えば未対応ブラウザでも使用可能
- **731Byte**しかなく、[圧縮前のソースコード](https://github.com/ApoorvSaxena/lozad.js/blob/master/src/lozad.js)としても70行程度
- pure JS
- 2017年12月時点で**Github3000スター超え** の実力の持ち主

### 実装例
先程同様、以下のような`index.html`を対象とします。
(class名が`lozad`に変わっていることに注意)

```html:index.html
<div id="images">
  <img src="" data-src="image_1.jpg" class="lozad" />
  <img src="" data-src="image_2.jpg" class="lozad" />
  <img src="" data-src="image_3.jpg" class="lozad" />
</div>
```
#### case1. 基本形
```js
const observer = lozad();
observer.observe();
```

以上。これだけで画面内に入ったタイミングでLazyLoadが行われます。
驚くほどにシンプルですね。

ちなみに、デフォルトの設定は以下です

- セレクタ: `.lozad`
- 画面領域内に入ると
    - `data-src`があれば、`src`にセットされる
    - `data-srcset`があれば、`srcset`にセットされる
    - `data-background-image`があれば、`backgroundImage`にセットされる

#### case2. `Intersection Observer`のオプションを設定する場合
この場合は、lozadの引数として以下のように渡してあげればOKです。

- 第1引数: cssセレクタ
- 第2引数: `Intersection Observer`のオプション

```js
const observer = lozad('.lozad', {
    rootMargin: '5%',
});
observer.observe();
```

`Intersection Observer`での実装例と比較すると、いかにシンプルに実装できているか一目瞭然ですね！

#### case3. 画面領域内に入った際に任意の処理を行いたい場合
第2引数の`load`に任意の処理を行う関数を渡してあげましょう。

```js
const observer = lozad('.lozad', {
    load: function(el) {
        console.log(el.intersectionRect);
        el.src = el.getAttribute('data-src');
    }
});
observer.observe();
```

LoadMore(無限スクロール)やLogを吐く場合は、この方法で実装してあげればいいですね。

#### case4. 動的に追加したDOMを監視対象にする場合
これもライブラリがよしなに処理をしてくれるので、`observer.observe()`を再度実行するだけでOK

```js
const observer = lozad();
observer.observe();

// 動的にDOMを追加
const el = document.getElementById('images')
let div = document.createElement('img');
div.src = "";
div.dataset.src = "image_4.jpg";
div.class = "lozad";
el.appendChild(div);

// 再度observe()を呼び出すだけ
observer.observe();
```

内部的な処理としては、読み込み済みか否かのフラグとして`data-loaded="true or false"`が対象要素に付加されており、これを元に読み込みが完了していない要素だけを対象にObserveしてくれます。

こういった処理の面倒を勝手に見てくれるのはありがたいですね！！

# おわりに
今回は、`Lozad.js`について紹介しました。
非常にシンプルなライブラリですが、単調な記述だったり面倒な部分をスマートに吸収してくれているのが伝わりましたでしょうか。

ぜひ、使える箇所があれば積極的に使ってみてください。

さて、明日は14日は、
世界のパデラー @hirashunshun によるスマートホームのお話です。
おたのしみに！！

## 宣伝
マンガボックスでは一緒に働くメンバーを募集しています。

- フロントエンドばりばり書いていきたい！！
    - Vue.jsを積極投下しています
- 電子書籍に関する技術に興味あるよ！！
- 領域関係なくどんどん挑戦していきたい！！

という方はもちろん、
「ちょっと興味ありそう」「もっと色々聞いてみたい」
という方は、[こちらから応募](https://www.wantedly.com/projects/97773)頂くか、[@yukagil](https://twitter.com/yukagil)までゆるっとご連絡いただければと思います。
(特にサーバーサイドエンジニア、積極募集中です。一緒に働いてください:bow_tone1:)

お待ちしております！！