Wasm(Go) + TypeScript + React アプリ入門
GoでCLIツールを作成した際、ブラウザでも利用できるようにしたくなったため、ロジック部分をWasmとして再利用する方法を検討しました。GoをWasmにビルドして、ブラウザで使ったこと自体はあったのですが、TypeScriptやReactと組み合わせて使ったのは初めてだったので、その結果を記事に残しておきます。
を参考にしました。
成果物
Wasm(Go)
まずWasmにビルドする用のGoファイルを作成します。
main.go
//go:build js && wasm
package main
import (
"errors"
"syscall/js"
)
func main() {
c := make(chan struct{}, 0)
js.Global().Set("hello", js.FuncOf(hello))
js.Global().Set("nums", js.FuncOf(nums))
js.Global().Set("add", js.FuncOf(add))
js.Global().Set("addAndSub", js.FuncOf(addAndSub))
js.Global().Set("div", js.FuncOf(div))
<-c
}
// func hello() string {
// return "Hello, WebAssembly!"
// }
func hello(this js.Value, args []js.Value) interface{} {
return js.ValueOf("Hello, WebAssembly!")
}
// func nums() []int {
// return []int{1, 2, 3, 4, 5}
// }
func nums(this js.Value, args []js.Value) interface{} {
return js.ValueOf([]interface{}{1, 2, 3, 4, 5})
}
// func add(v1, v2 int) int {
// return v1 + v2
// }
func add(this js.Value, args []js.Value) interface{} {
v1 := args[0].Int()
v2 := args[1].Int()
return js.ValueOf(v1 + v2)
}
// func addAndSub(v1, v2 int) (int, int) {
// return v1 + v2, v1 - v2
// }
func addAndSub(this js.Value, args []js.Value) interface{} {
v1 := args[0].Int()
v2 := args[1].Int()
return js.ValueOf(map[string]interface{}{
"sum": v1 + v2,
"diff": v1 - v2,
})
}
// func div(v1, v2 int) (int, error) {
// if v2 == 0 {
// return 0, errors.New("Divide by zero")
// }
// return v1 / v2, nil
// }
func div(this js.Value, args []js.Value) interface{} {
v1 := args[0].Int()
v2 := args[1].Int()
if v2 == 0 {
return js.ValueOf(map[string]interface{}{
"quot": 0,
"error": errors.New("Divide by zero").Error(),
})
}
return js.ValueOf(map[string]interface{}{
"quot": v1 / v2,
"error": nil,
})
}
多値返しの関数やerror
型を返す関数は、TypeScriptに合わせてオブジェクト型で返すようにしました。
js.Global().Set()
関数でJavaScriptのグローバル関数として設定します。
これをWasmへビルドしておきます。名前は何でもいいのですが、今回はhello.wasmとしておきます。
GOOS=js GOARCH=wasm go build -o hello.wasm main.go
これでwasmのバイナリファイルができたので、Reactのプロジェクトを作成していきます。
React
上で作成したwasmファイルをReactで利用する手順を書いていきます。
各ステップの最後にそのステップでのtree情報を載せておきます。設定ファイルなどは省略しています。
初期化
今回はBun + Viteを使ってReactアプリを初期化します。おそらく今後の手順はほとんど変わらないと思うので各自好きな方法をお使いください。
bun create vite@latest front --template react-ts
cd front
bun install
ついでに不要なSVGやCSSを削除しておきます。
tree
.
├── index.html
├── package.json
└── src
├── App.tsx
└── main.tsx
Wasmの読み込み
wasmファイルを読み込んで、上で定義した関数を型安全に扱えるようにします。
まず、wasmファイルを読み込んで実行するために、wasmファイルとwasm_exec.jsをpublic
ディレクトリに追加します。また、index.html
にwasm_exec.js
を読み込む処理を書きます。
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
+ <script src="/wasm_exec.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
次に、hello-wasm.ts
でwasmファイルの読み込みと、型定義を行います。
Hello
interfaceを関数をまとめたオブジェクトの型として定義し、init
でそれを返すことにしました。
linterはwasmファイルで定義された関数の存在を知らないため、declare
宣言で関数が存在するものとして扱えるようにします。ここで、適宜関数の型を追加するようにしてください。
後の読み込み処理はwasm_exec.htmlと大体同じです。Go
クラスはwasm_exec.js
の中で定義されたものなので、そのままでは型情報がないとlinterに怒られるため、型情報を追加しておきます。
bun add --save @types/golang-wasm-exec
hello-wasm.ts
export interface Hello {
Hello: () => string;
Nums: () => number[];
Add: (a: number, b: number) => number;
AddAndSub: (a: number, b: number) => { sum: number; diff: number };
Div: (a: number, b: number) => { quot: number; error: string };
}
declare function hello(): string;
declare function nums(): number[];
declare function add(a: number, b: number): number;
declare function addAndSub(a: number, b: number): { sum: number; diff: number };
declare function div(a: number, b: number): { quot: number; error: string };
export const init = async (path: string): Promise<Hello> => {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch(path),
go.importObject
);
const instance = result.instance;
go.run(instance);
return {
Hello: hello,
Nums: nums,
Add: add,
AddAndSub: addAndSub,
Div: div,
};
};
これでwasmファイルを使う準備はほとんどできました。実際に動くか確かめてみましょう。
App.tsx
で動かしてみます。
App.tsx
import { init } from "./lib/hello-wasm/hello-wasm";
function App() {
const hello = init("/hello.wasm");
hello.then((h) => {
console.log(h.Hello());
console.log(h.Nums());
console.log(h.Add(1, 2));
console.log(h.AddAndSub(1, 2));
console.log(h.Div(4, 2));
console.log(h.Div(1, 0));
});
return (
<>
<h1>WASM(Go) + React</h1>
</>
);
}
export default App;
これを実行して少し待つと、開発者モードのconsoleに次のように出力されました。
Hello, WebAssembly!
(5) [1, 2, 3, 4, 5]
3
{sum: 3, diff: -1}
{error: null, quot: 2}
{quot: 0, error: 'Divide by zero'}
ちゃんと動いていることが確認できましたね🎉
tree
.
├── index.html
├── package.json
├── public
│ ├── hello.wasm
│ └── wasm_exec.js
└── src
├── App.tsx
├── lib
│ └── hello-wasm
│ └── hello-wasm.ts
└── main.tsx
hooks化
先ほどのステップで動くことが確認できたのですが、複数の場所で関数を使いたい場合に、毎回init
でwasmを読み込んでしまいます。また、Promise
型で返されるため、扱いが難しいです。これらの問題の解決も兼ねて、使いやすいようにhooksとして切り出しておきます。
したいことは、一度だけwasmファイルを読み込み、読み込みが完了したタイミングで、hooksの呼び出し元の(複数の)componentを再レンダリングすることです。こういうときは、useSyncExternalStore
hookを使うと良い(らしい)です。useSyncExternalStore
の詳しい説明は公式ドキュメントに任せます。
まず、subscribe可能なStore
を定義します
store
export type Store<Value> = {
publish(value: Value): void;
subscribe(onStoreChange: () => void): () => void;
getSnapshot(): Value;
};
// useSyncExternalStore で使うためのストアを作成します。
// useSyncExternalStore(store.subscribe, store.getSnapshot)
const createStore = <Value>(initialValue: Value): Store<Value> => {
let currentValue: Value = initialValue;
let listeners: (() => void)[] = [];
return {
publish(value: Value): void {
currentValue = value;
for (const listener of listeners) {
listener();
}
},
subscribe(onStoreChange: () => void): () => void {
listeners = [...listeners, onStoreChange];
return () => {
listeners = listeners.filter((listener) => listener !== onStoreChange);
};
},
getSnapshot(): Value {
return currentValue;
},
};
};
export default createStore;
このStore
とともにuseSyncExternalStor
を使ってuseHello
hookを定義します。
useHello
import { useSyncExternalStore } from "react";
import { init, Hello } from "../../lib/hello-wasm/hello-wasm";
import createStore from "../../lib/store/store";
const helloStore = createStore<Hello | undefined>(undefined);
let loading = false;
const useHello = (): Hello | undefined => {
if (!helloStore.getSnapshot() && !loading) {
loading = true;
init("/hello.wasm")
.then((hello) => {
helloStore.publish(hello);
})
.catch((error) => {
console.error(error);
})
.finally(() => {
loading = false;
});
}
return useSyncExternalStore(helloStore.subscribe, helloStore.getSnapshot);
};
export default useHello;
これらをそれぞれのcomponentで使ってみます。
components
import useHello from "../hooks/useHello/useHello";
const Hello: React.FC = () => {
const hello = useHello();
const onClick = () => {
if (hello) {
alert(hello.Hello());
}
};
return hello ? (
<button type="button" onClick={onClick}>
Hello
</button>
) : (
<div>loading...</div>
);
};
export default Hello;
import useHello from "../hooks/useHello/useHello";
const Nums: React.FC = () => {
const hello = useHello();
const onClick = () => {
if (hello) {
alert(hello.Nums());
}
};
return hello ? (
<button type="button" onClick={onClick}>
Nums
</button>
) : (
<div>loading...</div>
);
};
export default Nums;
import { useState } from "react";
import useHello from "../hooks/useHello/useHello";
const Add: React.FC = () => {
const hello = useHello();
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const onClick = () => {
if (hello) {
alert(hello.Add(a, b));
}
};
return hello ? (
<div>
<label htmlFor="a">a:</label>
<input
type="number"
id="a"
value={a}
onChange={(e) => setA(Number(e.target.value))}
/>
<label htmlFor="b">b:</label>
<input
type="number"
id="b"
value={b}
onChange={(e) => setB(Number(e.target.value))}
/>
<button type="button" onClick={onClick}>
Add
</button>
</div>
) : (
<div>loading...</div>
);
};
export default Add;
import { useState } from "react";
import useHello from "../hooks/useHello/useHello";
const AddAndSub: React.FC = () => {
const hello = useHello();
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const onClick = () => {
if (hello) {
const { sum, diff } = hello.AddAndSub(a, b);
alert(`Sum: ${sum}, Diff: ${diff}`);
}
};
return hello ? (
<div>
<label htmlFor="a">a:</label>
<input
type="number"
id="a"
value={a}
onChange={(e) => setA(Number(e.target.value))}
/>
<label htmlFor="b">b:</label>
<input
type="number"
id="b"
value={b}
onChange={(e) => setB(Number(e.target.value))}
/>
<button type="button" onClick={onClick}>
AddAndSub
</button>
</div>
) : (
<div>loading...</div>
);
};
export default AddAndSub;
import { useState } from "react";
import useHello from "../hooks/useHello/useHello";
const Div: React.FC = () => {
const hello = useHello();
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const onClick = () => {
if (hello) {
const { quot, error } = hello.Div(a, b);
if (error) {
alert(error);
} else {
alert(quot);
}
}
};
return hello ? (
<div>
<label htmlFor="a">a:</label>
<input
type="number"
id="a"
value={a}
onChange={(e) => setA(Number(e.target.value))}
/>
<label htmlFor="b">b:</label>
<input
type="number"
id="b"
value={b}
onChange={(e) => setB(Number(e.target.value))}
/>
<button type="button" onClick={onClick}>
Div
</button>
</div>
) : (
<div>loading...</div>
);
};
export default Div;
App.tsx
import Add from "./components/Add";
import AddAndSub from "./components/AddAndSub";
import Div from "./components/Div";
import Hello from "./components/Hello";
import Nums from "./components/Nums";
function App() {
return (
<>
<h1>WASM(Go) + React</h1>
<Hello />
<Nums />
<Add />
<AddAndSub />
<Div />
</>
);
}
export default App;
実行してみると、同じタイミングで読み込みが完了して、ちゃんと動いていることも確認できます。
tree
.
├── index.html
├── package.json
├── public
│ ├── hello.wasm
│ └── wasm_exec.js
└── src
├── App.tsx
├── components
│ ├── Add.tsx
│ ├── AddAndSub.tsx
│ ├── Div.tsx
│ ├── Hello.tsx
│ └── Nums.tsx
├── hooks
│ └── useHello
│ └── useHello.ts
├── lib
│ ├── hello-wasm
│ │ └── hello-wasm.ts
│ └── store
│ └── store.ts
├── main.tsx
└── vite-env.d.ts
まとめ
意外と簡単にReactでWasmを利用することができて感動しました。やはりTypeScriptで型安全に扱えるのは嬉しいです。これはWasmに限った話ではないかもしれませんが、外部から読み込む関係上、Promise
やundefined
にせざるを得ない場面があるため、扱いが少し面倒臭いのが難点に思いました。SWR
などを上手く使うことでこれらの問題は解決できるかもしれません。
余談ですが、Go側でpanicを起こすとwasmファイルの実行が終了してしまうというのもあり、Goの関数らしくerrorを返して呼び出し元で処理する方針を取りました。普段Goを書いているからかもしれませんが、やはりこの仕様はいいなと思いました。TS側でTSっぽくかきたいなら、error
がnull
でないときはerror
を投げて値だけ返すようなwrap用の関数を用意してもいいかもしれませんね。
名古屋のAI企業「来栖川電算」の公式publicationです。AI・ML を用いた認識技術・制御技術の研究開発を主軸に事業を展開しています。 公式HP→ kurusugawa.jp/ , 採用情報→ kurusugawa.jp/jobs/
Discussion