お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。

ブラウザのタブがバックグラウンド状態になっているとJSの実行が止まることがあり、そうするとsetInterval()が死ぬので、これを回避する技。

結論としてはWeb Worker APIのWorkerインターフェースを使うことで解決できる。

確認環境

Env ver
Microsoft Edge 126.0.2592.87

サンプルコード

/**
 * @param {() => void} cb 実行するコールバック
 * @param {number} interval 実行間隔
 * @returns 停止用の関数
 * */
const createWorkerInterval = (cb, interval) => {
  const src = "self.addEventListener('message', (msg) => { setInterval(() => self.postMessage(null), msg.data) })";
  const wk = new Worker(`data:text/javascript;base64,${btoa(src)}`);

  wk.onmessage = () => cb();
  wk.postMessage(interval);

  return wk.terminate;
}

Node.jsとGoogle Chrome, Microsoft Edgeを用いて、try-catchとif文でエラー処理にかかる時間がどのくらい違うのかを調べた。

計測手法

次の4パターンを100万回実行した結果を記載している。

  1. Result型としてエラーオブジェクトをreturnし、if文で判定する方式
  2. Result型としてエラーインスタンスをreturnし、if文で判定する方式
  3. エラーオブジェクトをthrowし、try-catchで判定する方式
  4. エラーインスタンスをthrowし、try-catchで判定する方式

確認に使用したコード:https://gist.github.com/Lycolia/304bc9e825e821c2d582f3ef9f700817

計測結果

CPUによって処理速度がかなり変動するが、いずれの環境でも処理速度の速さは以下の通り。

  1. Result型としてエラーオブジェクトをreturnし、if文で判定する方式
  2. エラーオブジェクトをthrowし、try-catchで判定する方式
  3. Result型としてエラーインスタンスをreturnし、if文で判定する方式
  4. エラーインスタンスをthrowし、try-catchで判定する方式

計測に使用したCPU

CPU コア スレッド クロック L2キャッシュ L3キャッシュ
Intel Core i7 13700 16 24 5.20GHz 24.00MB 30.00MB
Intel Core i7 1265U 10 12 4.80GHz 6.50MB 12.00MB
AMD Ryzen 5 5600G 6 12 3.90GHz 3.00MB 16.00MB

Intel Core i7 13700端末

16C24T, 5.20GHz, L2 24.00MB, L3 30.00MB。ミドルタワーPC。

Node.js v16.20.2

処理環境

Env Ver
CPU Intel Core i7 13700
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 802
Result型としてエラーインスタンスをreturnし、if文で判定する方式 5,942
エラーオブジェクトをthrowし、try-catchで判定する方式 1,847
エラーインスタンスをthrowし、try-catchで判定する方式 6,527
Node.js v18.19.1

処理環境

Env Ver
CPU Intel Core i7 13700
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 887
Result型としてエラーインスタンスをreturnし、if文で判定する方式 5,826
エラーオブジェクトをthrowし、try-catchで判定する方式 2,006
エラーインスタンスをthrowし、try-catchで判定する方式 6,464
Node.js v20.11.1

処理環境

Env Ver
CPU Intel Core i7 13700
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 720
Result型としてエラーインスタンスをreturnし、if文で判定する方式 5,375
エラーオブジェクトをthrowし、try-catchで判定する方式 1,690
エラーインスタンスをthrowし、try-catchで判定する方式 5,978
Microsoft Edge 121.0.2277.128

処理環境

Env Ver
CPU Intel Core i7 13700
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 4,246
Result型としてエラーインスタンスをreturnし、if文で判定する方式 12,329
エラーオブジェクトをthrowし、try-catchで判定する方式 11,985
エラーインスタンスをthrowし、try-catchで判定する方式 14,105
Google Chrome 122.0.6261.58

処理環境

Env Ver
CPU Intel Core i7 13700
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 3,507
Result型としてエラーインスタンスをreturnし、if文で判定する方式 10,793
エラーオブジェクトをthrowし、try-catchで判定する方式 10,482
エラーインスタンスをthrowし、try-catchで判定する方式 12,452

Intel Core i7 1265U端末

10C12T, 4.80GHz, L2 6.50MB, L3 12.00MB。ノートPC。

Node.js v16.20.2

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 972
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,416
エラーオブジェクトをthrowし、try-catchで判定する方式 2,288
エラーインスタンスをthrowし、try-catchで判定する方式 13,993
Node.js v18.19.1

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,102
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,333
エラーオブジェクトをthrowし、try-catchで判定する方式 2,441
エラーインスタンスをthrowし、try-catchで判定する方式 12,827
Node.js v20.11.1

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 888
Result型としてエラーインスタンスをreturnし、if文で判定する方式 10,881
エラーオブジェクトをthrowし、try-catchで判定する方式 2,269
エラーインスタンスをthrowし、try-catchで判定する方式 12,254
Microsoft Edge 121.0.2277.128

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 7,526
Result型としてエラーインスタンスをreturnし、if文で判定する方式 34,761
エラーオブジェクトをthrowし、try-catchで判定する方式 39,005
エラーインスタンスをthrowし、try-catchで判定する方式 65,752
Google Chrome 122.0.6261.58

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 6,303
Result型としてエラーインスタンスをreturnし、if文で判定する方式 16,460
エラーオブジェクトをthrowし、try-catchで判定する方式 17,979
エラーインスタンスをthrowし、try-catchで判定する方式 23,378

AMD Ryzen 5 5600G端末

6C12T, 3.90GHz, L2 3.00MB, L3 16.00MB。ミニタワーPC。

この端末はOSがUbuntuであるため、これまでのWindows環境との単純比較はできない。

Node.js v16.20.2

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,786
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,242
エラーオブジェクトをthrowし、try-catchで判定する方式 3,880
エラーインスタンスをthrowし、try-catchで判定する方式 12,716
Node.js v18.19.1

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,933
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,094
エラーオブジェクトをthrowし、try-catchで判定する方式 3,839
エラーインスタンスをthrowし、try-catchで判定する方式 12,767
Node.js v20.11.1

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,714
Result型としてエラーインスタンスをreturnし、if文で判定する方式 10,741
エラーオブジェクトをthrowし、try-catchで判定する方式 3,444
エラーインスタンスをthrowし、try-catchで判定する方式 11,978
Microsoft Edge 121.0.2277.128

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 4,348
Result型としてエラーインスタンスをreturnし、if文で判定する方式 21,584
エラーオブジェクトをthrowし、try-catchで判定する方式 15,543
エラーインスタンスをthrowし、try-catchで判定する方式 23,119
Google Chrome 122.0.6261.57

これだけバージョンが合わなかった。

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 4,842
Result型としてエラーインスタンスをreturnし、if文で判定する方式 16,607
エラーオブジェクトをthrowし、try-catchで判定する方式 15,051
エラーインスタンスをthrowし、try-catchで判定する方式 18,095

全体サマリ表

凡例

  1. Result型としてエラーオブジェクトをreturnし、if文で判定する方式
  2. Result型としてエラーインスタンスをreturnし、if文で判定する方式
  3. エラーオブジェクトをthrowし、try-catchで判定する方式
  4. エラーインスタンスをthrowし、try-catchで判定する方式

サマリ画像

CPUによってかなり差が出ているが、Intel CPUの13700と1265Uを比較した場合、例外を作らない1, 3のケースでは処理に大きな差がないが、例外を作る2, 4のケースでは有意な差が認められるので、モバイル向けとデスクトップ向けでCPUの処理効率に差があることがわかる。

AMD CPUと比較した場合、Node.js環境で例外を作らないケースではIntel CPUに大きく引けを取るが、ブラウザ上でのそれではパフォーマンスは高い。また例外を作るケースでもNode.js・ブラウザ共にIntel CPUと比較した場合のパフォーマンスが比較的よいという、面白い結果になっている。但しOSが違うことの影響もあるため、単純比較はできないが…。

あとがき

ひとまずtry-catchを使うと遅くなるということが確かめられたので良かったし、環境によって劇的遅くなるケースがあるというのは思わない収穫だった。

これを試そうと思った切欠はtry-catchが乱用されているコードを見てパフォーマンス劣化に繋がっているのではないかという疑問を抱いたからだ。try-catchでパフォーマンスの劣化が起きることは知識としても経験としても知っていたのだが、ちゃんとレポートしたことがなかったので今回まとめてみた。

try-catchの利用によりパフォーマンスの劣化が起きるというのは、古い時代にあった開発のお作法を知っている人であれば、それなりに常識だとは思うが、最近は知られていないか、知られていても意識していないことが少なくないと思われるので、今回記事にしてみた感じだ。

100万回の実行なので細かい話になるとは思うがパフォーマンスチューニングは細かいことの積み重ねでもあるので、むやみやたらに例外を使わないことは重要だと思う。そもそも例外は制御しづらいので、可能な限りif文で処理を書くことが望ましいだろう。

処理が遅延する原因としては二つあると考えていて、一つは例外生成時に顕著であることからスタックトレースの生成を初めとしたエラーオブジェクトの生成に時間がかかっているのと、もう一つはcatchでも遅延が出ることから、コールスタックから最寄りのcatchを辿るのに時間を取られていると思われる。今回の検証では特に出してないが、以前確認した時の記憶が確かであれば、catchに入らない限り、tryだけで顕著に処理が遅延することはなかったと思う。

あと、どうでもいいことだがconsole.log()で結果を出したときにEdgeだけObjectのKeyの順序が他と違ったので転記しているときに微妙に不便だった。何回やっても同じだったので恐らくEdgeだけキーをソートするアルゴリズムが違うのだと思う。まぁObjectとかHashとかMapとかDictionaryみたいなやつは順序が保証されないので別にいいっちゃいいんだけど、まさか実装によってソート方式が違うというのは思いもしなかった。

EdgeとChromeで処理結果が優位に違うのも、恐らくこれが原因なのだろう。分からないが多分JSのエンジンの実装が違う気がする。

それとLinuxのEdgeにはマウスジェスチャー機能がないという悲しいことも分かった。

参考

投稿日:
言語::JavaScript言語::HTML

adiaryの改造でやったのでメモがてら

環境

Env Ver
Microsoft Edge 120.0.2210.144
PHP 8.2.10

サンプルコード

FORM形式での送信

Web標準でサポートされているため実装が非常に容易。特に理由がなければこれでいい

HTMLコード

<textarea onpaste="handlePasteForm(event)"></textarea>

JSコード

fetch()のヘッダ指定がないが、 fetch()を使う場合はFormDataを使うと勝手に生えるので気にしなくていい。他にもXMLHttpRequest.send()Navigator.sendBeacon()でも生えるらしい。

今回試したEdgeではバウンダリー文字列もちゃんと生えていた。

/** @param {ClipboardEvent} e */
const handlePasteForm = (e) => {
  if (e.clipboardData.files.length) {
    const pasteFile = e.clipboardData.files[0];
    const form = new FormData();
    form.append('image', pasteFile);

    fetch('./test.php', {
      method: 'POST',
      body: form
    });
  } else {
    // 何もしない
  }
};

PHPコード

Content-Typemultipart/form-dataの時だけ中身が入ってくる
参考:https://www.php.net/manual/ja/reserved.variables.files.php

<?php
$file_name = $_FILES['image']['name'];
move_uploaded_file($_FILES['image']['tmp_name'], './'. $file_name);
>

$_FILESの中身はvar_dumpした限りこんな感じだった

array(1) {
  ["image"]=>
  array(6) {
    ["name"]=>
    string(9) "image.png"
    ["full_path"]=>
    string(9) "image.png"
    ["type"]=>
    string(9) "image/png"
    ["tmp_name"]=>
    string(29) "C:\env\msys64\tmp\php3B90.tmp"
    ["error"]=>
    int(0)
    ["size"]=>
    int(3988)
  }
}

JSON形式での送信

Web標準ではないので、これといったやり方もなく、正直面倒だが、どうしてもJSONで送らないといけないときに。

HTMLコード

<textarea onpaste="handlePasteJson(event)"></textarea>

JSコード

HTTPではバイナリを送ることができないため、Base64にエンコードして送る。Base64エンコードに含まれる情報はファイルバイナリのみ。もしFORM送信のようにファイル名も送りたければ、別途送ってやる必要がある。

/** @param {File} file */
const encodeBase64 = (file) => {
  return new Promise ((resolve, reject) => {
    const fr = new FileReader();

    fr.onload = () => {
      const data = fr.result.split(',')[1];
      resolve(fr.result.split(',')[1]);
    };

    fr.onerror = (ev) => {
      reject(ev);
    };

    fr.readAsDataURL(file);
  });
};

/** @param {ClipboardEvent} e */
const handlePasteJson = async (e) => {
  if (e.clipboardData.files.length) {
    const pasteFile = e.clipboardData.files[0];

    const base64File = await encodeBase64(pasteFile);

    fetch('./test.php', {
      method: 'POST',
      headers: {
        'content-type': 'application/json'
      },
      body: JSON.stringify({ image: base64File })
    });
  } else {
    // 何もしない
  }
};

PHPコード

JSONリクエストを取るときはfile_get_contents('php://input')を利用する。$_POSTContent-Typeapplication/x-www-form-urlencodedないしmultipart/form-data時しか取れないからだ。つまりFORMリクエスト以外はfile_get_contents('php://input')で取ると覚えておけばよいだろう。
参考:https://www.php.net/manual/ja/wrappers.php.php#wrappers.php.input

以下のコードはかなりいい加減なので余りアテにしてはいけない(特に拡張子取得の下りがガバガバすぎる)が、取り合えずpngかjpegかgifが送られてきた場合は動いてくれる筈だ。

<?php
// 連想配列にするため、json_decode()の第二引数をtrueにする(指定しないとstdClassになる
$json = json_decode(file_get_contents('php://input'), true);
// Base64エンコードを解除する
$file = base64_decode($json['image']);

// 一時ファイルに落とす
file_put_contents('json_upload', $file);
// 拡張子判別
$mime = mime_content_type('json_upload');
// 拡張子取得
$ext = explode('/', $mime);

// 拡張子付きにリネーム
rename('json_upload', 'image.'. $ext[1]);

参考

JSONでファイルを投げる方式での実装状況については以下の記事が参考になる。

WebAPI でファイルをアップロードする方法アレコレ #API - Qiita

一気通貫で動くサンプルコード

今までの実装が全部動かせるサンプル

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  if(preg_match('/^multipart\/form-data;/', $_SERVER['CONTENT_TYPE'])) {
    // form
    $file_name = $_FILES['image']['name'];
    move_uploaded_file($_FILES['image']['tmp_name'], './'. $file_name);
  } else {
    // json
    $json = json_decode(file_get_contents('php://input'), true);
    $file = base64_decode($json['image']);

    file_put_contents('json_upload', $file);
    $mime = mime_content_type('json_upload');
    $ext = explode('/', $mime);

    rename('json_upload', 'image.'. $ext[1]);
  }
} else {
?>

<!DOCTYPE html>
<html>
<head>
  <meta charset='utf-8'>
  <meta http-equiv='X-UA-Compatible' content='IE=edge'>
  <title>Upload test</title>
  <meta name='viewport' content='width=device-width, initial-scale=1'>
  <script>
    /** @param {File} file */
    const encodeBase64 = (file) => {
      return new Promise ((resolve, reject) => {
        const fr = new FileReader();

        fr.onload = () => {
          const data = fr.result.split(',')[1];
          resolve(fr.result.split(',')[1]);
        };

        fr.onerror = (ev) => {
          reject(ev);
        };

        fr.readAsDataURL(file);
      });
    };

    /** @param {ClipboardEvent} e */
    const handlePasteForm = (e) => {
      if (e.clipboardData.files.length) {
        const pasteFile = e.clipboardData.files[0];
        const form = new FormData();
        form.append('image', pasteFile);

        fetch('./test.php', {
          method: 'POST',
          body: form
        });
      } else {
        // 何もしない
      }
    };

    /** @param {ClipboardEvent} e */
    const handlePasteJson = async (e) => {
      if (e.clipboardData.files.length) {
        const pasteFile = e.clipboardData.files[0];

        const base64File = await encodeBase64(pasteFile);

        fetch('./test.php', {
          method: 'POST',
          headers: {
            'content-type': 'application/json'
          },
          body: JSON.stringify({ image: base64File })
        });
      } else {
        // 何もしない
      }
    };
  </script>
</head>
<body>
  <div>Form:<textarea onpaste="handlePasteForm(event)"></textarea></div>
  <div>Json:<textarea onpaste="handlePasteJson(event)"></textarea></div>
</body>
</html>

<?php
}
?>

EdgeとChromeは同じだったがNode.jsでは異なる挙動をしていたのでそのメモ

確認環境

Env ver
Microsoft Edge 120.0.2210.9
Google Chrome 120.0.6099.130
Node.js 20.0.0

環境別の確認結果

Edge

DevToolsで確認

const err = new Error('test');
Object.getPrototypeOf(err);

// {name: 'Error', message: '', constructor: ƒ, toString: ƒ}

Google Chrome

DevToolsで確認

const err = new Error('test');
Object.getPrototypeOf(err);

// {name: 'Error', message: '', constructor: ƒ, toString: ƒ}

Node.js

node -iで確認

const err = new Error('test');
Object.getPrototypeOf(err);

// {}

ESM化が叫ばれて久しいですが、未だにJestはESMとCJSが混在したコードを処理してくれません。

Getting StartedにもSWCに対する言及がないので、きっともう忘れられているのでしょう。swc-project/jestの方も特にやる気はなさそうだし、やりたければ自分でPR書きましょうって感じだと思います。きっと。

確認環境

node_modules配下にESMで作られたモジュールが存在し、コードはTypeScript、トランスパイルにはSWCを利用する。

Env Ver
@swc/cli 0.1.62
@swc/core 1.3.92
@swc/jest 0.2.29
@swc/register 0.1.10
jest 29.7.0
typescript 5.2.2

やったけど意味がなかったこと

  • package.jsontypemoduleにする
  • jest.config.jstransformIgnorePatternsにESMモジュールのパスだけ除外する設定を書く
  • 上記に加えてtransform.jsc.pathpkg-name: ['node_modules/pkg-name']を追記する
  • node --experimental-vm-modules node_modules/jest/bin/jest.jsで実行する
    • 多少マシになったがコケるものはコケる
  • ESMで書かれたモジュールを丸ごとモックする
    • 一切効果なし

所感

多分Webpackでバンドルしてnode_modulesの中身も外も関係ない状態にするのが一番無難なのではないかと思いました。

Node.jsの組み込みテストランナーにすれば解決するかな?と思ったものの、こちらは現状SWCでは使えそうにないので諦めました。
参考までに以下のコマンドで走らさせられます。

node --require @swc/register --test ./src/**/*.spec.ts

取り敢えずESMに引っかったモジュールはCJS時のバージョンを維持しておくことにしましたが、このままだとSWC使えないし、なんとかなって欲しいですね。Webpack使えば解決できるのはわかるんですが、このために使いたくないので、テストを重視する場合、Vitestを持つViteが有力候補になって来そうです。

2024-02-17追記

esbuild + Node.js built-in test runnerの組み合わせであればテストはできるが肝心の実行ができず無意味だった