[TOOLS] 16 分鐘閱讀OraCore 編輯部

MDN 讓 Wasm 變成 JS 主機

我把 MDN 的 WebAssembly 指南拆成可直接套用的 JS 載入模式,順手補一份可複製的 Wasm loader 模板。

分享 LinkedIn
MDN 讓 Wasm 變成 JS 主機

我把 MDN 的 WebAssembly 指南拆成一套可直接複製的 JavaScript 載入流程,外加一份能立刻貼進專案的 Wasm loader 模板。

我用 WebAssembly 一陣子了,但老實說,很多教學都把它講得像性能仙丹,這點我最受不了。第一次把 Wasm 模組接進真實專案時,我踩到的坑其實很無聊:一邊是 build toolchain,一邊是 JavaScript glue,中間還夾著一堆半懂不懂的 API。文件沒錯,可是讀完還是很難知道到底該先做哪一步。

我真正想要的,不是再看一篇「Wasm 很快」的宣傳文,而是知道它跟 JavaScript 到底怎麼分工、什麼情況值得上、以及最短路徑怎麼把一個 module 接進瀏覽器。MDN 的 WebAssembly guide 就是我一直回頭看的來源,因為它至少有把元件講清楚,只是讀起來比較像參考手冊,不像操作菜單。我今天就是要把它翻成能上手的版本。

這篇不是要重新發明 WebAssembly。我是把 MDN 的意思拆開,直接告訴你:哪些觀念要先放在腦子裡、哪些 API 真正會用到、哪些地方其實不用想太多。最後我會給你一份可以直接複製的 loader,讓你不用從零拼整個整合流程。

來源錨點就是 MDN 這頁本身,WebAssembly guide,裡面把概念、JavaScript API、text format、以及相關子頁都串在一起。它沒有提供星數或 bookmark 數,我也不硬掰;它提供的是比較有用的東西:結構跟路徑。

先把 Wasm 放回它該待的位置

訂閱 AI 趨勢週報

每週精選模型發布、工具應用與深度分析,直送信箱。不定期,不騷擾。

不會寄垃圾信,隨時可取消。

“WebAssembly is designed to complement and run alongside JavaScript.”

翻譯一下就是:Wasm 不是來取代 JavaScript 的。JavaScript 才是主機,負責 DOM、事件、fetch、UI flow、狀態串接;Wasm 是你拿來處理特定重活的模組。這個分工如果搞反,整個專案就會開始長出奇怪的抽象層,然後大家一起痛苦。

MDN 讓 Wasm 變成 JS 主機

MDN 這句話其實很直白,但很多文章會故意講得很玄,好像你只要把 app 搬去 Wasm 就會比較快。我看過不少團隊花很多時間把一小段熱點包進 Wasm,結果真正的瓶頸根本是演算法、資料流,甚至只是 DOM 更新方式太爛。你把框架換成低階語言,問題不會自動消失。

我之前遇過一個影像處理 dashboard,某個轉換流程一直卡 UI。最後有效的做法不是「整個前端改成 Wasm」,而是把那段吃 CPU 的數學丟進小模組,其他部分全部留在 JS。這才是 MDN 在暗示你的路線:小範圍、明確邊界、只處理真的值得下放的工作。

實操寫法很簡單:先找一個單點任務,最好是 CPU-heavy,或是本來就存在於 C / C++ / Rust / C# 的既有邏輯。把它切成一個明確函式,讓 JS 負責呼叫,Wasm 負責算。你如果沒辦法用一句話說清楚邊界,那通常代表你還不需要 Wasm。

  • 適合:影像處理、壓縮、解析器、加密、物理模擬、既有 native library。
  • 不適合:表單互動、路由、DOM 操作、簡單資料轉換。

binary 不是細節,是整個工作方式

MDN 把 Wasm 說成「low-level assembly-like language with a compact binary format」。這句我覺得很重要,因為它直接點出 Wasm 的本質:你不是在 ship 一份一般意義上的 source code,而是在 ship 一個編譯後的 artifact。瀏覽器吃的是 binary,先驗證、再 instantiate、再執行。

白話一點講,Wasm 的世界不是「寫完直接跑」,而是「先編譯,再交給瀏覽器」。所以你要思考的是 build output,不是 runtime source。你的 source 通常在 Rust、C、C++ 或其他編譯語言裡,瀏覽器只是最後的 consumer,不是你的 compiler pipeline。

我以前最容易卡住的點,就是盯著 `.wasm` 檔案想把它當成可讀程式碼看。那根本是自找麻煩。MDN 另外提供 text format 的文章與轉換說明,就是因為 binary 本來就不是給人直接讀的。binary 是給機器;text format 才是給你在 debug 或學習時用的。

實作上,我會把 Wasm binary 當成一般 build artifact 來管理:進 CI、做版本控制、需要的話做 cache fingerprint,並且確保產物每次都能穩定產出。要看行為就看 text format 或 devtools,不要對著 binary 發呆。

如果你想看更底層的標準脈絡,可以看 WebAssembly project site;但如果你是在瀏覽器端整合,MDN 的 API 頁面才是你每天真的會碰到的那層。

JS 是 glue,不是配角,這反而比較正常

MDN 的意思很清楚:WebAssembly module 可以被 JavaScript 載入,兩邊可以共享功能。這就是實際模型。JS 負責載入 module、傳 imports、接 exports,然後把它接回整個 web platform。Wasm 不會幫你處理 DOM,也不會神奇地接管瀏覽器 API。

MDN 讓 Wasm 變成 JS 主機

翻譯一下就是:整合的難點都在邊界。邊界上會出現 imports、exports、memory、table。邊界搞錯,module 就會很彆扭;邊界弄乾淨,後面通常很安靜,這種安靜才是 production 想要的。

我自己的經驗是,JS wrapper 越小越好。不要包一層很厚的抽象,不要搞什麼「通用 runtime」,也不要為了看起來優雅就把 boundary 藏起來。你只需要 loader、instance、幾個 exports。MDN 的 JavaScript API reference 之所以有用,就是因為它直接把 WebAssembly.ModuleWebAssembly.InstanceWebAssembly.MemoryWebAssembly.Table 這些東西講出來,不跟你玩虛的。

實操寫法:寫一個單獨的 JS 檔,只負責載入和 wiring。App code 跟 loader 分開。共享狀態要先決定放哪裡:JS、Wasm memory,還是 export function。不要因為 demo 看起來很順,就把所有邊界揉成一團。

  • JS 用在 I/O、DOM、fetch、事件處理。
  • Wasm 用在可預期、計算密集的工作。
  • 能用明確 exports 就別偷塞隱性全域狀態。

載入路徑選錯,整個體驗就會很煩

MDN 列得很清楚:WebAssembly.compile()WebAssembly.compileStreaming()WebAssembly.instantiate()WebAssembly.instantiateStreaming()。如果你是在瀏覽器裡跑,我通常第一個會看 streaming 版本,因為瀏覽器可以邊收 bytes 邊編譯。這不是萬靈丹,但它是合理的預設,前提是你的 server 有把 MIME type 配對好。

也就是說,載入策略本身就是設計的一部分。你不是單純「import 一個檔案」而已,你是在選擇要先抓完再編譯,還是邊下載邊編譯。這會直接影響啟動時間、錯誤處理方式,還有你要寫多少 boilerplate。

我自己最常被這件事搞到的是 local dev。module 在某個環境可以跑,換一個環境就失敗,最後才發現是 server 沒有正確送出 content type,導致 streaming instantiate 出問題。很多人會怪 Wasm 不穩,其實只是 dev server 很爛。

實作上,我會在 production 先試 instantiateStreaming(),如果遇到 MIME 或奇怪環境,再 fallback 到 instantiate()。loader 要能明確拋錯,不要把失敗吞掉。模組載不進來,就早點報,不要讓後面才爆炸。

如果你要查瀏覽器相容性與行為細節,MDN 的 reference 比你自己猜準很多。要看標準側脈絡,可以順手看 W3C WebAssembly Community Group

memory 才是整合最容易翻車的地方

MDN 對 WebAssembly.Memory()說法很直白:它是可擴充的 ArrayBuffer,保存 instance 讀寫的原始 bytes。這段很多人會跳過,然後等到 bug 出現才開始痛。Wasm 不像 JavaScript 那樣直接丟物件給你,它給的是 memory、table、typed boundary。你要知道 bytes 在哪裡、誰擁有、誰負責讀寫。

白話一點就是:interop 不是免費的。module 如果期待 buffer,JS 就得提供或讀取;module 如果寫進 memory,JS 就要知道什麼時候去看;如果你在傳字串,就要處理 encoding;如果你在傳陣列,就要尊重 typed view。這些都不是「之後再說」的細節,而是第一天就會踩到的事。

我之前接一個 Rust module 到瀏覽器時,數學本身完全沒問題,錯的是邊界。我讀錯了 memory slice,結果看起來像 module 算錯,其實是我自己假設錯。這也是為什麼我一直覺得第一個 integration 要小到不能再小:先讓它只收一個數、吐一個數,先把 runtime 問題切乾淨,再談複雜資料。

實操寫法:先定義一份很窄的 memory contract。寫清楚 module 期待什麼、回傳什麼、誰負責 cleanup。只要 export function 會碰 memory,就配一個小 JS helper 來做編碼和解碼。不要讓每個呼叫端都重新猜 ABI。

MDN 也有提到 WebAssembly.Table()WebAssembly.Global()。這兩個你不一定天天用,但一旦碰到 function reference 或 dynamic linking,就真的少不了。平常不用,不代表可以不知道。

text format 不是教科書,是 debug 工具

MDN 有專門講 WebAssembly text format,也有文字轉 binary 的說明。這不是裝飾頁面,這是實戰工具。binary 本來就不好看,text format 則提供你一個人類可讀的方式,讓你去理解 module 到底在幹嘛。

也就是說,別期待瀏覽器幫你把 module 的問題講成白話。當東西壞掉時,text format 常常比你一直盯著高階語言 source 更快抓出問題,尤其是 import signature、export 名稱、控制流程這些地方。對學習者來說,它也比直接看 binary 好太多。

我以前就碰過一個 compiled module 的行為跟 source 預期不一樣,最後是 function signature mismatch。那種問題用高階語言看半天不一定看得出來,但一旦把 module 結構攤開,答案就很明顯。Wasm 的麻煩是它會很快把錯誤暴露出來;好處也是這樣,至少你不用拖到線上才知道。

實操寫法:把 text format 的頁面存成書籤,尤其是你要整合多種語言時。先檢查 module 結構,再去怪 browser。signature 看起來怪、export 對不上、import 找不到,先別亂改 app code,先看 module 本體。

  • signature 不對時,用 text format。
  • runtime 行為怪時,用 browser devtools。
  • 問題還在 Rust、C、C++ 時,回原始語言工具鏈。

MDN 真正有價值的是路線圖,不是口號

這份 guide 沒有在賣情緒,它是在列東西:概念、從不同語言編譯、載入方式、JavaScript API、reference objects、browser compatibility。這種結構我很買單,因為它跟實際工作順序一樣。先懂模型,再編譯,再載入,再 debug,最後才去看更底層的 reference。

翻譯一下就是:MDN 最適合拿來當 workflow map,而不是只拿來背定義。它會告訴你有哪些元件、哪裡會卡、以及你接下來該跳去哪個子頁面。這比很多只會講概念的文章實用太多。

我會信這種文件,因為它不假裝難的部分不存在。WebAssembly 的難點本來就不是單一 API,而是工具鏈、memory、載入策略、相容性一起來。MDN 沒把這些藏起來,這也是我一直推薦它的原因。

實操寫法:先把 guide 快速掃過一遍,接著直接跳到你 stack 對應的子頁。用 Rust 就看 Rust guide;在瀏覽器載入就看 JS API;在 debug 就看 text format。不要妄想一次把整頁背起來,沒必要。

可抄的模板

// Minimal browser-side WebAssembly loader pattern
// Based on MDN's WebAssembly guide and API reference

export async function loadWasm(url, imports = {}) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch Wasm: ${response.status} ${response.statusText}`);
  }

  // Preferred path when the server serves the correct MIME type
  if (WebAssembly.instantiateStreaming) {
    try {
      const { instance, module } = await WebAssembly.instantiateStreaming(response, imports);
      return { instance, module };
    } catch (err) {
      // Fall back if streaming fails for MIME or environment reasons
      console.warn("instantiateStreaming failed, falling back to ArrayBuffer instantiation", err);
    }
  }

  const bytes = await response.arrayBuffer();
  const result = await WebAssembly.instantiate(bytes, imports);
  return {
    instance: result.instance,
    module: result.module ?? null,
  };
}

// Example usage
const imports = {
  env: {
    // Add host functions here
    log: (value) => console.log("Wasm log:", value),
  },
};

const { instance } = await loadWasm("/assets/app.wasm", imports);

// Call exported functions from the module
if (instance.exports.add) {
  console.log("2 + 3 =", instance.exports.add(2, 3));
}

// Memory helper example
function readString(memory, ptr, len) {
  const bytes = new Uint8Array(memory.buffer, ptr, len);
  return new TextDecoder().decode(bytes);
}

function writeString(memory, ptr, text) {
  const bytes = new TextEncoder().encode(text);
  new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
  return bytes.length;
}

// If your module exports memory, you can use the helpers like this:
// const memory = instance.exports.memory;
// const message = readString(memory, 0, 12);
// const written = writeString(memory, 128, "hello wasm");

這份模板就是我會直接貼進專案的版本。它先嘗試 streaming,失敗就 fallback,然後把 boundary 留得很明白,不會把整個載入流程包成一坨看不懂的黑盒。

如果你的 module 是從 Rust、C 或 C++ 來的,build toolchain 換掉就好,JS wrapper 先別亂動。需要 custom imports 就塞進 env 或你 compiler 要的 namespace。需要 memory 存取,就把 helper 跟 loader 放一起,別讓後面的人自己猜 bytes 怎麼跑。

我這篇是在拆 MDN 的 WebAssembly guide,上面那份 loader template 是我自己整理出來的可用版本,不是直接抄 MDN 原碼。概念來自文件,包裝方式是我為了日常前端整合重新寫的。

另外我也參考了 MDN JavaScript interfaceWebAssembly project、以及 W3C WebAssembly Community Group;原始脈絡是 MDN,模板與解讀是我自己衍生整理的。