every Tech Blog

株式会社エブリーのTech Blogです。

Go Wasm の js.Value.Call はなぜ遅い? wasm_exec.js の内部実装から理解する

こんにちは @kyo です!

2026年2月21日に開催された Go Conference mini in Sendai 2026 にて、「GoとWasmでつくる軽量ブラウザUI」というタイトルで登壇させていただきました。この記事では、発表中にいただいたフィードバックについて深掘りをして得られた知見をご共有できたらと思います。

フィードバック: 「(*js.Value).Call は遅いので、bind したうえで Invoke するといいですよ」 from Hajime Hoshiさん、Go製ゲームエンジンEbitengineの作者

発表スライド speakerdeck.com


背景

Go の syscall/js パッケージでは、JS のメソッドを呼び出す方法が2つあります。

方法 Go コード 特徴
Call document.Call("getElementById", "myDiv") シンプルだが毎回オーバーヘッドあり
bind + Invoke getElementById.Invoke("myDiv") 初期化が必要だが高速

Call が遅い理由

前提知識: Go Wasm の仕組み

Go で書いた Wasm コードがブラウザの JS を呼び出すとき、直接呼べるわけではありません。 間に Wasm メモリwasm_exec.js(Go 公式提供の橋渡しスクリプト)を挟んでやりとりします。

Wasm メモリ(Linear Memory)とは?

Wasm メモリは WebAssembly の仕様で定義された WebAssembly.Memory オブジェクトで、 実体は Go(Wasm)と JavaScript の 両方からアクセスできる巨大なバイト配列ArrayBuffer)です。 「リニアメモリ(Linear Memory)」とも呼ばれます。

developer.mozilla.org

wasmbyexample.dev

普通、Go と JS はお互いの変数を直接見ることができませんが、 この共有のバイト配列を「伝言板」のように使うことで、データをやりとりできます。

例: document.Call("getElementById", "myDiv") の場合

  1. Go 側が "getElementById" という文字列をバイト列に変換して Wasm メモリに書き込む
  2. JS 側(wasm_exec.js)が Wasm メモリからそのバイト列を読み出す
  3. TextDecoder で JS の文字列に変換する(= loadString()
  4. その文字列を使って document["getElementById"] を探す(= Reflect.get()
  5. 見つけた関数を実行する

Invoke が速い理由は、このステップ 1〜4 を丸ごとスキップできるからです。 事前に関数への参照を取得しておけば、Wasm メモリを経由した文字列のやりとりが不要になります。

Call の処理の流れ

Go 側で document.Call("getElementById", "myDiv") を呼ぶと、 wasm_exec.js の以下のコードが実行されます:

// wasm_exec.js
"syscall/js.valueCall": (sp) => {
    sp >>>= 0;
    try {
        const v = loadValue(sp + 8);                    // ① オブジェクトを取得(例: document)
        const m = Reflect.get(v, loadString(sp + 16));  // ② ここが遅い(後述)
        const args = loadSliceOfValues(sp + 32);        // ③ 引数を取得(例: "myDiv")
        const result = Reflect.apply(m, v, args);       // ④ 関数を実行
        sp = this._inst.exports.getsp() >>> 0;
        storeValue(sp + 56, result);                    // ⑤ 結果をメモリに書き戻す
        this.mem.setUint8(sp + 64, 1);                  // ⑥ 成功フラグ
    } catch (err) {
        // エラー処理...
    }
},

② が遅い理由には二つの原因があります

const m = Reflect.get(v, loadString(sp + 16));
//                       ^^^^^^^^^^^^^^^^^^    ← (A) 文字列デコード
//        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^← (B) プロパティ検索

(A) loadString() — 文字列デコード

const loadString = (addr) => {
  const saddr = getInt64(addr + 0); // Wasmメモリ上の文字列の開始位置
  const len = getInt64(addr + 8); // 文字列の長さ(バイト数)
  return decoder.decode(
    // TextDecoder で バイト列 → JS文字列に変換
    new DataView(this._inst.exports.mem.buffer, saddr, len),
  );
};

Go が Wasm メモリに書き込んだバイト列を、TextDecoder を使って JavaScript の文字列 "getElementById" に変換しています。 この処理では毎回 new DataView の生成と decoder.decode() が走っています。

(B) Reflect.get() — プロパティ検索

補足: プロパティとプロパティ検索とは?
JavaScript のオブジェクトは、名前(キー)と値のペアの集まり です。 この「名前と値のペア」1つ1つを プロパティ と呼びます。
// document オブジェクトのイメージ(実際はもっと多い)
document = {
    "getElementById":    function(...) { ... },  // ← プロパティ
    "createElement":     function(...) { ... },  // ← プロパティ
    "querySelector":     function(...) { ... },  // ← プロパティ
    "title":             "My Page",              // ← プロパティ
    // ... 他にも数百のプロパティがある
};
プロパティ検索 とは、この中から名前を指定して値を探す処理です。 Go でいえば `map[string]any` から `map["getElementById"]` でキーを探すのに近いイメージです。
// プロパティ検索の例(どれも同じ意味)
document.getElementById; // ドット記法
document["getElementById"]; // ブラケット記法
Reflect.get(document, "getElementById"); // Reflect API(wasm_exec.js が使う方法)
Reflect.get(v, "getElementById");
// これは実質的に v["getElementById"] と同じ
// = document オブジェクトから "getElementById" という名前の関数を探す

JavaScript のオブジェクトからプロパティ名で関数を検索します。 ここの処理でも毎回この探索処理が走ります。

Invoke の処理の流れ

一方、getElementById.Invoke("myDiv") を呼ぶと

// wasm_exec.js
"syscall/js.valueInvoke": (sp) => {
    sp >>>= 0;
    try {
        const v = loadValue(sp + 8);                     // ① 関数そのものを取得(文字列ではない)
        const args = loadSliceOfValues(sp + 16);         // ② 引数を取得
        const result = Reflect.apply(v, undefined, args); // ③ 関数を直接実行
        sp = this._inst.exports.getsp() >>> 0;
        storeValue(sp + 40, result);                     // ④ 結果をメモリに書き戻す
        this.mem.setUint8(sp + 48, 1);                   // ⑤ 成功フラグ
    } catch (err) {
        // エラー処理...
    }
},

Call との違い

  • loadString() がない → 文字列デコードが不要
  • Reflect.get() がない → プロパティ検索が不要
  • v はすでに関数への参照なので、Reflect.apply() で直接呼ぶだけ

処理の違いまとめ

Call の処理:
  Go → [メソッド名をメモリに書く] → JS: loadString() → Reflect.get() → Reflect.apply()
       ~~~~~~~~~~~~~~~~~~~~~        ~~~~~~~~~~~~   ~~~~~~~~~~~~~
       毎回発生するオーバーヘッド       文字列デコード    プロパティ検索

Invoke の処理:
  Go → JS: Reflect.apply()

図解

1. Call パターン(毎回のオーバーヘッド)

2. bind + Invoke パターン(初回のみオーバーヘッド)

3. 処理ステップの比較

4. bind が必要な理由

JS ではメソッドをオブジェクトから切り離すと this コンテキストが失われます。 bindthis を固定しないと Invoke 時にエラーになります。


コード例

遅いパターン(毎回 Call

document := js.Global().Get("document")

for i := 0; i < 1000; i++ {
    // 毎回: 文字列書き込み → デコード → プロパティ検索 → 実行
    element := document.Call("getElementById", "myElement")
    element.Call("setAttribute", "data-index", i)
}

速いパターン(bind + Invoke

document := js.Global().Get("document")

// 初期化: bind で this を固定
getElementById := document.Get("getElementById").Call("bind", document)

for i := 0; i < 1000; i++ {
    // 毎回: 関数実行のみ(文字列処理・プロパティ検索なし)
    element := getElementById.Invoke("myElement")
    // ...
}

実用的なパターン: よく使うメソッドをまとめて事前バインド

var (
    document       = js.Global().Get("document")
    getElementById = document.Get("getElementById").Call("bind", document)
    createElement  = document.Get("createElement").Call("bind", document)
    querySelector  = document.Get("querySelector").Call("bind", document)
    consoleLog     = js.Global().Get("console").Get("log").Call("bind", js.Global().Get("console"))
)

func getElement(id string) js.Value {
    return getElementById.Invoke(id)
}

func newElement(tag string) js.Value {
    return createElement.Invoke(tag)
}

オーバーヘッド比較表

処理 Call bind + Invoke
文字列の Wasm メモリ書き込み 毎回 初回のみ
TextDecoder によるデコード 毎回 初回のみ
Reflect.get(プロパティ検索) 毎回 初回のみ
Reflect.apply(関数呼び出し) 毎回 毎回
makeArgSlices + storeArgs 毎回 毎回

ベンチマーク結果(10,000回呼び出し)

各メソッドを計測した実測結果

DOM操作(実際のJS API)

JS API自体の実行コストが含まれるため、相対的な差は小さい。

// Call パターン
document.Call("getElementById", "myElement")

// bind+Invoke パターン
getElementById := document.Get("getElementById").Call("bind", document)
getElementById.Invoke("myElement")
対象メソッド Call (ms) bind+Invoke (ms) 差分 速度比
document.getElementById 48.7 46.6 +2.1 ms 1.05倍
console.log 68.3 59.3 +9.0 ms 1.15倍
element.setAttribute 26.8 25.8 +1.0 ms 1.04倍

DOM操作自体のコストが大きいため、Call と bind+Invoke の差は 3〜15% 程度に留まる。

純粋なオーバーヘッド検証

JS側の処理コストを排除し、Call 固有のオーバーヘッドを可視化。

空の関数(何もしない関数)

JS側に何もしない関数を用意し、呼び出しオーバーヘッドだけを測定。

// Call パターン: 毎回「文字列デコード → プロパティ検索 → 関数実行」
noopObj.Call("noop")

// bind+Invoke パターン: 事前バインド済みなので「関数実行」のみ
noop := noopObj.Get("noop").Call("bind", noopObj)
noop.Invoke()
対象 Call (ms) bind+Invoke (ms) 差分 速度比
noop 1.90 0.40 +1.50 ms 4.76倍

JS側の処理コストがないため、Call 固有のオーバーヘッド(文字列デコード + プロパティ検索)が約4〜5倍の差としてはっきり現れる。

メソッド名の長さによる影響

Call は毎回メソッド名を文字列デコードするため、名前が長いほどコストが増えるか検証。

// 短いメソッド名(1文字)
obj.Call("a")

// 長いメソッド名(30文字)
obj.Call("abcdefghijklmnopqrstuvwxyz1234")

// bind+Invoke はどちらも同じ(事前バインド済み)
fn := obj.Get("a").Call("bind", obj)
fn.Invoke()
対象 Call (ms) bind+Invoke (ms) 差分 速度比
メソッド名 "a"(1文字) 1.80 0.50 +1.30 ms 3.61倍
メソッド名 "abcdefghij...1234"(30文字) 2.10 0.60 +1.50 ms 3.50倍

メソッド名の長さはほぼ影響しない。TextDecoder のコストは小さく、Go↔JS間の境界越え自体(valueCall のスタック操作 + Reflect.get)の方がはるかに大きい。(メソッドをたくさん増やしたらもっと差が出るかも)


いつ使い分けるか

シナリオ 推奨 理由
高頻度呼び出し(60fps 描画、大量 DOM 操作) bind + Invoke オーバーヘッド削減の効果が大きい
低頻度呼び出し(ボタンクリック等) Call でOK 可読性を優先、パフォーマンス差は体感できない
同じメソッドをループで繰り返し呼ぶ bind + Invoke 最もメリットが出るケース

まとめ

  • Call は毎回「文字列の Wasm メモリ書き込み → TextDecoder によるデコード → Reflect.get によるプロパティ検索」という3つのオーバーヘッドが発生する
  • bind + Invoke は事前に関数参照を取得・固定しておくことで、これらのオーバーヘッドをすべてスキップし、Reflect.apply で直接関数を実行できる
  • 純粋なオーバーヘッド比較では約4〜5倍の差があり、高頻度呼び出し(描画ループや大量DOM操作)では効果が大きい
  • 一方、DOM操作自体のコストが大きい場面では差は数%程度に留まるため、低頻度の呼び出しでは Call のシンプルさを優先して良さそう
  • よく使うメソッドを var でまとめて事前バインドしておくのが実用的なパターン

最後に

Go Conference mini in Sendai で Hajime Hoshi さんからいただいた「Call は遅いので bind + Invoke がいいですよ」というフィードバックは、最初は「そういうテクニックがあるんだな」程度の理解でした。しかし実際に wasm_exec.js のソースコードを読んでみると、Call が遅い理由は単なる「関数呼び出しの方法の違い」ではなく、Go と JavaScript という2つの異なるランタイムが Wasm メモリという共有バイト配列を介してやりとりする仕組みそのものに起因していることがわかりました。

普段 Go を書いているだけでは意識しない「文字列がバイト列として Wasm メモリに書き込まれ、JS 側で TextDecoder によってデコードされる」という一連の流れを知ったことで、Go Wasm が裏側でどれだけの処理をしているのかを実感できました。と同時に、wasm_exec.js がたった1つのファイルで Go と JS の橋渡しをすべて担っていることに、改めてすごさを感じました。

カンファレンスでのたった一言のフィードバックが、ここまで深い学びにつながるとは思っていませんでした。発表して、フィードバックをもらって、それを深掘りする——このサイクルの価値を改めて実感しています。


参考