猫でもわかるスクロールイベントパフォーマンス改善ポイント2018

## TL;DR

- パフォーマンスの重要性をおさらい
- Scroll Jank とは
- 60fpsとリフレッシュレート
- スクロールイベントを最適化する


## パフォーマンスの重要性をおさらい

<img src="https://qiita-image-store.s3.amazonaws.com/0/220757/b0d63d25-6736-46af-ec4e-045677b36ab6.jpeg" width="50%">
<img src="https://qiita-image-store.s3.amazonaws.com/0/220757/3c9c4176-32aa-4f5c-f99f-a964a04363c9.jpeg" width="50%">
<img src="https://qiita-image-store.s3.amazonaws.com/0/220757/9f81bf6e-5b29-6747-8af8-182f9e83d07e.jpeg" width="50%">
<img src="https://qiita-image-store.s3.amazonaws.com/0/220757/3f43dc62-becd-7461-f62b-2bfd8bfc20b6.jpeg" width="50%">
<img src="https://qiita-image-store.s3.amazonaws.com/0/220757/115ec2b7-4732-98fe-9f42-d0b53e6cc66a.jpeg" width="50%">

### 検索エンジンと広告収益事例

GoogleとMicrosoftの検索エンジンチームによって2009年に発表された調査

**レスポンスの遅延が招く収益への影響:**

- 0.5秒遅延 → -1.2％
- 1.0秒遅延 → -2.8%
- 2.0秒遅延 → -4.3%

http://conferences.oreilly.com/velocity2009/public/schedule/detail/8523

### スクロールイベントは負荷が高い

フロントエンドの実装をしたことのある方は、スクロールイベントを使った実装をしたことも多いはず。

スクロールイベントは発生頻度が高いため、負荷が高くなりがちです。スクロールイベントのリスナーは適切に実装しないとScroll Jankの発生・UXの低下を招いてしまいます。

![LT_TechLunch_20180720.003.jpeg](https://qiita-image-store.s3.amazonaws.com/0/220757/115ec2b7-4732-98fe-9f42-d0b53e6cc66a.jpeg)

パフォーマンスを出来るだけ下げないような実装事例を紹介していきます。

## Scroll Jank とは

ページのスクロールをした際に、スクロールが詰まったような遅延を
「Scroll Jank」と呼びます。

### Scroll Jankが起きるパティーン

下記の2点をピックアップします。

- スクロールイベント内で`preventDefault()`が実行されている可能性がある
- スクロールイベント内で要素のサイズや位置を取得する処理がある（Forced Synchronous Layout が発生する可能性がある）


#### スクロールイベント内で`preventDefault()`が実行されている可能性がある

可能性がある…?🤔

例えば、スクロールイベント内で`preventDefault()`が実行されたら、スクロール処理は中止されます。しかしながら、ブラウザ側はイベント内で`preventDefault()`が実行されるかどうかを事前に判定できません。（そのイベントが実行が終了するまで判定ができない）
そのため、イベント内の処理が終了するのを待つ（＝遅延が発生する）ことになります。

いわゆる**Scroll Jank** 発生原因のほとんどはコレです。

#### スクロールイベント内で要素のサイズや位置を取得する処理がある（Forced Synchronous Layout が発生する可能性がある）

Forced Synchronous Layoutはレイアウト計算処理で発生します。（「レイアウト処理」が実行されないと期待する値が分からない系の処理）
ただ、近年、こういった処理を実施しなければならない処理は、Intersection Observer や `position: sticky` を使うことでスマートかつ良いパフォーマンスで実装できるため、減りつつある（はず）。

## 60fpsとリフレッシュレート

一般的な端末は画面を1秒間に60回リフレッシュする
→ 間隔は、1000ミリ秒 / 60 = およそ16.66ミリ秒 

**この間隔からズレるとフレームレートが低下して、画面上の描画がブレる**（ジャンクが発生）

> <img src="https://qiita-image-store.s3.amazonaws.com/0/220757/6a912d68-a921-cfe9-dbe8-478ba7290595.png" width="50%">
>https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution?hl=ja


## スクロールイベントを最適化する

### スクロールイベント内で`preventDefault()`がされてない事をブラウザに教える

> Event Listener Options  passive

この指定で「処理が`preventDefault()`を実行していない」という事が明示できるようになった
→ **イベント内の処理が終了を待たず判定が可能になった**

#### 指定方法
`addEventListener`の第三引数に`{passive: true}`を指定するだけ

```js
document.addEventListener('scroll', function() {
  // SUGOI SHORI
}, {passive: true});
```

#### 互換性の問題

Internet Explorer「こんにちは」
![image.png](https://qiita-image-store.s3.amazonaws.com/0/220757/12916ad9-96b3-3604-21a1-4716c39c5e95.png)
https://caniuse.com/#feat=passive-event-listener


非対応ブラウザで同処理を実行すると、元々のuseCaptureがtrueになってしまう
一応、このpassiveが使えるかどうかの検出は可能

```js
/* "passive" が使えるかどうかを検出 */
var passiveSupported = false;

try {
  window.addEventListener("test", null, Object.defineProperty({}, "passive", { get: function() { passiveSupported = true; } }));
} catch(err) {}

/* リスナーを登録 */
var elem = document.getElementById('elem');

elem.addEventListener('touchmove', function listener() {
  /* do something */
}, passiveSupported ? { passive: true } : false);
```

未対応ブラウザにOpera miniがあるが、IEのみ回避すれば良いのであれば、単純に下記のように書くことができる。

```js
var elem = document.getElementById('elem');

elem.addEventListener('touchmove', function listener() {
  /* do something */
}, !document.documentMode ? { passive: true } : false);
```

※`document.documentMode`でIEかどうかを判定


#### jQueryで`passive`を使う方法は…？

**ない**


### スクロールイベントの処理を間引く

#### setTimeout

間引き処理として、setTimeoutが広く利用されてきた。
次回の処理をスケジューリングし処理を頻繁に実行させないようにする事ができる。

- ブラウザ側の準備に関わらず必ず実行される
- タブが非アクティブ時でも実行される

```js
var timer = null;
var FPS = 1000 / 60;

function func() {
    clearTimeout(timer);
    timer = setTimeout(function() {
      // YABAI SHORI
    }, FPS);
}

document.addEventListener('scroll', func, {passive: true});
```

※delayに渡す値を  1000 / 60 (16.66ms) にしている（60fps相当）


#### throttle


[lodash](https://www.npmjs.com/package/lodash) や [Underscore.js](https://www.npmjs.com/package/underscore) に throttle 関数がある

先述の`setTimeout`での実装と目的は同じ

```js
import { throttle } from 'lodash';
const FPS = 1000 / 60;

document.addEventListener('scroll', throttle(func, FPS), {passive: true});
```

#### requestAnimationFrame

処理を待つように時間指定するのではなく、次のフレームのレンダリングが準備が整った時に呼び出される。
ほかの処理に割り込まれてフレームのレンダリングが遅延することなく適切なタイミングで呼び出される。

- ブラウザの画面リフレッシュと同じタイミングで呼び出される
- 画面が非アクティブ時には実行されない
- なにげにIE11でも使える

```js
var ticking = false;

function func() {
  if (!ticking) {
    requestAnimationFrame(function() {
      ticking = false;
      // GREAT SHORI
    });
    ticking = true;
  }
}

document.addEventListener('scroll', func, {passive: true});
```


#### デモ

<p data-height="427" data-theme-id="0" data-slug-hash="jpVYpJ" data-default-tab="result" data-user="kikuchi-hiroyuki" data-pen-title="requestAnimationFrame vs throttle vs setTimeout" class="codepen">See the Pen <a href="https://codepen.io/kikuchi-hiroyuki/pen/jpVYpJ/">requestAnimationFrame vs throttle vs setTimeout</a> by kikuchi hiroyuki (<a href="https://codepen.io/kikuchi-hiroyuki">@kikuchi-hiroyuki</a>) on <a href="https://codepen.io">CodePen</a>.</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>


`setTimeout`だけ異常にもたつく



<!--
![adv2018.gif](https://qiita-image-store.s3.amazonaws.com/0/217528/f4a43011-7518-cea6-8738-5ae49297899f.gif)
[aratana Advent Calendar 2018](https://qiita.com/advent-calendar/2018/aratana) 21日目の記事です。

こんにちは！　アラタナのフロントエンドエンジニア菊地です。

アラタナでは毎月[ランチタイムにLT会を開催](https://lab.aratana.jp/archive/category/TechLunch)しています。そこで共有したスクロールイベントのパフォーマンスについて記事に起こしました。
-->