
こんにちは @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)」とも呼ばれます。
普通、Go と JS はお互いの変数を直接見ることができませんが、 この共有のバイト配列を「伝言板」のように使うことで、データをやりとりできます。

例: document.Call("getElementById", "myDiv") の場合
- Go 側が
"getElementById"という文字列をバイト列に変換して Wasm メモリに書き込む - JS 側(
wasm_exec.js)が Wasm メモリからそのバイト列を読み出す TextDecoderで JS の文字列に変換する(=loadString())- その文字列を使って
document["getElementById"]を探す(=Reflect.get()) - 見つけた関数を実行する
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() — プロパティ検索
// 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 コンテキストが失われます。
bind で this を固定しないと 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 の橋渡しをすべて担っていることに、改めてすごさを感じました。
カンファレンスでのたった一言のフィードバックが、ここまで深い学びにつながるとは思っていませんでした。発表して、フィードバックをもらって、それを深掘りする——このサイクルの価値を改めて実感しています。