🐭

Wasm(Go) + TypeScript + React アプリ入門

2024/09/25に公開

GoでCLIツールを作成した際、ブラウザでも利用できるようにしたくなったため、ロジック部分をWasmとして再利用する方法を検討しました。GoをWasmにビルドして、ブラウザで使ったこと自体はあったのですが、TypeScriptやReactと組み合わせて使ったのは初めてだったので、その結果を記事に残しておきます。

https://github.com/xuri/excelize-wasm

を参考にしました。

成果物

https://github.com/hamao0820/wasm-react

https://wasm-react.vercel.app/

Wasm(Go)

まずWasmにビルドする用のGoファイルを作成します。

main.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.jspublicディレクトリに追加します。また、index.htmlwasm_exec.jsを読み込む処理を書きます。

index.html
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ファイルの読み込みと、型定義を行います。
Hellointerfaceを関数をまとめたオブジェクトの型として定義し、initでそれを返すことにしました。
linterはwasmファイルで定義された関数の存在を知らないため、declare宣言で関数が存在するものとして扱えるようにします。ここで、適宜関数の型を追加するようにしてください。
後の読み込み処理はwasm_exec.htmlと大体同じです。Goクラスはwasm_exec.jsの中で定義されたものなので、そのままでは型情報がないとlinterに怒られるため、型情報を追加しておきます。

https://www.npmjs.com/package/@types/golang-wasm-exec

bun add --save @types/golang-wasm-exec
hello-wasm.ts
src/hello-wasm/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
src/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を再レンダリングすることです。こういうときは、useSyncExternalStorehookを使うと良い(らしい)です。useSyncExternalStoreの詳しい説明は公式ドキュメントに任せます。

まず、subscribe可能なStoreを定義します

store
src/store/store.ts
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を使ってuseHellohookを定義します。

useHello
src/hooks/useHello/useHello.ts
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
src/components/Hello.tsx
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;
src/components/Nums.tsx
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;
src/components/Add.tsx
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;
src/components/AddAndSub.tsx
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;
src/components/Div.tsx
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
src/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に限った話ではないかもしれませんが、外部から読み込む関係上、Promiseundefinedにせざるを得ない場面があるため、扱いが少し面倒臭いのが難点に思いました。SWRなどを上手く使うことでこれらの問題は解決できるかもしれません。
余談ですが、Go側でpanicを起こすとwasmファイルの実行が終了してしまうというのもあり、Goの関数らしくerrorを返して呼び出し元で処理する方針を取りました。普段Goを書いているからかもしれませんが、やはりこの仕様はいいなと思いました。TS側でTSっぽくかきたいなら、errornullでないときはerrorを投げて値だけ返すようなwrap用の関数を用意してもいいかもしれませんね。

来栖川電算

Discussion