У WebAssembly в браузерах судьба неровная. Одни видят в нём способ перенести на фронтенд любые вычисления и получить скорость нативного кода, другие помнят лишние сто килобайт к бандлу и часы возни с загрузкой модуля. Обе картины неполные. Замеры на наборе SPEC CPU дают Wasm в среднем полтора раза медленнее нативных сборок, но против JavaScript на горячих вычислительных циклах он выигрывает кратно, и именно здесь Rust оказывается удобным источником модулей. Разберём, где прибавка к скорости реальна, чем за неё платят и что встретит команда, которая решит встроить Wasm в существующий фронтенд на Vite или Webpack.
Откуда берётся разница в скорости между WebAssembly и JavaScript
Механика выигрыша понятна без деталей. JavaScript движок исполняет код через JIT: сначала интерпретирует, потом компилирует горячие функции, специализирует их под замеченные типы и выбрасывает оптимизации, если предположения рушились. Сборка мусора идёт параллельно с работой, динамические типы проверяются в рантайме. Wasm приходит в браузер уже проверенным и скомпилированным: типы известны на входе, память линейная и своя, сборщика мусора внутри модуля нет. Движку нечего угадывать, он просто генерирует машинный код.
Практическое следствие для вычислений: мусор не собирается посреди цикла, потому что его там нет, аллокации идут явными вызовами внутри модуля, а горячий цикл не деоптимизируется из-за того, что в массиве однажды встретилась строка. Предсказуемость это отдельный вид скорости: девяносто девятый перцентиль задержки обработки кадра важнее среднего значения, и здесь скомпилированный модуль дисциплинированнее интерпретатора.
Разрыв с нативным кодом измерен на больших программах: замеры на наборе SPEC CPU показали среднее замедление Wasm на 45 процентов в Firefox и на 55 процентов в Chrome, с пиками 2.08 и 2.5 раза. На маленьких числовых ядрах разрыв близок к паритету, около 10 процентов. Против JavaScript картина переворачивается: на горячих циклах с числами и массивами модуль из Rust выигрывает кратно, а на обвязке, обработке событий и работе с разметкой преимущества нет, там JavaScript и так достаточно быстр. Характер задачи решает больше, чем сам факт переезда: целочисленные циклы и обработка Float64Array выигрывают сильнее всего, строковая обвязка и вызовы внешних сервисов почти не выигрывают, а генерация разметки не выигрывает никогда.
Сборочный конвейер Rust в Wasm через wasm-pack и wasm-bindgen
Конвейер сборки короткий. Компилятору добавляется цель, инструменты ставятся пакетным менеджером:
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
В манифесте указывается библиотечный тип крейта и зависимость связки:
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2.129"
Сборка на веб-цель выдаёт готовый ES-модуль с обёрткой:
wasm-pack build --target web
Макрос wasm_bindgen превращает функции Rust в экспорты. Пример честного вычислительного ядра, перевод RGBA буфера в градации серого по формуле BT.601:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn grayscale(rgba: &[u8]) -> Vec<u8> {
rgba.chunks_exact(4)
.flat_map(|px| {
let lum = (px[0] as u32 * 299 + px[1] as u32 * 587 + px[2] as u32 * 114) / 1000;
[lum as u8, lum as u8, lum as u8, px[3]]
})
.collect()
}
Версии на момент работы: wasm-pack 0.15.0, wasm-bindgen 0.2.129, web-sys 0.3.106. Крейт web-sys открывает браузерные API через типизированные обёртки, js-sys даёт стандартные конструкции вроде Promise и Reflect, комплект gloo собирает частые связки вроде таймеров и работы с файлами в готовые интерфейсы.
Макрос берёт на себя и грязную работу: генерирует JS-клей, экспортирует функции, конвертирует типы. Vec<u8> на выходе становится Uint8Array, срез на входе собирается из переданного массива. Паники модуля по умолчанию теряются в консоли, поэтому при старте ставят хук, а релиз поджимают профилем:
#[wasm_bindgen(start)]
fn setup() {
console_error_panic_hook::set_once();
}
[dependencies]
console_error_panic_hook = "0.1"
[profile.release]
opt-level = "s"
lto = true
Уровень оптимизации s и линковка LTO ужимают байткод в обмен на время сборки, для фильтров и кодеков сделка честная. Тесты живут рядом: wasm-bindgen-test запускает проверки прямо в браузере, команда wasm-pack test --headless --chrome гоняет их в CI без открытого окна.
Реальные цифры производительности и почему они зависят от характера задачи
Цифры из исследований стоит читать с законом Амдаля в голове. Если вычислительное ядро занимает 90 процентов времени страницы, его ускорение в пять раз сократит общее время почти втрое. Если ядро занимает 10 процентов, даже десятикратный рывок даст странице всего 9 процентов. Отсюда рабочий критерий один: сначала профилировщик, потом Wasm, а не наоборот.
Домашний замер делается за минуты: одна и та же функция на чистом JavaScript, тот же вызов через модуль, серия прогонов и Performance API:
const t0 = performance.now();
for (let i = 0; i < 200; i++) grayscale(frame);
const wasmMs = performance.now() - t0;
Оба варианта прогревают перед замером, сравнивают медиану, а не лучший прогон, и гоняют на данных реального размера, а не на буфере из тысячи элементов. Чужие бенчмарки годятся как ориентир, свой профиль важнее, и обещанный выигрыш в разы честно подтверждается только на реальном ядре.
Платформа добавляет множители. 128-битные SIMD инструкции вошли в браузерные движки в 2021 году, Safari подтянулся позже, и Rust собирает их по явному флагу:
RUSTFLAGS="-C target-feature=+simd128" wasm-pack build --target web --release
Потоки дают честную параллельность через SharedArrayBuffer, а для задач, привыкших к Rayon, есть крейт wasm-bindgen-rayon, разворачивающий пул воркеров прямо в браузере. На обработке кадров видео комбинация SIMD и потоков превращает разы в десятки, но только если данные не пересекают границу модуля на каждом шаге.
Стоимость границы между JavaScript и Wasm и правильная форма обмена данными
Скорость чаще утекает не из модуля, а на его границе. Каждый вызов экспорта пересекает раздел между двумя мирами: значения конвертируются, числа и строки копируются, контекст переключается. Один вызов на пиксель убивает всю выгоду от скомпилированного кода, один вызов на весь буфер возвращает её обратно. Плохой дизайн виден сразу:
for (const px of pixels) {
out.push(module.convertPixel(px));
}
Правильный дизайн передаёт данные целиком:
const out = module.grayscale(rgba);
Большие массивы живут в линейной памяти Wasm, JavaScript видит их как TypedArray, и копируются данные, а не вызовы. Линейная память растёт страницами по 64 килобайта, представление на неё выдаётся одним обращением, поэтому мультимегабайтный кадр проходит через память дешевле, чем тысячи маленьких значений по одному. Отдельная плата на границе это строки: JavaScript держит их в UTF-16, Rust в UTF-8, и каждая передача строки через раздел означает конвертацию кодировок. Длинные тексты разумно передавать байтами и собирать уже внутри модуля. Для структур сложнее чисел есть serde-wasm-bindgen, но каждая конвертация в JsValue стоит времени, поэтому приём один и тот же: меньше конвертаций, крупнее пакеты. Граница модулей это интерфейс по расходам, и проектируют её так же тщательно, как внешнее API.
Подключение модуля в существующий фронтенд на Vite или Webpack
Готовый модуль встраивается в проект парой строк:
import init, { grayscale } from "./pkg/image_filter.js";
await init();
const out = grayscale(rgba);
Выход цели web это обычный ES-модуль, который Vite подхватывает напрямую. В Webpack 5 для асинхронных Wasm сборок включается флаг experiments.asyncWebAssembly в конфигурации. Загрузка идёт через WebAssembly.instantiateStreaming, которой от сервера нужен MIME тип application/wasm, и это единственная обязательная серверная правка.
Тяжёлый модуль грузят лениво, динамическим импортом в момент первого обращения:
const { init, grayscale } = await import("./pkg/image_filter.js");
await init();
Так редактор изображений не платит за фильтры до открытия первого снимка, а сам файл кешируется браузером как обычная статика, имя с хешем от содержимого обновляет кеш автоматически.
За размером бандла стоят инструменты Binaryen:
wasm-opt -O3 -o pkg/image_filter_bg.wasm pkg/image_filter_bg.wasm
wasm-strip pkg/image_filter_bg.wasm
Первый дожимает и ускоряет байткод, второй срезает отладочные секции в релизе, а twiggy показывает, что именно раздувает модуль. Типичный модуль без лишних зависимостей умещается в десятки килобайт и сжимается gzip на порядок, так что транспорт для пользователя почти ничего не стоит.
Ограничения платформы от DOM и отладки до заголовков для потоков
Список ограничений полезно выучить заранее. DOM из Wasm недоступен напрямую: web-sys это обёртки над JavaScript вызовами, и работа с разметкой остаётся во фронтенде. Отладка беднее исходного Rust: source maps для Wasm в движках всё ещё развиваются, имена функций спасает секция name, которую wasm-bindgen сохраняет в сборке, логи уходят в консоль через web-sys без потерь, а вот точек останова внутри модуля инструменты дают меньше, чем хотелось бы.
Потоки требуют от страницы кросс-доменной изоляции, без неё SharedArrayBuffer недоступен:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Заголовки простые, но каждый сторонний ресурс страницы обязан соблюдать CORP, и на проектах с внешними виджетами это первый подводный камень.
Для отзывчивого интерфейса модуль уезжает в воркер: главный поток не дёргается, кадры обрабатываются параллельно.
const worker = new Worker(new URL("./filter.worker.ts", import.meta.url), { type: "module" });
worker.postMessage(frame, [frame.buffer]);
Передача буфера через transferable не копирует данные, воркер с Wasm внутри возвращает готовый кадр, страница в это время продолжает реагировать на ввод. Ещё одна деталь платформы: движки уже завезли сборщик мусора прямо в Wasm, однако Rust-мир живёт на линейной памяти, и для вычислительных ядер это скорее плюс, контроль над аллокациями остаётся у разработчика.
Когда выбор в пользу Wasm оправдан и когда это лишний слой
Список честных кандидатов на переезд короткий:
- обработка изображений, звука и видео прямо на клиенте;
- парсеры и компиляторы больших структур в браузере;
- криптография, архивация и кодирование данных;
- повторное использование готовых C, C++ или Rust ядер без переписывания.
Список подтверждается продуктами. Движок рендеринга Figma собран из C++ в Wasm, Google Earth переехал в браузер тем же путём, ядро веб-версии Photoshop собрано в Wasm, ffmpeg и SQLite имеют официальные браузерные сборки. Общее у всех случаев одно: тяжёлое вычислительное ядро уже существовало и просто сменило платформу, а JavaScript остался слоем интерфейса вокруг него.
Противопоказания симметричны. UI логика, работа с разметкой, простые трансформации форм и вызовы внешних API на Wasm не ускоряются, бандл тяжелеет, а отладка усложняется. Если вычислительного ядра нет и его нормально написать на JavaScript, Wasm станет лишним слоем с налогом на сопровождение: второй язык, второй конвейер сборки, вторая отладка в проекте.
Взвешенный итог для команды выглядит так. WebAssembly из Rust это не замена JavaScript во фронтенде, а ускоритель его горячих участков: полуторакратного запаса против нативного кода хватает, чтобы на числовых ядрах уходить от JavaScript в разы, а цена интеграции с современными wasm-pack и wasm-bindgen свелась к паре строк импорта и одному MIME типу на сервере. Переезд планируют от профилировщика: ядро, съедающее основное время, окупает конвейер сборки, обвязка не окупит и часа. Границу модулей держат узкой: один вызов на буфер вместо тысячи вызовов на пиксель, линейная память вместо конвертаций, DOM в JavaScript, тяжёлое в воркере. Тогда Wasm приносит то, ради чего его и пускают во фронтенд: скорость, недоступную интерпретатору, и надёжность типизированного Rust там, где раньше жила горячая циклом мегафункция на JavaScript.