四天動手、一天收攏:編出一顆 .wasm、在瀏覽器裡叫它、把資料跟狀態放進去、量出一張有贏有輸的表。每天都做了東西,但每天只夠做那一天要看的那一角,零件散在四個專案裡。昨天說要把它們裝成一個完整的專案,今天做。它只有兩頁:「導入」把兩顆檔案的 import 表跟 export 表印在自己的頁面上,底下就是叫它;「量測」把同一批題目直接比出來——wasm 沒有普遍比較快,只贏在特定形狀。
原始碼在 GitHub 的 wasm-lab:https://github.com/liren0907/wasm-lab/tree/main/ithome-30days/browser-side
整個專案還是量測那天那個 SvelteKit 專案,原封搬過來;為了好看,畫面層直接用 shadcn-svelte,元件裝在 src/lib/components/ui/,這些都只負責畫,跟 wasm 沒有關係。真正的主角是 src/lib/ 裡兩支純 TypeScript:wasm.ts 把兩顆檔案載進來,bench.ts 負責量;接下來兩步各講一支。
導入頁回答一個問題:這兩顆檔案是怎麼進來的?答案本來就在載入器裡,只是沒印出來。instantiateStreaming 回的東西裡除了 instance 還有一個 module,import 表跟 export 表直接問它就有;大小另外數一次;counter 改成編譯一次,每按一次鈕就從同一個 module 重新建一個 instance——狀態跟著 instance 走,這句話在程式碼裡終於長成對的形狀。wasm.ts 動到的三段(其餘略):
const res = await fetch(addUrl);
const addBytes = (await res.clone().arrayBuffer()).byteLength;
const { module: addModule, instance } = await WebAssembly.instantiateStreaming(res, {});
const counterModule = await WebAssembly.compileStreaming(counterRes);
export async function newCounter(): Promise<() => number> {
const instance = await WebAssembly.instantiate(counterModule, {});
return instance.exports.inc as () => number;
}
function describe(name: string, bytes: number, m: WebAssembly.Module): FileInfo {
return { name, bytes, imports: WebAssembly.Module.imports(m), exports: WebAssembly.Module.exports(m) };
}
畫面上一張卡:那一行程式碼,底下兩顆檔案各一列,三欄各答一題——它多大、它要什麼、它給什麼。export 表是它給外面的東西,三種:函式你叫、memory 你讀寫、global 你讀。add.wasm 那八支函式都是你在 Rust 裡寫的,最後兩個淡色的是 linker 留的行李,不是我們寫的。import 表是它跟外面借的函式,兩顆都是空的——它們從頭到尾不會主動叫外面任何東西,只等著被叫;前幾天那個空的 {} 就是你回答這張表。
兩張表合起來一句話:export 表是你叫它的,import 表是它叫你的,表以外沒有第三條路。7 跟 35 不在任何一張表上——參數是你叫它時跟著那一次呼叫進去的,import 表列的是它開工前跟你借的東西,不是你每次交給它的材料。再底下三張卡片——加法、hash、計數器——照舊。計數器那張卡是同一句話的另一面:counter.wasm 裡有一個數字跟一支 inc,表上只有 inc,所以那個數字外面讀不到也改不了,要歸零只能再建一個 instance——那顆鈕做的就是這件事。

bench.ts 是量測那天那支檔,除了拿掉每題底下那句說明,一字沒動。它給頁面五樣東西:bench 暖身、跑固定輪數、取中位數、回傳值餵進 sink;verify 兩邊各算一次,checksum 對不上就不量;yieldToBrowser 每題之間把主執行緒還給瀏覽器;pageIsVisible 分頁在背景就拒測;readSink 把 sink 讀出來。規矩那天講過,今天只點名。頁面按下「開始」之後,每一題做的就是這幾行:
const check = verify(c);
if ('error' in check) {
phase = 'error';
status = { key: 'mismatch', arg: check.error };
return;
}
const results = c.variants.map((v) => bench(v.name, v.fn, c.warmup, c.rounds));
rows.push({ label: c.label, checksum: check.checksum, results });
一題一列,四欄:JS 中位數、wasm 中位數、倍數、checksum,倍數一倍以上綠、以下琥珀。截圖是我這台機器跑的,跟那天同一個量級:mandelbrot 0.93×,差不多,純數值 JS 本來就是 V8 的強項;convolve 3.42×,wasm 贏的形狀,線性記憶體不付邊界檢查;兩百萬次 add() 0.77×,輸,函式本體只有一個加法,量到的全是跨邊界;word count 1.27×,小贏,還多付了一次編碼跟複製。攤提六列從 1.05× 爬到 3.98×,總工作量一樣,每次呼叫做得越多,邊界越不見。贏的兩題都是算得密、資料一次交過去,輸的那題輸給被 inline 掉的函式呼叫——wasm 沒有普遍比較快,只贏在特定形狀,前言那句話,證據在這張表。

今天的 diff 裡,wasm 那幾支真正動的只有 wasm.ts,而且只做四件事:接住 module、數 bytes、counter 改成編譯一次、交出兩張表。kernels-js.ts、fixtures.ts 跟 crate/ 一個字沒動;bench.ts 跟 cases.ts 只少了那句說明。npm run wasm 還是那一條,兩顆 .wasm 也是同一份。畫面層從素表格換成一整套元件庫,載入器多印了兩張表,那顆檔案本身沒有察覺——昨天那句「一顆檔案,誰載都一樣」,在自己專案裡長這樣;導入頁那兩格空的 import 表就是它的原因——它們不跟宿主要任何東西。
一句帶走:專案長成兩頁——導入頁把 export 表跟空的 import 表印在自己的頁面上,量測頁的數字沒變。