Перейти к содержимому

Rust и WebAssembly

WebAssembly (часто сокращают до Wasm) позволяет запускать код на языках вроде Rust прямо в браузере, рядом с JavaScript. Представь второй двигатель в браузере: страницей по-прежнему управляет JavaScript, но тяжёлую работу — фильтры для картинок, физику, разбор больших файлов — можно отдать быстрому скомпилированному коду на Rust.

На этой странице ты напишешь маленькую библиотеку на Rust, скомпилируешь её в Wasm и вызовешь со страницы в браузере. Здесь ничего не запускается в Rust Playground (нужны браузер и дополнительные инструменты), поэтому большинство примеров — обычные блоки кода для твоего компьютера. А вот части на чистом Rust можно попробовать сразу.

WebAssembly — это компактный бинарный формат для программ. Его умеет запускать любой крупный браузер, со скоростью, близкой к нативной, в той же защищённой «песочнице», что и JavaScript. И это не только для браузеров: Wasm запускают и на серверах, и на edge-платформах.

Rust — один из самых популярных языков для Wasm, и на то есть причины:

  • Нет сборщика мусора и тяжёлой среды выполнения. Rust не нужно тащить вместе с твоим кодом дополнительный механизм, поэтому файлы .wasm получаются маленькими.
  • Отличные инструменты. wasm-bindgen сам пишет связующий код между Rust и JavaScript, а wasm-pack собирает всё одной командой.
  • Безопасность и скорость. Ты получаешь ту же безопасность памяти и ту же производительность, что и на обычном компьютере.

Сначала скажи Rust, что он может компилировать под цель (target) Wasm. Цель — это «на какой машине будет работать код»; wasm32-unknown-unknown означает «32-битный Wasm, без конкретной операционной системы»:

Terminal window
rustup target add wasm32-unknown-unknown

Затем установи wasm-pack. Он компилирует твой крейт, запускает wasm-bindgen и складывает всё нужное в одну папку. Можно воспользоваться официальным установщиком или собрать его через Cargo:

Terminal window
# установщик для Linux / macOS с сайта wasm-pack
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# или на любой системе, где установлен Rust
cargo install wasm-pack --locked

Модуль Wasm — это библиотека, а не программа с main. Создай её:

Terminal window
cargo new --lib hello-wasm
cd hello-wasm

Потом отредактируй Cargo.toml. Строка crate-type говорит Cargo собрать библиотеку, пригодную для .wasm (cdylib), а wasm-bindgen — это крейт, который соединяет Rust и JavaScript:

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"

Теперь напиши две функции в src/lib.rs. Атрибут #[wasm_bindgen] помечает каждую как «вызываемую из 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!("Hello, {name}! This string was built in Rust.")
}

Это весь код на Rust, который нужен. Обрати внимание: это обычные функции. wasm-bindgen сам превращает строку JavaScript в &str, а возвращённую String — обратно в строку JavaScript.

Сначала проверь логику на своём компьютере

Заголовок раздела «Сначала проверь логику на своём компьютере»

Вот привычка, которая экономит много времени: держи настоящую работу в обычных функциях Rust и проверяй их на своём компьютере через cargo test или быстрый main. Отлаживать в браузере медленнее, чем в терминале. Та же логика, только без атрибута, работает где угодно:

fn add(a: i32, b: i32) -> i32 {
a + b
}
fn greet(name: &str) -> String {
format!("Hello, {name}! This string was built in Rust.")
}
fn main() {
println!("{}", greet("Ferris"));
println!("2 + 3 = {}", add(2, 3));
}

Вывод:

Hello, Ferris! This string was built in Rust.
2 + 3 = 5
▶ Попробуй сам

Приветствие мы оставили на английском, чтобы оно совпадало с кодом библиотеки выше и с тем, что потом настоящая страница покажет в браузере («Привет, Ferris! Эта строка собрана в Rust.»).

Когда логика работает правильно, обёртка #[wasm_bindgen] — всего лишь тонкая дверь в браузер. (Тесты с пометкой #[test] тоже запускаются на твоём компьютере через cargo test — см. Тестирование.)

В папке крейта выполни:

Terminal window
wasm-pack build --target web

--target web означает «сделай файлы, которые браузер загрузит напрямую тегом <script type="module">», без сборщика вроде webpack. Когда мы это запустили, в новой папке pkg/ оказалось:

pkg/
├── hello_wasm_bg.wasm скомпилированный код на Rust
├── hello_wasm.js связующий JavaScript: загружает модуль и оборачивает каждую функцию
├── hello_wasm.d.ts типы TypeScript для твоих функций
├── hello_wasm_bg.wasm.d.ts
└── package.json

Дефис в hello-wasm превратился в имени файлов в подчёркивание. По умолчанию wasm-pack build делает release-сборку (оптимизированную); добавь --dev, чтобы во время экспериментов собирать быстрее, но без оптимизаций.

Создай index.html рядом с Cargo.toml (чтобы pkg/ лежала рядом с ним):

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello from Rust</title>
</head>
<body>
<p id="message">Loading…</p>
<script type="module">
import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // скачать и запустить файл .wasm
const text = greet("Ferris");
const sum = add(2, 3);
document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`;
</script>
</body>
</html>

Экспорт по умолчанию, init(), скачивает файл .wasm и запускает его. После этого add и greet — обычные функции JavaScript.

Браузеры не загружают модули с адреса file://, поэтому нужен маленький локальный веб-сервер. Если у тебя установлен Python, быстрее всего так:

Terminal window
python3 -m http.server 8080

Открой http://localhost:8080 в браузере. У нас страница показала:

Hello, Ferris! This string was built in Rust. 2 + 3 = 5

Это предложение собрал Rust, работающий внутри браузера. 🦀

Что дёшево проходит через границу (а что нет)

Заголовок раздела «Что дёшево проходит через границу (а что нет)»

Rust и JavaScript работают с памятью по-разному. У модуля Wasm свой блок памяти, и значения приходится передавать между двумя сторонами. Некоторые поездки бесплатны, а за другие приходится платить копированием:

Тип в Rust В JavaScript Цена
i32, u32, f64, bool number, boolean Дёшево: передаётся напрямую
&str, String string Копия плюс перевод из UTF-16 в UTF-8 (или обратно)
&[u8], Vec<u8> Uint8Array Копия в память Wasm (или из неё)
&mut [u8] Uint8Array Копируется туда, а после вызова — обратно

Отсюда золотое правило: делай мало вызовов, и пусть каждый выполняет много работы. Один вызов Rust для обработки целой картинки — это быстро. Миллион вызовов, по одному на пиксель, тратит большую часть времени на пересечение границы.

Вот как это выглядит. Эта функция за один вызов делает ярче целый буфер значений пикселей:

use wasm_bindgen::prelude::*;
/// Делает каждый пиксель ярче. JS передаёт Uint8Array.
#[wasm_bindgen]
pub fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
*p = p.saturating_add(amount);
}
}

Из JavaScript после await init() ты вызовешь brighten(pixels, 20) с Uint8Array, и массив изменится на месте. Сам цикл — чистый Rust, так что его можно проверить на своём компьютере:

fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
// saturating_add останавливается на 255, а не переполняется
*p = p.saturating_add(amount);
}
}
fn main() {
let mut pixels = [10, 100, 250];
brighten(&mut pixels, 20);
println!("{pixels:?}");
}

Вывод:

[30, 120, 255]
▶ Попробуй сам

Заметь: 250 + 20 превратилось в 255, а не в падение или «прокрутку». saturating_add останавливается на максимуме. Для цветов это именно то, что нужно. Подробнее — в уроке Переполнение целых чисел.

Каждый байт файла .wasm нужно скачать, прежде чем страница сможет им воспользоваться. Несколько советов:

  • Всегда выкладывай release-сборку. Для нашего крошечного примера wasm-pack build --dev выдал файл .wasm размером 58 591 байт, а обычная release-сборка — 21 530 байт. (Это на нашей машине с выключенным wasm-opt — у тебя цифры будут другими.)
  • Пусть wasm-pack запускает wasm-opt. В release-режиме wasm-pack по умолчанию прогоняет результат через оптимизатор wasm-opt из Binaryen, и обычно файл становится ещё меньше.
  • Включи сжатие на сервере. Наш файл в 21 530 байт после gzip весил 8464 байта.
  • Попробуй настройки, нацеленные на размер, и измерь результат:
[profile.release]
opt-level = "z" # оптимизировать размер ("s" — более мягкий вариант)
lto = true
codegen-units = 1

Будь честен с собой и измеряй. Для нашего примера из двух функций эти настройки не помогли — файл вышел на 22 817 байт, то есть чуть больше. Обычно они окупаются в крейтах покрупнее. Вещи вроде format! и сообщений паники тоже тянут за собой код, поэтому крейт без них может оказаться намного меньше.

Несколько примеров, и у каждого есть рассказ самой компании (на английском):

Забыть crate-type = ["cdylib"]. Без этой строки wasm-pack сразу останавливается. К счастью, сообщение об ошибке точно говорит, что добавить:

Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

wasm-pack пишет, что crate-type должен быть cdylib, и показывает строки, которые нужно вставить в Cargo.toml.

Открыть index.html двойным щелчком. Страница так и показывает «Loading…», а в консоли браузера (в Chrome) написано:

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.

Браузер заблокировал загрузку скрипта политикой CORS, потому что адреса file:// для таких запросов не разрешены. Решение: раздай папку локальным веб-сервером, например python3 -m http.server, и открой адрес http://localhost.

Вызвать функцию до await init(). Модуль Wasm ещё не загружен, и связующему коду нечего вызывать. Когда мы убрали строку с init(), Chrome показал Cannot read properties of undefined (reading '__wbindgen_free'). Всегда сначала делай await init().

Вызвать несколько функций Rust из JavaScript — отличное начало. Если хочешь написать на Rust всё веб-приложение, посмотри на эти фреймворки. Оба компилируются в Wasm и позволяют писать компоненты на Rust, немного похоже на React:

  • Leptos (на английском) — быстрый современный фреймворк, который умеет ещё и отрисовывать страницы на сервере.
  • Yew (на английском) — зрелый фреймворк на основе компонентов.
  • WebAssembly — быстрый и безопасный бинарный формат, который умеют запускать браузеры (и серверы); Rust хорошо в него компилируется, потому что у него нет сборщика мусора.
  • Добавь цель через rustup target add wasm32-unknown-unknown и установи wasm-pack.
  • Помечай функции #[wasm_bindgen], укажи crate-type = ["cdylib", "rlib"] и собирай через wasm-pack build --target web.
  • В JavaScript сначала await init(), потом вызывай свои функции; открывай страницу по http://, а не file://.
  • Числа проходят через границу дёшево, а строки и срезы копируются — так что делай мало вызовов, но крупных.
  • Выкладывай release-сборки со сжатием и измеряй, прежде чем доверять настройкам размера.

Проверь себя

1. Какая команда добавляет цель Rust для WebAssembly в браузере?

2. Что делает атрибут #[wasm_bindgen]?

3. Страница застряла на «Loading…», а в консоли ошибка CORS для адреса file:///. Как это исправить?

4. Что из этого дешевле всего передать из JavaScript в Rust?

Результат: 0 / 4

  • Компиляция Rust в WebAssembly — MDN (перевод на русский) — пошаговое руководство, в котором собирают и запускают проект на wasm-pack.
  • The wasm-bindgen Guide (на английском) — все поддерживаемые типы, вызов API браузера и множество примеров.
  • Документация wasm-pack (на английском) — все параметры build, цели и профили.
  • wasm32-unknown-unknown — The rustc book (на английском) — что поддерживает эта цель и какие у неё ограничения.
  • WebAssembly — MDN (перевод на русский) — что такое Wasm и как его выполняют браузеры, без привязки к Rust.