高頻度で発生するブラウザイベントのイベントリスナー呼び出し最適化

ページのスクロールやウィンドウのリサイズによって、JavaScriptで表示を変える、という話はちょくちょくある。例えば、パララックスみたいなやつをイメージしてもらえばいいだろう。

ウェブフロントエンドエンジニアが「ううっ...」となる瞬間だ。スクロールイベントやウィンドウリサイズイベントの発生頻度は高い。そのイベントリスナーでDOM操作(もっと突っ込むとリフローやレイアウトを引き起こすコストが高いDOM操作)をおこなうと、FPSの低下を引き起こす。

そういった話がきたら、デザイナーにパフォーマンスが低下するかもしれないことや低下する理由を説明したり、代わりの仕様を提案したりする。
それでもどうしてもと言われたら、イベントリスナーの呼び出しを最適化することで解決を図る。

お手軽なのは、underscoreやlodashにあるようなthrottle関数を使って、DOM操作をおこなう関数が呼ばれる回数を減らすこと。
もっとしっかりやる場合、滑らかに表示させるために、呼ばれる回数を単純に減らすのではなくブラウザが描画処理をおこなう前にDOM操作するように、window.requestAnimationFrameを使う。これは、ブラウザが描画処理を行う前に実行する関数を登録できるAPIだ。

普段は、ブラウザ毎のprefixの有無や非対応のブラウザでも対応できるように、以下の様なヘルパー関数を用意している。

let requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
let callbacks = [];
let running = false;

let resize = function () {
  if (!running) {
    running = true;

    if (window.requestAnimationFrame) {
      window.requestAnimationFrame(runCallbacks);
    } else {
      setTimeout(runCallbacks, 66);
    }
  }
};

let runCallbacks = function () {
  callbacks.forEach(function (callback) {
    callback();
  });

  running = false;
};

let addCallback = function (callback) {
  if (callback) {
    callbacks.push(callback);
  }
};

export default function (callback) {
  if (!callbacks.length) {
    window.addEventListener('resize', resize);
  }
  addCallback(callback);
}

呼び出し最適化とは話がずれるが、可能であれば、DOM操作をGPU合成を有効にするCSSプロパティ(transformやopacity)を変更するだけに書き換えて、リフロー・レイアウト、ペイントを発生させないようにするのもパフォーマンスの改善に繋がるかもしれない。
ただ、先に述べたCSSプロパティの変更に留まらない場合は、描画の度、描画した内容をGPUに転送しないといけないので、パフォーマンス低下を引き起こす。またGPU合成をおこなう範囲が大きい場合も転送やレイヤー作成にコストがかかるので注意しないといけない。レイヤーは、Chrome DevToolsの「Show layer borders」で確認できる。
(GPU合成は、とりあえずtransformやopacityを使っておけばハッピーになれるとかいう甘っちょろいものではない)