超速!Webページ速度改善ガイド

第4章 レンダリング処理の基礎知識

理想

スムーズな UI 操作

  • 動きが滑らか → スクロールやアニメーションなど
  • 応答が速やかである → hover 時のエフェクトなど

最適化方針

60 FPS(Frames per second:1秒間に画面を更新する回数)を目指す

  1. 1 フレーム内の処理を軽減すること
  2. ブラウザ内部処理による最適化を活かす

第7章 スクリプト処理の調査と改善

重いスクリプト処理の調査と改善

調査

  1. FPS が低下してそうなタイミングで、performance パネルでプロファイルする
  2. Bottom-Up タブで重い処理を調べる

改善

非同期化

DOM 操作に加えて重い処理をしている場合、表示の反映が遅れる。
DOM 操作をした後は直ちにレンダリング処理を行い、ブラウザに反映される方が良い。

API のキャッシュの localStorage への保存など遅延させても良い処理は非同期化させる。

  1. setTimeout()メソッドを使用

指定ミリ秒が経過している間にメッセージとしてキューに追加して、擬似非同期化を実現できる。

// DOM操作
const div = document.querySelector("#target");
div.textContent = "こんにちは";

// 重い処理を非同期化
setTimeout(() => {
  for (let i = 0; 0 < 1000; i++) {
    console.log("check");
  }
}, 0);
  1. requestIdleCallback()メソッドを使用

ブラウザがアイドルになったタイミングでスクリプト処理を実行する。
setTimeout()は指定ミリ秒が経過したら強制的にコールバック関数を実行する。

const taskQueue = [task1, task2, task3, task4];
const isDone = false;

const runTasks = ({ didTimeout, timeRemaining }) => {
  // didTimeout:timeoutオプションで指定された時間よりも後にコールバック関数が呼び出された場合、true
  // timeRemaining():アイドル中の処理として与えられた残り時間を返す
  while (taskQueue.length && timeRemaining() > 0) {
    const task = taskQueue.shift();
    task.run();
  }
  if (taskQueue.length) {
    requestIdleCallback(runTasks);
  }
  isDone = true;
};
const requestId = requestIdleCallback(
  runTasks,
  // 2秒経ってからコールバック関数が呼び出されたらタイムアウト扱いになる
  // (コールバック関数が呼び出されない訳ではない)
  { timeout: 2000 }
);

// アイドル状態になる前にユーザが離脱して、コールバック関数が呼び出されない場合を想定
window.addEventListener("beforeunload", () => {
  if (!isDone) {
    runTaks();
  }
});

// キャンセルしたい場合
cancelIdleCallback(requestId);

実行間隔の間引き

scroll や keydown, input などの短い時間に大量に発生するイベントや間隔の短いタイマー内で DOM 操作や重い処理をしている場合、、画面のちらつきな度を招いている。

まずは、高頻度に実行する必要があるか考える。

仕方がない場合、実行間隔を調整。

→ Lodash の throttle()debounce()関数を使う

throttle():高頻度で起こる処理を一定時間のうち実行を一度に抑える
debounce():高頻度で起こる処理が終了して一定時間が経つと一度だけ実行する

textarea.addEventListener(
  "input",
  throttle(() => {
    // 連続してinputイベントが発生しても
    // 100msの間に一度しか実行されない
  }, 100)
);

window.addEventListener(
  ("scroll",
  debounce(() => {
    // scrollイベントが発生しなくなってから
    // 200ms経つと一度だけ実行される
  }))
);

Worker スレッドへの処理移譲

どうしても行われければいけない重い処理でメインスレッドを占有している場合、メインスレッドとは別のワーカースレッドに処理を異常することでレンダリング処理などを円滑に実行できる。

ただし、ワーカースレッドでは利用可能なブラウザ API が制限されている。

main.js
window.addEventListener('message', event => {
  if (event.origin !== 'https://example.com') {
    return;
  }
  console.log(event.data);
});

const button = document.querySelector('button');
button.addEventListener('click', event => {
  window.postMessage({
    command: 'fibonacci',
    number: 50
  })
});
worker.js
self.addEventListener('message', event => {
  if (event.data.command === 'fibonacci') {
    const result = fibonacci(event.data.number);
    postMessage(result);
  }
})

const fibonacci = (i) => {
  // フィボナッチ数列を計算
}

メモリリークの調査と改善

調査

  1. performance パネルの Memory チェックを入れて計測し、ヒープ領域のメモリ、ドキュメント、DOM ノードの数、イベントリスナの数の推移が記録される
  2. メモリリークが発生してる場合、解放されないメモリが増え続け、ヒープを圧迫していることが多い

改善

グローバル変数などに DOM オブジェクトを参照したままでにしている場合グローバル変数 = nullなどで GC の回収対象にする。

高頻度で実行される GC の調査と改善

調査

GC が高頻度で実行されると、perfomance パネルのヒープ領域のグラフのメモリがノコギリの歯のように推移している

改善

オブジェクトの生成と破棄を繰り返さないように、事前に生成したオブジェクトをプールすることで GC の対象から外す。このことをオブジェクトプールと呼ぶ。

import MemoryPool from "memoryPool";

const pool = new MemoryPool(Array);

// poolから格納したオブジェクト(配列)を取り出せる(空の場合は新しいオブジェクトを作成して返す)
const array = pool.allocate();

// 使わなくなったオブジェクト(配列)をプールに格納する
pool.free(array);

未解放のイベントリスナとタイマーの調査と改善

イベントリスナやタイマーは明示的に解放しないと実行されないハンドラとして残り続けるため、常に監視、実行しないといけないためメモリや CPU の負荷になる。

調査

performance パネルの Memory のチェックを入れた状態でイベントリスナの数の推移を確認

改善

実行され続けるタイマーの削除

タイマーは明示的に停止しないと実行され続ける。

clearInterval()で停止する。

const timerId = setInterval(() => {
  console.log("Interval Timer");
}, 1000);

// タイマーは破棄されない
// timerId = null;

clearInterval(timerId);

setInterval()を削除

setInterval()で呼び出す処理が重くインターバルより時間がかかると、前回の処理が終了する前に次のタイマー処理が予約(キューに追加)されてしまう。

→ 代わりにsetTimeout()を再帰的に実行することで、前回の処理が終わるまで次の処理がキューに追加されることがない。

const interval = () => {
  setTimeout(() => {
    console.log("Interval Timer");

    interval();
  }, 1000);
};

interval();

イベントリスナの削除

使わなくなった DOM のイベントリスナなどは明示的に削除しないと残ったままになる。

removeEventListener()メソッドでリスナの解除をする

const listener = () => console.log("button is clicked");
button.addEventListener("click", listener);

button.removeEventListener("click", listener);
document.removeChild(button);

once オプションを使用

イベントリスナが一度しか実行されない場合、once オプションを追加する

document.addEventListener(
  "click",
  () => {
    console.log("document is clicked");
  },
  { once: true }
);

第8章 画像の最適化に役立つテクニック

圧縮方法

  • 可逆圧縮

    元のデータの配列をアルゴリズムによって短く表現する方法

    対応している画像形式:PNG, GIF, WebP

    • メリット

      元のデータにデコードできるため、データの劣化がされない

  • 非可逆圧縮

    人が劣化を感じにくい部分のデータを主に省略して、データ量を小さくする方法

    対応している画像形式:JPEG, WebP

    • メリット

      可逆圧縮に比べて高い圧縮率

    • デメリット

      データの劣化が起こる

表現方法

  • ラスタ(ビットマップ)

    ピクセル一つ一つに対応する色情報から画像を表現する形式

    • メリット

      データの記録方法として単純なことから、アプリケーションから扱うときのデコード処理などの負荷が小さい

    • デメリット

      データが画像サイズに比例して大きくなりやすい

      拡大・縮小したりすると、劣化する

  • ベクタ

    点の座標とそれを結ぶ線などを数値データ化し、演算によって画像を再現(ラスタライズ)する形式

    違うサイズで再利用されるロゴやアイコンに使いやすい

    • メリット

      拡大・縮小してもぼやけたり、劣化しない

      画像サイズにファイルサイズが左右されない(ラスタと比べるとファイルサイズが小さい)

    • デメリット

      繊細な画像を表現するとファイルサイズが大きくなってしまう

保持方法

  • ベースライン

    画像データを一つのブロックに保持する標準的な形式

    対応している形式:JPEG, GIF, PNG

  • プログレッシブ

    低解像度から高解像度(オリジナル)の状態を複数ブロックに分割して保持する形式

    ブラウザは画像データを低解像度の状態から高解像度の状態へレンダリングしていくため、最初は粗く、ロードが進むにつれ徐々に鮮明になっていく

    ユーザー体験に良い

    対応している形式:JPEG, GIF, PNG, WebP

画像形式

  • JPEG

    • 24 ビットのフルカラー対応
    • 非可逆圧縮
    • ラスタ
  • GIF

    • 256 色で表現
    • 可逆圧縮
    • ラスタ
    • 透明色に対応
    • アニメーション(画像をフレーム数分保持しているだけなのでファイルサイズが大きくなる)
  • PNG

    • 24 ビットのフルカラー対応
    • 可逆圧縮
    • ラスタ
    • 透明色に対応
  • WebP

    • 他の形式よりも 20%以上ファイルサイズを小さくできる
    • 可逆圧縮/非可逆圧縮
    • ラスタ
    • 透明色にも対応
    • アニメーション
  • SVG

    • XML をベースとしたテキスト
    • ベクタ
    • CSS でスタイル評価が可能
    • JS で DOM 操作してアニメーションを実現できたりする
    • アクセシビリティの提供(画像メタデータを埋め込める)
  <svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 200 200">
    <g>
      <title>青い円グラフです</title>
      <desc>hogehogeについての調査です</desc>
      <circle cx="50" cy="50" r="50" fill="blue" />
    </g>
  </svg>