WebAssembly in TypeScript

WebAssembly in TypeScript

Questo articolo spiega WebAssembly in TypeScript.

Spiegheremo metodi pratici e facili da comprendere per integrare TypeScript e WebAssembly.

YouTube Video

WebAssembly in TypeScript

WebAssembly (Wasm) è un runtime in formato binario che funziona quasi alla velocità nativa all'interno del browser. Chiamando Wasm da TypeScript, puoi sfruttare in modo efficiente processi computazionalmente intensivi e librerie native esistenti scritte in C/C++ o Rust.

Flusso Base di Esecuzione

Qui spiegheremo il flusso di esecuzione base di Wasm. TypeScript (o il browser) recupera il file .wasm, lo istanzia e chiama le funzioni esportate.

    1. Crea un file binario .wasm usando AssemblyScript, Rust, C++ o prepara uno esistente.
    1. Recupera il file .wasm in TypeScript (o nel browser) e istanzialo in modo sincrono o asincrono.
    1. Chiama le funzioni esportate e condividi la memoria utilizzando WebAssembly.Memory se necessario.

WebAssembly.instantiateStreaming

Successivamente, mostreremo un esempio di base per caricare un file Wasm e chiamare una funzione esportata. Il browser deve supportare instantiateStreaming.

Il codice seguente è un esempio di come recuperare simple.wasm dal server e chiamare la funzione 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);
  • Le funzioni dentro Wasm sono memorizzate in instance.exports.
  • Poiché TypeScript non riceve le informazioni sui tipi, devi usare @ts-ignore o creare le tue definizioni di tipo.

Flusso di lavoro con AssemblyScript

AssemblyScript ti permette di scrivere Wasm con una sintassi simile a TypeScript, rendendolo una scelta accessibile per gli sviluppatori TypeScript. Qui prepariamo una funzione semplice, la compiliamo in .wasm e .d.ts, e la richiamiamo da 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}
  • Utilizzando asc (il compilatore di AssemblyScript), puoi generare un file .wasm e, opzionalmente, un file di definizione dei tipi .d.ts. Per provarlo in locale, installa assemblyscript con npm e compilalo.
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

Ecco un esempio di recupero e chiamata dal lato 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 richiede una gestione attenta dei modelli di memoria e delle stringhe, ma è molto facile da usare per calcoli numerici di base.

Rust + wasm-bindgen (Un'opzione potente e comunemente utilizzata)

Questa sezione spiega il flusso di lavoro per scrivere Wasm in Rust e integrarlo con JavaScript o TypeScript usando wasm-bindgen. Qui utilizziamo una semplice funzione Fibonacci come esempio per mostrare come importare il modulo generato come modulo ES.

Esporta le funzioni dal lato Rust usando 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}
  • Quando compili con wasm-pack o la CLI di wasm-bindgen, vengono generate le definizioni di tipo per TypeScript e i wrapper JS, consentendoti di importarli direttamente come 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

Dal lato TypeScript, importa e usa il modulo ES da pkg.

 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 genera i wrapper JavaScript e le definizioni di tipo .d.ts, rendendo semplice l'utilizzo da TypeScript. Si prega di notare che quando si specifica web per l'opzione --target del comando wasm-pack, è necessaria l'inizializzazione asincrona.

Esempio reale di condivisione della memoria: passaggio e elaborazione di array (Basso livello)

Quando si scambiano grandi quantità di dati con Wasm, è importante condividere ArrayBuffer per uno scambio efficiente dei dati. Qui mostriamo un esempio usando AssemblyScript, ma lo stesso principio si applica con wasm-bindgen di Rust.

Dal lato AssemblyScript, prepara una funzione esportata per scrivere in memoria. Ad esempio, una funzione per elevare al quadrato ogni elemento di un array apparirebbe così.

 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}

Per specificare le impostazioni di memoria utilizzate da AssemblyScript, prepara il seguente 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
  • Per chiamare questa funzione, devi copiare l'ArrayBuffer nello spazio di memoria Wasm e passare il puntatore.

Di seguito è riportato un esempio che utilizza WebAssembly.Memory in TypeScript per copiare i dati e chiamare la funzione.

 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 è la memoria lineare condivisa; minimizzare le copie migliora il più possibile la velocità di elaborazione. Nota anche che i puntatori si riferiscono a posizioni in byte, mentre i TypedArray sono gestiti tramite il numero di elementi, quindi fai attenzione a non confondere queste differenze.

Gestione di sicurezza dei tipi: prepara le definizioni di tipo TypeScript

Le esportazioni Wasm sono oggetti JavaScript, quindi fornire definizioni di tipo dal lato TypeScript renderà lo sviluppo più facile. Ecco un semplice esempio di file di definizione di tipo.

Il seguente mostra la definizione di tipo minima che puoi creare manualmente come simple.d.ts.

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • Inserendo questo tra i typeRoots nel tuo tsconfig.json o usando declare module abiliterai il controllo dei tipi. wasm-pack genera comodamente file .d.ts automaticamente, quindi è utile utilizzarli.

Pattern di inizializzazione a runtime: Sincrono vs Asincrono

Poiché i moduli Wasm richiedono I/O (fetch) e compilazione, l'inizializzazione asincrona è comune. Tuttavia, esiste anche un pattern in cui si mette in cache WebAssembly.Module in anticipo e lo si istanzia in modo sincrono.

Di seguito è mostrata la struttura di base del codice per l'inizializzazione asincrona di WebAssembly. Nei progetti reali, questo pattern è raccomandato.

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}
  • L'inizializzazione asincrona rende facile integrare in modo flessibile la gestione degli errori e il caricamento lazy, rendendola la più conveniente nello sviluppo pratico. Inoltre, il codice generato da wasm-pack include un'API init() per l'inizializzazione, quindi abituarsi a questo flusso faciliterà il tuo lavoro.

Considerazioni pratiche sulle prestazioni

Ecco alcuni punti da tenere a mente per miglioramenti significativi delle prestazioni. Riferisciti a questi suggerimenti di ottimizzazione quando combini TypeScript e WebAssembly.

  • Quando le chiamate di funzione sono molto frequenti, l'overhead delle chiamate tra JavaScript e Wasm può diventare un collo di bottiglia. Si consiglia di raggruppare i dati ed elaborarli tutti insieme per quanto possibile.
  • L'allocazione e la copia della memoria aumentano il carico di elaborazione. Utilizza buffer condivisi e puntatori per minimizzare queste operazioni.
  • Fai attenzione durante la gestione dei numeri in virgola mobile. In TypeScript diventano di tipo number, ma puoi gestirli correttamente abbinando i tipi dal lato Wasm.

Riepilogo

Combinando TypeScript e WebAssembly puoi ottenere prestazioni quasi native nel browser. Questo è particolarmente efficace per attività di calcolo intensivo o quando si vogliono sfruttare asset nativi esistenti. Questa combinazione è un'opzione molto potente quando desideri migliorare le prestazioni della tua applicazione web.

Puoi seguire l'articolo sopra utilizzando Visual Studio Code sul nostro canale YouTube. Controlla anche il nostro canale YouTube.

YouTube Video