WebAssembly en TypeScript

WebAssembly en TypeScript

Cet article explique WebAssembly en TypeScript.

Nous expliquerons des méthodes pratiques et faciles à comprendre pour intégrer TypeScript et WebAssembly.

YouTube Video

WebAssembly en TypeScript

WebAssembly (Wasm) est un format binaire d'exécution qui fonctionne à une vitesse proche du natif dans le navigateur. En appelant Wasm depuis TypeScript, vous pouvez exploiter efficacement des processus intensifs en calcul et des bibliothèques natives existantes écrites en C/C++ ou Rust.

Flux d'exécution de base

Ici, nous allons expliquer le flux d'exécution de base de Wasm. TypeScript (ou le navigateur) récupère le fichier .wasm, l'instancie et appelle les fonctions exportées.

    1. Créez un binaire .wasm en utilisant AssemblyScript, Rust, C++, ou préparez-en un existant.
    1. Récupérez le fichier .wasm dans TypeScript (ou dans le navigateur) et instanciez-le de manière synchrone ou asynchrone.
    1. Appelez les fonctions exportées et partagez la mémoire en utilisant WebAssembly.Memory si nécessaire.

WebAssembly.instantiateStreaming

Ensuite, nous allons montrer un exemple de base de chargement d'un fichier Wasm et d'appel d'une fonction exportée. Le navigateur doit prendre en charge instantiateStreaming.

Le code suivant est un exemple de récupération de simple.wasm depuis le serveur et d'appel à la fonction 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);
  • Les fonctions à l'intérieur de Wasm sont stockées dans instance.exports.
  • Comme TypeScript ne reçoit pas d'information de type, vous devez utiliser @ts-ignore ou créer vos propres définitions de type.

Flux de travail avec AssemblyScript

AssemblyScript vous permet d'écrire du Wasm avec une syntaxe similaire à TypeScript, ce qui en fait un choix accessible pour les développeurs TypeScript. Ici, nous préparons une fonction simple, la compilons en .wasm et .d.ts, puis l'appelons depuis 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}
  • En utilisant asc (le compilateur AssemblyScript), vous pouvez générer un fichier .wasm ainsi qu’un fichier de définitions de types .d.ts, si vous le souhaitez. Pour l'essayer localement, installez assemblyscript avec npm et compilez-le.
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

Voici un exemple de récupération et d'appel depuis le côté 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 nécessite une gestion soigneuse des modèles de mémoire et des chaînes de caractères, mais il est très facile à utiliser pour des calculs numériques de base.

Rust + wasm-bindgen (Une option puissante et fréquemment utilisée)

Cette section explique le flux de travail pour écrire du Wasm en Rust et le relier à JavaScript ou TypeScript à l'aide de wasm-bindgen. Ici, nous utilisons une fonction Fibonacci simple comme exemple pour montrer comment importer le module généré en tant que module ES.

Exportez les fonctions du côté Rust en utilisant 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}
  • Lorsque vous compilez avec wasm-pack ou le CLI wasm-bindgen, des définitions de type pour TypeScript et des wrappers JS sont générés, ce qui vous permet de les importer directement comme 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

Du côté TypeScript, importez et utilisez le module ES depuis 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 génère des wrappers JavaScript et des définitions de type .d.ts, ce qui facilite l'utilisation depuis TypeScript. Veuillez noter que lorsque vous spécifiez web pour l'option --target de la commande wasm-pack, une initialisation asynchrone est requise.

Exemple concret de partage de mémoire : passage et traitement de tableaux (bas niveau)

Lors de l'échange de grandes quantités de données avec Wasm, il est important de partager ArrayBuffer pour un échange de données efficace. Ici, nous montrons un exemple avec AssemblyScript, mais le même principe s'applique avec wasm-bindgen de Rust.

Côté AssemblyScript, préparez une fonction exportée pour écrire dans la mémoire. Par exemple, une fonction pour élever au carré chaque élément d'un tableau ressemblerait à ceci.

 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}

Pour spécifier les paramètres de mémoire utilisés par AssemblyScript, préparez le fichier asconfig.json suivant.

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
  • Pour appeler cette fonction, vous devez copier le ArrayBuffer dans l'espace mémoire de Wasm et passer le pointeur.

Ci-dessous un exemple d'utilisation de WebAssembly.Memory dans TypeScript pour copier des données et appeler la fonction.

 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 est la mémoire linéaire partagée ; minimiser les copies améliore la vitesse de traitement autant que possible. Notez également que les pointeurs font référence à des positions en octets, tandis que les TypedArray sont gérés par nombre d'éléments ; faites attention à ne pas confondre ces différences.

Gestion typée : préparer des définitions de types TypeScript

Les exportations Wasm sont des objets JavaScript, donc fournir des définitions de types du côté TypeScript facilitera le développement. Voici un exemple simple de fichier de définition de type.

Ce qui suit montre la définition de type minimale que vous pouvez créer manuellement sous le nom simple.d.ts.

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • En plaçant ceci dans le typeRoots de votre tsconfig.json ou en utilisant declare module, vous activerez la vérification de type. wasm-pack génère automatiquement des fichiers .d.ts, il est donc pratique de les utiliser.

Schémas d'initialisation à l'exécution : synchrone vs asynchrone

Comme les modules Wasm nécessitent des opérations d'E/S (récupération) et de compilation, l'initialisation asynchrone est courante. Toutefois, il existe aussi un schéma où vous mettez en cache WebAssembly.Module à l'avance et l'instanciez de façon synchrone.

Voici la structure de base du code pour initialiser WebAssembly de façon asynchrone. Dans les projets réels, ce schéma est recommandé.

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'initialisation asynchrone permet d'intégrer facilement la gestion des erreurs et le chargement paresseux, ce qui la rend la plus pratique en développement. De plus, le code généré par wasm-pack inclut une API init() pour l'initialisation, il est donc utile de s'habituer à ce flux pour travailler efficacement.

Considérations pratiques sur les performances

Voici quelques points à garder en tête pour améliorer significativement les performances. Veuillez vous référer à ces conseils d'optimisation lors de la combinaison de TypeScript et WebAssembly.

  • Lorsque les appels de fonctions sont très fréquents, le surcoût des appels entre JavaScript et Wasm peut devenir un goulot d'étranglement. Nous recommandons de regrouper les données et de les traiter en une seule fois autant que possible.
  • L'allocation et la copie de la mémoire augmentent la charge de traitement. Utilisez des tampons partagés et des pointeurs pour minimiser ces opérations.
  • Soyez prudent lors de la gestion des nombres à virgule flottante. En TypeScript, ils deviennent de type number, mais vous pouvez les gérer précisément en faisant correspondre les types du côté Wasm.

Résumé

En combinant TypeScript et WebAssembly, vous pouvez obtenir des performances proches du natif dans le navigateur. Ceci est particulièrement efficace pour les tâches à forte intensité de calcul ou lorsque vous souhaitez exploiter des ressources natives existantes. Cette combinaison est une option très puissante lorsque vous souhaitez améliorer les performances de votre application web.

Vous pouvez suivre l'article ci-dessus avec Visual Studio Code sur notre chaîne YouTube. Veuillez également consulter la chaîne YouTube.

YouTube Video