在 TypeScript 中使用 WebAssembly

在 TypeScript 中使用 WebAssembly

本文將說明如何在 TypeScript 中使用 WebAssembly。

我們將介紹整合 TypeScript 和 WebAssembly 的實用且易懂的方法。

YouTube Video

在 TypeScript 中使用 WebAssembly

WebAssembly(Wasm)是一種可以在瀏覽器內以接近原生速度運行的二進位格式執行環境。透過從 TypeScript 呼叫 Wasm,可以有效率地運用需要大量運算的流程,以及以 C/C++ 或 Rust 撰寫的現有原生函式庫。

基本執行流程

這裡我們將說明 Wasm 的基本執行流程。TypeScript(或瀏覽器)會取得 .wasm 檔案、將其實體化,並呼叫其匯出的函式。

    1. 使用 AssemblyScript、Rust、C++ 建立 .wasm 二進位檔,或直接準備現有的檔案。
    1. 在 TypeScript(或瀏覽器)中的抓取 .wasm 檔案,並同步或非同步地將其實體化。
    1. 呼叫匯出的函式,必要時可透過 WebAssembly.Memory 共用記憶體。

WebAssembly.instantiateStreaming

接下來我們會展示如何載入 Wasm 檔並呼叫匯出的函式的基本範例。瀏覽器需要支援 instantiateStreaming

以下的程式碼範例展示了從伺服器獲取 simple.wasm 並呼叫 add 函數的方法。

 1// TypeScript: load-and-call.ts
 2// Fetch and instantiate a wasm module and call its exported `add` function.
 3async function run() {
 4  const response = await fetch('http://localhost:3000/simple.wasm');
 5  // Use instantiateStreaming when available for efficiency.
 6  const { instance } = await WebAssembly.instantiateStreaming(response, {});
 7  // @ts-ignore
 8  const result = instance.exports.add(2, 3);
 9  console.log('2 + 3 =', result);
10}
11run().catch(console.error);
  • Wasm 內部的函式會存於 instance.exports 中。
  • 由於 TypeScript 無法取得型別資訊,因此你需要使用 @ts-ignore 或自行定義型別。

使用 AssemblyScript 的工作流程

AssemblyScript 可以用與 TypeScript 類似的語法撰寫 Wasm,非常適合 TypeScript 開發者使用。這裡我們準備了一個簡單的函數,將其編譯成 .wasm.d.ts,並從 TypeScript 呼叫它。

1// assembly/index.ts (AssemblyScript)
2// npm install --save-dev assemblyscript
3
4// Export a simple function to add two integers.
5export function add(a: i32, b: i32): i32 {
6  return a + b;
7}
  • 透過使用 ascAssemblyScript 編譯器),你可以生成 .wasm 檔案,並且可以選擇產生型別定義檔 .d.ts。如果要在本地測試,可以用 npm 安裝 assemblyscript,然後進行編譯。
1# build commands
2# npm install --save-dev assemblyscript
3npx asc assembly/index.ts   -o build/simple.wasm   -t build/simple.wat   --bindings esm   --exportTable   --sourceMap
4
5# optionally generate d.ts with --exportRuntime or use as-bind / loader tools

以下是從 TypeScript 端進行抓取與呼叫的範例。

1// ts client that loads AssemblyScript-generated wasm
2async function runAssemblyScript() {
3  const res = await fetch('http://localhost:3000/build/simple.wasm');
4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
5  // @ts-ignore
6  console.log('AssemblyScript add:', instance.exports.add(10, 7));
7}
8runAssemblyScript().catch(console.error);
  • AssemblyScript 雖然在記憶體模型與字串上需特別處理,但用於基本數值運算時非常簡單易用。

Rust + wasm-bindgen(強大且常用的選項)

本節說明如何使用 wasm-bindgen,將以 Rust 編寫的 Wasm 和 JavaScript 或 TypeScript 進行橋接的工作流程。這裡以簡單的 Fibonacci 函式作為示例,說明如何將產生的模組匯入為 ES 模組。

在 Rust 端使用 wasm-bindgen 匯出函式。

 1// src/lib.rs (Rust)
 2// install wasm-pack from https://drager.github.io/wasm-pack/installer/
 3use wasm_bindgen::prelude::*;
 4
 5// Export a function to JavaScript using wasm-bindgen.
 6#[wasm_bindgen]
 7pub fn fib(n: u32) -> u32 {
 8    if n <= 1 { return n; }
 9    let mut a = 0;
10    let mut b = 1;
11    for _ in 2..=n {
12        let tmp = a + b;
13        a = b;
14        b = tmp;
15    }
16    b
17}
  • 使用 wasm-packwasm-bindgen CLI 編譯時,會自動產生 TypeScript 型別定義與 JS 包裝器,可直接以 ESM 匯入。
1# build with wasm-pack
2# install wasm-pack from https://drager.github.io/wasm-pack/installer/
3wasm-pack build --target nodejs --out-dir pkg

在 TypeScript 端,從 pkg 匯入並使用 ES 模組。

 1// Node.js: import WASM module built with --target web
 2// import init, { fib } from '../pkg/my_wasm_module.js';
 3// Node.js: import WASM module built with --target nodejs
 4import wasm from '../pkg/my_wasm_module.js';
 5
 6async function run() {
 7  //await init();   // --target web
 8  console.log('fib(10)=', wasm.fib(10));
 9}
10
11run().catch(console.error);
  • wasm-pack 會產生 JavaScript 包裝器與 .d.ts 型別定義檔,使其容易在 TypeScript 中使用。請注意,當你在 wasm-pack 指令中將 --target 選項設為 web 時,必須進行非同步初始化。

實際記憶體共享範例:傳遞與處理陣列(低階應用)

在與 Wasm 交換大量資料時,共享 ArrayBuffer 以提高資料交換效率非常重要。這裡以 AssemblyScript 作為範例,Rust 的 wasm-bindgen 也採用相同原理。

在 AssemblyScript 端,準備一個能寫入記憶體的匯出函式。例如對每個陣列元素進行平方的函式如下。

 1// assembly/array_ops.ts (AssemblyScript)
 2// Square values in place in the wasm linear memory starting at `ptr` for `len` elements.
 3export function square_in_place(ptr: usize, len: i32): void {
 4  // Treat memory as a pointer to 32-bit integers.
 5  for (let i = 0; i < len; i++) {
 6    let offset = ptr + (i << 2); // i * 4 bytes
 7    let value = load<i32>(offset);
 8    store<i32>(offset, value * value);
 9  }
10}

若要指定 AssemblyScript 的記憶體設定,請準備以下的 asconfig.json

1{
2  "options": {
3    "memoryBase": 0,
4    "importMemory": false,
5    "initialMemory": 1,
6    "maximumMemory": 10
7  }
8}
1 npx asc assembly/array_ops.ts   -o build/array_ops.wasm   -t build/array_ops.wat   --bindings esm   --exportTable   --sourceMap
  • 呼叫此函式時,需要將 ArrayBuffer 複製到 Wasm 記憶體空間,並傳遞指標。

以下是如何在 TypeScript 中利用 WebAssembly.Memory 複製資料並呼叫函式的範例。

 1// TypeScript: use memory to pass array to wasm
 2async function runArrayOps() {
 3  const res = await fetch('http://localhost:3000/build/array_ops.wasm');
 4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
 5  // @ts-ignore
 6  const memory: WebAssembly.Memory = instance.exports.memory;
 7  // Create a view into wasm memory.
 8  const i32View = new Int32Array(memory.buffer);
 9
10  // Example data
11  const input = new Int32Array([1, 2, 3, 4]);
12  // Choose an offset (in i32 elements) to copy data to (simple example: at index 0).
13  const offset = 0;
14  i32View.set(input, offset);
15
16  // Call wasm function: ptr in bytes, len in elements
17  // @ts-ignore
18  instance.exports.square_in_place(offset * 4, input.length);
19
20  // Read back result
21  const result = i32View.slice(offset, offset + input.length);
22  console.log('squared:', result);
23}
24runArrayOps().catch(console.error);
  • memory.buffer 是共用直線記憶體,盡量減少複製動作以提升處理速度。另外,指標是以位元組為單位的位置,而 TypedArray 則以元素數量管理,請小心區分勿混淆。

型別安全的處理方式:準備 TypeScript 型別定義

Wasm 匯出物件為 JavaScript 物件,因此在 TypeScript 端提供型別定義能讓開發更加便利。這裡提供一個簡單的型別定義檔範例。

以下是你可以手動建立的最簡型 simple.d.ts 型別定義。

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • 將此檔放入 tsconfig.jsontypeRoots 或使用 declare module 就能開啟型別檢查。wasm-pack 會自動產生 .d.ts 型別定義檔,請善加利用。

執行時初始化模式:同步與非同步

由於 Wasm 模組需要 I/O(抓取)與編譯,通常會用非同步初始化方式。但也有一種方式是事先快取 WebAssembly.Module,再以同步方式實體化。

以下是非同步初始化 WebAssembly 的基礎程式結構。在實際專案中建議採用這種模式。

1// async init pattern
2async function initWasm(url: string) {
3  const res = await fetch(url);
4  const { instance, module } = await WebAssembly.instantiateStreaming(res, {});
5  return instance;
6}
  • 非同步初始化方便靈活地加入錯誤處理和延遲加載,實務開發中最為方便。此外,用 wasm-pack 產生的程式會包含一個 init() 初始化 API,熟悉這種流程將有助於專案開發順利。

實用效能考量

以下是幾點需要注意的重要效能優化建議。在結合 TypeScript 與 WebAssembly 時,請參考下列最佳化要點。

  • 如果函式呼叫非常頻繁,JavaScript 與 Wasm 之間的呼叫開銷可能會成為瓶頸。建議盡量將資料分批處理,一次完成多項運算。
  • 記憶體配置與複製會增加處理負擔。善用共享緩衝區及指標,以減少這些操作。
  • 處理浮點數時需要格外留意。在 TypeScript 中會被當作 number 型別,建議在 Wasm 端對應型別以保證精度正確。

總結

透過結合 TypeScript 與 WebAssembly,可在瀏覽器中實現接近原生的效能。這在需要大量運算的任務,或想善用現有原生資產時特別有效。若想提升網頁應用效能,這種組合是非常強大的選擇。

您可以在我們的 YouTube 頻道上使用 Visual Studio Code 來跟隨上述文章一起學習。 請也查看我們的 YouTube 頻道。

YouTube Video