Go×WebAssemblyを使ってDOM操作を試してみる

今回はWebAssemblyについて試してみました。以下目次で進めて行きます。

WebAssembyとは

WebAssemblyは、ブラウザーで実行できるバイナリコードです。WebではJavaScriptが必須ですが、リッチな処理が実現出来るようになるにつれ性能が不足するシーンが増えてきています。この問題解決のために実行が高速になるよう設計され誕生したのがWebAssemblyです。JavaScript以外の言語からコンパイルされていることを前提としており、調査したところ代表的な言語としてはC、C++、Rustです。ただし、WebAssemblyをロードして、JavaScriptから呼出すのでJavaScriptの代替えというわけではないことには留意しておきましょう。

WebアプリケーションにおけるWebAssemblyイメージ
f:id:tanabebe:20190901111756p:plain

Goでは1.11以降のVersionであれば、コードをWebAssemblyとしてコンパイル出来る機能が試験的ではありますがリリースされているため、今回はGoから試してみたいと思います。

Go 1.11 added an experimental port to WebAssembly. Go 1.12 has improved some parts of it, with further improvements expected in Go 1.13.

今回のコード

こちらに公開しております。
github.com

作成した画面


console.log

f:id:tanabebe:20190901100719g:plain


HTML書き換え

f:id:tanabebe:20190901100814g:plain


alertの表示 f:id:tanabebe:20190901100940g:plain


text-boxの入力値を別のtext-boxへ

f:id:tanabebe:20190901101301g:plain

前提条件

  • OS:macOS Majave
  • IDE:GoLand
  • GoVersion:1.12.9

Goのコードを書いていく

console.logに出力するためのGo側のコードを書いていきます。

// main.go

package main

import (
    "fmt"
    "syscall/js"
)

// documentオブジェクト取得用
var document = js.Global().Get("document")

// bodyのDOM取得
var body = document.Get("body")

func main() {
    // goからbuttonのDOMを作成する
    cLogBtn := createElement("button")
    // cLogBtnボタンのテキストを設定
    cLogBtn.Set("textContent", "console log!!")
    // buttonをbodyへ追加
    body.Call("appendChild", cLogBtn)
    // cLogBtnにclickのEventLisnerを設定
    cLogBtn.Call("addEventListener", "click", js.FuncOf(func(js.Value, []js.Value) interface{} {
        fmt.Println("Hello Webassembly!")
        return nil
    }))
    // プログラムが終了しないように待機
    select {}
}

// 使用頻度が高そうなので、対象DOMを作成する関数を用意
func createElement(elementName string) js.Value {
    return document.Call("createElement", elementName)
}

HTMLの作成

HTMLを作成してWebAssembly実行に必要なコードを記述します。console.log出力用のボタンはGoから作成していますのでHTMLには記述なしです。

<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>wasam-demo</title>
    <script src="./wasm_exec.js"></script>
    <script>
        (async () => {
            const go = new Go();
            const { instance } = await WebAssembly.instantiateStreaming(
                fetch('main.wasm'),
                go.importObject
            );
            await go.run(instance);
        })();
        async function run() {
            console.clear();
            await go.run(inst);
            inst = await WebAssembly.instantiate()
        }

    </script>
</head>
<body>
    <div id="message">Hello, World</div>
</body>
</html>

WebAssemblyへコンパイルする

ここまで来たら、Goのプログラムをコンパイルします。以下のコマンドを打ちます。

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

実行するとmain.wasmというファイルが生成されます。macOSWindowsLinux向けのバイナリをクロスコンパイル出来るのでGOOSGOARCH環境変数を変更すれば他OSの実行環境へのコンパイルも可能です。ここではJavaScriptの実行環境をOSとし、WebAssemblyとしてコンパイルしています。

GOROOTからwasm_exec.jsをコピーしてくる

ブラウザで実行するためにwasm_exec.jsをコピーしてきます。wasm_exec.jsはGoから JavaScriptへリンクするための実行命令が含まれているスクリプトになります。では以下のコマンドを打ってコピーしてきます。

❯❯❯ cp $GOROOT/misc/wasm/wasm_exec.js .

HTTPサーバーを実行し、確認する

ローカル環境で動作させるために以下をgo getしてから実行していきます。

❯❯❯ go get https://github.com/mattn/serve
❯❯❯ serve -a :8080

これでhttp://localhost:8080/にアクセスすると画面の確認が出来ます。読み込み時はDOMの生成をGoで行っているので少し遅れてボタン表示がされます。 f:id:tanabebe:20190901105707g:plain

次は表示されているHTMLのテキストを書き換えていきます。

HTMLのテキストを書き換え

main.goに以下を追記していきます。func main()に追記。

// buttonDOMを作成する
textChangeBtn := createElement("button")
// textChangeBtnのテキストを設定
textChangeBtn.Set("textContent", "text change!!")
// buttonをbodyへ追加
body.Call("appendChild", textChangeBtn)
// textChangeにclick時のEventLisnerを設定
textChangeBtn.Call("addEventListener", "click", js.FuncOf(func(js.Value, []js.Value) interface{} {
    message := getElementByID("message")
    message.Set("innerHTML", "Hello, WebAssembry!!")
    return nil
}))

func mainの外に以下を追記

// 使用頻度が高そうなので、対象のDOMのIDを取得する関数を用意
func getElementByID(targetID string) js.Value {
    return document.Call("getElementById", targetID)
}

今回もDOMをGoから作成しているのでHTML側の修正は行いませんが、Go側を修正したのでコンパイルした後、HTTPサーバーを実行します。

❯❯❯ GOOS=js GOARCH=wasm go build -o main.wasm
❯❯❯ serve -a :8080

※以降Go側の修正があり、再実行時はこの手順を実施します

画面から確認するとテキストの変更が出来ることが確認出来ました! f:id:tanabebe:20190901110555g:plain

alertのダイアログを表示させる

main.goグローバル変数として以下を追記します。

// windowオブジェクトを取得
var window = js.Global()

main.gofunc main内に以下を追記します。

// buttonのDOMを生成
alertBtn := createElement("button")
// alertBtnのテキストを設定
alertBtn.Set("textContent", "alert!!")
// buttonをbodyへ追加
body.Call("appendChild", alertBtn)
// alertBtnにclick時のEventLisnerを設定
alertBtn.Call("addEventListener", "click", js.FuncOf(func(js.Value, []js.Value) interface{} {
    window.Call("alert", "Hello!!")
    return nil
}))

コンパイルした後、HTTPサーバーを実行して画面を確認していきます。無事alertのダイアログが表示されました! f:id:tanabebe:20190901111423g:plain

text-boxの入力値を別のtext-boxへ反映させる

main.goに以下を追記していきます。func main()に追記。

// textエリアの入力値を取得
getElementByID("in").Call("addEventListener", "keyup", js.FuncOf(func(js.Value, []js.Value) interface{} {
    getElementByID("out").Set("value", getElementByID("in").Get("value"))
    return nil
}))

index.htmlbodyタグ内に以下を追加します。

<div><input type="text" id="in"></div>
<div><input type="text" id="out"></div>

コンパイルした後、HTTPサーバーを実行して画面を確認します。こちらも問題なくDOM操作が出来ました。
f:id:tanabebe:20190901115717g:plain

躓いた点

ビルドが出来ない問題

main.goにimportしているsyscall/jsを認識してくれずビルドが不可な状態になりました。調査したところ以下で同じエラーが再現しており、私も同じようにGoLand上で設定を変更したところ動作するようになりました。
Go × WebAssemblyで電卓のWebアプリを作ってみた - Sansan Builders Box

GoLandのBuild設定 f:id:tanabebe:20190901123021p:plain

js.NewCallbackには変更がある

js.NewCallbackという記述をちらほら見ますが、こちらは現在FuncOf()に変更されているので注意が必要です。

まとめ

WebAssemblyを使用して、GoからJavaScriptと同じ振る舞いが出来る事が確認できました。WebAssemblyはスピードにフォーカスを当てているので今回のようなケースでは実感しづらいですが、実際のシチュエーションとしてはゲーム、大規模なWebアプリ制作などが考えられます。フロントエンド側の技術が苦手な私でもDOM操作がGoから行えるのは魅力的です。まだ比較的新しい技術ではありますが、今後のWebプログラマーとして関わる方には動向が気になる技術ではないでしょうか。 WebAssemblyでデモ的に作成されたゲームもあるので気になる方はこちらもチェックしてはいかがでしょう。

参考

js - The Go Programming Language WebAssembly: 「なぜ」と「どうやって」 [翻訳記事] - DEV Community 👩‍💻👨‍💻 GoでWebAssemblyに触れよう ドキュメントオブジェクトモデル (DOM) - Web API | MDN