JavaScript 回呼 Go


在〈Go 呼叫 JavaScript〉看過如何在 Go 中取得 JavaScript 的函式,然後予以呼叫,若你曾稍微瞭解過〈WebAssembly〉,就會發覺,這跟 WebAssembly 匯入函式至 WebAssembly 的方式不同。

這是 JavaScript 的 wasm_exec.js 以及 Go 的 syscall/js 居中之緣故,在 wasm_exec.html 中你也可以看到載入、編譯、實例化 WebAssembly 的過程:

if (!WebAssembly.instantiateStreaming) { // polyfill
    WebAssembly.instantiateStreaming = async (resp, importObject) => {
        const source = await (await resp).arrayBuffer();
        return await WebAssembly.instantiate(source, importObject);
    };
}

const go = new Go();
let mod, inst;
WebAssembly.instantiateStreaming(fetch("test.wasm"), go.importObject).then((result) => {
    mod = result.module;
    inst = result.instance;
    document.getElementById("runButton").disabled = false;
});

async function run() {
    console.clear();
    await go.run(inst);
    inst = await WebAssembly.instantiate(mod, go.importObject); // reset instance
}

Go 有自己的匯入物件,也就是 go.importObject,這個物件主要是 JavaScript 環境與 Go 編譯出來的 WebAssembly 之橋樑,將 JavaScript 的值與 Go 的結構實例作了個對應,因此,不用自己匯入某個函式,只要取得某個作為名稱空間的 JavaScript 物件,取得上頭對應的特性,像是函式,就可以在 Go 中操作。

也就是說,如果想要在 Go 中定義函式,然後在 JavaScript 中呼叫,就是將 Go 中定義的函式,設定給某個對應的 JavaScript 物件,之後就可以在 JavaScript 環境中使用了,只不過在定義時,必須留意 JavaScript 與 Go 的型態對應。

可以被 JavaScript 環境呼叫的 Go 函式,必須被包裝為 js.Callback 型態,這個結構型態內嵌 js.Value,也就是它也是一種值,想要建立 js.Callback 實例,可以透過 js.NewCallback 函式(定義在 callback.go)。

要能被 JavaScript 呼叫的 Go 函式,參數型態是 []js.Value,也就是 js.Valuesliceslice 的元素代表著呼叫函式時傳入的引數,你可以想像 JavaScript 函式中 arguments 的對應型態。

例如,顯示加總至某個指定 DOM 物件的函式,可以如下定義:

package main

import "syscall/js"

func main() {
    // 註冊在 JavaScript 全域
    js.Global().Set("printSumTo", js.NewCallback(printSum))
    // 阻斷 main 流程
    select {}   
}

func printSum(args []js.Value) {
    c1 := args[0]         // 結果顯示到這個 div 
    numbers := args[1:]   // 接下來是要加總的數字
    c1.Set("innerHTML", sum(numbers))
}

func sum(numbers []js.Value) int {
    var sum int
    for _, val := range numbers {
        sum += val.Int()
    }
    return sum
}

目前 Go 給 JavaScript 回呼用的函式不支援傳回值,未來也許會進一步支援,如果你想將結果帶回 JavaScript 環境,就是以副作用的方式實現,例如改變某個 JavaScript 物件的狀態,像是這邊是改變某個 DOM 的 innerHTML

因為 Go 的 main 執行完,模組的程式就結束了,這樣 Go 中定義的函式就沒有了,然而,事件會是在之後才發生,因而要被回呼的函式必須存活著,為了這個目的,範例中使用 select {} 來阻斷流程,視需求而定,你也可以用別的方式來設計某種阻斷。

至於 JavaScript 的部份,來稍微修改一下 wasm_exec.html:

<!doctype html>
<!--
Copyright 2018 The Go Authors. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.
-->
<html>

<head>
    <meta charset="utf-8">
    <title>Go wasm</title>
</head>

<body>

    <script src="wasm_exec.js"></script>
    <script>
        if (!WebAssembly.instantiateStreaming) { // polyfill
            WebAssembly.instantiateStreaming = async (resp, importObject) => {
                const source = await (await resp).arrayBuffer();
                return await WebAssembly.instantiate(source, importObject);
            };
        }

        const go = new Go();
        let mod, inst;
        WebAssembly.instantiateStreaming(fetch("test.wasm"), go.importObject).then((result) => {
            mod = result.module;
            inst = result.instance;
            document.getElementById("runButton").disabled = false;
        }).then(_ => {   // 實例化模組之後就執行
            console.clear();
            go.run(inst);       
        });
    </script>   

    <script>
        function run() {
            // 呼叫 Go 定義的回呼函式
            printSumTo(document.getElementById('c1'), 
                1, 2, 3, 4, 5);
        }
    </script>

    <button onClick="run();" id="runButton" disabled>Run</button>
    <div id="c1"></div>

</body>
</body>

</html>

按下 Run 之後,會呼叫 runAndPrintSum,這會先執行 run 函式,執行 WebAssembly 模組實例,對應的就是執行 Go 定義的 main,因為 run 是非同步的,接下來就會執行 printSumTo,因此 1 到 5 的加總結果,就會顯示到 idc1div 元素之中。

至於 WebAssembly API 的調整,想要瞭解這部份的話,可以看看〈WebAssembly〉中前三篇的說明。

故且不討論 WebAssembly API 怎麼寫,在自定義的 JavaScript 程式碼中,想要呼叫 Go 中定義的函式,其實感覺就是多了些額外的手續,而且不自然。

如果把一切都帶到 Go 中做,將 Go 中定義的函式,當成是某事件的回呼,會比較單純一些,例如:

package main

import (
    "strconv"
    "syscall/js"
)

func main() {
    // 註冊按鈕事件
    dom("runButton").Call("addEventListener", "click", js.NewCallback(cal))
    select {}
}

// 根據 id 取得 DOM 物件
func dom(id string) js.Value {
    return js.Global().Get("document").Call("getElementById", id)
}

// 按下 Run 的事件處理器
func cal(args []js.Value) {
    n1, _ := inputValue("n1")
    n2, _ := inputValue("n2")
    dom("r").Set("innerHTML", n1+n2)
}

// 取得輸入欄位值
func inputValue(id string) (int, error) {
    return strconv.Atoi(dom(id).Get("value").String())
}

至於 wasm_exec.html 可以如下調整:

<!doctype html>
<!--
Copyright 2018 The Go Authors. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.
-->
<html>

<head>
    <meta charset="utf-8">
    <title>Go wasm</title>
</head>

<body>
    <input id="n1"> + <input id="n2"> = <span id="r"></span><br>
    <button id="runButton" disabled>Run</button>


    <script src="wasm_exec.js"></script>
    <script>
        if (!WebAssembly.instantiateStreaming) { // polyfill
            WebAssembly.instantiateStreaming = async (resp, importObject) => {
                const source = await (await resp).arrayBuffer();
                return await WebAssembly.instantiate(source, importObject);
            };
        }

        const go = new Go();
        let mod, inst;
        WebAssembly.instantiateStreaming(fetch("test.wasm"), go.importObject).then((result) => {
            mod = result.module;
            inst = result.instance;
            document.getElementById("runButton").disabled = false;
        }).then(_ => {
            console.clear();
            go.run(inst);       
        });
    </script>   
</body>

</html>

這樣就可以進行頁面操作,就是個簡單的加法器:

JavaScript 回呼 Go

(這也許才是 Go 希望的,要你把東西都帶入 Go 中來做,JavaScript 環境的事件會呼叫 Go 的函式,然後在 Go 中計算,在 Go 中改變物件狀態、畫面等。)