wasm-bindgen basics
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ใน The Notes UI เราเก็บ note เป็น field ธรรมดา — พอใช้ได้จนกว่าสองเครื่องจะแก้ note เดียวกันตอน offline ทางแก้คือ CRDT และเราจะเขียนครั้งเดียวใน Rust แล้วรันในทุก browser เป็น WebAssembly ก่อนถึงตัว CRDT เอง เราต้องมี toolchain: Rust crate ที่ compile เป็น WASM และไฟล์ TypeScript ที่เรียกเข้าไปได้
บทนี้ export สองอย่างจาก crate crdt — free function หนึ่งตัวและ struct ที่มี constructor — build ด้วย wasm-pack build --target web แล้วเรียกทั้งคู่จาก web app ยังไม่มีอะไรเจาะจง CRDT; จุดหมายคือทำให้ path Rust→WASM→TypeScript ทำงานครบวงจร บทถัดไปจะได้โฟกัสที่ merge logic แทนที่จะเป็นงานเดินท่อ
merge logic ต้อง เหมือนกันเป๊ะ บนทุก client ถ้าแต่ละเครื่อง reimplement CRDT ใน TypeScript ความต่างเล็ก ๆ จะทำลาย convergence — การรับประกันทั้งหมด การเขียนครั้งเดียวใน Rust แล้ว compile เป็น WASM ให้ core ที่ portable, เร็ว และ test ดีตัวเดียว
wasm-bindgen คือสิ่งที่ทำให้ core นั้น เรียกได้ Rust กับ JavaScript ไม่แชร์ type system หรือ heap กัน; wasm-bindgen สร้าง glue ที่ให้ method ของ JS class เอื้อมไปถึง method ของ Rust struct โดย marshal argument และ return value ระหว่างทาง wasm-pack ขับ build ทั้งหมด — โดยรัน Rust compiler ด้วย target ที่ถูกต้อง, รัน wasm-bindgen ทับ output แล้วปล่อย directory pkg/ ที่มี binary .wasm พร้อม JS loader และ TypeScript type definition
เราใช้ --target web โดยเฉพาะ: เพราะปล่อย ES module ตัวจริงที่คุณ import ด้วยฟังก์ชัน init() ไม่ต้องมี bundler plugin ไม่ต้องมี Node shim ตรงกับแอป Astro/Vite ที่พูดภาษา ES module อยู่แล้ว
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”wasm-pack build --target web vs --target bundler
- Pros: ปล่อย ES module ธรรมดาพร้อม
init()ชัดเจน; คุณimportเข้าแอป Vite/Astro หรือแม้แต่<script type="module">ดิบ ๆ ได้โดยไม่ต้อง config bundler เพิ่ม การ initialize ชัดเจน ดังนั้น เมื่อไหร่ ที่ WASM โหลดอยู่ในการควบคุมของคุณ - Cons: คุณต้องจำไว้ว่าต้อง
await init()ก่อน call แรก และคุณ fetch ไฟล์.wasmเองตอน runtime--target bundlerซ่อนinit()ไว้หลัง WASM support ของ bundler ซึ่งลื่นกว่า — แต่เฉพาะภายใน bundler ที่รองรับ และควบคุมจังหวะโหลดได้น้อยกว่า
Rust→WASM engine vs a hand-written TypeScript CRDT
- Pros: implementation เดียว, test ชุดเดียว, พฤติกรรมเหมือนกันทุกที่; enum และ exhaustive
matchของ Rust ทำให้โค้ดจัดการ op ผิดแบบแนบเนียนได้ยาก - Cons: toolchain ตัวที่สอง (Rust + wasm-pack) ใน repo, build step ก่อน web app รันได้ และ data-marshalling boundary ที่ต้องคิดถึง (บทถัดไป) สำหรับแอป ง่าย ๆ นี่เกินจำเป็น — เรายอมรับต้นทุนนี้เพราะ CRDT คือส่วนที่ยากและ critical ต่อความถูกต้อง
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. crates/crdt/Cargo.toml
หัวข้อที่มีชื่อว่า “1. crates/crdt/Cargo.toml”crate compile เป็น dynamic library แบบ C (cdylib) เพื่อให้กลายเป็นไฟล์ .wasm ได้ และเป็น rlib ด้วย เพื่อให้ Rust unit test link แบบปกติได้
[package]name = "crdt"version = "0.1.0"edition = "2021"
[lib]crate-type = ["cdylib", "rlib"]
[dependencies]wasm-bindgen = "0.2"2. crates/crdt/src/lib.rs
หัวข้อที่มีชื่อว่า “2. crates/crdt/src/lib.rs”สอง export version() เป็น free function Clock เป็น struct ที่มี #[wasm_bindgen(constructor)] — ฝั่ง JS กลายเป็น new Clock() และเป็น Lamport counter ที่เราจะพึ่งพาหนักเมื่อ CRDT มาถึง: ทุก edit ได้ tick ที่เพิ่มขึ้นแบบ monotonic
use wasm_bindgen::prelude::*;
/// A free function — becomes a named export in the generated JS.#[wasm_bindgen]pub fn version() -> String { env!("CARGO_PKG_VERSION").to_string()}
/// A struct exported as a JS class.#[wasm_bindgen]pub struct Clock { counter: u32,}
#[wasm_bindgen]impl Clock { /// `#[wasm_bindgen(constructor)]` maps to `new Clock()` in JS. #[wasm_bindgen(constructor)] pub fn new() -> Clock { Clock { counter: 0 } }
/// `&mut self` methods mutate the Rust struct that the JS object points at. pub fn tick(&mut self) -> u32 { self.counter += 1; self.counter }
/// A getter reads without mutating. #[wasm_bindgen(getter)] pub fn now(&self) -> u32 { self.counter }}สังเกตว่า counter เป็น u32 ไม่ใช่ u64 โดยตั้งใจ: u64 ของ Rust ข้ามเข้า JavaScript เป็น BigInt ซึ่งเทียบและ serialize ยาก u32 marshal เป็น number ธรรมดา เราตั้งขนาด Lamport clock ตามนั้น — บทถัดไปครอบคลุมว่าทำไม boundary จึงกำหนดทางเลือกเหล่านี้
3. Build it: wasm-pack build --target web
หัวข้อที่มีชื่อว่า “3. Build it: wasm-pack build --target web”รันคำสั่งนี้จาก crates/crdt จะสร้าง crates/crdt/pkg/ ที่มี crdt.js (loader), crdt_bg.wasm (binary) และ crdt.d.ts (types)
cd crates/crdtwasm-pack build --target web4. apps/web/src/wasm-smoke.ts
หัวข้อที่มีชื่อว่า “4. apps/web/src/wasm-smoke.ts”เรียกทั้งสอง export จาก TypeScript default export คือ init — คุณต้อง await ครั้งหนึ่งก่อน call อื่นใด เพราะเป็นตัวที่ fetch และ instantiate binary .wasm path เอื้อมออกจาก apps/web เข้าไปใน pkg/ ที่ build แล้ว
import init, { version, Clock } from '../../crates/crdt/pkg/crdt.js';
export async function smokeTest(): Promise<void> { await init(); // fetch + instantiate the .wasm — required before any call
console.log('crdt version', version());
const clock = new Clock(); clock.tick(); clock.tick(); console.log('clock.now', clock.now); // getter → 2
clock.free(); // return the Rust struct's memory (see the next lesson)}ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”Build crate แล้วยืนยันว่า output pkg/ มีอยู่:
cd crates/crdtwasm-pack build --target webls pkgคาดหวัง — loader, binary และ type definition อยู่ครบ:
crdt.d.ts crdt.js crdt_bg.wasm crdt_bg.wasm.d.ts package.jsonสำหรับ build check ที่ไม่ต้องใช้ browser ให้รัน Rust test ของ crate เพิ่มโค้ดนี้ลง lib.rs แล้วรัน cargo test:
#[cfg(test)]mod tests { use super::*;
#[test] fn clock_ticks_monotonically() { let mut c = Clock::new(); assert_eq!(c.tick(), 1); assert_eq!(c.tick(), 2); assert_eq!(c.now(), 2); }}cargo testคาดหวัง:
running 1 testtest tests::clock_ticks_monotonically ... ok
test result: ok. 1 passed; 0 failedCheck your understanding:
- ทำไมคุณต้อง
await init()ก่อนเรียกversion()หรือnew Clock()? #[wasm_bindgen(constructor)]เปลี่ยนอะไรเกี่ยวกับวิธีที่ JavaScript ใช้ structClock?- ทำไม
wasm-pack build --target webเหมาะกับแอป Astro/Vite มากกว่า--target no-modules? - ทำไม Lamport counter จึงเป็น type
u32แทนu64สำหรับ WASM boundary?
เราตั้ง Rust→WASM toolchain ขึ้นมา: crate crdt ที่ export ฟังก์ชันและ struct, build ด้วย wasm-pack build --target web แล้วเรียกจาก TypeScript หลัง await init() นั่นคือท่อที่ CRDT จะวิ่งบน call clock.free() และทางเลือก u32-vs-BigInt ต่างใบ้ถึงความลึกจริง ๆ — ว่าข้อมูลและ memory ข้ามเส้น JS↔WASM อย่างไร
boundary นั้นคือลำดับต่อไป: The JS↔WASM boundary →