← Назад к списку тем

28. WebAssembly

wasm-bindgen, wasm-pack, взаимодействие с JS, размер бинарника, wasm-opt, WASI.

Почему Rust — естественный кандидат для WASM

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.

🔑 Rust не «портирует» рантайм в WASM — он компилируется в него так же, как в любой другой таргет LLVM. Нет GC-паузы, нет скрытых аллокаций рантайма, размер стартового шима — единицы KB против сотен KB у языков со сборщиком мусора.

wasm-bindgen: граница между Rust и JS

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: сборка и упаковка под npm

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синхронный requireCLI-тулы, серверный код на Node.js
no-modulesглобальная переменнаяlegacy-окружения без ESM

На практике для продакшена почти всегда выбирают bundler (интеграция с существующим фронтенд-пайплайном) или web (когда WASM грузится лениво, отдельно от основного бандла, что важно для code-splitting тяжёлых модулей).

Взаимодействие с Web API: web-sys, js-sys, async

Крейт 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/JS (в цикле, по одному примитиву за раз) обычно медленнее, чем эквивалентный код целиком на JS — каждый вызов это переход контекста плюс, возможно, аллокация на одной из сторон. Проектируйте API так, чтобы граница пересекалась редко и крупными порциями данных (batch), а не гранулярно.

Размер бинарника: wasm-opt, аллокаторы, profile.release

Дефолтная сборка 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 -Oz15-40%время сборки, требует отдельный бинарь Binaryen
убрать std::fmt/паник-сообщениязаметно для маленьких модулейменее информативные ошибки
✅ Типичный путь: opt-level = "z" + lto = true + panic = "abort" + strip = true в Cargo.toml, затем wasm-opt -Oz поверх готового артефакта. Это стабильно даёт 3-5-кратное сокращение размера без изменения кода.

WASI: WebAssembly вне браузера

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-bindgen и WASI решают разные задачи и почти не пересекаются: первый — про интеграцию с конкретным JS-хостом и его объектной моделью, второй — про переносимый системный интерфейс, не зависящий от JS вообще. Смешивать их в одном таргете сборки не получится — это разные target triple.

Когда WASM оправдан, а когда нет

  • Оправдано: compute-intensive задачи в браузере — обработка изображений/видео, кодеки, физические движки, криптография, парсинг больших форматов (сравнимо с нативной скоростью, в разы быстрее JS на числодробительных циклах).
  • Оправдано: переиспользование существующей Rust/C++ кодовой базы в вебе без переписывания на JS/TS (например, общее ядро бизнес-логики между backend и frontend).
  • Оправдано: песочница для недоверенного кода — плагины, пользовательские скрипты, serverless с быстрым холодным стартом.
  • Не оправдано: типовой CRUD UI, работа с DOM как основная нагрузка — граница Rust/JS на каждое обращение к DOM обычно медленнее, чем просто писать эту логику на JS/TS, плюс тяжелее в отладке.
  • Не оправдано: «модно» как единственная причина — WASM добавляет шаг сборки, увеличивает время разработки (два языка, границу между ними, отладку через source maps) и стоит того только когда измеримый профиль показывает, что именно вычисления — узкое место.

Практический чек-лист перед тем, как тянуть WASM в проект: профилируйте JS-версию первой и убедитесь, что узкое место — вычисления, а не I/O или рендеринг; оцените размер полезной нагрузки (лишние 200-500 KB на медленном мобильном канале — это заметная задержка первого рендера); заложите время на отладочный тулинг (source maps для WASM менее зрелые, чем для JS).

✅ Золотое правило: WASM выигрывает там, где доминируют чистые вычисления над данными уже в памяти. Как только в горячем пути появляется частое пересечение границы Rust/JS с мелкими объектами — выгода тает или уходит в минус.