Мост Rust и JS: wasm-bindgen, срезы без копий и колбэки
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Мост Rust и JS: wasm-bindgen, срезы без копий и колбэки
В прошлом уроке мы увидели голую правду границы: через неё ходят только числа четырёх типов, а строку «вернуть» это значит положить байты в линейную память и отдать наружу пару чисел, смещение и длину. Работает, но писать руками такие плоские обёртки для каждой функции, а на стороне JS ещё и читать память по указателю, скучно и легко ошибиться.
wasm-bindgenавтоматизирует ровно эту скуку: он генерирует обёртки с обеих сторон, и через границу будто сами собой начинают ходить строки, структуры, срезы и даже замыкания. Сегодня разберём, как он это делает и где у него цена. А в конце честно посмотрим, почему наши собственные движки (bcpu, NES, netcode)wasm-bindgenнамеренно не используют, и когда какой путь правильный.
Что вообще делает wasm-bindgen
wasm-bindgen это пара из двух частей: процедурный макрос-атрибут #[wasm_bindgen] на стороне Rust и генератор JS-обёртки на стороне сборки. Помечаешь функцию атрибутом, и она получает человеческую сигнатуру со строками и структурами, а всю возню с упаковкой в линейную память макрос дописывает за тебя.
Вот канонический пример. Слева то, что ты пишешь, и больше ничего:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Привет, {name}!")
}
&str на входе и String на выходе это не типы значений WebAssembly, их там нет. Но из JS это выглядит как обычная функция greet("мир"), которая возвращает строку. Под капотом сгенерированная обёртка делает ровно то, что мы делали руками: кодирует строку в UTF-8, кладёт байты в линейную память, передаёт через границу смещение и длину, на выходе читает результат обратно и собирает JS-строку. Просто это написал не ты.
Экспорт структур: указатель становится классом
Самое заметное удобство это структуры. Помечаешь struct и его impl, и на стороне JS появляется полноценный класс с конструктором и методами:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Counter {
value: i32,
}
#[wasm_bindgen]
impl Counter {
#[wasm_bindgen(constructor)]
pub fn new(start: i32) -> Counter {
Counter { value: start }
}
pub fn bump(&mut self) {
self.value += 1;
}
#[wasm_bindgen(getter)]
pub fn value(&self) -> i32 {
self.value
}
}
import { Counter } from './pkg/my_crate.js';
const counter = new Counter(10);
counter.bump();
console.log(counter.value); // 11
А что за объект на самом деле держит JS? Не копию структуры. Сам Counter живёт байтами в линейной памяти Rust, а JS-объект хранит лишь его адрес там. Методы это вызовы экспортированных функций с этим адресом первым аргументом. Помнишь, в прошлом уроке нам пришлось прятать машину bcpu в thread_local, потому что плоский ABI не умел отдать наружу владение структурой? Вот wasm-bindgen именно это и автоматизирует: даёт JS ручку на Rust-объект и переводит вызовы методов в плоские функции. Цена за это: пока JS держит объект, его надо явно освободить вызовом .free(), иначе байты в линейной памяти не вернутся (сборщик мусора JS про чужую кучу ничего не знает).
Срезы и нулевое копирование
С байтовыми буферами есть тонкость, которая важна для эмуляторов. Если объявить параметр как &[u8], wasm-bindgen скопирует срез через границу: выделит место в линейной памяти, перельёт туда байты из JS, отдаст Rust. Для маленьких данных это незаметно, а для кадра картинки 256 на 240 или потока аудио, который гоняется 60 раз в секунду, копия туда и обратно на каждый кадр это уже заметный налог.
Поэтому для горячих больших буферов идут другим путём, тем самым «отдать указатель и длину», только теперь со стороны Rust. Rust говорит JS, где в линейной памяти лежит кадр, а JS читает этот участок напрямую, без копии, через окно Uint8Array поверх памяти. На стороне Rust это js_sys::Uint8Array::view, который оборачивает срез без копирования. Операция unsafe, и не просто так: окно живёт, только пока память не переаллоцирована, а любой рост кучи в Rust отвязывает старый буфер.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn frame_view(engine: &Engine) -> js_sys::Uint8Array {
// Окно поверх линейной памяти, без копии. Валидно до следующего роста памяти.
unsafe { js_sys::Uint8Array::view(engine.framebuffer()) }
}
Запомни правило: нулевое копирование на горячем пути (кадр, аудио, пакет), копию для маленького и разового. Брать окно заново перед каждым чтением, а не кэшировать его в переменной.
Звать JS из Rust: js-sys и web-sys
До сих пор JS звал Rust. Обратно тоже можно, и это тот же механизм импортов из прошлого урока. Объявляешь внешнюю функцию в блоке extern, и wasm-bindgen свяжет её с настоящей JS-функцией:
#[wasm_bindgen]
extern "C" {
// Импорт: console.log из браузера. Сами в консоль мы не умеем.
#[wasm_bindgen(js_namespace = console)]
fn log(message: &str);
}
Писать такие биндинги ко всему браузерному API руками было бы адом, поэтому есть два готовых крейта. js-sys покрывает сам язык JavaScript: Array, Date, Math, Promise, типизированные массивы. А web-sys покрывает Web API браузера: DOM, canvas, Web Audio, события. API огромен, поэтому web-sys разбит на feature-флаги, и в Cargo.toml включают ровно нужные типы:
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
[dependencies.web-sys]
version = "0.3"
features = ["Window", "Document", "HtmlCanvasElement", "CanvasRenderingContext2d"]
С этим Rust дотягивается до canvas и рисует прямо из WASM:
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
#[wasm_bindgen]
pub fn draw_pixel(canvas: web_sys::HtmlCanvasElement, x: f64, y: f64) -> Result<(), JsValue> {
let ctx = canvas
.get_context("2d")?
.ok_or("нет 2d-контекста")?
.dyn_into::<web_sys::CanvasRenderingContext2d>()?;
ctx.fill_rect(x, y, 1.0, 1.0);
Ok(())
}
Заметь Result<(), JsValue>: ошибка Rust на границе превращается в брошенное JS-исключение. А оператор ? работает ровно как ты привык.
Замыкания: колбэки, которые переживают вызов
Браузер событийный: requestAnimationFrame, обработчики кликов, таймеры. Все они хотят колбэк, и Rust-замыкание надо как-то отдать наружу. Делает это Closure. Тонкость одна, зато коварная: JS вызовет колбэк потом, а значит замыкание должно пережить функцию, которая его создала. Просто создать и выйти нельзя, замыкание уничтожится и колбэк повиснет.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn start_ticking(window: web_sys::Window) {
// FnMut, потому что тикаем многократно.
let callback = Closure::<dyn FnMut()>::new(move || {
// ... один кадр движка ...
});
window
.set_interval_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
16,
)
.unwrap();
// Отдаём замыкание навсегда: оно нужно на всё время жизни страницы.
callback.forget();
}
callback.forget() намеренно «течёт» памятью: замыкание живёт до конца страницы. Для единственного глобального тикера это нормально. Если же колбэков много и они приходят-уходят, замыкание держат живым в долгоживущей структуре (поле движка, Rc) и роняют, когда оно больше не нужно. Это та же дисциплина времени жизни, что и везде в Rust, просто на стыке с чужим рантаймом.
Сборка: wasm-pack и Trunk
Руками вызывать cargo build --target wasm32-unknown-unknown и потом дёргать генератор обёрток никто не заставляет. Есть два инструмента под две ситуации.
wasm-pack собирает крейт в готовый пакет: .wasm, JS-обёртку и .d.ts с типами, всё в каталог pkg/. Это правильный выбор, когда WASM это кусок внутри большего фронтенда, например виджет внутри нашего Astro-сайта:
wasm-pack build --target web --release
// Astro-остров: грузим пакет и инициализируем модуль.
import init, { Counter } from '../pkg/my_crate.js';
await init(); // подтягивает и инстанцирует .wasm
const counter = new Counter(0);
Trunk наоборот собирает всё приложение, когда фронтенд целиком на Rust (Yew, Leptos): берёт index.html, компилирует, связывает ассеты, поднимает dev-сервер. Правило: кусок внутри JS это wasm-pack, весь сайт на Rust это Trunk.
А что у нас: почему движки без wasm-bindgen
Теперь честный разворот. Всё, что описано выше, это стандарт экосистемы, и знать его обязательно. Но наши собственные движки (bcpu, эмулятор NES, netcode) wasm-bindgen не используют. Вот реальный комментарий из examples/our-nes, загрузчик движка для виджетов:
// Это наш эмулятор (examples/our-nes, feature wasm), собранный в wasm32. JS не
// исполняет ни одного опкода: он грузит образ ROM, дёргает кадр или шаг и читает
// готовый кадр, аудио и состояние процессора из линейной памяти. Без wasm-bindgen:
// импортов нет, экспортируются memory и функции nes_*.
JS-сторона грузит модуль голым WebAssembly.instantiateStreaming и читает память сама:
async function instantiate(): Promise<WebAssembly.Instance> {
const { instance } = await WebAssembly.instantiateStreaming(fetch(WASM_URL), {});
return instance;
}
Почему ручной путь, а не удобный wasm-bindgen? Три причины, и все прагматичные. Первая: поверхность узкая. Движку нужно с десяток функций, и все принимают и возвращают числа (nes_step, nes_reg, указатель на кадр). Строки и структуры через границу почти не ходят, а ровно ради них и нужен wasm-bindgen. Вторая: ноль зависимостей и контроль над бандлом. Никакого генератора, никакой JS-обёртки в сборке, .wasm это самодостаточный файл, а склейка это тот самый код, что мы читали в прошлом уроке. Третья: педагогика. Когда ты сам читаешь линейную память по указателю, видно, что происходит, а это и есть цель раздела. wasm-bindgen бы это спрятал.
Отсюда правило выбора на будущее. Богатый API, строки, структуры и колбэки на каждом шагу, и важна скорость разработки, бери wasm-bindgen, это девяносто процентов случаев. Узкая горячая поверхность из числовых функций, важен размер и контроль, минимум зависимостей, можно пойти ручным путём, как наши движки. Оба варианта это один и тот же механизм из прошлого урока (числа через границу, всё прочее через линейную память), просто wasm-bindgen пишет обёртки за тебя, а ручной путь оставляет их тебе. В следующем уроке мы заведём наш ручной движок в браузере: загрузим bcpu и NES в canvas, со звуком и шаговым режимом.
Что унести из урока
wasm-bindgen автоматизирует ровно ту ручную возню с упаковкой в линейную память, что мы делали в прошлом уроке: помечаешь функцию или структуру атрибутом #[wasm_bindgen], и через границу будто сами собой ходят строки, структуры и срезы. Структура на JS становится классом, но держит лишь указатель на Rust-объект в линейной памяти, поэтому её надо явно освобождать через .free(). Срез &[u8] копируется через границу, а на горячем пути (кадр, аудио) копию заменяют нулевым копированием через окно Uint8Array::view, которое валидно только до следующего роста памяти. Звать JS из Rust дают крейты js-sys (сам язык) и web-sys (Web API по feature-флагам), а замыкания-колбэки оборачивает Closure, и тут главное это пережить вызов, через forget() или хранение в долгоживущей структуре. Собирают всё это wasm-pack (кусок для JS-фронтенда) или Trunk (всё приложение на Rust). А наши движки идут ручным путём без wasm-bindgen, потому что поверхность узкая и числовая, и так нагляднее. Дальше: эмулятор в браузере.