ユーザーを中心に考えるパフォーマンス - WebWorkerでメインスレッドの JS 処理ブロックを防ぐ


はじめに

ユーザーというのは大変せっかちです。
Webパフォーマンスにおいては、RAILが一つの目安としてよく言われますが、基準を満たせているでしょうか?

表面上のデザインや機能だけでなく「非機能」についてもしっかり考えることで確実にサービスの品質をあげることができると考えています。

作成しているサービスで使いやすいサービスを提供できているのか?
自分で作ったサービスはRAILの基準を全く満たせていないどころか、アイドル状態 を作ることが全く考えられておらず、もたつきが発生し、快適な使い心地の面で問題を抱えていました。

今回は問題となっている画面を WebWorkerを用いてアイドル状態を作ることができたので、
方法と、プロファイリングを確認して結果を共有させていただきます。

*現時点(2018/12/20)ではクロームで動作確認を行いましたが、他ブラウザでの挙動を把握していないため、プロダクションには反映していません。あらかじめご了承くださいまし。

問題


作成しているサービスはブラウザだけでバリアブル印刷(データに基づいて印刷する内容を変えて印刷を行うこと)ができるようにしたいな〜と思って作り始めたlabelmake.jpというサービスです。宛名ラベルとかが作れます。

このサービスではユーザーエージェントがPCの場合、左側のスプレットシートに入力した情報を右側のIframeで埋め込んだPDFビューワーにリアルタイムに反映させます。

セキュリティ的な観点でサーバーでPDFを作成せず、ブラウザでPDFを作成します。

最大で一度に20ページのPDFを作成することができるのですが、ブラウザの力でPDFを作成するため、大きなサイズのPDFを作成する場合にメインスレッドがPDFを作成するまでブロックされ、その間ユーザーの入力を処理できずにいました。

かくつくサイト。いやもっとか。もたつくサイト。

方法

今回の例だと右側のIframeで埋め込んだPDFビューワーに表示するPDFの生成にメインスレッドの処理を奪われていました。

この処理はユーザーの入力に比べるとレイテンシがある程度許容されると考えているので、この処理を別のスレッドに移し、バックグラウンドで実行させれば、次のユーザーの入力を十分処理できるようにメインスレッドを利用可能な状態にすることができる。と考えました。

WebWorkerを利用する方法はいくつかあります。
下記の記事は少し古いですが、参考になりました。
4パターンのWebWorker生成方法とインラインワーカーの技法

私は今回はインラインワーカーを生成するモジュールを作成して利用しましたが、原理はすごく単純というかオーバーヘッドなどをシビアに考えなければ数行で作れます。

wknというnpmモジュールにして公開しています。(実験で作成したのでプロダクションで使うときは自己責任で。)

wknは 関数+引数 を引数にとってそれらを別スレッドで実行してくれるユーティリティとしてお考えください。参考までにwknのJSdocは下記。

/**
 * Takes function and arguments, moves the processing to another thread, 
 * and receives the processing result on Promise.
 * @param {Function} function to have retunrn value. (Be processed in Web Worker context)
 * @param {...*} [arguments] arguments
 * @returns {Promise} Returns Processing result as Promise
 */

これがメインスレッドを占有していたコード

修正前
const getPdfBlob = (datas, image, positionData, pageSize) {
  const docDefinition = createDocDefinition(datas, image, positionData, pageSize);
  const pdf = pdfMake.createPdf(docDefinition); // メインスレッドで実行したくない重たい処理
  return new Promise(resolve => pdf.getBlob(blob => resolve(blob))); // メインスレッドで実行したくない重たい処理
}

これをwknを用いて下記のように別スレッドに処理を移動させます。

修正後
const getPdfBlob = (datas, image, positionData, pageSize) {
    const docDefinition = createDocDefinition(datas, image, positionData, pageSize);
    return wkn(_docDefinition => {
      // ここから別スレッド
      // 依存をインポートする
      self.importScripts("https://cdn.com/pdfmake/pdfmake.min.js", "https://cdn.com/pdfmake/vfs_fonts.js");
      // 下記の2行は重たい処理だけど別スレッドで実行されるためメインスレッドをブロックしない
      const pdf = self.pdfMake.createPdf(_docDefinition); 
      return new Promise(_resolve => pdf.getBlob(blob => _resolve(blob)));
      // ここまで別スレッド
    }, docDefinition);
}

ここではwknを使用していますが、上記で紹介した記事を参考にやりやすい方法で別スレッドに移行させるといいと思います。

結果

クロームのプロファイリングツールを使って計測しました。
下記のgifのように左側のスプレットシートを操作し、右側のIframeで埋め込んだPDFビューワーを2回レンダリングさせたものを、修正前と修正後で、メインスレッドのスクリプティングに焦点を当てて比べてみます。(手動で操作したので若干ブレがありますが...)

修正前

メインスレッドのスクリプティングは計測時間全体の8981msに対して2261.9msでした。

修正後

メインスレッドのスクリプティングは計測時間全体の7626msに対して259.0msでした。
メインスレッドのスクリプティングの時間が大幅に短縮されたこと+赤枠で囲っているようにWorkerスレッドが生成されていることからPDFの作成処理を別スレッドに移動されたことを確認できました。
計測時間全体に対するメインスレッドのアイドル時間も伸びていますね

また、数値だけでなく、修正後は左側のテーブルを編集しても、もたつきが無くなり、ユーザーのアクションを阻害せず気持ちよく操作できました。

終わりに

今回の例はブラウザでPDFを作成する例でしたのであまり一般的でない例かもしれませんが、
メインスレッドの処理を見直し、別スレッドに重たい処理を移動させることでもたつきを解消することができました。

ChormeからWebWorkerはこれからさらに活用されていく流れもあるようですし、なにかの参考になれば嬉しいです。