WebAssembly — бинарный формат инструкций для стек-машины, исполняемый в песочнице (браузер, серверный рантайм, embedded-плагин). Он не даёт готового рантайма: нет GC, нет стандартной библиотеки потоков или файловой системы «из коробки» — всё это либо предоставляет хост через импортируемые функции, либо линкуется в сам модуль. Именно поэтому языки с рантаймом (Go со своим GC-планировщиком, JVM-языки) проигрывают Rust по стартовому размеру и предсказуемости: Rust компилируется в WASM почти так же, как в нативный таргет — без сборщика мусора, с детерминированным временем жизни объектов и минимальным shim-слоем поверх системных вызовов.
// Компиляция в WASM без браузерного окружения (WASI)
// rustup target add wasm32-wasip1
// cargo build --target wasm32-wasip1 --release
// Компиляция для браузера/JS-интеропа (нет прямого доступа к ОС)
// rustup target add wasm32-unknown-unknown
// cargo build --target wasm32-unknown-unknown --release
Ключевое архитектурное решение — какой таргет выбрать. wasm32-unknown-unknown ничего не знает про системные вызовы: любой доступ к «внешнему миру» (DOM, fetch, файлы) идёт через явно объявленные extern-функции, которые предоставляет хост. wasm32-wasip1 (и новее — wasm32-wasip2) линкуется против WASI и получает стандартизованный набор системных примитивов — тогда можно писать почти обычный Rust с std::fs, но исполняться это будет вне браузера, в рантаймах вроде Wasmtime.
wasm-bindgen — это связка из proc-макроса, генератора JS/TypeScript-обёрток и рантайм-библиотеки. Макрос #[wasm_bindgen] на функции или структуре указывает инструменту, как сгенерировать биндинги: примитивные числовые типы (i32, f64 и т.д.) передаются через границу напрямую как аргументы WASM-функций, а всё остальное — строки, срезы, произвольные структуры — требует сериализации или прохода через индекс в специальной таблице объектов (JsValue).
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Counter {
value: i32,
}
#[wasm_bindgen]
impl Counter {
#[wasm_bindgen(constructor)]
pub fn new(initial: i32) -> Counter {
Counter { value: initial }
}
pub fn increment(&mut self, by: i32) -> i32 {
self.value += by;
self.value
}
#[wasm_bindgen(getter)]
pub fn value(&self) -> i32 {
self.value
}
}
// Строки и сложные структуры — через сериализацию
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
Из JS сгенерированный код выглядит как обычный ES-модуль:
import init, { Counter, greet } from "./pkg/mylib.js";
await init();
const c = new Counter(10);
console.log(c.increment(5)); // 15
console.log(greet("Rust"));
Для произвольных JS-объектов (например, вернуть JSON-подобную структуру без строгой схемы) используют JsValue напрямую вместе с serde-wasm-bindgen — это дороже, чем передача чисел, но проще типизированной ручной раскладки по памяти.
#[wasm_bindgen], живёт на Rust-стороне и передаётся в JS как непрозрачный указатель (handle). JS обязан вызвать .free() (генерируется автоматически при выходе объекта из области видимости в TS/JS-обёртках, но при ручном управлении легко забыть) — иначе получите утечку линейной памяти WASM-модуля.
wasm-pack оборачивает cargo build + wasm-bindgen-cli + опциональный wasm-opt в один шаг и генерирует готовый npm-пакет с package.json, типами .d.ts и JS-загрузчиком. Таргет сборки существенно влияет на то, как модуль будет подключаться потребителем.
# сборка под бандлер (webpack/vite) — модуль импортируется как ESM,
# инициализация WASM зашита в сгенерированный JS
wasm-pack build --target bundler --release
# сборка для прямого использования в браузере без бандлера
# (fetch + WebAssembly.instantiateStreaming вручную или через init())
wasm-pack build --target web --release
# сборка под Node.js (CommonJS require, синхронная загрузка)
wasm-pack build --target nodejs --release
# публикация как обычный npm-пакет
wasm-pack publish
| Таргет | Загрузка модуля | Типичный сценарий |
|---|---|---|
web | ручной await init(), ESM | прямой <script type="module">, без сборщика |
bundler | инициализация встроена в импорт | webpack, vite, rollup — совместимо с code-splitting |
nodejs | синхронный require | CLI-тулы, серверный код на Node.js |
no-modules | глобальная переменная | legacy-окружения без ESM |
На практике для продакшена почти всегда выбирают bundler (интеграция с существующим фронтенд-пайплайном) или web (когда WASM грузится лениво, отдельно от основного бандла, что важно для code-splitting тяжёлых модулей).
Крейт web-sys — это сгенерированные из WebIDL биндинги почти ко всему Web Platform API (DOM, Canvas, Fetch, WebSocket и т.д.), а js-sys — биндинги к встроенным JS-типам (Array, Promise, Map, Reflect). Оба крейта feature-gated: включаете только нужные типы, иначе счёт идёт на сотни фич-флагов и раздувание сборки.
use wasm_bindgen::prelude::*;
use web_sys::{window, Document, Element};
#[wasm_bindgen]
pub fn render_message(text: &str) -> Result<(), JsValue> {
let window = window().ok_or("no window")?;
let document: Document = window.document().ok_or("no document")?;
let el: Element = document.create_element("div")?;
el.set_text_content(Some(text));
document.body().unwrap().append_child(&el)?;
Ok(())
}
Асинхронный JS (Promise-based API вроде fetch) сводится к Rust-футурам через wasm-bindgen-futures: JsFuture оборачивает Promise, а spawn_local запускает Rust-таск на однопоточном браузерном event loop (полноценного multi-threaded executor тут нет — WASM в браузере по умолчанию single-threaded, если явно не задействованы Web Workers + SharedArrayBuffer).
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{Request, Response, window};
#[wasm_bindgen]
pub fn fetch_data(url: String) {
spawn_local(async move {
let req = Request::new_with_str(&url).unwrap();
let resp_value = JsFuture::from(window().unwrap().fetch_with_request(&req))
.await
.unwrap();
let resp: Response = resp_value.dyn_into().unwrap();
// resp.text() / resp.json() возвращают ещё один Promise
});
}
Колбэки (например, обработчики событий DOM) требуют Closure — обёртки, которая держит Rust-замыкание живым, пока JS может его вызвать. Забытый closure.forget() или неправильное время жизни — классический источник use-after-free на этой границе, поэтому Closure в API спроектирован так, чтобы явно требовать решения о владении (forget — намеренная утечка ради простоты, либо хранение в структуре для последующего drop).
WASM-модуль работает с единым линейным адресным пространством (ArrayBuffer со стороны JS). Всё, что не влезает в примитивный тип, должно быть либо скопировано через эту границу, либо доступно через прямой указатель в память модуля — второе (zero-copy) требует дисциплины, потому что JS должен точно знать оффсет и длину, а Rust не должен переместить/освободить буфер, пока JS его читает.
// Zero-copy передача буфера: Rust отдаёт указатель + длину,
// JS создаёт view поверх WASM-памяти без копирования
#[wasm_bindgen]
pub fn process_buffer(data: &[u8]) -> usize {
// wasm-bindgen сам копирует Uint8Array -> Vec<u8> на входе,
// т.к. владение должно быть однозначным на одной стороне
data.iter().filter(|&&b| b > 127).count()
}
// Настоящий zero-copy: экспортируем указатель на статический буфер,
// JS читает его через new Uint8Array(memory.buffer, ptr, len)
static mut SCRATCH: [u8; 4096] = [0; 4096];
#[wasm_bindgen]
pub fn scratch_ptr() -> *const u8 {
unsafe { SCRATCH.as_ptr() }
}
По умолчанию &[u8] / Vec<u8> в сигнатуре #[wasm_bindgen]-функции означает копирование: wasm-bindgen копирует Uint8Array из JS-памяти в WASM-память на входе и обратно на выходе. Для редких крупных вызовов это несущественно, но при частых вызовах в горячем пути (например, обработка кадров видео 60 раз в секунду) копирование каждого буфера становится доминирующей статьёй расходов — тогда оправдан явный проброс указателя/длины и работа с memory.buffer напрямую на JS-стороне.
Дефолтная сборка Rust даже в release-режиме включает панические сообщения, символы отладки в форме debug-info для DWARF и не всегда агрессивно инлайнит — типичный «hello world» с wasm-bindgen легко весит 150-300 KB до оптимизации. Для веба, где модуль качается по сети, это существенно.
// Cargo.toml — агрессивные настройки под размер
[profile.release]
opt-level = "z" # оптимизация под размер, не под скорость ("s" — мягче)
lto = true # link-time optimization — устраняет мёртвый код между крейтами
codegen-units = 1 # меньше параллелизма компиляции, лучше инлайнинг/dedup
panic = "abort" # убирает код раскрутки стека (unwinding) — экономит десятки KB
strip = true # убирает debug-символы из итогового артефакта
# wasm-opt (часть Binaryen) — постобработка .wasm:
# устраняет мёртвый код на уровне самого WASM, инлайнит, сокращает секции
wasm-opt -Oz -o output_opt.wasm output.wasm
# wasm-pack применяет wasm-opt автоматически при наличии в PATH
# (или через wasm-opt = ["-Oz"] в [package.metadata.wasm-pack.profile.release])
# twiggy — анализ, что именно занимает место в бинарнике
twiggy top -n 20 output_opt.wasm
twiggy monos output_opt.wasm # поиск мономорфизаций generic-кода, раздувающих размер
Аллокатор по умолчанию (dlmalloc в стандартной библиотеке) достаточно компактен для большинства случаев. Исторически рекомендовали wee_alloc как более лёгкую замену, но проект фактически заброшен (последние годы без активного мейнтенанса, известные баги с фрагментацией) — сейчас для тех редких случаев, когда нужен кастомный аллокатор меньшего размера или более предсказуемого поведения, смотрят в сторону talc или просто оставляют системный аллокатор, компенсируя размер через wasm-opt и LTO.
| Приём | Экономия | Компромисс |
|---|---|---|
opt-level = "z" | 10-30% | иногда медленнее рантайм-исполнение |
panic = "abort" | 20-50 KB | нет unwinding — паника всегда завершает процесс/трап |
wasm-opt -Oz | 15-40% | время сборки, требует отдельный бинарь Binaryen |
убрать std::fmt/паник-сообщения | заметно для маленьких модулей | менее информативные ошибки |
opt-level = "z" + lto = true + panic = "abort" + strip = true в Cargo.toml, затем wasm-opt -Oz поверх готового артефакта. Это стабильно даёт 3-5-кратное сокращение размера без изменения кода.
WASI (WebAssembly System Interface) — это принципиально другой подход к границе между модулем и хостом. Вместо того чтобы генерировать JS-специфичные биндинги (как wasm-bindgen), WASI стандартизирует набор системных вызовов — открытие файлов, сокеты, часы, случайные числа — по образцу POSIX, но с моделью безопасности на основе capability (модуль получает доступ только к тем ресурсам, которые ему явно передал хост при инстанцировании).
| wasm-bindgen (wasm32-unknown-unknown) | WASI (wasm32-wasip1/p2) | |
|---|---|---|
| Среда исполнения | браузер, JS-рантайм | Wasmtime, Wasmer, серверные/edge-платформы |
| Доступ к ОС | нет, всё через JS-хост | стандартизованные системные вызовы |
std::fs, std::net | не работает без ручных биндингов | работает «из коробки» (в рамках capability) |
| Типичный сценарий | интерактивный UI, тяжёлые вычисления в вебе | CLI-инструменты, serverless/edge-функции, плагины |
// Обычный std-код, который просто работает под WASI —
// никаких extern "C" или #[wasm_bindgen] не нужно
use std::fs;
use std::env;
fn main() -> std::io::Result<()> {
let path = env::args().nth(1).expect("usage: prog <file>");
let contents = fs::read_to_string(&path)?;
println!("{} bytes", contents.len());
Ok(())
}
// cargo build --target wasm32-wasip1 --release
// wasmtime --dir=. target/wasm32-wasip1/release/prog.wasm -- input.txt
Практические сценарии WASI: изоляция плагинов третьих сторон в вашем приложении (модуль не может выйти за пределы выданных ему capability — сильнее, чем контейнер по гранулярности), serverless/edge-функции (Fastly Compute, Cloudflare Workers частично, Fermyon Spin) — холодный старт WASM-модуля на порядки быстрее контейнера, встраиваемые скриптовые движки внутри более крупного нативного приложения.
Экосистема сейчас в переходе к WASI Preview 2 и component model — вместо плоского набора POSIX-подобных функций (Preview 1) вводятся типизированные интерфейсы (WIT — WASM Interface Types) с поддержкой составных типов, ресурсов и композиции нескольких WASM-компонентов между собой без пересборки. Это ещё не так стабильно обкатано в проде, как Preview 1, но именно туда движется тулинг (cargo component, wit-bindgen).
Практический чек-лист перед тем, как тянуть WASM в проект: профилируйте JS-версию первой и убедитесь, что узкое место — вычисления, а не I/O или рендеринг; оцените размер полезной нагрузки (лишние 200-500 KB на медленном мобильном канале — это заметная задержка первого рендера); заложите время на отладочный тулинг (source maps для WASM менее зрелые, чем для JS).