一、简介

Go 语言在前几个月前发布了 1.11 版本,个人感觉影响最大的除了 Go Module 外就是支持 WebAssembly 了,正好 Go 和 JS 都是我自己喜欢的语言,本文就简单介绍下如何使用 Go 语言开发编译 WebAssembly 模块,并在 Node.js 和浏览器端使用。

关于 Go 语言环境安装,请参考 GO 语言官网

二、Hello Go WebAssembly

首先,还是最简单的 Hello World 开始,简单介绍如何将 Go 源码编译为 wasm 文件

建立 main.go 文件,输入如下的代码:

package main

import (
    "fmt"
)

func main() {
    fmt.Println("Hello Go WebAssembly")
}

这就是最简单 Go 源代码文件,在代码当前目录执行如下的编译命令将这段代码编译为 wasm 文件

GOARCH=wasm GOOS=js go build -o lib.wasm main.go

这段命令就是 Go 的交叉编译命令;其中 GOARCH, GOOS 分别指代编译的目标系统和平台,比如我们可以换为 GOOS=linux GOARCH=amd64 来编译为 Linux 下的 64 位可执行程序;-o 指定输出文件名。

执行完成后会在当前目录生成 WebAssembly 的二进制字节码文件 lib.wasm

三、浏览器端引入并执行 wasm 文件

我们先来看看浏览器端如何引入生成的 lib.wasm 文件,建立 index.html 文件,根据 Go 仓库中内容,文件如下:

<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>Go WebAssembly</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();

        WebAssembly.instantiateStreaming(fetch("lib.wasm"), go.importObject).then(async (result) => {
            mod = result.module;
            inst = result.instance;
            await go.run(result.instance)
        });
    </script>
</body>
</html>

其中 wasm_exec.js 文件为官方提供的一份 Go WebAssembly 的依赖文件,请从 https://github.com/golang/go/blob/release-branch.go1.11/misc/wasm/wasm_exec.js 获取。

重点是下面这段代码

const go = new Go();
WebAssembly.instantiateStreaming(fetch("lib.wasm"), go.importObject).then(async (result) => {
    mod = result.module;
    inst = result.instance;
    await go.run(result.instance)
});

其中 Go 这个全局对象是 wasm_exec.js 文件中定义的,先实例化这个对象;接着使用 fetch 方法获取到 lib.wasm 文件,然后通过 WebAssembly.instantiateStreaming 方法编译和实例化 WebAssembly 代码,其中第二个参数 go.importObject 是向 lib.wasm 代码中导入的一些运行时对象,感兴趣可以看看 wasm_exec.js 文件;最后调用 go.run(result.instance) 执行实例化的代码。

这里需要注意的是:index.html 必须放在 Web 服务器上,使用 HTTP 协议访问,所以使用 Nginx 或者 Go、Node 自己写一个小服务器都可以;还有是 lib.wasm 文件的 Content-Type 必须是 application/wasm,所以可以在 Nginx 的配置文件 mime.types 中添加 application/wasm wasm; 这样一行,或者其他服务器使用自己的方式来实现。

例如我这里访问 http://127.0.0.1/go-web-assembly/ 就可以看到执行结果了

执行结果

四、Node.js 引入并调用 wasm 文件

官方的仓库提供了一种 node 命令行调用 wasm 的方式,没有提供 node 代码中调用 wasm 的方式,我简单将 wasm_exec.js 文件修改,提交了个 npm 模块 go-wasm,使用这个模块来引用 wasm 文件

建立 index.js 文件,输入如下内容

const {Go} = require('go-wasm')

const start = async function start() {
    const go = new Go()
    const result = await WebAssembly.instantiate(fs.readFileSync('./lib.wasm'), go.importObject)
    await go.run(result.instance)
}

start();

执行 node index.js 可以看到控制台中输出了 "Hello Go WebAssembly"。

结合上面浏览器中的输出,可以看到 Go 代码中的 fmt.Println 都会通过 console.log 出来,但其实浏览器端和 Node.js 端又是不一样的,在 wasm_exec.js 中可以看到,Go 代码中的输出会调用 runtime.wasmWrite 方法,而这个方法会从共享内存中取出数据和输出类型(标准输出还是错误输出等),在 JS 端 wasmWrite 实现中调用了 fs.writeSync 将输出数据写入对应的输出类型中,Node.js 中就是直接调用 fs 模块,而浏览器端实现了 fs.writeSync 到 console.log 中,最终输出到浏览器的开发者工具中。

五、在 Go 代码中调用 JS 变量

上面的基础代码只是介绍了 JS 中调用 wasm,并没有太多的实际用处,接下来就看看在 Go 代码和 JS 如何互相调用;

在 Go 代码中调用 JS 变量需要引入一个标准库 syscall/js,例如如下的代码,我们可以通过 Go 代码获取 JS 中变量的值,调用 JS 中的方法

package main

import (
    "syscall/js"
)

func readUserAgent() string {
    return js.Global().Get("navigator").Get("userAgent").String()
}

func alert(str string) {
    js.Global().Get("alert").Invoke(str)
}

func main() {
    userAgent := readUserAgent()
    alert(userAgent)
}

可以看到,我们通过 js.Global().Get("navigator").Get("userAgent").String() 来获取浏览器环境的 global.navigator.userAgent 属性,就是 UserAgent 字符串,然后调用 js.Global().Get("alert").Invoke(str) 来执行 alert 方法,将 UserAgent 显示出来,其中 alert 调用也可以改为 js.Global().Call("alert", str) 这种方式;

syscall/js 标准库提供了一个 js.Value 的类型,指代 Javascript 中的变量,上面的代码 js.Global()js.Global().Get("xxx") 等都会返回一个 js.Value 的类型,js.Value 类型拥有下面一些方法可供我们来使用:

  • js.Value.Get(p string) js.Valuejs.Value.Set(p string, x interface{}) 这两个方法获取和设置对象的属性
  • js.Value.Index(i int) js.Valuejs.Value.SetIndex(i int, x interface{})js.Value.Length() int 这三个方法分别获取和设置数组项的值,获取数组的长度
  • js.Value.Call(m string, args ...interface{}) js.Value 调用对象的方法,args 传入调用的参数,返回函数的返回值
  • js.Value.Invoke(args ...interface{}) js.Value 执行函数,返回函数返回值
  • js.Value.New(args ...interface{}) js.Value 通过 new 调用函数,返回函数返回值
  • js.Value.InstanceOf(t js.Value) bool 类似 JS 中 instanceof 操作,返回 true 或 false
  • 除了上面这些方法,还有 js.Value.Type() 获取 js.Value 的类型,还有 js.Value.Int()js.Value.Bool()js.Value.Float()js.Value.String() 等分别将 js.Value 类型转换为 Go 语言变量类型,对应规则如下:
| Go                     | JavaScript             |
| ---------------------- | ---------------------- |
| js.Value               | [its value]            |
| js.TypedArray          | typed array            |
| js.Callback            | function               |
| nil                    | null                   |
| bool                   | boolean                |
| integers and floats    | number                 |
| string                 | string                 |
| []interface{}          | new array              |
| map[string]interface{} | new object             |

更详细的文档参考GoDoc

六、在 JS 代码中调用 Go 变量、函数

除了在 Go 代码中调用 JS 环境的一些变量外,我们也可以将 Go 中的方法,变量导出来,在 JS 代码中调用,如下代码:

package main

import (
    "syscall/js"
)

func add(i []js.Value) {
    valueA := i[0].Int()
    valueB := i[1].Int()
    sum := valueA + valueB
    js.Global().Get("console").Call("log", "sum: ", js.ValueOf(sum))
}

func main() {
    c := make(chan struct{}, 0)
    callback := js.NewCallback(add)
    js.Global().Set("add", callback)
    defer callback.Release()
    <-c
}

这里使用 js.NewCallback(fn func(args []Value)) js.Callback 这个函数将 Go 函数 add 转换并添加到 JS 全局变量中去

然后我们在 JS 中就可以使用下面这段代码来调用了

<body>
    <script src="./wasm_exec.js"></script>
    <script>
        // 省略部分代码

        const go = new Go();

        WebAssembly.instantiateStreaming(fetch("lib.wasm"), go.importObject).then(async (result) => {
            await go.run(result.instance)
        });

        let count = 0;
        const runFunction = async function runFunction() {
            add(1, count++)
        }
    </script>
    <button onClick="runFunction()" id="addButton">Add</button>
</body>

还有在 Go 代码中,我们使用了 Channel 将 main 函数阻塞住,防止 Go 代码执行结束后就退出,因为 JS 调用是在点击按钮后触发的,这时候需要保证 wasm 程序还在运行中;

JS 调用 Go 函数,不能直接获取函数的返回值,需要将返回值设置在全局变量中,而且 Go 注册到 JS 的函数是异步调用的函数,所以全局变量并不能直接同步取出,所以上面的例子是在 Go 语言中使用 console 来取巧,比较合适的例子参考下面参考文章2中的例子,带有返回值的同步函数会在 Go 1.12 版本中支持。

七、结语

WebAssembly 在性能上相比 JavaScript 能好一些,所以给一些 Web 应用提供了更大的可能性,例如以后可以在网页端使用更复杂的图片处理、视频渲染;也让 Node 端可以实现之前通过编译来实现的一些模块,比如 node-sass 这个模块就可以加载 wasm 文件来处理,省去了本地编译原生 node 模块的步骤,或者做类似验证码生成的功能,通过 wasm 文件直接调用 Go 语言等内置的图形库,补充了 Node 自身图形库缺失的问题。

WebAssembly 还可以在一些对于代码保护方面,可以将一些敏感的代码使用 wasm 文件加载,达到混淆代码的作用,提高分析代码的逻辑的门槛。

本文简单的介绍了在 JS 中调用 Go 编译的 WebAssembly 文件,Go 语言支持 WebAssembly 还处在开始阶段,许多特性还暂时不支持;后面随着 Go 版本的升级,我也会不时添加关于这个话题更多的文章。

参考文章

  1. Go 1.11: WebAssembly for the gophers
  2. Go WebAssembly Tutorial - Building a Calculator Tutorial