Эта страница переведена с английского. Читать страницу на английском
Zeyn отображает страницы на сервере. Некоторые поверхности слишком интерактивны для этого: редактор на canvas, диаграмма, тяжёлый график. Остров (island) это компонент на Rust, скомпилированный в WebAssembly, который сервер размещает на странице, в остальном отображаемой на сервере. Всё вокруг него остаётся обычным HTML.
Острова находятся за Cargo feature islands крейта zeyn. Приложения, не включающие её, ничего из этого не компилируют, а страницы без острова ничего не платят, даже когда она включена (см. «Производительность» ниже).
1. Составные части
| Часть | Где | Роль |
|---|---|---|
zeyn::ui::Island | сервер | Узел-заглушка: props, запасное содержимое, привязанные действия |
App::islands(dir) | сервер | Отдаёт собранные файлы и загрузчик |
zeyn-island | общий и браузер | Remote (контракт вызываемого действия), Island::call (клиент) |
| Общий крейт | оба | Props, сообщения и remotes как простые типы serde |
| Крейт острова | браузер | cdylib, экспортирующий mount(root) и, по желанию, unmount(root) |
zeyn build-islands | CLI | Собирает крейты островов в файлы с хешами и манифест |
Типичная структура, как в examples/showcase:
examples/showcase/
shared/ showcase-shared: props, Count, SaveCount (serde + zeyn-island only)
islands/counter/ the island crate, [lib] name = "counter", crate-type = ["cdylib", "rlib"]
src/islands.rs the screen and the action
dist/islands/ output of zeyn build-islands (not committed)2. Общие типы
Общий крейт зависит от serde и от zeyn-island без features, поэтому сервер не компилирует код для браузера. Remote называет действие, которое остров может вызвать, и фиксирует его типы:
#[derive(Serialize, Deserialize)]
pub struct Count { pub value: i64 }
pub struct SaveCount;
impl zeyn_island::Remote for SaveCount {
const NAME: &'static str = "save_count";
type Input = Count;
type Output = Count;
}3. Серверная сторона
#[action(permission = "showcase.islands.write")]
pub async fn save_count(count: Count, store: CounterStore) -> Result<Count> { ... }
Island::new("counter")
.props(&CounterProps { start, texts })
.label(t!("islands.counter"))
.fallback(Alert::info(t!("islands.fallback")))
.action::<SaveCount, _>(save_count)action::<R, A> компилируется только тогда, когда вход действия равен R::Input, а его выход равен Result<R::Output>, поэтому две стороны не могут разойтись. Остров может вызывать только те действия, которые привязаны к его узлу.
fallback принимает любые узлы: текстовое резюме, сетку только для чтения. Он показывается до загрузки острова, при отключённом JavaScript и при сбое загрузки. Его сетки и другие компоненты работают как обычно.
Загрузка: .on_interaction() ждёт первого указателя, касания или фокуса на заглушке, .on_idle() загружает, когда браузер простаивает, а по умолчанию загрузка происходит, когда заглушка оказывается в пределах 200 px от области просмотра. Загрузка никогда не блокирует страницу.
Зарегистрируйте папку вывода один раз: App::islands(concat!(env!("CARGO_MANIFEST_DIR"), "/dist/islands")). Без манифеста в ней сервер пишет в лог предупреждение, а острова показывают свой запасной вариант.
4. Сторона острова
#[wasm_bindgen]
pub fn mount(root: web_sys::Element) -> Result<(), JsValue> {
let island = zeyn_island::Island::new(root.clone());
let props: CounterProps = island.props().map_err(|e| JsValue::from_str(&e.message))?;
// build DOM under root, attach listeners
zeyn_island::spawn(async move {
match island.call::<SaveCount>(&Count { value: 3 }).await {
Ok(saved) => { /* ... */ }
Err(e) => { /* e.message is localized by the server */ }
}
});
Ok(())
}Island::call отправляет POST /_zeyn/data с экраном острова, параметрами и путём узла, именем remote в качестве key и входными данными. Он отправляет CSRF-токен страницы в x-zeyn-csrf и cookie сессии. Сервер проверяет, как для каждого компонента: CSRF, доступ экрана, guards feature, permission действия, затем выполняет interceptors и действие с лимитом времени. Другого пути нет: остров не может добраться до действия, которое не привязано к его узлу, и ничто в островах не обходит проверку.
Ошибки возвращаются как CallError { status, code, message }. status равен 0 для локальных сбоев (network, props, input, answer).
Экспортируйте unmount(root), когда остров держит таймеры, кадры анимации или сокеты. Загрузчик вызывает его, когда заглушка покидает документ (навигация, закрытое окно).
Переводы: передавайте тексты, которые показывает остров, в его props, уже локализованными сервером, чтобы остров не поставлял каталог.
5. Сборка
cd examples/showcase
zeyn build-islands # every crate in islands/, output in dist/islands
zeyn build-islands --out public/islands islands/counterДля каждого крейта выполняется:
cargo build --lib --release --target wasm32-unknown-unknownс настройками размера, переданными через--config(opt-level = "z",lto = true,codegen-units = 1,panic = "abort", без отладочной информации), поэтому собственный релизный профиль приложения не затрагивается;wasm-bindgen --target web --no-typescript --remove-name-section --remove-producers-section(версияwasm-bindgen-cliдолжна совпадать с крейтомwasm-bindgen,0.2.130);wasm-opt -Oz, когда binaryen есть вPATH;- запись
{name}.{hash}.jsи{name}_bg.{hash}.wasm(64-битный хеш FNV от содержимого) и обновлениеmanifest.json.
zeyn dev делает то же самое перед каждой сборкой приложения, когда у приложения есть папка islands/, и не следит за dist/.
Сервер отдаёт только имена файлов, перечисленные в манифесте, по адресу /_zeyn/islands/{file}, с application/wasm или text/javascript и Cache-Control: public, max-age=31536000, immutable. Файлы читаются с диска при каждом запросе, поэтому не занимают память сервера.
6. Content Security Policy
Для компиляции WebAssembly нужен 'wasm-unsafe-eval' в script-src. Zeyn добавляет его только на страницы, экран которых содержит остров; каждая другая страница сохраняет строгую политику из docs/runtime-protocol.md. 'wasm-unsafe-eval' разрешает WebAssembly.compile и instantiate и ничего больше: eval, new Function и встроенные скрипты остаются заблокированными, а модули по-прежнему приходят только с origin приложения.
Политика принадлежит документу и не может быть смягчена позже. Поэтому, когда nav.js загружает экран с островом (частичный ответ несёт x-zeyn-islands: 1) в документ, загруженный без неё, происходит откат к полной загрузке страницы. Когда у документа уже есть политика островов, переходы между экранами с островами и без них остаются на месте.
Сервер находит острова по атрибуту data-zeyn-island="; экранированные данные пользователя никогда не могут породить эту кавычку, поэтому набранный пользователем текст не может включить политику.
7. Поверхности canvas
zeyn::ui::Canvas::new(name, description) это остров для рисования. Описание обязательно: canvas невидим для вспомогательных технологий, поэтому страница должна сказать, что он показывает и какими клавишами им управлять. Сервер отображает его абзацем над островом и указывает на него в aria-describedby заглушки.
Со стороны острова zeyn-island с feature canvas предоставляет canvas::Surface:
let surface = Surface::new(&stage, Editor::new(..), Prefer::TwoD, &texts.canvas, &described_by, 420)?;
surface.update(|editor| editor.add_box()); // change the scene, draw on the next frame| Забота | Что делает поверхность |
|---|---|
| Графика | Prefer::Gl пробует WebGL 2 и откатывается к 2D; webgpu_available() сообщает, есть ли navigator.gpu, для сцен, которые сами настраивают WebGPU на surface.canvas() |
| Высокий DPI | Резервный буфер следует за devicePixelRatio и размером элемента (ResizeObserver); 2D-рисование предварительно масштабируется, поэтому сцена работает в CSS-пикселях |
| Ввод | Нажатие, перемещение и отпускание указателя (с захватом указателя), колесо (дельты строк и страниц переводятся в пиксели) и клавиши, как значения Input в CSS-пикселях |
| Кадры | requestAnimationFrame только после ввода, использованного сценой, после update, изменения размера или пока Scene::animating возвращает true. Неподвижная картинка не стоит процессорного времени |
| Значения по умолчанию | У ввода, использованного сценой, отменяется действие по умолчанию (стрелки не прокручивают страницу); возвращайте false для Tab, чтобы фокус мог уйти |
| Очистка | destroy() удаляет обработчики, наблюдатель и canvas; вызывайте его из unmount |
Canvas получает tabindex="0", role="application", подпись и aria-describedby.
Шаблон доступности, как в редакторе диаграмм showcase (examples/showcase/islands/diagram):
- Клавиатура на canvas:
]и[выбирают фигуры, стрелки двигают их, Delete удаляет,+,-и0масштабируют, Escape снимает выделение. Клавиши действуют, только пока у canvas есть фокус. - Обычные элементы управления: панель кнопок делает то же без canvas.
- Текстовое резюме: список под canvas описывает каждую фигуру и следует за каждым изменением; строка
role="status"объявляет выделение и результаты сохранения. - Запасной вариант: сервер отображает то же резюме, поэтому пользователи без WebAssembly по-прежнему читают диаграмму.
Редактор сохраняет через SaveDiagram, а сервер ограничивает то, что получает (Diagram::clamped: не более 200 фигур, уникальные id, подписи по 40 символов, ограниченные координаты), потому что острову никогда не доверяют.
8. Производительность
| Что | Значение |
|---|---|
| HTML обычной страницы, запросы, политика | без изменений: нет meta, нет загрузчика, нет дополнительного запроса |
nav.js | +281 байт (откат к полной загрузке) |
Загрузчик /_zeyn/islands.js, только страницы с островами | 3.9 КБ, 1.7 КБ gzip |
.wasm острова-счётчика (без wasm-opt) | 127.6 КБ, 52.8 КБ gzip |
Связующий .js острова-счётчика | 18.0 КБ, 4.2 КБ gzip |
.wasm редактора диаграмм (поверхность canvas, без wasm-opt) | 169.4 КБ, 67.8 КБ gzip |
Редактор диаграмм с живым редактированием (добавляет yrs, см. docs/realtime.md) | 408.4 КБ, 144.3 КБ gzip |
Связующий .js редактора диаграмм | 30.3 КБ, 6.1 КБ gzip |
| Память сервера на остров | нет; файлы передаются потоком с диска, props формируются при каждом запросе |
Большая часть размера счётчика приходится на serde_json и код форматирования из core. Острова, которые их разделяют, платят за них по разу каждый; wasm-opt обычно снимает ещё 10 до 20 процентов.
9. Ограничения
- Остров внутри окна (
open_window) требует, чтобы страница, открывающая окно, содержала политику островов; размещайте окна с островами на страницах с островами или открывайте их как экраны. - Замыкания, подключённые через
Closure::forget, живут столько же, сколько страница; освобождайте их вunmount, когда остров может монтироваться много раз. - Сами острова на сервере ничего не отображают; запасной вариант это то, что получают поисковые системы, программы чтения с экрана до загрузки и пользователи без JavaScript. Сделайте его содержательным.