放送で紹介する用。書きかけです。
ちょっと伸びたのでさらに追記。これは元々自分の勉強がてら書いていたもので、これを書く過程でどうしても自分の理解では説明できないところがあり koba789 に依頼してペアプロをしてもらった、という流れがあります。その結果が次の動画です。
生放送の流れを円滑にするために資料を公開しましたが、この記事は未完成で、あとでさらに整理して別途記事にまとめるつもりです
これは、 TypeScript でフロントエンドをやってるような人が Rust + wasm-pack でフロントエンドを書けるようになるためのチュートリアルです。
Rust で書きつつ、 TypeScript で相当するコードをコメントで残しておきます。
(自分も勉強しながら書いてるのでつっこみどころはあると思います)
TBD
Deno Swc Rome Prisma といったライブラリが Rust で書かれています。特にライブラリ作者のような経験値が高い層から熱烈に支持されていて、今後も Rust がカバーするフロントエンドの領域が増えていくと予想されます。
最初から wasm-pack + wasm-bindgen をやると、 Rust の基本的な構文でつまずきます。なので可能なら Rust の文法をちゃんと抑えながら学ぶのをおすすめします。自分はこの事実と向き合うまでに、時間をだいぶ無駄にしました。
この資料では、Rust と wasm_bindgen の頻出パターンを速習できるようにしてるつもりですが、実際やりたいことをやろうとすると無限にエッジケースを踏むと思います。その時は他の資料を頼ってください。
(というか自分が正しく教えられるほどの経験値が溜まってないです)
- 環境構築
- Rust 速習
- wasm_bindgen と wasm-pack
- js-sys と web-sys
- dioxus: 宣言的UI アプリケーション実装
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
$ rustup update stable
これで rustc と cargo が使えるようになります。フロントエンド的には、 cargo は npm 兼 webpack みたいなものです。rustc は cargo が隠蔽してくれてるので、あまり使いません。
$ rustc --version
rustc 1.60.0 (7737e0b5c 2022-04-04)
$ cargo --version
cargo 1.60.0 (d1fd9fe2c 2022-03-01)
Wasm をやるならコンパイラターゲットとして、wasm32 と wasm32-wasi を入れておきましょう。
$ rustup target add wasm32-unknown-unknown
$ rustup target add wasm32-wasi
ついでに cargo-edit を入れておくのをおすすめします。yarn add foo
相当の cargo add
コマンドが入ります。
$ cargo install cargo-edit
vscode ユーザーは rust-analyzer を入れておくといいです。ほぼ必須です。
https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer
最初に素の(wasm ではない) Rust プロジェクトを作成します。
$ cargo new --bin hello
$ cd rust_study
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/hello`
Hello, world!
このときの src/main.rs はこうなっています。
fn main() {
// console.log("Hello, world");
println!("Hello, world!");
}
とりあえず cargo run
は src/main.rs
の main 関数を実行する、と理解してください。
Cargo.toml が package.json に相当します。
[package]
name = "hello"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
package.json でいうコレ相当です。
{
"name": "hello",
"version": "0.1.0",
"dependencies": {}
}
edition は rust のモジュールシステムの解決処理で使われる属性で、無理やり当てはめるなら "type": "module"
みたいなのに相当しますが、今は忘れて結構です。
cargo が npm client だとしたら crates.io が npmjs.com に対応します。
crates.io: Rust Package Registry
# npm install --save regex | regex の実体は全然別だけど説明用にあると仮定
$ cargo add regex
Cargo.toml の dependencies に追加されます。
[dependencies]
regex = "1.5.5"
// import { Regex } from "regex";
use regex::Regex;
fn main() {
// const re = /^\d{4}-\d{2}-\d{2}$/;
let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
// console.assert(re.test("2014-01-01"))
assert!(re.is_match("2014-01-01"));
}
先に学習のための便利機能を紹介しておきます。
Cargo は examples/00_hello.rs
があるとき、 cargo run --example 00_hello
すると、00_hello.rs
の main 関数を実行します。
このリポジトリでは、最初の println! する exapmles/00_hello.rs
と regex を使うだけの例を examples/01_regex.rs
を置いています。
$ cargo run --example 00_hello
$ cargo run --example 01_regex
書きながら他の example も足していきます。
学習のために、小さなコード片をユニットテストで書いて検証できるようにしておくといいでしょう。
fn main() {
// console.log("${add(1, 2)}")
println!("{}", add(1, 2));
}
/*
function add(a: number, b: number): number {
return a + b;
}
*/
fn add(a: i32, b: i32) -> i32 {
a + b // 最後の行のセミコロン省略で return a + b; 相当
}
/*
vitest のインラインテスト https://vitest.dev/guide/in-source.html#setup
if (import.meta.vitest) {
test("test_add", () => {
expect(add(1, 2)).toBe(3);
});
}
*/
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
cargo test
で実行できます。
# もし src/main.rs に↑のコードを書いてる場合
$ cargo test
Compiling hello v0.1.0 (/Users/kotaro.chikuba/mizchi/rust-study/hello)
Finished test [unoptimized + debuginfo] target(s) in 0.23s
Running unittests (target/debug/deps/hello-73d7d25eed0cb92d)
running 1 test
test test_add ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
# リポジトリをクローンしてる場合
$ cargo test --example 02_test
まず、 Rust 特有の難しさを気にしないレベルで、一旦 TS に対応する概念を当てはめながらコードを理解してみましょう。
pub fn main() {
// 基本型
let _u32: u32 = 1;
let _u64: u64 = 1;
let _i32: i32 = -1;
let _i64: i64 = -1;
let _f32: f32 = 1.1;
let _i64: f64 = 1.1111;
let _bool: bool = true;
// 文字列リテラルは &str 型
let _str: &str = "hello";
// 変形できる String 型
let _string: String = "hello".to_string();
// ライフタイムを管理する Box 型
let _box: Box<i32> = Box::new(1);
// 再代入可能な let mut
let mut _mx = 1;
_mx = 3;
// 代入
// const a = 1;
let a: i32 = 1;
// if 式。if 文ではないのに注意。JS に当てはめるなら三項演算子
// let b = a > 0 ? 2 : 3;
let b = if a > 0 {
2
} else {
3
};
// パターンマッチ
// if や 関数ブロックと同じく、最後に評価したセミコロンレスな式を返す
/*
switch (b) {
case 2:
console.log("2");
break;
case 3:
console.log("3");
break;
default:
console.log("default");
break;
}
*/
match b {
2 => println!("2"),
3 => println!("3"),
_ => println!("other"),
};
// 変数の宣言。let だけなら書き換えられないので let mut になる (Rust で const は別の意味になる)
// let c = 0;
let mut c = 0;
// while ループ
/*
while (c < 10) {
console.log(c);
c++;
}
*/
while c < 10 {
println!("{}", c);
c += 1;
};
// 配列
// const list: number[] = [1, 2, 3] as const;
let list = vec![1, 2, 3];
// 配列のイテレーション
/*
for (const i of list) {
console.log(list[i]);
}
*/
for i in list.iter() {
println!("{}", i);
};
// 構造体の宣言
// TS の type と同じ意味ではないが、 object の宣言と合わせると同じような意味になる。
/*
type Struct = {
a: number;
b: number;
};
*/
struct Point {
x: i32,
y: i32,
}
// 構造体のインスタンスを作成
// const p: Point = { x: 1, y: 2 };
let p = Point { x: 1, y: 2 };
// console.log(`${p.x}:${p.y}`);
println!("{}:{}", p.x, p.y);
// impl による構造体のメソッド定義
/*
impl で Point に対して関数を実装する。
厳密に透過なわけではないが、先の Point が IPoint だったとして、
それを実装した class がある、と解釈してもよい。 Rust に class はない。
class Point implements IPoint {
constructor(public x: number, public y: number) {}
distance(other: Point) {
return Math.sqrt(
(this.x * other.y) ** 2 +
(this.y * other.y) ** 2
);
}
}
*/
impl Point {
fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
// 他の Point 型とのユークリッド距離を返す
// &self は .distance を呼び出すときの this 相当
fn distance(&self, other: &Self) -> f64 {
((
(self.x - other.x).pow(2) +
(self.y - other.y).pow(2)
) as f64).sqrt()
}
}
// const p1 = new Point(1, 2);
let p1 = Point::new(1, 2);
// const p2 = new Point(3, 4);
let p2 = Point::new(3, 4);
// console.log(`distance p1-p2: ${p1.distance(p2)}`);
// 詳細は後述するが & は参照型で、構造体は参照で渡すように実装することが多い
println!("distance p1-p2: {}", p1.distance(&p2));
// 関数宣言
/*
function incr(i: number): number {
return i + 1;
}
*/
fn incr(i: i32) -> i32 {
i + 1
}
// 関数呼び出し
// console.assert(incr(1) === 2);
assert!(incr(1) == 2);
// 匿名関数
// const handler = (i: number): number => 1 + 1;
let handler = |i: i32| -> i32 {
i + 1
};
let _ = handler(1);
// 文字列
// 基本的に不変な str 型と 可変な String 型がある
// const s: string = "my-string";
let my_str = "my-string";
// let my_string = my_str.slice();
let mut my_string = my_str.to_string();
// my_string += " - appended";
my_string += " - appended";
// console.log(my_string);
println!("{}", my_string); // my-string - appended
// HashMap 型
// const map: Map<string, number> = new Map();
let mut map: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
// map.set("foo", 1);
map.insert("foo", 1);
// map.set("bar", 2);
map.insert("bar", 2);
// [...map.entries()].forEach(([key, value]) => console.log(`${key}:${value}`));
map.into_iter().for_each(|(k, v)| println!("{}:{}", k, v));
// HashSet
// const set = new Set();
let mut set: std::collections::HashSet<i32> = std::collections::HashSet::new();
// set.add(1);
set.insert(1);
// set.add(2);
set.insert(2);
// [...set].forEach(i => console.log(i));
set.into_iter().for_each(|v| println!("{}", v));
// Optional
/*
const s1: string | number = 5;
if (s1) {
console.log(s1);
} else {
throw new Error();
}
*/
let s1: Option<i32> = Some(5);
match s1 {
Some(s) => println!("{}", s),
None => unreachable!(),
}
// const s1_unwrap = 1;
let s1_unwrap = s1.unwrap();
println!("{}", s1_unwrap.to_string());
// None
/*
const s2 = null;
if (s2 != null) {
throw new Error();
} else {
console.log("None");
}
*/
let s2: Option<i32> = None;
match s2 {
Some(_) => unreachable!(),
None => println!("None"),
}
// const tmp = 1;
// const m = tmp ?? tmp * 2;
// console.log(m);
let m = Some(1).map(|x| x * 2);
println!("{:?}", m); // Some(2)
// unwrap 構文
fn _trying() -> Result<i32, ()> {
let x: Result<i32, ()> = Ok(1 as i32);
// Result 型を返す関数の中だけで使える x? オペレータ
// unwrap に失敗すると Err(()) が return される
let y: i32 = x?;
Ok(y)
}
}
_
がちょくちょく付いてるのは未使用変数の警告を回避するためです。
TBD
TBD
Rust のコードを読むためには crate がどのように名前解決するかを知る必要があります。
src/lib.rs
が crate(パッケージ単位)を外に公開する際のインターフェースになります。
Cargo.toml の name = "hello"
のとき、次の関数を pub で宣言します。
pub fn hello() {
println!("Hello");
}
src/main.rs
はパッケージ名を通して src/lib.rs
を参照します。
use hello::hello;
fn main() {
hello();
}
実行
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/hello`
Hello
さらに他のファイルに分割したい時は、 lib.rs からそのファイル名で pub mod <name>;
します。
pub fn foo() -> i32 {
internal()
}
// ここは参照できない
fn internal() -> i32 {
1
}
pub mod foo;
pub fn hello() {
println!("Hello");
}
use hello::hello;
use hello::foo::foo;
fn main() {
hello();
println!("{}", foo());
}
また、今回は使いませんが、 src/main.rs
と examples/*.rs
以外の src 以下のファイルは、 use crate::<name>
でパッケージ名を crate で参照できます。
ディレクトリを分割する際は <dirname>/mod.rs
がエントリポイントになります。
src/
sub/
mod.rs
lib.rs
mani.rs
pub fn sub() {
println!("sub");
}
pub mod sub;
pub mod foo;
pub fn hello() {
println!("Hello");
}
use hello::hello;
use hello::foo::foo;
use hello::sub::sub;
fn main() {
hello();
println!("{}", foo());
sub();
}
https://doc.rust-jp.rs/book-ja/ch14-03-cargo-workspaces.html
wasm-pack は rust project を wasm にコンパイルするツールです。
$ rustup target add wasm32-unknown-unknown
$ rustup target add wasm32-wasi
次に wasm-pack をインストールします。
$ curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
$ wasm-pack new hello-wasm
$ cd hello-wasm
必ずしも wasm-pack new
で wasm-pack 用のプロジェクトを作る必要はなく、 wasm-pack でビルドするための要件は Cargo.toml に次の記述があることです。他は色々とおまけです。
[lib]
crate-type = ["cdylib"]
とりあえずこの状態でビルドします。
$ wasm-pack build --release --target web --out-name mod
$ tree pkg
pkg
├── README.md
├── mod.d.ts
├── mod.js
├── mod_bg.wasm
├── mod_bg.wasm.d.ts
└── package.json
pkg 以下にビルドします。TypeScript 用の .d.ts
や package.json
を出力している点に注目してください。この出力自体が独立したパッケージになっています。
web ビルドは web の wasm 仕様をサポートしている deno で実行することができます。
~/m/r/hello-wasm ||HEAD⚡?
$ deno
Deno 1.20.3
exit using ctrl+d or close()
> const mod = await import('./pkg/mod.js');
undefined
> await mod.default();
{ memory: WebAssembly.Memory {}, greet: [Function: 1] }
> mod.greet();
Hello, hello-wasm! [Enter]
undefined
>
後述する関数は greet() 関数は alert 関数をラップしていて、これは deno の alert の実装を呼んでいることになります。
それでは、コードを読んでいきましょう。
mod utils;
use wasm_bindgen::prelude::*;
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
extern {
fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet() {
alert("Hello, hello-wasm!");
}
まず知るべきは wasm_bindgen です。
https://github.com/rustwasm/wasm-bindgen
#[wasm_bindgen]
pub fn fn_name() {...}
によって、関数を外に向けて公開します。 pkg に出力された TypeScript 側のインターフェースを見てみましょう。
/* tslint:disable */
/* eslint-disable */
/**
*/
export function greet(): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly greet: () => void;
}
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {InitInput | Promise<InitInput>} module_or_path
*
* @returns {Promise<InitOutput>}
*/
export default function init (module_or_path?: InitInput | Promise<InitInput>): Promise<InitOutput>;
await init()
で初期化したあとに、 greet が呼べます。
#[wasm_bindgen]
pub fn greet() {
alert("Hello, hello-wasm!");
}
これは外(JS)のグローバル変数がどのようなインターフェースで存在しているかを Rust に教えます。ただ、ここは後述する js-sys | web-sys を使うと、あまり使うことはないと思います。
wee_allock はメモリアロケータを wasm 用の軽量なものに置き換えることで、ビルドサイズを10kb ぐらい減らすことができます。
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
wasm 内部でランタイムエラーが発生した時、 console_error_panic_hook::set_once();
すると、Rust 内部で発生したエラーを console.error で出力します。
pub fn set_panic_hook() {
// When the `console_error_panic_hook` feature is enabled, we can call the
// `set_panic_hook` function at least once during initialization, and then
// we will get better error messages if our code ever panics.
//
// For more details see
// https://github.com/rustwasm/console_error_panic_hook#readme
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}
これもビルド時に 30k ぐらいサイズが増えます。
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
これをビルドして実行します。
$ wasm-pack build --release --target web --out-name mod
$ deno eval "(await import('./pkg/mod.js')).default().then((m) => console.log(m.add(1,2)))"
3
TS型定義もちゃんと噛み合ってます。
//...
export function add(a: number, b: number): number;
このときのビルドサイズを見てみましょう。
$ la pkg/*.wasm
168B pkg/mod_bg.wasm
wee_alloc 抜きでビルドします。
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[wasm_bindgen]
pub fn get_text() -> String {
"Hello".into()
}
deno 用のスクリプトを置いて実行
import init, { get_text } from "./pkg/mod.js";
await init();
console.log(get_text());
$ deno run --allow-read run.ts
Hello
wasm-bindgen の規約で String を返すと JS 側でも string になります。
js-sys は wasm-bindgen 上で JS のプリミティブオブジェクトをラップしたもので、 web-sys は ブラウザ API をラップしたものです。
$ cargo add web-sys --features console
web-sys は様々なAPIのバインディングがありますが、今回は console だけを使います。
// ...
#[wasm_bindgen]
pub fn greet() {
web_sys::console::log_1(&"hello".into());
}
.into()
は引数として期待されている型に変換する実装が呼び出し側にあれば、その型に変換する処理です。
これを into を使わないで書くとこうなります。
web_sys::console::log_1(&JsValue::from_str("hello"));
これを deno から呼ぶコード
import init, { get_text, greet } from "./pkg/mod.js";
await init();
greet();
(ビルドと実行は略)
extern を使わないと言ったのは、web_sys がある程度ラップしてくれるからです。
TBD
ここまでライブラリとしての wasm をビルドする方法で、ターミナル上でやってきました。
そろそろブラウザ上で動かしたいですよね。色々やり方はあるんですが、trunk を使うと楽です。
thedodd/trunk: Build, bundle & ship your Rust WASM application to the web.
cargo install --locked trunk
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" name="viewport" content="width=device-width, initial-scale=1.0" charset="UTF-8">
</head>
<body>
<div id="main"> </div>
</body>
</html>
trunk は src/main.rs をエントリポイントにするので、これを置きましょう。
fn main() {
hello_wasm::greet();
}
この時JSに向けてAPIを公開しているわけではないので greet 関数などの wasm_bindgen は外して大丈夫です。
$ trunk serve
http://localhost:8080 で起動するので、これを開きます。
console に web_sys::console::log_1
で実装した hello のメッセージが流れてるはずです。