背景
今期は、担当案件にてパフォーマンスを意識する機会が多くありました。
色々と行った取り組みの中に、ファーストビュー表示の高速化のため「ビューポート内に存在しないコンテンツを非同期で読み込みする」というものがありました。これは、ページスクロールの過程で対象の要素が画面に表示された際に対応したコンテンツに必要な情報をAjaxで取得してコンテンツを非同期的に構築していくというものです。
コンテンツの非同期的な読込例としては、オートページャーやTwitterのタイムラインなどを想像して頂けると理解しやすいかと思います。その他、画像の遅延読み込みなどでこういった手法がよく使われています。
一部、今回の取り組みも交えて、要素監視について手法を紹介したいと思います。
よくある事例
以下のような要素検知処理はよく見かけるかと思います。
$(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
を使用しているのであれば、以下のような使用方法で簡単にスクロール処理を間引くことが出来ます。
_.throttle(func, [wait=0], [options={}])
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
これまで紹介したスクロールイベントを使った要素検知ではoffset()
やscrollTop()
などを実行していました。これらの処理は、対象DOMの位置を取得するため同期的にLayout計算が発生します。大量のイベントが発火してしまうスクロールイベント内で、これらの処理を行うとスクロールを阻害してしまいますし、その都度計算処理を行わせるためパフォーマンスも良くありません。
Intersection Observer を使う
Intersection Observer
というAPIを使えば、スクロールイベントを使用せず指定要素を監視する事ができます。
ここ最近は利用可能なブラウザも増え、Intersection Observer
の利用も多くなってきていると思います。(IE/Safariは少し忘れておいてください…)
実装例
これまでの実例をIntersection Observer
を使って実装すると、ざっくり以下のような形で実装が可能です。
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
など監視条件を細かく設定できますが割愛です。
サンプル
Internet Explorer と Safari の対応は?
そろそろ IE と Safari を思い出してください。
現状、これらのブラウザでIntersection Observer
はサポートされていません。これらのブラウザでIntersection Observer
を利用する場合は、Polyfill
が必要となります。
例えば intersection-observer-polyfill を利用した場合は、下記の記述でモダンブラウザと同様にIntersection Observer
を利用することができます。
import IntersectionObserver from 'intersection-observer-polyfill';
<script src="intersection-observer-polyfill/dist/IntersectionObserver.js"></script>
<script>
(function () {
var observer = new IntersectionObserver(function () {});
})();
</script>
レガシーブラウザでも同じ動きを同じ記述でさせたい場合はPolyfill
を使うと便利です。
担当案件の導入事例
結論から述べると、今回担当案件では、Intersection Observer
の導入はできませんでした。
理由としては、以下の通りです。
- プロダクトの Internet Explorer/Safari 利用シェアが全体の7割を占めている (スマホ含む)
- 影響範囲の少ないようにしたい
案件のシェア率を鑑みるとPolyfill
へ流す割合の方が大きくなってしまいます。また、Internet Explorer と Safari の場合、確認する環境が Windows/macOS/iOS と複数環境にまたがってしまうため、テスト工数が通常よりも多くなります。今回のスケジュールはタイトであり、出来るだけ影響範囲を少なくしたかったので、Polyfill (Intersection Observer)
の導入は今回は断念しました。
どうしたのか
Intersection Observer
が使えないため、代替ライブラリを導入することにしました。
「サイズが小さく、依存関係が少なくて、幅広いブラウザに対応している」ことを条件として、色々と試した結果、in-view.jsというライブラリを導入することにしました。コンテンツの非同期読み込みの足がかりとして、まずは in-view.js を利用することにしました。
この in-view.js は内部的にはIntersection Observer
は使わず、Mutation Observerを使って要素監視をしているようです。先述で紹介したのと同じようにthrottle
で処理を間引きながら、window
のload
/resize
/scroll
イベントで100ミリ秒
に1回の頻度で監視しているようでした。
下記のようにシンプルに要素監視のイベントを記述する事ができます。
inView('#target').once('enter', doSomething);
Intersection Observer
に比べてパフォーマンス面で少々不安は残りましたが、リリース後も大きな問題などは発生していません。今後、パフォーマンスの定期的な監視とブラウザのシェア状況などを考慮してIntersection Observer
の利用を改めて考えていきたいと思っています。
おわり
スクロールイベントは慎重に使っていきましょう。もしこれから新規案件などで要素監視を行うのであれば、Intersection Observer
を使うのがベストプラクティスだと思います。
レガシーなプロダクトでも少しずつモダンな実装を取り込み、プロダクトの新陳代謝を良くする事は重要です。パフォーマンス改善はもちろんですが、保守開発をしやすくする開発環境を構築していきましょう