Rust et WebAssembly
WebAssembly (souvent abrégé en Wasm) permet d’exécuter du code écrit dans des langages comme Rust à l’intérieur d’un navigateur web, à côté de JavaScript. Voyez-le comme un second moteur dans le navigateur : JavaScript pilote toujours la page, mais quand il y a un travail lourd (filtres d’image, physique, analyse de gros fichiers), vous pouvez le confier à du code Rust compilé et rapide.
Sur cette page, vous allez écrire une toute petite bibliothèque Rust, la compiler en Wasm et l’appeler depuis une page web. Rien de tout cela ne tourne dans le Playground Rust (il faut un navigateur et des outils supplémentaires) : la plupart des exemples sont donc de simples blocs de code à essayer sur votre machine. Les parties en Rust pur, en revanche, vous pouvez les tester tout de suite.
Qu’est-ce que WebAssembly, et pourquoi Rust s’y prête si bien
Section intitulée « Qu’est-ce que WebAssembly, et pourquoi Rust s’y prête si bien »WebAssembly est un format binaire compact pour les programmes. Tous les grands navigateurs savent l’exécuter, à une vitesse proche du natif, dans le même bac à sable de sécurité que JavaScript. Et ce n’est pas réservé aux navigateurs : des serveurs et des plateformes en périphérie de réseau (edge) exécutent aussi du Wasm.
Rust est l’un des langages les plus populaires pour Wasm, pour plusieurs raisons :
- Pas de ramasse-miettes (garbage collector) ni de gros runtime. Rust n’a pas besoin d’embarquer de mécanique supplémentaire avec votre code, donc les fichiers
.wasmpeuvent rester petits. - D’excellents outils.
wasm-bindgenécrit pour vous le code de liaison entre Rust et JavaScript, etwasm-packconstruit le tout en une seule commande. - Sûreté et vitesse. Vous gardez la même sécurité mémoire et les mêmes performances que sur un ordinateur de bureau.
Installer les outils
Section intitulée « Installer les outils »D’abord, indiquez à Rust qu’il peut compiler pour la cible (target) Wasm. Une cible, c’est « le type de machine sur lequel le code va tourner » ; wasm32-unknown-unknown signifie « Wasm 32 bits, sans système d’exploitation particulier » :
rustup target add wasm32-unknown-unknownEnsuite, installez wasm-pack. Il compile votre crate, lance wasm-bindgen et range tout ce dont vous avez besoin dans un seul dossier. Utilisez l’installeur officiel, ou compilez-le avec Cargo :
# Installeur Linux / macOS, depuis le site de wasm-packcurl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# ou, sur n'importe quel système où Rust est installécargo install wasm-pack --lockedÉcrire une bibliothèque Rust pour Wasm
Section intitulée « Écrire une bibliothèque Rust pour Wasm »Un module Wasm est une bibliothèque, pas un programme avec un main. Créez-en une :
cargo new --lib hello-wasmcd hello-wasmPuis modifiez Cargo.toml. La ligne crate-type demande à Cargo de produire une bibliothèque adaptée à .wasm (cdylib), et wasm-bindgen est la crate qui relie Rust et JavaScript :
[package]name = "hello-wasm"version = "0.1.0"edition = "2021"
[lib]crate-type = ["cdylib", "rlib"]
[dependencies]wasm-bindgen = "0.2"Écrivez maintenant deux fonctions dans src/lib.rs. L’attribut #[wasm_bindgen] marque chacune d’elles comme « appelable depuis JavaScript » :
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn add(a: i32, b: i32) -> i32 { a + b}
#[wasm_bindgen]pub fn greet(name: &str) -> String { format!("Bonjour, {name} ! Cette phrase a été construite en Rust.")}C’est tout le Rust dont vous avez besoin. Remarquez que ce sont des fonctions ordinaires : wasm-bindgen se charge de transformer une chaîne JavaScript en &str, et la String renvoyée de nouveau en chaîne JavaScript.
Tester d’abord la logique en natif
Section intitulée « Tester d’abord la logique en natif »Voici une habitude qui fait gagner beaucoup de temps : gardez le vrai travail dans des fonctions Rust ordinaires, et testez-les sur votre ordinateur avec cargo test ou un petit main. Déboguer dans un navigateur est plus lent que dans un terminal. La même logique, sans l’attribut, tourne partout :
fn add(a: i32, b: i32) -> i32 { a + b}
fn greet(name: &str) -> String { format!("Bonjour, {name} ! Cette phrase a été construite en Rust.")}
fn main() { println!("{}", greet("Ferris")); println!("2 + 3 = {}", add(2, 3));}Sortie :
Bonjour, Ferris ! Cette phrase a été construite en Rust.2 + 3 = 5Une fois la logique correcte, l’enveloppe #[wasm_bindgen] n’est qu’une mince porte vers le navigateur. (Les tests marqués #[test] tournent aussi en natif avec cargo test : voir Les tests.)
Compiler avec wasm-pack
Section intitulée « Compiler avec wasm-pack »Depuis le dossier de la crate, lancez :
wasm-pack build --target web--target web signifie « produire des fichiers qu’un navigateur peut charger directement avec une balise <script type="module"> », sans avoir besoin d’un bundler comme webpack. Chez nous, le nouveau dossier pkg/ contenait :
pkg/├── hello_wasm_bg.wasm le code Rust compilé├── hello_wasm.js le code de liaison JavaScript qui le charge et enveloppe chaque fonction├── hello_wasm.d.ts les types TypeScript de vos fonctions├── hello_wasm_bg.wasm.d.ts└── package.jsonLe tiret de hello-wasm est devenu un tiret bas dans les noms de fichiers. Par défaut, wasm-pack build fait un build release (optimisé) ; ajoutez --dev pour obtenir un build plus rapide et non optimisé pendant vos essais.
L’appeler depuis JavaScript
Section intitulée « L’appeler depuis JavaScript »Créez index.html à côté de Cargo.toml (pour que pkg/ se trouve juste à côté) :
<!doctype html><html lang="fr"><head> <meta charset="utf-8"> <title>Bonjour depuis Rust</title></head><body> <p id="message">Chargement…</p>
<script type="module"> import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // télécharge et démarre le fichier .wasm
const text = greet("Ferris"); const sum = add(2, 3); document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`; </script></body></html>L’export par défaut, init(), télécharge le fichier .wasm et le démarre. Ensuite, add et greet sont des fonctions JavaScript normales.
Les navigateurs refusent de charger des modules depuis une adresse file:// : il vous faut donc un petit serveur web local. Si Python est installé, voici le plus rapide :
python3 -m http.server 8080Ouvrez http://localhost:8080 dans votre navigateur. Chez nous, la page affichait :
Bonjour, Ferris ! Cette phrase a été construite en Rust. 2 + 3 = 5Cette phrase a été construite par Rust, en train de tourner dans le navigateur. 🦀
Ce qui traverse la frontière à bas prix (et ce qui coûte)
Section intitulée « Ce qui traverse la frontière à bas prix (et ce qui coûte) »Rust et JavaScript ne partagent pas la mémoire de la même façon. Le module Wasm possède son propre bloc de mémoire, et les valeurs doivent voyager d’un côté à l’autre. Certains trajets sont gratuits, d’autres coûtent une copie :
| Type en Rust | En JavaScript | Coût |
|---|---|---|
i32, u32, f64, bool |
number, boolean |
Faible : passé directement |
&str, String |
string |
Une copie, plus une conversion UTF-16 vers UTF-8 (ou l’inverse) |
&[u8], Vec<u8> |
Uint8Array |
Une copie vers (ou depuis) la mémoire Wasm |
&mut [u8] |
Uint8Array |
Copié à l’entrée, puis recopié après l’appel |
La règle d’or est donc : faire peu d’appels, mais qui font chacun beaucoup de travail. Appeler Rust une fois pour traiter toute une image est rapide. Appeler Rust un million de fois, une fois par pixel, revient à passer l’essentiel du temps à franchir la frontière.
Voici à quoi cela ressemble. Cette fonction éclaircit tout un tampon de valeurs de pixels en un seul appel :
use wasm_bindgen::prelude::*;
/// Rend chaque pixel plus clair. JS passe un Uint8Array.#[wasm_bindgen]pub fn brighten(pixels: &mut [u8], amount: u8) { for p in pixels.iter_mut() { *p = p.saturating_add(amount); }}Côté JavaScript, après await init(), vous appelleriez brighten(pixels, 20) avec un Uint8Array, et le tableau serait modifié sur place. La boucle elle-même est du Rust pur, vous pouvez donc la tester en natif :
fn brighten(pixels: &mut [u8], amount: u8) { for p in pixels.iter_mut() { // saturating_add s'arrête à 255 au lieu de déborder *p = p.saturating_add(amount); }}
fn main() { let mut pixels = [10, 100, 250]; brighten(&mut pixels, 20); println!("{pixels:?}");}Sortie :
[30, 120, 255]Remarquez que 250 + 20 a donné 255, et non un plantage ou un retour à zéro : saturating_add s’arrête à la valeur maximale. C’est exactement ce qu’il faut pour des couleurs.
Garder le fichier .wasm léger
Section intitulée « Garder le fichier .wasm léger »Chaque octet de votre fichier .wasm doit être téléchargé avant que la page puisse s’en servir. Quelques conseils :
- Livrez toujours le build release. Pour notre petit exemple,
wasm-pack build --deva produit un fichier.wasmde 58 603 octets ; le build release par défaut faisait 21 546 octets. (Mesuré sur notre machine avecwasm-optdésactivé : vos chiffres seront différents.) - Laissez wasm-pack lancer
wasm-opt. En mode release, wasm-pack passe par défaut le résultat dans l’optimiseurwasm-optde Binaryen, ce qui le réduit généralement encore. - Activez la compression sur votre serveur. Notre fichier de 21 546 octets ne pesait plus que 8 479 octets après gzip.
- Essayez des réglages orientés taille, puis mesurez :
[profile.release]opt-level = "z" # optimiser la taille ("s" est une version plus douce)lto = truecodegen-units = 1Soyez honnête avec vous-même et mesurez. Pour notre exemple à deux fonctions, ces réglages n’ont pas aidé : le fichier est ressorti à 22 833 octets, un peu plus gros. Ils sont en général plus rentables sur de grosses crates. Des choses comme format! et les messages de panic ajoutent aussi du code : une crate qui les évite peut être beaucoup plus petite.
Où Wasm est utilisé dans la vraie vie
Section intitulée « Où Wasm est utilisé dans la vraie vie »Quelques exemples, chacun avec l’article de l’entreprise concernée (en anglais) :
- Figma compile son éditeur de design écrit en C++ vers WebAssembly et a constaté un temps de chargement divisé par 3. (Ce n’est pas du Rust, mais cela montre ce que Wasm peut faire dans une grosse application.)
- Cloudflare Workers prend en charge Rust grâce à la crate
workers-rs: votre code Rust est compilé en Wasm et tourne sur les serveurs de Cloudflare partout dans le monde. - Shopify Functions sont des modules WebAssembly qui personnalisent les boutiques, et Shopify recommande Rust comme le langage le plus performant pour les écrire.
- L’extension de navigateur de 1Password a fait passer son remplissage de pages à WebAssembly et l’a mesuré au moins deux fois plus rapide.
Erreurs courantes
Section intitulée « Erreurs courantes »Oublier crate-type = ["cdylib"]. Sans cette ligne, wasm-pack s’arrête tout de suite. Heureusement, le message d’erreur (en anglais) vous dit exactement quoi ajouter :
Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]crate-type = ["cdylib", "rlib"]Ouvrir index.html par un double-clic. La page reste bloquée sur « Chargement… », et la console du navigateur (dans Chrome) affiche :
Access to script at 'file:///…/pkg/hello_wasm.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-extension, chrome-untrusted, data, http, https, isolated-app.Autrement dit, la politique CORS bloque le script parce qu’il est chargé depuis file://. La solution : servez le dossier avec un serveur web local, comme python3 -m http.server, et ouvrez plutôt l’adresse http://localhost.
Appeler une fonction avant await init(). Le module Wasm n’est pas encore chargé, et le code de liaison n’a donc rien à appeler. Quand nous avons supprimé la ligne init(), Chrome a affiché Cannot read properties of undefined (reading '__wbindgen_free'). Faites toujours await init() en premier.
Et ensuite : des applications web complètes en Rust
Section intitulée « Et ensuite : des applications web complètes en Rust »Appeler quelques fonctions Rust depuis JavaScript est un excellent début. Si vous voulez construire toute l’application web en Rust, regardez ces frameworks. Tous deux compilent en Wasm et vous permettent d’écrire des composants en Rust, un peu comme avec React :
- Leptos (en anglais) : un framework rapide et moderne, qui sait aussi générer les pages côté serveur.
- Yew (en anglais) : un framework mûr, basé sur des composants.
Récapitulatif
Section intitulée « Récapitulatif »- WebAssembly est un format binaire rapide et sûr que les navigateurs (et les serveurs) savent exécuter ; Rust s’y compile très bien car il n’a pas de ramasse-miettes.
- Ajoutez la cible avec
rustup target add wasm32-unknown-unknown, et installezwasm-pack. - Marquez les fonctions avec
#[wasm_bindgen], réglezcrate-type = ["cdylib", "rlib"], et compilez avecwasm-pack build --target web. - En JavaScript, faites d’abord
await init(), puis appelez vos fonctions ; servez la page enhttp://, pas enfile://. - Les nombres traversent la frontière à bas prix ; les chaînes et les slices sont copiées : faites donc peu d’appels, mais bien remplis.
- Livrez des builds release compressés, et mesurez avant de faire confiance aux réglages de taille.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Quelle commande ajoute la cible Rust pour le WebAssembly du navigateur ?
2. Que fait l'attribut #[wasm_bindgen] ?
3. Votre page reste bloquée sur « Chargement… » et la console affiche une erreur CORS pour une adresse file:///. Que faut-il faire ?
4. Lequel de ces éléments coûte le moins cher à passer de JavaScript à Rust ?
Score : 0 / 4
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Compiling from Rust to WebAssembly — MDN (en anglais) : un tutoriel pas à pas qui construit et lance un projet wasm-pack.
- The wasm-bindgen Guide (en anglais) : tous les types pris en charge, comment appeler les API du navigateur, et de nombreux exemples.
- Documentation de wasm-pack (en anglais) : toutes les options de
build, les cibles et les profils. - wasm32-unknown-unknown — The rustc book (en anglais) : ce que cette cible prend en charge, et ses limites.
- WebAssembly sur MDN : ce qu’est Wasm et comment les navigateurs l’exécutent, indépendamment de Rust.