集約的な JavaScript

デフォルトでブラウザはレイアウト、リフロー、ガベージコレクションだけでなく、ページ内のすべての JavaScript もひとつのスレッドで実行します。これは長い間実行する JavaScript がスレッドをブロックして、ページの不応答やユーザエクスペリエンスの悪化を招くおそれがあるということです。

フレームレートおよびウォーターフォールツールを使用して、いつ JavaScript がパフォーマンスの問題を起こしているかを知る、および特に注意が必要な関数を選び出すことができます。

本記事では長い間実行する JavaScript が応答性の問題を起こしているサンプルサイトを使用して、問題を修正するために 2 種類の方法を適用していきます。ひとつは長い間実行する JavaScript を複数の部品に分けて、それらのスケジューリングに requestAnimationFrame を使用する方法、もうひとつは web worker を使用して関数全体を別のスレッドに分ける方法です。

自身でも試してみたい場合は、デモ Web サイトがこちらにあります。

動画版のウォークスルーも用意しています:

デモ Web サイトは以下のようなものです:

ここには 3 つのコントロールがあります:

  • JavaScript の実行方法を制御するラジオボタン。ブロックが発生するひとつの操作をメインスレッドで実行する、requestAnimationFrame() を使用して小規模な操作の集まりをメインスレッドで実行する、Worker を使用して別のスレッドで実行する。
  • "Do pointless computations!" と記載された、JavaScript を実行するボタン。
  • CSS アニメーションを開始・終了するボタン。これはブラウザに、バックグラウンドで実行するタスクを与えます。

ラジオボタンで "Use blocking call in main thread" を選択して、記録を始めましょう:

  • "Start animations" ボタンを押します。
  • パフォーマンスプロファイルの記録を始めます。
  • "Do pointless computations!" ボタンを 2~3 回押します。
  • プロファイルの記録を終了します。

どのような結果になるかはマシンにより異なりますが、おそらく以下のようになるでしょう:

この画像の上半分はウォーターフォールの概要です。これはウォーターフォールをコンパクトに表示したビューであり、記録中にブラウザが行った処理は何かを示します。桃色はほとんどの場合 CSS の再計算、一部はリフローです。これは、プロファイルで終始実行している CSS アニメーションです。また連続したの橙色のブロックが 3 つありますが、これは JavaScript を実行していることを表します。それぞれ、ボタンを押したときです。

下半分はタイムラインの概要と時系列が合わせられており、フレームレートを示しています。記録中のほとんどはフレームレートが良好ですが、ボタンを押すたびに大きく落ち込んでいます。

それら 3 か所のうちひとつを選択して、メインのウォーターフォールビューで詳しく見ることができます:

ここではボタンを押したときに、ブラウザが JavaScript の関数をひとつまたは連続的に実行して、メインスレッドを 71.73 ミリ秒、言い換えるとフレーム 4 つ分の時間ブロックしています。

どの関数でしょう? フレームチャートビューに切り替えると、それがわかります:

これは、その時点で実行している JS のコールスタックを表示します。スタックの一番上は calculatePrimes() という関数であり、ファイル名や行番号がわかります。以下に掲載したコードで、直近の呼び出し元を見てみましょう:

const iterations = 50;
const multiplier = 1000000000;

function calculatePrimes(iterations, multiplier) {
  var primes = [];
  for (var i = 0; i < iterations; i++) {
    var candidate = i * (multiplier * Math.random());
    var isPrime = true;
    for (var c = 2; c <= Math.sqrt(candidate); ++c) {
      if (candidate % c === 0) {
          // not prime
          isPrime = false;
          break;
       }
    }
    if (isPrime) {
      primes.push(candidate);
    }
  }
  return primes;
}

function doPointlessComputationsWithBlocking() {
  var primes = calculatePrimes(iterations, multiplier);
  pointlessComputationsButton.disabled = false;
  console.log(primes);
}

ここではかなり大きな数に対して、(とても非効率な) 素数の判定を 50 回行っています。

requestAnimationFrame を使用する

この問題を解決するための最初の試みとして、関数をいくつかの自己充足した小さな関数に分割して、requestAnimationFrame() を使用してそれらをスケジューリングします。

requestAnimationFrame() は与えられた関数を、各フレームで再描画を行う直前に実行するようブラウザに指示します。それぞれの関数が適度に小さければ、ブラウザは実行時間を、フレーム間に与えられた時間内に収めることができるでしょう。

calculatePrimes() の分割はとてもシンプルです。別の関数で、それぞれの値が素数であるかの計算を行います:

function doPointlessComputationsWithRequestAnimationFrame() {

  function testCandidate(index) {
    // finishing condition
    if (index == iterations) {
      console.log(primes);
      pointlessComputationsButton.disabled = false;
      return;
    }
    // test this number
    var candidate = index * (multiplier * Math.random());
    var isPrime = true;
    for (var c = 2; c <= Math.sqrt(candidate); ++c) {
      if (candidate % c === 0) {
          // not prime
          isPrime = false;
          break;
       }
    }
    if (isPrime) {
      primes.push(candidate);
    }
    // schedule the next
    var testFunction = testCandidate.bind(this, index + 1);
    window.requestAnimationFrame(testFunction);
  }

  var primes = [];
  var testFunction = testCandidate.bind(this, 0);
  window.requestAnimationFrame(testFunction);
}

こちらのバージョンを試してみましょう。"Use requestAnimationFrame" と記載されたラジオボタンを選択して、新たにプロファイルを記録します。すると、記録は以下のようになるでしょう:

これはまさに、私たちが期待していたものです。一続きの橙色のブロックに代わり、ボタンを押すたびにとても短い橙色のブロックがたくさん並んでいます。橙色のブロックは 1 個ずつのフレームに分かれて現れており、またそれぞれのブロックが、requestAnimationFrame() から呼び出された関数 1 個を表しています。なお、このプロファイルではボタンを 2 回しか押していないことに注意してください。

関数の呼び出しは CSS アニメーションに由来する桃色のブロックの間に挟み込まれており、またそれぞれの関数は、全体のフレームレートを落とすことなく処理できるほど十分に小さくなっています。

ここでは requestAnimationFrame が応答性の問題の解決策として機能しましたが、潜在的な問題点が 2 つあります:

  • 長い間実行する関数を、個別の自己充足した関数に分割することが難しい場合があります。今回のシンプルなケースでも、より複雑なコードになりました。
  • 分割したバージョンでは、実行時間が長くなります。実は、処理にどれだけかかるかをかなり正確に言うことができます。処理は 50 回繰り返しており、またブラウザは 1 秒間に約 60 個のフレームを生成します。よって、すべての計算を実行するためにはほぼ 1 秒かかり、これはユーザエクスペリエンスやプロファイルから明らかになります。

Web Worker を使用する

ここでは、Web Worker を使用して問題を解決します。Web Worker を使用すると、別のスレッドで JavaScript を実行できます。メインスレッドと Worker スレッドは互いに直接呼び出すことはできませんが、非同期メッセージ API を使用して通信できます。

メインスレッドのコードは以下のようになります:

const iterations = 50;
const multiplier = 1000000000;

var worker = new Worker("js/calculate.js");

function doPointlessComputationsInWorker() {

  function handleWorkerCompletion(message) {
    if (message.data.command == "done") {
      pointlessComputationsButton.disabled = false;
      console.log(message.data.primes);
      worker.removeEventListener("message", handleWorkerCompletion);
    }
  }

  worker.addEventListener("message", handleWorkerCompletion, false);

  worker.postMessage({
    "multiplier": multiplier,
    "iterations": iterations
  });
}

元のコードと比べたときの主な違いは、以下のものが必要であることです:

  • Worker を作成する
  • 計算の準備ができたときに、Worker へメッセージを送信する
  • "done" メッセージをリッスンする。これは、Worker が完了したことを示すメッセージです。

また、新たに "calculate.js" ファイルが必要であり、こちらは以下のようになります:

self.addEventListener("message", go);

function go(message) {
  var iterations = message.data.iterations;
  var multiplier = message.data.multiplier;
  primes = calculatePrimes(iterations, multiplier);

  self.postMessage({
    "command":"done",
    "primes": primes
  });
}

function calculatePrimes(iterations, multiplier) {
  var primes = [];
  for (var i = 0; i < iterations; i++) {
    var candidate = i * (multiplier * Math.random());
    var isPrime = true;
    for (var c = 2; c <= Math.sqrt(candidate); ++c) {
      if (candidate % c === 0) {
          // not prime
          isPrime = false;
          break;
       }
    }
    if (isPrime) {
      primes.push(candidate);
    }
  }
  return primes;
}

Worker では処理の開始を指示するメッセージをリッスンする、および処理が完了したときに "done" メッセージを送ることが必要です。実際に計算を行っている部分のコードは、最初のバージョンのコードと完全に同じです。

このバージョンはどのように実行されるのでしょう? ラジオボタンを "Use a worker" に切り替えて、新たにプロファイルを記録してください。結果は以下のようになるでしょう:

このプロファイルでは、ボタンを 3 回押しています。ウォーターフォールの概要で元のバージョンと比べると、ボタンを押したときにはとても短い橙色のマーカーが 2 個あることがわかります:

  • click イベントの処理と Worker の処理開始を行う、doPointlessComputationsInWorker() 関数
  • Worker が "done" を発信したときに実行される、handleWorkerCompletion() 関数

これら 2 つの関数の間で Worker は素数の判定を行っていますが、メインスレッドの応答性には少しも影響を与えていないように見受けられます。これはあり得ないと思うかもしれませんが、Worker は別のスレッドで実行しますのでマルチコアプロセッサの利点を享受できます。これはシングルスレッドの Web サイトでは得られません。

Web Worker の主な制限は、Worker で実行するコードでは DOM API を使用できないことです。

ドキュメントのタグと貢献者

 このページの貢献者: yyss
 最終更新者: yyss,