Вступление
Эта книга познакомит вас с Веб-фреймворком Leptos. В ней мы разберём фундаментальные концепции необходимые для написания приложений, начиная от простого приложения с рендерингом в браузере и постепенно перейдём к полностековому приложению с рендерингом на стороне сервера и гидратацией.
Данное руководство не требует от вас каких-либо знаний о мелкозернистой реактивности (англ. fine-grained reactivity) или об особенностях современных Веб-фреймворков. Но мы предполагаем, что вы знакомы с Rust, HTML, CSS, а также с DOM и с простыми Web API.
Leptos больше всего похож на такие фреймворки как Solid (JavaScript) и Sycamore (Rust). У него есть схожие черты и с другими фреймворками, такими как React (JavaScript), Svelte (JavaScript), Yew (Rust), и Dioxus (Rust). Так что знание одного из этих фреймворков может помочь вам понять Leptos.
Более детальную документацию по каждой части API можно найти на Docs.rs.
Исходный код этой книги доступен здесь. Запросы Pull с исправлениями опечаток и уточнениями всегда кстати.
Начало работы
Есть два пути чтобы начать работу с Leptos:
-
Рендеринг на клиенте (CSR) с Trunk - отличный вариант если просто хочется сделать шустрый сайт на Leptos, или работать с уже существующим сервером или API. В режиме CSR, Trunk компилирует Leptos приложение в WebAssembly (WASM) и запускает его в браузере как обычное одностраничное Javascript приложение (SPA). Примущества Leptos CSR включают более быструю сборку и ускоренный итеративный цикл разработки, а также более простую ментальную модель и больше вариантов развёртывания вашего приложения. CSR приложения имеют и некоторые недоставки: время первоначальной загрузки для пользователей будет дольше в сравнении с подходом серверного рендеринга (SSR), а ещё пресловутые проблемы SEO, которыми сопровождаются одностраничники на JS, применимы и к приложениям на Leptos CSR. Также стоит заметить, что "под капотом" используется автоматически генерируемый скрипт на JS, подгружающий бинарник WASM с Leptos, так что JS должен быть включен на устройстве пользователя, чтобы ваше CSR приложение отображалось нормально. Как и во всей программной инженерии, здесь есть компромиссы, которые вам нужно учитывать.
-
Full-stack, серверный рендеринг (SSR) c
cargo-leptos
— SSR это отличный вариант для построения веб-сайтов в стиле CRUD и кастомных веб-приложений если вы хотите, чтобы Rust был и на клиенте и на сервере. При использовании варианта Leptos SSR, приложение рендерится в HTML на сервере и отправляется в браузер; затем в дело вступает WebAssembly, вооружая HTML, чтобы ваше приложение стало интерактивным — этот процесс называется "гидратация". На стороне сервера, Leptos SSR приложения тесно интегрируются либо с фреймворком на выбор — Actix-web или Axum, так что можно использовать крейты этих сообществ при построении сервера Leptos. Преимущества выбора Leptos SSR включают в себя помощь в получении наилучшего времени первоначальной загрузки и оптимальные очки SEO для вашего веб-приложения. SSR приложения могут также кардинально упростить клиент-серверное взаимодействие с помощью Leptos-фичи под названием "серверные функции", которая позволяет прозрачно вызывать функции на сервере из клиентского кода (об этом позже). Full-stack SSR, впрочем, это не только пони, питающиеся радугой, есть и недоставки, они включают в себя более медленный цикл разработки (потому что вам нужно перекомпилировать и сервер и клиент, когда вы меняете Rust код), а также некоторую дополнительную сложность, которую вносит гидратация.
К концу этой книги у вас будет ясное представление о том, на какие компромиссы идти и какой путь избирать — CSR или SSR — в зависимости от требований вашего проекта.
В Части 1 этой книги мы начнём с клиентского рендеринга Leptos сайтов и построения реактивных UI используя Trunk
для отдачи нашего JS и WASM бандла в браузер.
Мы познакомим вас с cargo leptos
во Части 2 этой книги, которая посвящена работе со всей мощью
Leptos в его full-stack SSR режиме.
Если вы пришли из мира Javascript и такие термины как клиентский рендеринг (CSR) и серверный рендеринг (SSR) вам незнакомы,
самый простой способ понять разницу между ними — это по аналогии:
CSR режим в Leptos похож на работу с React (или с фреймворком основаным на сигналах, таким как SolidJS) и сфокусирован
на создании UI на клиентской стороне, который можно использовать с любым серверным стеком.
Использование режима SSR в Leptos похоже на работу с full-stack фреймворком как Next.js в мире React
(или "SolidStart" фреймворком в SolidJS) — SSR помогает строить сайты и приложения, которые рендрятся на сервере и затем отправляются клиенту.
SSR может помочь улучшить производительность загрузки и доступность сайта,
а также упростить работу одному человеку *сразу* и над клиентом и на сервером без необходимости переключать контекст
между разными языками для frontend и backend.
Фреймворк Leptos может быть использовать либо в режиме CSR, чтобы просто сделать UI (как React), а может в
full-stack SSR режиме (как Next.js), так чтобы вы могли писать и UI и серверную часть на одном языке: на Rust.
Привет, Мир! Подготовка к Leptos CSR разработке
Первым делом убедитесь что Rust установлен и обновлен (здесь инструкции, если нужны)
Если инструмент Trunk
для запуска сайтов на Leptos CSR ещё не установлен, его можно установить так:
cargo install trunk
А затем создайте простой Rust проект
cargo init leptos-tutorial
cd
в директорию только что созданного проекта leptos-tutorial
и добавьте leptos
в качестве зависимости
cargo add leptos --features=csr,nightly
Или без nightly
если вы на стабильной версии Rust
cargo add leptos --features=csr
Использование
nightly
Rust, иnightly
feature в Leptos включает синтаксис вызова функции для геттеров и сеттеров сигналов, который использован в большей части этой книги.Чтобы использовать nightly Rust, можно либо выбрать nightly для всех Rust проектов, выполнив
rustup toolchain install nightly rustup default nightly
либо только для этого проекта
rustup toolchain install nightly cd <into your project> rustup override set nightly
Если хотите использовать стабильную версию Rust с Leptos, то так тоже можно. В этом руководстве и примерах просто используйте методы
ReadSignal::get()
иWriteSignal::set()
вместо вызова геттеров и сеттеров как будто они функции.
Убедитесь, что добавили цель сборки wasm32-unknown-unknown
, чтобы Rust мог компилировать код в WebAssembly для запуска в браузере.
rustup target add wasm32-unknown-unknown
Создайте простой файл index.html
в корне директории leptos-tutorial
<!DOCTYPE html>
<html>
<head></head>
<body></body>
</html>
И добавьте простое “Привет, Мир!” в ваш main.rs
use leptos::*;
fn main() {
mount_to_body(|| view! { <p>"Привет, Мир!"</p> })
}
Структура директорий должна выглядеть как-то так
leptos_tutorial
├── src
│ └── main.rs
├── Cargo.toml
├── index.html
Теперь запустите trunk serve --open
из корня вашей директории leptos-tutorial
.
Trunk должен автоматически скомпилировать приложение и открыть браузер по-умолчанию.
При внесении правок в main.rs
, Trunk перекомпилирует исходный код и перезагрузит страницу.
Добро пожаловать в мир разработки UI с помощью Rust и WebAssembly (WASM), приводимый в действие Leptos и Trunk!
Под Windows, нужно учитывать, что `trunk server --open` может не работать. При проблемах с `--open` просто
используйте `trunk serve` и откройте вкладку браузера вручную.
Теперь прежде чем мы начнем создавать ваш первый реальный UI c Leptos, есть пара вещей, о которых стоит знать, чтобы сделать вашу разработку с Leptos чуточку проще.
Улучшения опыта разработчика на Leptos
Есть пара вещей, которые можно сделать, чтобы улучшить ваш процесс разработки веб-сайтов и приложения на Leptos. Возможно, вам стоит потратить несколько минут и настроить окружение, чтобы оптимизировать процесс разработки, особенно если хотите программировать попутно с примерами из этой книги.
1) Настройте console_error_panic_hook
По-умолчанию паники, которые происходят во время выполнения вашего WASM кода в браузере просто выбросят ошибку в браузере
с неинформативным сообщением вроде Unreachable executed
и трассировкой стека, указывающей на WASM бинарник.
console_error_panic_hook
даёт настоящую трассировку стека Rust с номерами строк в Rust коде.
Настроить это очень просто:
- Выполните
cargo add console_error_panic_hook
в своём проекте - В функции main добавьте вызов
console_error_panic_hook::set_once();
Если это не понятно, вот пример.
Теперь сообщения о паниках в консоле браузере будут намного лучше!
2) Автоподстановка в редакторе внутри #[component]
и #[server]
Из-за природы макросов (они могут разворачивать что угодно из чего угодно, но только если ввода вполне корректен в тот момент) rust-analyzer'у может быть сложно делать должную автоподстановку и другую поддержку.
При проблемах с использований этих макросов в вашем редакторе, можно настроить rust-analyzer
так, чтобы он
игнорировал определенные процедурные макросы. Особенно для макроса #[server]
, который добавляет аннотации к телам функций,
но в действительно ничего не трансформирует в теле функции, это может быть очень полезно.
Начиная с версии Leptos 0.5.3, поддержка rust-analyzer была добавлена для #[component]
, но при проблемах
можно добавить #[component]
в игнор лист (см. ниже).
Учтите что это будет означать, что rust-analyzer
ничего не будет знать о свойствах вашего компонента, что может породить
собственный набор ошибок или предупреждений в IDE>
VSCode settings.json
:
"rust-analyzer.procMacro.ignored": {
"leptos_macro": [
// optional:
// "component",
"server"
],
}
VSCode
с cargo-leptos
settings.json
:
"rust-analyzer.procMacro.ignored": {
"leptos_macro": [
// optional:
// "component",
"server"
],
},
// if code that is cfg-gated for the `ssr` feature is shown as inactive,
// you may want to tell rust-analyzer to enable the `ssr` feature by default
//
// you can also use `rust-analyzer.cargo.allFeatures` to enable all features
"rust-analyzer.cargo.features": ["ssr"]
neovim
с lspconfig
:
require('lspconfig').rust_analyzer.setup {
-- Other Configs ...
settings = {
["rust-analyzer"] = {
-- Other Settings ...
procMacro = {
ignored = {
leptos_macro = {
-- optional: --
-- "component",
"server",
},
},
},
},
}
}
Helix, в .helix/languages.toml
:
[[language]]
name = "rust"
[language-server.rust-analyzer]
config = { procMacro = { ignored = { leptos_macro = [
# Optional:
# "component",
"server"
] } } }
Zed, в settings.json
:
{
-- Other Settings ...
"lsp": {
"rust-analyzer": {
"procMacro": {
"ignored": [
// optional:
// "component",
"server"
]
}
}
}
}
SublimeText 3, LSP-rust-analyzer.sublime-settings
в Goto Anything...
меню:
// Settings in here override those in "LSP-rust-analyzer/LSP-rust-analyzer.sublime-settings"
{
"rust-analyzer.procMacro.ignored": {
"leptos_macro": [
// optional:
// "component",
"server"
],
},
}
3) Настройка leptosfmt
с rust-analyzer
(необязательно)
leptosfmt
это форматер для Leptos макроса view!
(внутри которого обычно пишется UI код).
Поскольку макрос view!
включает 'RSX' (как JSX) стиль написания UI, cargo-fmt сложнее авто-форматировать код внутри макроса view!
. leptosfmt
это крейт, который решает проблемы с форматированием и поддерживает чистоту и красоту UI кода в стиле RSX.
leptosfmt
может быть установлен и использован через командную строку или из редактора кода:
Для начала установите его командой cargo install leptosfmt
.
Если хотите использовать настройки по-умолчанию из командной строки, просто запустите leptosfmt ./**/*.rs
из корня вашего проекта, чтобы отформатировать все Rust файлы используя leptosfmt
.
Если хотите настроить ваш редактор для работы с leptosfmt
или хотите кастомизировать настройки leptosfmt
, пожалуйста обратитесь к инструкциям доступным в leptosfmt
github repo's README.md.
Только учтите, что для наилучших результатов рекомендуется настраивать работу редактора c leptosfmt
на уровне workspace.
Сообщество Leptos и leptos-*
крейты
Сообщество
Одно финальное замечание прежде чем мы начнём разработку на Leptos: смело присоединяйтесь к нашему растущему сообществу Leptos в Discord и на Github.
Наш Discord в особенности активен и дружелюбен — мы бы очень хотели вас там видеть!
Если вы найдёте какую-либо главу или объяснение непонятным во время чтения книги, просто упомяните об этом в канале "docs-and-education" или задайте вопрос в "help" чтобы могли всё прояснить и обновить книгу для других.
Если по мере путешествия с Leptos у вас возникнут вопросы "как сделать 'x' с помощью Leptos", воспользуйтесь поиском по Discord каналу "help", чтобы посмотреть не был ли поднят этот вопрос ранее, или же без стеснения опубликуйте ваш вопрос — сообщество помогает новичкам и очень отзывчиво.
"Обсуждения" на Github также отличное место чтобы задавать вопросы и оставаться в курсе анонсов Leptos.
И конечно, если вы столкнулись с багом во время разработки с Leptos или хотите создать запрос нового функционала (или поделиться исправлением бага / новой фичей), откройте задачу в Github трекере.
Leptos-* крейты
Сообщество создало растущее число крейтов связанных с Leptos, которые помогут вам быстрее стать продуктивными в ваших Leptos проектах — ознакомиться со списком крейтов построенных на базе Leptos и выложенных сообществом можно в Awesome Leptos репозитории на Github.
Если хотите найти последние, новейшие крейты для Leptos, ознакомьтесь с секцией "Tools and Libraries" в Leptos Discord.
В этой секции есть канал для форматера Leptos-макроса view!
(#leptosfmt); есть канал для библиотеки утилит "leptos-use";
канал для библиотеки UI-компонентов "leptonic"; и канал "libraries" где обсуждаются новые leptos-*
крейты прежде чем они
проложат себе путь в растущий список крейтов и ресурсов доступных в Awesome Leptos.
Часть 1: Построение Пользовательского Интерфейса
В первой части этой книги мы рассмотрим построение пользовательских интерфейсов на стороне клиента используя Leptos.
"Под капотом" Leptos и Trunk добавляют на страницу небольшой код на Javascript, который подзагружает Leptos UI, скомпилированный в WebAssembly для обеспечения реактивности вашего CSR (client-side rendered) веб-сайта.
Часть 1 познакомит вас с простыми инструментами, которые вам понадобятся, чтобы построить реактивный пользовательский интерфейс с помощью Leptos и Rust. К концу первой части вы наверняка сможете создать шустрый синхронный веб-сайт, который рендерится в браузере и который можно будет развернуть на любом хостинге статических сайтов, таком как Github Pages или Vercel.
Чтобы извлечь максимум из этой книги мы советуем вам запускать код примеров по ходу чтения.
В главах [Начало работы](https://book-ru.leptos.dev/getting_started/) и [Leptos DX](https://book-ru.leptos.dev/getting_started/leptos_dx.html),
мы разобрали настройку простого проекта с Leptos и Trunk, включая обработку ошибок WASM в браузере.
Этого простого сетапа вам будет достаточно для начала разработки с Leptos.
Если вы предпочитаете начать используя более полнофункциональный шаблон, демонстрирующий как настроить ряд обыденных
вещей, которые можно встретить в реальных Leptos проектах, например: маршрутизация (она описана далее в книге),
вставка тегов `<Title>` и `<Meta>` в `<head>`, и несколько других фишек, тогда смело используйте репозиторий
[шаблона leptos-rs `start-trunk`](https://github.com/leptos-rs/start-trunk) чтобы начать работу.
Шаблон `start-trunk` требует установленных `Trunk` и `cargo-generate`, которые можете поставить выполнив `cargo install trunk` и `cargo install cargo-generate`.
Чтобы создать проект, используя этот шаблон, просто выполните
`cargo generate --git https://github.com/leptos-community/start-csr`
затем выполните
`trunk serve --port 3000 --open`
в директории созданного приложения, чтобы начать разработку приложения.
Сервер Trunk будет перезагружать страницу с вашим приложением всякий раз, когда вы что-то меняете в исходных файлах,
делая разработку сравнительно плавной.
Простой компонент
Пример "Привет, Мир!" был очень простым. Давайте перейдём к чему-то больше напоминающему настоящее приложение.
Для начала давайте изменим функцию main
так чтобы вместо рендеринга всего приложения, она рендерила лишь
компонент <App/>
. Компоненты являются основной единицей композиции и дизайна в большинстве Веб-фреймворков: они
представляют секцию DOM с независимым, обозначенным поведением. В отличие от HTML элементов, они именуются
с помощью PascalCase
, так что большинство Leptos приложений начинаются с компонента вроде <App/>
.
fn main() {
leptos::mount_to_body(|| view! { <App/> })
}
Теперь давайте определим сам <App/>
компонент. Поскольку это сравнительно просто, я сначала покажу вам его целиком,
а затем мы пройдемся по нему строка за строкой.
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<button
on:click=move |_| {
// в ветке stable, это будет выглядеть как set_count.set(3);
set_count(3);
}
>
"Click me: "
// в ветке stable, это будет выглядеть как {move || count.get()}
{move || count()}
</button>
}
}
Сигнатура Компонента
#[component]
Как и все определения компонентов, это определение начинается с макроса #[component]
. #[component]
добавляет
аннотации к функции чтобы она могла использоваться как компонент в вашем Leptos приложении. Мы рассмотрим некоторые
другие способности этого макроса через пару глав.
fn App() -> impl IntoView
Каждый компонент это функция со следующими характеристиками
- Принимает ноль или более аргументов любого типа.
- Возвращает
impl IntoView
, непрозрачный тип, включающий в себя всё, что может быть возвращено из блока Leptosview
.
Аргументы функции-компонента собираются вместе в одну структуру, которая надлежащим образом строится макросом
view
.
Тело Компонента
Телом функции-компонента является установочная функция, которая выполняется лишь раз, в отличие от рендерных функций, которые могут повторно выполняться множество раз. Обычно она будет использоваться для создания реактивных переменных, определения побочных эффекты, которые выполняются в ответ на изменения значений этих переменных, а также для описания пользовательского интерфейса.
let (count, set_count) = create_signal(0);
create_signal
создаёт сигнал, основную единицу реактивных изменений и управления состоянием в Leptos.
Она возвращает кортеж вида (getter, setter)
. Чтобы получить доступ к текущему значению используйте count.get()
(или краткий вариант count()
, доступный в nightly
версии Rust). Чтобы задать текущее значение вызывайте
set_count.set(...)
(или set_count(...)
).
.get()
клонирует значение, а.set()
перезаписывает его. Зачастую более эффективным является использовать.with()
или.update()
; ознакомьтесь с документацией кReadSignal
и кWriteSignal
если хотите узнать больше об этих компромиссах уже сейчас.
View (Представление)
Leptos описывает пользовательские интерфейсы с помощью JSX-подобного формата через макрос view
.
view! {
<button
// объявим слушатель события с помощью on:
on:click=move |_| {
set_count(3);
}
>
// текстовые узлы обрачиваются в кавычки
"Click me: "
// блоки могут содержать Rust код
{move || count()}
</button>
}
Это должно быть по большей части просто для понимания: выглядит как HTML со специальным свойством on:click
задающим слушатель события click
, текстовым узлом отформатированным в виде Rust строки, а дальше...
{move || count()}
что бы это ни значило.
Люди иногда шутят, что они используют больше замыканий в своём первом Leptos приложении чем они использовали их за всю жизнь до этого. И это подмечено справедливо. Попросту говоря, передача функции во view говорит фреймворку: — Эй, это что-то что может измениться.
Когда мы нажимаем на кнопку и вызываем set_count()
, сигнал count
обновляется. Замыкание move || count()
, чье значение зависит от значения count
, выполняется повторно, и фреймворк совершает точечное изменение этого текстового узла,
не трогая ничего больше в вашем приложении. Это то, что позволяет совершать невероятно низкозатратные изменения DOM.
Пользователи Clippy и те, у кого острый глаз, могли заметить, что данное замыкание избыточно,
по крайней мере при использовании nightly
версию Rust. В nightly
сигналы само по себе являются функциями,
так что данное замыкание не требуется. Как итог, можно использовать view более простого вида:
view! {
<button /* ... */>
"Click me: "
// identical to {move || count()}
{count}
</button>
}
Запомните — и это очень важно — только функции обладают реактивностью. Это означает, что {count}
и {count()}
ведут себя
очень по-разному в макросе view. {count}
передает функцию, говоря фреймворку обновлять view каждый раз когда count
изменяется.
{count()}
получает значение count
единожды и передает i32
во view, лишь единожды вызывая его рендеринг, без реактивности.
Разница видна в CodeSandbox примере ниже!
Давайте же внесём одно последнее изменение. set_count(3)
весьма бесполезная вещь в обработчике нажатия.
Давайте заменим "задать 3 в качестве значение" на "инкрементировать значение на 1":
move |_| {
set_count.update(|n| *n += 1);
}
Как можете видеть, когда как set_count
просто задает значение, set_count.update()
дает нам мутабельную ссылку и
мутирует значение не двигая его. Оба варианта вызывают реактивное обновление нашего UI.
По ходу этого руководства мы будем использовать CodeSandbox для демонстрации интерактивных примеров. Наведя на любую из переменных вы увидите подробности от `rust-analyzer`` и документацию того что происходит. Можете смело форкать примеры и играть с ними самостоятельно!
[Кликните чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/1-basic-component-3d74p3?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
> Чтобы увидеть браузер в песочнице вам может понадобиться нажать `Add DevTools >
Other Previews > 8080.`
<template>
<iframe src="https://codesandbox.io/p/sandbox/1-basic-component-3d74p3?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::*;
// The #[component] macro marks a function as a reusable component
// Components are the building blocks of your user interface
// They define a reusable unit of behavior
#[component]
fn App() -> impl IntoView {
// here we create a reactive signal
// and get a (getter, setter) pair
// signals are the basic unit of change in the framework
// we'll talk more about them later
let (count, set_count) = create_signal(0);
// the `view` macro is how we define the user interface
// it uses an HTML-like format that can accept certain Rust values
view! {
<button
// on:click will run whenever the `click` event fires
// every event handler is defined as `on:{eventname}`
// we're able to move `set_count` into the closure
// because signals are Copy and 'static
on:click=move |_| {
set_count.update(|n| *n += 1);
}
>
// text nodes in RSX should be wrapped in quotes,
// like a normal Rust string
"Click me"
</button>
<p>
<strong>"Reactive: "</strong>
// you can insert Rust expressions as values in the DOM
// by wrapping them in curly braces
// if you pass in a function, it will reactively update
{move || count()}
</p>
<p>
<strong>"Reactive shorthand: "</strong>
// signals are functions, so we can remove the wrapping closure
{count}
</p>
<p>
<strong>"Not reactive: "</strong>
// NOTE: if you write {count()}, this will *not* be reactive
// it simply gets the value of count once
{count()}
</p>
}
}
// This `main` function is the entry point into the app
// It just mounts our component to the <body>
// Because we defined it as `fn App`, we can now use it in a
// template as <App/>
fn main() {
leptos::mount_to_body(|| view! { <App/> })
}
view
: Dynamic Classes, Styles and Attributes
Ранее мы рассмотрели как использовать макрос view
для создания слушателей событий и для создания динамического текста
путём передачи функции (такой как сигнал) во view.
Но конечно же есть и другие вещи, которые вы возможно захотите обновлять в пользовательском интерфейсе. В этом разделе мы рассмотрим способы изменения классов, стилей и атрибутов динамически, а также познакомим вас с концепцией произвольных сигналов.
Давайте начнём с простого компонента, который должен быть вам знаком: нажатие на кнопку чтобы инкрементировать счетчик.
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<button
on:click=move |_| {
set_count.update(|n| *n += 1);
}
>
"Click me: "
{move || count()}
</button>
}
}
Пока что это просто пример из предыдущей главы.
Динамические Классы
Теперь предположим что я хочу динамически обновлять список CSS классов этого элемента.
К примеру, я хочу, скажем, добавлять класс red
когда значение счетчика нечётно. Я могу добиться этого, используя
синтаксис вида class:
.
class:red=move || count() % 2 == 1
class:
атрибуты принимают
- имя класса, следующее за двоеточием (
red
) - значение, которое может быть либо
bool
либо функцией возвращающейbool
Когда значение равно true
, класс добавляется. Когда значение равно false
, класс удаляется.
И если значение это функция, тогда класс будет реактивно обновляться когда сигнал изменяется.
Теперь каждый раз когда я нажимаю на кнопку цвет текст будет чередоваться между красным и черным, так как число становится то чётным, то нечётным.
<button
on:click=move |_| {
set_count.update(|n| *n += 1);
}
// the class: syntax reactively updates a single class
// here, we'll set the `red` class when `count` is odd
class:red=move || count() % 2 == 1
>
"Click me"
</button>
Если запускаете примеры по ходу чтения, убедитесь, что открыли
index.html
и добавили что-то вроде:<style> .red { color: red; } </style>
Некоторые имена CSS классов не могут быть напрямую распознаны макросом view
, особенно если они включают в себя набор из тире,
чисел и других символов. В таком случае можно использовать синтаксис кортежей: class=("name", value)
всё так же будет обновлять единственный класс.
class=("button-20", move || count() % 2 == 1)
Динамические Стили
Отдельные CSS свойства могут напрямую обновляться через схожий синтаксис вида style:
.
let (x, set_x) = create_signal(0);
view! {
<button
on:click={move |_| {
set_x.update(|n| *n += 10);
}}
// set the `style` attribute
style="position: absolute"
// and toggle individual CSS properties with `style:`
style:left=move || format!("{}px", x() + 100)
style:background-color=move || format!("rgb({}, {}, 100)", x(), 100)
style:max-width="400px"
// Set a CSS variable for stylesheet use
style=("--columns", x)
>
"Click to Move"
</button>
}
Динамические Атрибуты
То же самое применимо и к простым атрибутам. Передача простой строки или значения примитива в качества значения атрибута делает его статическим. Передача функции (включая сигнал) в качестве атрибута делает так, что значение будет обновляться реактивно. Давайте добавим ещё один элемент в наш view:
<progress
max="50"
// signals are functions, so `value=count` and `value=move || count.get()`
// are interchangeable.
value=count
/>
Теперь каждый раз когда мы устанавливаем значение count
, не только class
элемента <button>
будет чередоваться, но и
value
элемента <progress>
будет увеличиваться, что означает, что и наш индикатор выполнения будет двигаться вперёд.
Произвольные Сигналы
Давайте заглянем на уровень глубже, шутки ради.
Как вы уже знаете, реактивные интерфейсы можно создавать, просто передавая функции в view
. Это означает, что мы можем
легко менять наш индикатор выполнения. К примеру, предположим мы хотим, чтобы он двигался вдвое быстрее:
<progress
max="50"
value=move || count() * 2
/>
Но представьте, что мы хотим использовать это вычисление в более чем одном месте. Сделать это можно с помощью производного сигнала: замыкания которое обращается к сигналу.
let double_count = move || count() * 2;
/* insert the rest of the view */
<progress
max="50"
// we use it once here
value=double_count
/>
<p>
"Double Count: "
// and again here
{double_count}
</p>
Производные сигналы позволяют вам создавать реактивные вычисляемые значения, которые могут быть использованы сразу в нескольких местах в приложении с минимальными накладными расходами.
Примечание: Использование производного сигнала означает что вычисление запускается по разу на каждый раз когда сигнал меняется
(когда count()
меняется) и по разу на каждое место где мы обращаемся к double_count
; другими словами, дважды. Данное вычисление очень дёшево,
так что ничего страшного. Мы разберём мемоизацию в другой главе, она была придумана для решения этой проблемы при дорогих вычислениях.
Продвинутая тема: Вставка Сырого HTML
Макрос
view
поддерживает дополнительный атрибут,inner_html
, который можно использовать для прямого задания тела любого элемента в виде HTML, затирая при этом любых дочерние элементы, которые могли в нём находиться. Обратите внимание, что он не экранирует HTML, который вы передаете. Вам следует убедиться в том, что передаваемый HTML собран из доверенных источников или что все HTML-сущности экранированы, дабы предотвратить cross-site scripting (XSS) атаки.let html = "<p>This HTML will be injected.</p>"; view! { <div inner_html=html/> }
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/2-dynamic-attributes-0-5-lwdrpm?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/2-dynamic-attributes-0-5-lwdrpm?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::*;
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
// a "derived signal" is a function that accesses other signals
// we can use this to create reactive values that depend on the
// values of one or more other signals
let double_count = move || count() * 2;
view! {
<button
on:click=move |_| {
set_count.update(|n| *n += 1);
}
// the class: syntax reactively updates a single class
// here, we'll set the `red` class when `count` is odd
class:red=move || count() % 2 == 1
>
"Click me"
</button>
// NOTE: self-closing tags like <br> need an explicit /
<br/>
// We'll update this progress bar every time `count` changes
<progress
// static attributes work as in HTML
max="50"
// passing a function to an attribute
// reactively sets that attribute
// signals are functions, so `value=count` and `value=move || count.get()`
// are interchangeable.
value=count
></progress>
<br/>
// This progress bar will use `double_count`
// so it should move twice as fast!
<progress
max="50"
// derived signals are functions, so they can also
// reactively update the DOM
value=double_count
></progress>
<p>"Count: " {count}</p>
<p>"Double Count: " {double_count}</p>
}
}
fn main() {
leptos::mount_to_body(App)
}
Компоненты и Свойства
Пока что мы строили всё наше приложения в одном единственном компоненте. В малюсеньких примерах это нормально, но любом реальном приложении вам нужно будет разбивать пользовательский интерфейс на компоненты, чтобы разбить его на более маленькие, переиспользуемые, компонуемые куски.
Взять к примеру наш индикатор выполнения. Представьте, два индикатора выполнения вместо одного: один пусть движется вперёд по одному тику за клик, а другой по два тика за клик.
Этого можно добиться просто создав два элемента <progress>
:
let (count, set_count) = create_signal(0);
let double_count = move || count() * 2;
view! {
<progress
max="50"
value=count
/>
<progress
max="50"
value=double_count
/>
}
Но конечно же это не слишком хорошо масштабируется. Если хотите добавить третий индикатор выполнения, придётся добавить этот код ещё раз. И если захотите в нём что-либо изменить, придётся менять это в трёх местах.
Вместо этого, давайте создадим компонент <ProgressBar/>
.
#[component]
fn ProgressBar() -> impl IntoView {
view! {
<progress
max="50"
// hmm... where will we get this from?
value=progress
/>
}
}
Есть только одна проблема: progress
не задана. Откуда она должна появиться?
Когда мы задавали всё вручную, мы просто использовали локальные имена переменных.
Нам нужен какой-то способ передать аргумент в наш компонент.
Свойства Компонента
Мы делаем это используя свойства компонента или "props". Если когда-нибудь использовали другой frontend фреймворк, эта идея наверняка для вас не нова. Попросту говоря, свойства для компонентов это то же, что атрибуты для HTML элементов: они позволяют вам передавать дополнительную информацию в компонент.
Свойства в Leptos объявляются путём добавления дополнительных аргументов в функцию компонента.
#[component]
fn ProgressBar(
progress: ReadSignal<i32>
) -> impl IntoView {
view! {
<progress
max="50"
// now this works
value=progress
/>
}
}
Теперь мы можем использовать наш компонент во view нашего основного компонента <App/>
.
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<button on:click=move |_| { set_count.update(|n| *n += 1); }>
"Click me"
</button>
// now we use our component!
<ProgressBar progress=count/>
}
}
Использование компонента во view очень похоже на использование HTML элемента. Как можно заметить,
элементы и компоненты легко отличить, поскольку компоненты всегда имеют имена вида PascalCase
. Свойство progress
передаётся как если бы это был атрибут HTML элемента. Просто.
Реактивные и Статические Свойства
Как видно из этого примера, progress
принимает реактивный тип ReadSignal<i32>
, а не обычный i32
. Это очень важно.
Свойства компонентов не наделены особым смыслом. Компонент это просто функция, которая выполняется единожды для установки
пользовательского интерфейса. Единственный способ попросить интерфейс реагировать на изменения это передать ему сигнальный тип.
Так что если у вас есть свойство компонента, которое со временем будет меняться, как наше progress
, оно должно быть сигналом.
Необязательные свойства (optional
)
Сейчас настройка max
жестко задана в коде. Давайте же и её станем принимать в качестве свойства. Но с подвохом:
давайте сделаем это свойство необязательным, добавив аннотацию #[prop(optional)]
к аргументу функции нашего компонента.
#[component]
fn ProgressBar(
// mark this prop optional
// you can specify it or not when you use <ProgressBar/>
#[prop(optional)]
max: u16,
progress: ReadSignal<i32>
) -> impl IntoView {
view! {
<progress
max=max
value=progress
/>
}
}
Теперь мы можем написать <ProgressBar max=50 progress=count/>
, или же мы можем пропустить max
чтобы использовать значение по-умолчанию (т.е. <ProgressBar progress=count/>
). Значением по-умолчанию у
необязательного свойства это его значение Default::default()
, которое для u16
будет 0
.
В случае с индикатором выполнения, максимальное значение 0
не очень-то полезно.
Так что давайте зададим вместо него своё значение по-умолчанию.
Свойства с default
Назначить значение по-умолчанию отличное от Default::default()
можно с помощью #[prop(default = ...)
.
#[component]
fn ProgressBar(
#[prop(default = 100)]
max: u16,
progress: ReadSignal<i32>
) -> impl IntoView {
view! {
<progress
max=max
value=progress
/>
}
}
Свойства с обобщенными типами
Прекрасно. Но мы начали с двух счетчиков: один движим count
, а другой производным сигналом double_count
.
Давайте воссоздадим это путем передачи double_count
в качестве свойства progress
в ещё одном <ProgressBar/>
.
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
let double_count = move || count() * 2;
view! {
<button on:click=move |_| { set_count.update(|n| *n += 1); }>
"Click me"
</button>
<ProgressBar progress=count/>
// add a second progress bar
<ProgressBar progress=double_count/>
}
}
Хм... не компилируется. Понять почему довольно просто: мы объявили что свойство progress
принимает ReadSignal<i32>
,
а double_count
имеет тип не ReadSignal<i32>
. Как сообщит вам rust-analyzer
, её тип
|| -> i32
, то есть, замыкание, которое возвращает i32
.
Есть пара способов справиться с этим. Первый это сказать: — Ну, я знаю, что ReadSignal
это функция и я знаю, что
замыкание это функция; может мне просто принимать любую функцию?
Подкованные в вопросе могут знать, что оба типа реализуют типаж Fn() -> i32
.
Так что можно использовать обобщенный компонент:
#[component]
fn ProgressBar(
#[prop(default = 100)]
max: u16,
progress: impl Fn() -> i32 + 'static
) -> impl IntoView {
view! {
<progress
max=max
value=progress
/>
}
}
Это вполне оправданный способ написать данный компонент: progress
теперь принимает любое значение реализующее типаж Fn()
.
Обобщенные свойства могут также быть определены используя
where
или используя inline обобщения такие какProgressBar<F: Fn() -> i32 + 'static>
. Учтите, что поддержкаimpl Trait
синтаксиса была выпущена в версии 0.6.12; если вы получите сообщение об ошибке, возможно вам нужно сделатьcargo update
чтобы удостовериться в том, что у вас последняя версия.
Обобщения должны быть использованы где-то в свойствах компонента. Это потому, что свойства встраиваются в структуру,
так что все обобщенные типы должны быть использованы в структуре. Зачастую этого легко добиться используя
необязательное свойство с типом PhantomData
. Потом можно указать обобщенный тип во view используя
синтаксис для выражения типов: <Component<T>/>
(но не в turbofish-стиле <Component::<T>/>
).
#[component]
fn SizeOf<T: Sized>(#[prop(optional)] _ty: PhantomData<T>) -> impl IntoView {
std::mem::size_of::<T>()
}
#[component]
pub fn App() -> impl IntoView {
view! {
<SizeOf<usize>/>
<SizeOf<String>/>
}
}
Следует учитывать, что есть некоторые ограничения. Например, наш парсер макроса view не умеет обрабатывать вложенные обобщенные типы как
<SizeOf<Vec<T>>/>
.
Свойства с into
Есть ещё один способ реализовать это: использовать #[prop(into)]
.
Этот атрибут автоматически вызывает .into()
у значений, которая передаются как свойства, что позволяет
легко передавать свойства разных типов.
В этом случае, полезно будет знать о типе Signal
. Signal
это перечисляемый тип,
способный представить читаемый реактивный сигнал любого вида. Его полезно использовать во время определения API для компонентов,
которые вы захотите переиспользовать передавая в них разные виды сигналов. Тип MaybeSignal
полезен когда вы
хотите иметь возможность принимать либо статическое либо реактивное значение.
#[component]
fn ProgressBar(
#[prop(default = 100)]
max: u16,
#[prop(into)]
progress: Signal<i32>
) -> impl IntoView
{
view! {
<progress
max=max
value=progress
/>
}
}
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
let double_count = move || count() * 2;
view! {
<button on:click=move |_| { set_count.update(|n| *n += 1); }>
"Click me"
</button>
// .into() converts `ReadSignal` to `Signal`
<ProgressBar progress=count/>
// use `Signal::derive()` to wrap a derived signal
<ProgressBar progress=Signal::derive(double_count)/>
}
}
Необязательные обобщенные свойства
Учтите, что нельзя объявлять необязательные обобщенные свойства. Давайте посмотрим, что будет если всё же попробовать:
#[component]
fn ProgressBar<F: Fn() -> i32 + 'static>(
#[prop(optional)] progress: Option<F>,
) -> impl IntoView {
progress.map(|progress| {
view! {
<progress
max=100
value=progress
/>
}
})
}
#[component]
pub fn App() -> impl IntoView {
view! {
<ProgressBar/>
}
}
Rust услужливо выдаст ошибку:
xx | <ProgressBar/>
| ^^^^^^^^^^^ cannot infer type of the type parameter `F` declared on the function `ProgressBar`
|
help: consider specifying the generic argument
|
xx | <ProgressBar::<F>/>
| +++++
Обобщения в компонентах можно задать используя синтаксис <ProgressBar<F>/>
(в макросе view
без turbofish).
Задание корректного типа здесь невозможно; замыкания и функции в целом являются неименуемыми типами.
Компилятор может отобразить их с помощью сокращения, но их нельзя задать.
Однако, это можно обойти передавая конкретный тип Box<dyn _>
или &dyn _
:
#[component]
fn ProgressBar(
#[prop(optional)] progress: Option<Box<dyn Fn() -> i32>>,
) -> impl IntoView {
progress.map(|progress| {
view! {
<progress
max=100
value=progress
/>
}
})
}
#[component]
pub fn App() -> impl IntoView {
view! {
<ProgressBar/>
}
}
Поскольку компилятор Rust теперь знает конкретный тип свойства, а следовательно и его размер в памяти даже в случае None
, это без проблем компилируется.
Конкретно в данном случае,
&dyn Fn() -> i32
вызовет проблемы с временем жизни ссылки, но в других случаях это может быть допустимо.
Документирование Компонентов
Это один из наименее необходимых, но наиболее важных разделов этой книги. Документирование компонентов и свойств не является строго необходимым. Оно может оказаться очень важным — зависит от размера команды и приложения. Но это очень просто и приносит плоды незамедлительно.
Чтобы задокументировать компонент и его свойства достаточно просто добавить doc-комментарии перед компонентом и перед каждым свойством:
/// Shows progress toward a goal.
#[component]
fn ProgressBar(
/// The maximum value of the progress bar.
#[prop(default = 100)]
max: u16,
/// How much progress should be displayed.
#[prop(into)]
progress: Signal<i32>,
) -> impl IntoView {
/* ... */
}
Это всё, что вам нужно. Эти комментарии ведут себя как обычные Rust doc-комментарии, за исключением того, что в отличие от аргументов Rust функций, можно задокументировать каждое свойство в отдельности.
Документация будет автоматически сгенерирована для вашего компонента, его структуры Props
и для каждого поля,
использованного для добавления свойства. и каждое из аргументов использованных для добавления свойств. Осознать
насколько это мощная штука может быть сложновато, поэтому можно навести мышь на имя компонента или свойства и узреть мощь компонента
#[component]
вкупе с rust-analyzer
.
Продвинутая Тема:
#[component(transparent)]
Все Leptos компоненты возвращают
-> impl IntoView
. Хотя некоторым нужно возвращать данные напрямую без какой-либо дополнительной обёртки. Они могут быть помечены с помощью#[component(transparent)]
, в этом они будут возвращать ровно то, что они возвращают без какой-либо обработки со стороны системы рендерингаЭто используется по большей части в двух ситуациях:
Создание обёрток над
<Suspense/>
или<Transition/>
, которые возвращают прозрачную suspense-структуру для должной интеграции с SSR и гидратацией.Рефакторинг определений
<Route/>
дляleptos_router
в отдельные компоненты, поскольку<Route/>
это прозрачный компонент, возвращающий структуруRouteDefinition
, а не view.В общем случае вам не нужно использовать прозрачные компоненты, если вы не создаёте кастомные компоненты-обёртки, подпадающие под одну из этих двух категорий.
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/3-components-0-5-5vvl69?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/3-components-0-5-5vvl69?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::*;
// Composing different components together is how we build
// user interfaces. Here, we'll define a reusable <ProgressBar/>.
// You'll see how doc comments can be used to document components
// and their properties.
/// Shows progress toward a goal.
#[component]
fn ProgressBar(
// Marks this as an optional prop. It will default to the default
// value of its type, i.e., 0.
#[prop(default = 100)]
/// The maximum value of the progress bar.
max: u16,
// Will run `.into()` on the value passed into the prop.
#[prop(into)]
// `Signal<T>` is a wrapper for several reactive types.
// It can be helpful in component APIs like this, where we
// might want to take any kind of reactive value
/// How much progress should be displayed.
progress: Signal<i32>,
) -> impl IntoView {
view! {
<progress
max={max}
value=progress
/>
<br/>
}
}
#[component]
fn App() -> impl IntoView {
let (count, set_count) = create_signal(0);
let double_count = move || count() * 2;
view! {
<button
on:click=move |_| {
set_count.update(|n| *n += 1);
}
>
"Click me"
</button>
<br/>
// If you have this open in CodeSandbox or an editor with
// rust-analyzer support, try hovering over `ProgressBar`,
// `max`, or `progress` to see the docs we defined above
<ProgressBar max=50 progress=count/>
// Let's use the default max value on this one
// the default is 100, so it should move half as fast
<ProgressBar progress=count/>
// Signal::derive creates a Signal wrapper from our derived signal
// using double_count means it should move twice as fast
<ProgressBar max=50 progress=Signal::derive(double_count)/>
}
}
fn main() {
leptos::mount_to_body(App)
}
Итерация
Будь то вывод TODO-листа, отображение таблицы, показ изображений продукта — итерация списка элементов это часто встречающая задача в веб-приложениях. Грамотная сверка различий между изменяющимися наборами элементов это, возможно, одна из самых сложных задач для фреймворка.
Leptos поддерживает два разных паттерна для итерации элементов:
- Для статических view:
Vec<_>
- Для динамических списков:
<For/>
Статические Views с Vec<_>
Иногда нужно отобразить элемент несколько раз, но элементы отображаемого списка меняется нечасто. В таких случаях,
важно помнить, что в view
можно вставить любой Vec<IV> where IV: IntoView
. Другими словами, если можно отрендерить T
,
то можно и Vec<T>
.
let values = vec![0, 1, 2];
view! {
// this will just render "012"
<p>{values.clone()}</p>
// or we can wrap them in <li>
<ul>
{values.into_iter()
.map(|n| view! { <li>{n}</li>})
.collect::<Vec<_>>()}
</ul>
}
Leptos также предоставляет вспомогательную функцию .collect_view()
, которая позволяет вам собрать любой итератор
типа T: IntoView
в Vec<View>
.
let values = vec![0, 1, 2];
view! {
// this will just render "012"
<p>{values.clone()}</p>
// or we can wrap them in <li>
<ul>
{values.into_iter()
.map(|n| view! { <li>{n}</li>})
.collect_view()}
</ul>
}
Тот факт, что список статический, не означает, что интерфейс должен быть статическим. Динамические элементы можно рендерить как часть статического списка.
// create a list of 5 signals
let length = 5;
let counters = (1..=length).map(|idx| create_signal(idx));
// each item manages a reactive view
// but the list itself will never change
let counter_buttons = counters
.map(|(count, set_count)| {
view! {
<li>
<button
on:click=move |_| set_count.update(|n| *n += 1)
>
{count}
</button>
</li>
}
})
.collect_view();
view! {
<ul>{counter_buttons}</ul>
}
Можно рендерить и Fn() -> Vec<_>
реактивно. Но знайте, что при любом изменении каждый элемент списка будет отрендерен заново.
Это весьма неэкономично! К счастью, есть способ получше.
Динамический Рендеринг с помощью компонента <For/>
Компонент <For/>
это
динамический список с ключами. Он принимает три свойства:
each
: функция (такая как сигнал), она возвращает элементыT
, по которым будет итерацияkey
: функция принимает&T
и возвращает постоянный уникальный ключ или IDchildren
: превращает каждыйT
во view
key
это ключ. Можно добавлять, удалять, и передвигать элементы внутри списка. Пока ключ каждого элемента неизменен,
фреймворку не нужно рендерить заново ни один из элементов, только если не были добавлены новые элементы,
и он может очень низкозатратно вставить добавлять, удалять и двигать элементы по мере их изменения. Это позволяет чрезвычайно
низкозатратно обновлять список по мере его изменений, с минимальной дополнительной работой.
Создание хорошего значения key
может быть немного сложно. В общем случае вам не стоит использовать для этого индекс,
поскольку он не стабилен — если вы удаляете или передвигаете элементы, их индексы меняются.
А вот генерировать уникальный ID для каждого ряда во время его создания и использовать этот ID в функции key
— отличная идея,
Ознакомьтесь с компонентом <DynamicList/>
(см. ниже) в качестве примера.
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/4-iteration-0-5-pwdn2y?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/4-iteration-0-5-pwdn2y?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::*;
// Iteration is a very common task in most applications.
// So how do you take a list of data and render it in the DOM?
// This example will show you the two ways:
// 1) for mostly-static lists, using Rust iterators
// 2) for lists that grow, shrink, or move items, using <For/>
#[component]
fn App() -> impl IntoView {
view! {
<h1>"Iteration"</h1>
<h2>"Static List"</h2>
<p>"Use this pattern if the list itself is static."</p>
<StaticList length=5/>
<h2>"Dynamic List"</h2>
<p>"Use this pattern if the rows in your list will change."</p>
<DynamicList initial_length=5/>
}
}
/// A list of counters, without the ability
/// to add or remove any.
#[component]
fn StaticList(
/// How many counters to include in this list.
length: usize,
) -> impl IntoView {
// create counter signals that start at incrementing numbers
let counters = (1..=length).map(|idx| create_signal(idx));
// when you have a list that doesn't change, you can
// manipulate it using ordinary Rust iterators
// and collect it into a Vec<_> to insert it into the DOM
let counter_buttons = counters
.map(|(count, set_count)| {
view! {
<li>
<button
on:click=move |_| set_count.update(|n| *n += 1)
>
{count}
</button>
</li>
}
})
.collect::<Vec<_>>();
// Note that if `counter_buttons` were a reactive list
// and its value changed, this would be very inefficient:
// it would rerender every row every time the list changed.
view! {
<ul>{counter_buttons}</ul>
}
}
/// A list of counters that allows you to add or
/// remove counters.
#[component]
fn DynamicList(
/// The number of counters to begin with.
initial_length: usize,
) -> impl IntoView {
// This dynamic list will use the <For/> component.
// <For/> is a keyed list. This means that each row
// has a defined key. If the key does not change, the row
// will not be re-rendered. When the list changes, only
// the minimum number of changes will be made to the DOM.
// `next_counter_id` will let us generate unique IDs
// we do this by simply incrementing the ID by one
// each time we create a counter
let mut next_counter_id = initial_length;
// we generate an initial list as in <StaticList/>
// but this time we include the ID along with the signal
let initial_counters = (0..initial_length)
.map(|id| (id, create_signal(id + 1)))
.collect::<Vec<_>>();
// now we store that initial list in a signal
// this way, we'll be able to modify the list over time,
// adding and removing counters, and it will change reactively
let (counters, set_counters) = create_signal(initial_counters);
let add_counter = move |_| {
// create a signal for the new counter
let sig = create_signal(next_counter_id + 1);
// add this counter to the list of counters
set_counters.update(move |counters| {
// since `.update()` gives us `&mut T`
// we can just use normal Vec methods like `push`
counters.push((next_counter_id, sig))
});
// increment the ID so it's always unique
next_counter_id += 1;
};
view! {
<div>
<button on:click=add_counter>
"Add Counter"
</button>
<ul>
// The <For/> component is central here
// This allows for efficient, key list rendering
<For
// `each` takes any function that returns an iterator
// this should usually be a signal or derived signal
// if it's not reactive, just render a Vec<_> instead of <For/>
each=counters
// the key should be unique and stable for each row
// using an index is usually a bad idea, unless your list
// can only grow, because moving items around inside the list
// means their indices will change and they will all rerender
key=|counter| counter.0
// `children` receives each item from your `each` iterator
// and returns a view
children=move |(id, (count, set_count))| {
view! {
<li>
<button
on:click=move |_| set_count.update(|n| *n += 1)
>
{count}
</button>
<button
on:click=move |_| {
set_counters.update(|counters| {
counters.retain(|(counter_id, _)| counter_id != &id)
});
}
>
"Remove"
</button>
</li>
}
}
/>
</ul>
</div>
}
}
fn main() {
leptos::mount_to_body(App)
}
Итерация более сложных структур через <For/>
Эта глава чуть глубже рассматривает итерацию над вложенными структурами данных. Её место здесь, с другой главой об итерации, но можете без зазрения совести пропустить её если хотите пока ограничиться простыми темами.
Задача
Только что я сказал, что фреймворк не рендерит повторно никакие элементы в одном из рядов, если ключ не изменился. Поначалу это наверно кажется разумным, но об это легко споткнуться.
Давайте рассмотрим пример, в котором каждый из элементов нашего ряда является какой-то структурой данных. Представьте, например, что элементы поступают из какого-то JSON массива ключей и элементов:
#[derive(Debug, Clone)]
struct DatabaseEntry {
key: String,
value: i32,
}
Давайте объявим простой компонент, который будет итерировать ряды и отображать каждый из них:
#[component]
pub fn App() -> impl IntoView {
// start with a set of three rows
let (data, set_data) = create_signal(vec![
DatabaseEntry {
key: "foo".to_string(),
value: 10,
},
DatabaseEntry {
key: "bar".to_string(),
value: 20,
},
DatabaseEntry {
key: "baz".to_string(),
value: 15,
},
]);
view! {
// when we click, update each row,
// doubling its value
<button on:click=move |_| {
set_data.update(|data| {
for row in data {
row.value *= 2;
}
});
// log the new value of the signal
logging::log!("{:?}", data.get());
}>
"Update Values"
</button>
// iterate over the rows and display each value
<For
each=data
key=|state| state.key.clone()
let:child
>
<p>{child.value}</p>
</For>
}
}
Обратите внимание на синтаксис
let:child
. В предыдущей главе мы познакомили вас с<For/>
со свойствомchildren
. Мы можем создавать это значение напрямую внутри компонента<For>
без выхода за пределы макросаview
:let:child
вкупе с<p>{child.value}</p>
в коде выше эквивалентноchildren=|child| view! { <p>{child.value}</p> }
При нажатии на кнопку Update Values
... ничего не происходит. Или точнее:
сигнал обновляется, новое значение логируется, но значение {child.value}
для каждого ряда не обновляется.
Давайте посмотрим: это потому что мы забыли добавить замыкание, чтобы сделать его реактивным?
Попробуем {move || child.value}
.
...Нет. Ничего не поменялось.
Проблема вот в чём: как я говорил, каждый ряд рендерится повторно только когда его ключ меняется.
Мы обновили значение для каждого ряда, но ключ ни для одного ряда не поменялся, поэтому ничего и не отрендерилось повторно.
И если посмотреть на тип child.value
, то это простой i32
, а не реактивный ReadSignal<i32>
или прочее подобное.
Это значит даже если мы обернём его в замыкание, значение в этом ряду никогда не будет обновлено.
Есть три возможных решения:
- изменять значение
key
так, что он всегда меняется когда структура данных меняется - изменить тип
value
чтобы он стал реактивным - принимать реактивный срез структуры данных вместо использования каждого ряда напрямую
Вариант 1: Изменение ключ
Каждый ряд рендерится повторно лишь когда его ключ меняется. Наши ряды в примере выше не рендерились повторно, поскольку ключ не менялся. Так почему бы просто не заставить ключ меняться?
<For
each=data
key=|state| (state.key.clone(), state.value)
let:child
>
<p>{child.value}</p>
</For>
Теперь мы составляем key
из ключа и значения. Это значит что при любом изменении значения ряда, <For/>
будет считать,
что это полностью новый ряд и будет заменять им старый.
Преимущества
Это очень легкий вариант. Мы можем сделать его даже легче добавив #[derive(PartialEq, Eq, Hash)]
перед DatabaseEntry
, в таком случае мы сможем написать просто key=|state| state.clone()
.
Недостатки
Это наиболее затратный вариант из трёх. Каждый раз когда значение ряда изменяется, предыдущий элемент <p>
выбрасывается и
заменяется полностью новым. Вместо мелкозернистого обновление текстового узла происходит настоящий повторный рендеринг целого ряда
при каждом измении, и дороговизна этой операция пропорциональна тому насколько сложный UI у этого ряда.
Как можно заметить, мы также не избежим клонирования всей структуры данных, чтобы <For/>
мог иметь копию ключа.
Для более сложных структур это быстро становится дурной затеей!
Вариант 2: Вложенные Сигналы
Если мы всё же хотим мелкозернистую реактивность (англ. fine-grained reactivity)
для значения, одним из вариантов явлется обернуть value
каждого ряда в сигнал.
#[derive(Debug, Clone)]
struct DatabaseEntry {
key: String,
value: RwSignal<i32>,
}
RwSignal<_>
это “read-write сигнал”, совмещающий геттер и сеттер в одном объекте.
Я использую его здесь потому, что в структуре его хранить немного проще, чем отдельно геттер и сеттер.
#[component]
pub fn App() -> impl IntoView {
// start with a set of three rows
let (data, set_data) = create_signal(vec![
DatabaseEntry {
key: "foo".to_string(),
value: create_rw_signal(10),
},
DatabaseEntry {
key: "bar".to_string(),
value: create_rw_signal(20),
},
DatabaseEntry {
key: "baz".to_string(),
value: create_rw_signal(15),
},
]);
view! {
// when we click, update each row,
// doubling its value
<button on:click=move |_| {
data.with(|data| {
for row in data {
row.value.update(|value| *value *= 2);
}
});
// log the new value of the signal
logging::log!("{:?}", data.get());
}>
"Update Values"
</button>
// iterate over the rows and display each value
<For
each=data
key=|state| state.key.clone()
let:child
>
<p>{child.value}</p>
</For>
}
}
Эта версия работает! И если вы посмотрите через DOM инспектор в браузере, то увидите, что в этой версии, в отличие от
предыдущей, лишь отдельные текстовые узлы обновляются. Передача сигнала напрямую в {child.value}
работает, поскольку сигналы остаются реактивными при передаче их во view.
Обратите внимание, я заменил set_data.update()
на data.with()
. .with()
это способ получить доступ к значению сигнала,
не клонируя его. В данном случае мы лишь обновляем внутренние значение, не трогая список значений: поскольку сигналы имеют собственное состояние,
нам в действительности вовсе не нужно обновлять сигнал data
, так что иммутабельный вызов .with()
вполне подходит.
Фактически эта версия не обновляет
data
, так что<For/>
здесь в сущности выполняет роль статического списка из прошлой главы, и это мог бы быть обычный итератор. Но<For/>
будет полезен если в будущем мы захотим добавлять или удалять ряды.
Преимущества
Это самый низкозатратный вариант, и он напрямую соотносится с остальной ментальной моделью данного фреймворка: значения, меняющиеся со временем обёрнуты в сигналы, что значит. что интерфейс интерфейс может на них реагировать.
Недостатки
Вложенная реактивность может быть громоздкой если вы получаете данные из API или другого неподконтрольного источника, и не желаете создавать ещё одну структуру оборачивая каждое поле в сигнал.
Вариант 3: Мемоизированные Срезы
Leptos предоставляет примитив называемый create_memo
,
который создает производной вычисление, которое вызывает реактивное обновление только когда его значение изменилось.
Это позволяет создать реактивные значения для суб-полей крупной структуры данных, без необходимости оборачивать поля этой структуры в сигналы.
Большая часть приложения может остаться неизменной относительно первоначальной (сломанной) версии, но <For/>
будет
выглядеть так:
<For
each=move || data().into_iter().enumerate()
key=|(_, state)| state.key.clone()
children=move |(index, _)| {
let value = create_memo(move |_| {
data.with(|data| data.get(index).map(|d| d.value).unwrap_or(0))
});
view! {
<p>{value}</p>
}
}
/>
Здесь вы заметите несколько отличий:
- мы конвертируем сигнал
data
в нумерующий итератор - мы используем свойство
children
явным образом, чтобы упростить выполнение кода внеview
- мы создаем мемоизированное значение
value
и используем его и во view. Это полеvalue
, на самом деле, не использует значениеchild
передаваемое в каждый ряд. Вместо этого, оно использует индекс и обращается непосредственно кdata
чтобы получить значение.
Теперь, каждый раз, когда data
меняется, каждое мемоизированное значение будет пересчитано. Если его значение изменилось, оно поменяет свой текстовый узел
без повторного рендеринга всего ряда.
Преимущества
Мы получаем ту же мелкозернистую реактивность, что и в версии с оборачиваем в сигналы, без необходимости оборачивать данные в сигналы.
Недостатки
Это создание мемоизированного значения для каждого ряда внутри цикла <For/>
немного более сложно чем использовать вложенными сигналы.
Например, вы заметите, что мы должны защищаться от возможной паники со стороны data[index]
, используя data.get(index)
,
поскольку перезапуск memo может быть спровоцирован сразу после того как ряд был удален. (Это потому что и memo для каждого
ряда и весь <For/>
зависят от одного и того же сигнала data
, а порядок выполнения для нескольких реактивных
значений зависящих от одного сигнала не гарантирован.)
Также обратите внимание, несмотря на то, как мемоизированные значения запоминают свои реактивные изменения, одним и тем же вычисления все-таки нужно каждый раз выполняться повторно, чтобы проверять значение, так что вложенные реактивные сигналы всё ещё будут менее затратны при отслеживании обновлений в данном случае.
Формы и поля ввода
Формы и поля ввода — важная часть интерактивных приложений. Есть два паттерна взаимодействия с полями ввода в Leptos. Те из вас, кто знаком с React, SolidJS или похожим фреймворком могут их знать: использование контролируемых или неконтролируемых элементов форм.
Контролируемые поля ввода
В случае с "контролируемым полем ввода" фреймворк контролирует состояние элемента. При каждом input
событии, он
обновляет локальный сигнал, хранящий текущее состояние, который, в свою очередь, обновляет значение свойства value
.
Есть две важные вещи, которые нужно запомнить:
-
Событие
input
срабатывает (почти) при каждом изменении элемента, когда какchange
(обычно) срабатывает только после снятия фокуса с поля ввода. Обычно лучше подходитon:input
, но мы хотим оставить вам свободу выбора. -
Значение атрибута
value
лишь задает первоначальное значение поля ввода, то есть, оно обновляет значение поля ввода лишь до тех пор, пока вы не начали печатать. Свойствоvalue
продолжает обновлять поле вода и после этого. По этой причине, обычно вам стоит использоватьprop:value
. (Это также справедливо дляchecked
иprop:checked
в<input type="checkbox">
.)
let (name, set_name) = create_signal("Controlled".to_string());
view! {
<input type="text"
on:input=move |ev| {
// event_target_value is a Leptos helper function
// it functions the same way as event.target.value
// in JavaScript, but smooths out some of the typecasting
// necessary to make this work in Rust
set_name(event_target_value(&ev));
}
// the `prop:` syntax lets you update a DOM property,
// rather than an attribute.
prop:value=name
/>
<p>"Name is: " {name}</p>
}
Зачем нужно
prop:value
?Веб-браузеры это самая вездесущая и стабильная платформа рендеринга графических пользовательских интерфейсов из всех существующих.
Они также сохранили невероятную обратную совместимость на протяжении трёх десятков лет. Это неизбежно означает, что есть и странности.
Одна из странностей состоит в том, что есть разница между HTML атрибутами и свойствами DOM элемента, то есть, между так называемым "атрибутом", что берется из HTML и может быть задан DOM-элементу с помощью
.setAttribute()
, и между "свойством" — полем в JavaScript-представлении этого элемента HTML.В случае с
<input value=...>
, атрибутvalue
задаёт первоначальное значение этого поля ввода, а свойствоvalue
задаёт его текущее значение. Возможно самый простой способ разобраться с этим это открытьabout:blank
и выполнить следующий код на JavaScript в консоли браузера строчка за строчкой:// создадим поле ввода и добавим его в DOM const el = document.createElement("input"); document.body.appendChild(el); el.setAttribute("value", "тест"); // изменяет значение поля ввода el.setAttribute("value", "тест тест"); // тоже изменяет значение поля ввода // теперь напечайте что-нибудь в поле ввода: удалите какие-нибудь символы и т. д. el.setAttribute("value", "ещё разок?"); // ничего не должно было измениться. смена "первоначального значения" теперь ни на что не влияет // однако... el.value = "А это работает";
Многие другие frontend фреймворки объединяют атрибуты и свойства или в порядке исключения для полей ввода устанавливают значение корректно. Может Leptos'у тоже стоит так делать; но пока что я предпочитаю давать пользователям максимальную степень контроля над тем что они задают — атрибут или свойство, и делаю всё, что в моих силах, чтобы рассказать людям о реальном поведении браузера, вместо того, чтобы скрывать его.
Неконтролируемые поля ввода
В случае "неконтролируемого поля ввода" браузера сам управляет состояние элемента поля ввода.
Вместо того чтобы постоянно обновлять сигнал, чтобы он содержал значение поля, мы используем NodeRef
для
получения доступа к полю ввода когда мы хотим получить его значение.
В данном примере мы уведомляем фреймворк лишь когда <form>
вызывает событие submit
.
Обратите внимание на использование модуля leptos::html
, который предоставляет набор типов для каждого элемента HTML.
let (name, set_name) = create_signal("Uncontrolled".to_string());
let input_element: NodeRef<html::Input> = create_node_ref();
view! {
<form on:submit=on_submit> // on_submit defined below
<input type="text"
value=name
node_ref=input_element
/>
<input type="submit" value="Submit"/>
</form>
<p>"Name is: " {name}</p>
}
Данный view уже должен быть достаточно понятен без объяснений. Обратите внимание на следующие вещи:
- В отличие от примера с контролируемыми полями ввода, мы используем
value
(неprop:value
). Это потому, что мы просто задаём первоначальное значение поля ввода и позволяем браузеру его состояние. (Мы могли бы использоватьprop:value
вместо этого.) - Мы используем
node_ref=...
чтобы заполнитьNodeRef
. (Более старые примеры иногда используют_ref
. Это то же само, ноnode_ref
лучше поддерживаетсяrust-analyzer
)
NodeRef
это наподобие реактивного умного указателя: мы можем использовать его для доступа к узлу DOM, на который тот указывает.
Значение NodeRef
задаётся когда соответствующий элемент отрендерен.
let on_submit = move |ev: leptos::ev::SubmitEvent| {
// stop the page from reloading!
ev.prevent_default();
// here, we'll extract the value from the input
let value = input_element()
// event handlers can only fire after the view
// is mounted to the DOM, so the `NodeRef` will be `Some`
.expect("<input> should be mounted")
// `leptos::HtmlElement<html::Input>` implements `Deref`
// to a `web_sys::HtmlInputElement`.
// this means we can call`HtmlInputElement::value()`
// to get the current value of the input
.value();
set_name(value);
};
Наш обработчик on_submit
получит доступ к значению поля ввода и использует его при вызове set_name
.
Чтобы получить доступ к узлу DOM хранимому в NodeRef
, мы можем просто вызвать его как функцию (или использовать .get()
).
Она вернёт тип Option<leptos::HtmlElement<html::Input>>
, но мы знаем, что элемент уже был примонтирован (как иначе возникло событие!),
так что вызов unwrap() здесь безопасен.
Затем мы можем .value()
чтобы получить значение поля ввода, поскольку NodeRef
даёт нам доступ к корректно типизированному
HTML элементу.
Посмотрите на web_sys
and HtmlElement
чтобы узнать больше об использовании leptos::HtmlElement
.
Также посмотрите полный CodeSandbox пример в конце этой страницы.
Особые случаи: <textarea>
и <select>
Эти два элемента формы склонны вызывать непонимание, каждый по-своему.
<textarea>
В отличие от <input>
, элемент <textarea>
не поддерживает атрибут value
.
Вместо этого, он получает своё значение в качестве текстового узла в своих HTML детях.
В текущей версии Leptos (а точнее, в Leptos 0.1-0.6), создание динамического child-узла влечёт также вставку узла комментария-маркера.
Это может вызвать некорректный рендеринг <textarea>
(и проблемы во время гидратации) если попытаться использовать этот элемент для
отображения какого-то динамического контента.
Вместо этого можно передать нереактивное первоначальное значение внутрь <textarea>
и использовать prop:value
, чтобы задавать
текущее значение. (<textarea>
не поддерживает атрибут value
, но поддерживает
свойство value
...)
view! {
<textarea
prop:value=move || some_value.get()
on:input=/* etc */
>
/* plain-text initial value, does not change if the signal changes */
{some_value.get_untracked()}
</textarea>
}
<select>
Элемент <select>
может таким же образом контролироваться через свойство value
самого <select>
,
<option>
с этим значением будет выбран автоматически.
let (value, set_value) = create_signal(0i32);
view! {
<select
on:change=move |ev| {
let new_value = event_target_value(&ev);
set_value(new_value.parse().unwrap());
}
prop:value=move || value.get().to_string()
>
<option value="0">"0"</option>
<option value="1">"1"</option>
<option value="2">"2"</option>
</select>
// a button that will cycle through the options
<button on:click=move |_| set_value.update(|n| {
if *n == 2 {
*n = 0;
} else {
*n += 1;
}
})>
"Next Option"
</button>
}
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/5-forms-0-5-rf2t7c?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/5-forms-0-5-rf2t7c?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::{ev::SubmitEvent, *};
#[component]
fn App() -> impl IntoView {
view! {
<h2>"Controlled Component"</h2>
<ControlledComponent/>
<h2>"Uncontrolled Component"</h2>
<UncontrolledComponent/>
}
}
#[component]
fn ControlledComponent() -> impl IntoView {
// create a signal to hold the value
let (name, set_name) = create_signal("Controlled".to_string());
view! {
<input type="text"
// fire an event whenever the input changes
on:input=move |ev| {
// event_target_value is a Leptos helper function
// it functions the same way as event.target.value
// in JavaScript, but smooths out some of the typecasting
// necessary to make this work in Rust
set_name(event_target_value(&ev));
}
// the `prop:` syntax lets you update a DOM property,
// rather than an attribute.
//
// IMPORTANT: the `value` *attribute* only sets the
// initial value, until you have made a change.
// The `value` *property* sets the current value.
// This is a quirk of the DOM; I didn't invent it.
// Other frameworks gloss this over; I think it's
// more important to give you access to the browser
// as it really works.
//
// tl;dr: use prop:value for form inputs
prop:value=name
/>
<p>"Name is: " {name}</p>
}
}
#[component]
fn UncontrolledComponent() -> impl IntoView {
// import the type for <input>
use leptos::html::Input;
let (name, set_name) = create_signal("Uncontrolled".to_string());
// we'll use a NodeRef to store a reference to the input element
// this will be filled when the element is created
let input_element: NodeRef<Input> = create_node_ref();
// fires when the form `submit` event happens
// this will store the value of the <input> in our signal
let on_submit = move |ev: SubmitEvent| {
// stop the page from reloading!
ev.prevent_default();
// here, we'll extract the value from the input
let value = input_element()
// event handlers can only fire after the view
// is mounted to the DOM, so the `NodeRef` will be `Some`
.expect("<input> to exist")
// `NodeRef` implements `Deref` for the DOM element type
// this means we can call`HtmlInputElement::value()`
// to get the current value of the input
.value();
set_name(value);
};
view! {
<form on:submit=on_submit>
<input type="text"
// here, we use the `value` *attribute* to set only
// the initial value, letting the browser maintain
// the state after that
value=name
// store a reference to this input in `input_element`
node_ref=input_element
/>
<input type="submit" value="Submit"/>
</form>
<p>"Name is: " {name}</p>
}
}
// This `main` function is the entry point into the app
// It just mounts our component to the <body>
// Because we defined it as `fn App`, we can now use it in a
// template as <App/>
fn main() {
leptos::mount_to_body(App)
}
Порядок выполнения
В большинстве приложений вам порой нужно принять решение: Мне рендерить это как часть view или нет?
Мне рендерить <ButtonA/>
или <WidgetB/>
. Это и есть порядок выполнения (англ. control flow).
Несколько советов
Думая о том, как сделать это в Leptos, важно помнить несколько вещей:
- Rust это язык ориентированный на выражения: такие выражения порядка выполнения, как
if x() { y } else { z }
иmatch x() { ... }
возвращают свои значения. Это делает их очень полезными для декларативных пользовательских интерфейсов. - Для любого
T
реализующегоIntoView
— другими словами, для любого типа, который Leptos может рендерить —Option<T>
иResult<T, impl Error>
тоже реализуютIntoView
. И так же какFn() -> T
рендерит реактивныйT
,Fn() -> Option<T>
иFn() -> Result<T, impl Error>
тоже реактивны. - В Rust есть множество полезных хелперов, таких как Option::map,
Option::and_then,
Option::ok_or,
Result::map,
Result::ok, и
bool::then, позволяющих вам декларативно конвертировать значения между несколькими стандартными типами, все из которых
могут быть отрендерены. Потратить время на чтение документации к
Option
иResult
это один из лучших способов выйти на новый уровень в Rust. - И всегда помните: чтобы быть реактивными, значения должны быть функциями. Ниже будет видно, как я постоянно оборачиваю вещи в
move ||
замыкания. Это чтобы убедиться, что они действительно выполняются повторно когда меняется сигнал, от которого они зависят, поддерживая реактивность UI.
Ну и что?
Чтобы немного собрать всё воедино: на практике это значит, что большую часть контроля над порядком выполнения можно реализовать при помощи нативного Rust кода, без особых компонентов, управляющих порядком выполнения и не обладая специальными знаниями.
К примеру, давайте начнём с простого сигнала и производного сигнала:
let (value, set_value) = create_signal(0);
let is_odd = move || value() & 1 == 1;
Если вы не понимаете что происходит с
is_odd
, то не волнуйтесь. Это лишь простой способ проверить число на нечётность, используя побитовое «И» с 1.
Мы можем использовать эти сигналы и обычный Rust чтобы реализовать большую часть управления порядком выполнения.
Оператор if
Предположим, я хочу выводить одну надпись если число нечётно и другую если оно чётно. Что скажете вот об этом?
view! {
<p>
{move || if is_odd() {
"Odd"
} else {
"Even"
}}
</p>
}
Выражение if
возвращает своё значение, а &str
реализует IntoView
, поэтому
Fn() -> &str
реализует IntoView
, так что это... просто работает!
Option<T>
Предположим мы хотим выводить надпись если число нечётно и ничего не выводить, если чётно.
let message = move || {
if is_odd() {
Some("Ding ding ding!")
} else {
None
}
};
view! {
<p>{message}</p>
}
Это отлично работает. Мы можем, как вариант, немного сократить код, используя bool::then()
.
let message = move || is_odd().then(|| "Ding ding ding!");
view! {
<p>{message}</p>
}
При желании можно даже обойтись без переменной. Хотя, лично я порой предпочитаю улучшенную поддержку cargo fmt
и rust-analyzer
,
которую получаешь, вынося такое за пределы view
.
Конструкция match
Мы всё ещё пишем код на обычном Rust, ведь так? Это означает, у вас в распоряжении вся мощь сопоставления с образцом, доступного в Rust.
let message = move || {
match value() {
0 => "Zero",
1 => "One",
n if is_odd() => "Odd",
_ => "Even"
}
};
view! {
<p>{message}</p>
}
А почему бы и нет? Живём только раз, не так ли?
Предотвращение чрезмерного рендеринга
А вот и не только раз.
Всё что мы только что сделали это в целом нормально. Но один нюанс, который вам стоит запомнить и обходиться с ним аккуратно. Каждая из функций управляющих порядком выполнения, из тех что мы создали к этому моменту, это в сущности производный сигнал: они будут перезапускаться при каждом изменении значения. В примерах выше, где значение меняется с четного на нечётное при каждом изменении, это нормально.
Но рассмотрим следующий пример:
let (value, set_value) = create_signal(0);
let message = move || if value() > 5 {
"Big"
} else {
"Small"
};
view! {
<p>{message}</p>
}
Это конечно работает. Но если добавить вывод в лог, то можно удивиться
let message = move || if value() > 5 {
logging::log!("{}: rendering Big", value());
"Big"
} else {
logging::log!("{}: rendering Small", value());
"Small"
};
Когда пользователь нажимает на кнопку будет что-то вроде этого:
1: rendering Small
2: rendering Small
3: rendering Small
4: rendering Small
5: rendering Small
6: rendering Big
7: rendering Big
8: rendering Big
... и так до бесконечности
Каждый раз когда value
меняется, if
выполняется снова. Оно и понятно, учитывая как устроена реактивность. Но у этого есть недостаток.
Для простого текстового узла повторное выполнение if
и повторный рендеринг это пустяки. Но представьте если бы было так:
let message = move || if value() > 5 {
<Big/>
} else {
<Small/>
};
<Small/>
перезапускается пять раз, затем <Big/>
бесконечно. Если они подгружают ресурсы, создают сигналы или даже просто создают узлы DOM,
это лишняя работа.
<Show/>
Компонент <Show/>
это ответ этим проблемам. Вы передаёте условную функцию when
и fallback
для показа в случае если when
вернула false
,
и дочерние элементы, которые будут отображаться если when
равно true
.
let (value, set_value) = create_signal(0);
view! {
<Show
when=move || { value() > 5 }
fallback=|| view! { <Small/> }
>
<Big/>
</Show>
}
<Show/>
мемоизирует условие when
, так что он рендерит <Small/>
лишь раз,
продолжая показывать тот же компонент пока value
не станет больше пяти;
затем он отрендерит <Big/>
один раз, продолжая его показывать вечно или пока value
не станет меньше пяти, тогда он снова отрендерит <Small/>
.
Это полезный инструмент чтобы избежать повторного рендеринга при использовании динамических if
выражений.
Как обычно, есть и накладные расходы: для очень маленького узла (как обновление одного текстового узла, обновления класса или атрибута)
конструкция move || if ...
будет менее затратна. Но если рендеринг какой-либо ветки хоть сколько-нибудь затратен,
используйте <Show/>
.
Заметка: конвертация типов
Это последняя вещь, о которой важно сказать в этом разделе.
Макрос view
не возвращает самый обобщенный оборачивающий тип View
.
Вместо этого, он возвращает такие типы как Fragment
или HtmlElement<Input>
. Это может немного раздражать, если
возвращать разные элементы HTML из разных ветвей условия.
view! {
<main>
{move || match is_odd() {
true if value() == 1 => {
// returns HtmlElement<Pre>
view! { <pre>"One"</pre> }
},
false if value() == 2 => {
// returns HtmlElement<P>
view! { <p>"Two"</p> }
}
// returns HtmlElement<Textarea>
_ => view! { <textarea>{value()}</textarea> }
}}
</main>
}
Эта сильная типизация в самом деле мощная штука, поскольку HtmlElement
кроме всего прочего является умным указателем:
каждый тип HtmlElement<T>
реализует Deref
для подходящего внутреннего типа web_sys
. Другими словами, view
в браузере
возвращает настоящие DOM элементы и у них можно вызывать нативные методы DOM.
Но это может немного раздражать в условной логике как здесь, поскольку в Rust вы не можете возвращать разные типы из разных ветвей условия. Есть два выхода из положения:
- Если у вас несколько
HtmlElement
типов, конвертируйте их вHtmlElement<AnyElement>
при помощи.into_any()
- Если у вас набор view-типов, не все из которых
HtmlElement
, конвертируйте их воView
через.into_view()
.
Вот тот же пример, с добавленной конвертацией:
view! {
<main>
{move || match is_odd() {
true if value() == 1 => {
// returns HtmlElement<Pre>
view! { <pre>"One"</pre> }.into_any()
},
false if value() == 2 => {
// returns HtmlElement<P>
view! { <p>"Two"</p> }.into_any()
}
// returns HtmlElement<Textarea>
_ => view! { <textarea>{value()}</textarea> }.into_any()
}}
</main>
}
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/6-control-flow-0-5-4yn7qz?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Please enable JavaScript to view examples.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/6-control-flow-0-5-4yn7qz?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::*;
#[component]
fn App() -> impl IntoView {
let (value, set_value) = create_signal(0);
let is_odd = move || value() & 1 == 1;
let odd_text = move || if is_odd() { Some("How odd!") } else { None };
view! {
<h1>"Control Flow"</h1>
// Simple UI to update and show a value
<button on:click=move |_| set_value.update(|n| *n += 1)>
"+1"
</button>
<p>"Value is: " {value}</p>
<hr/>
<h2><code>"Option<T>"</code></h2>
// For any `T` that implements `IntoView`,
// so does `Option<T>`
<p>{odd_text}</p>
// This means you can use `Option` methods on it
<p>{move || odd_text().map(|text| text.len())}</p>
<h2>"Conditional Logic"</h2>
// You can do dynamic conditional if-then-else
// logic in several ways
//
// a. An "if" expression in a function
// This will simply re-render every time the value
// changes, which makes it good for lightweight UI
<p>
{move || if is_odd() {
"Odd"
} else {
"Even"
}}
</p>
// b. Toggling some kind of class
// This is smart for an element that's going to
// toggled often, because it doesn't destroy
// it in between states
// (you can find the `hidden` class in `index.html`)
<p class:hidden=is_odd>"Appears if even."</p>
// c. The <Show/> component
// This only renders the fallback and the child
// once, lazily, and toggles between them when
// needed. This makes it more efficient in many cases
// than a {move || if ...} block
<Show when=is_odd
fallback=|| view! { <p>"Even steven"</p> }
>
<p>"Oddment"</p>
</Show>
// d. Because `bool::then()` converts a `bool` to
// `Option`, you can use it to create a show/hide toggled
{move || is_odd().then(|| view! { <p>"Oddity!"</p> })}
<h2>"Converting between Types"</h2>
// e. Note: if branches return different types,
// you can convert between them with
// `.into_any()` (for different HTML element types)
// or `.into_view()` (for all view types)
{move || match is_odd() {
true if value() == 1 => {
// <pre> returns HtmlElement<Pre>
view! { <pre>"One"</pre> }.into_any()
},
false if value() == 2 => {
// <p> returns HtmlElement<P>
// so we convert into a more generic type
view! { <p>"Two"</p> }.into_any()
}
_ => view! { <textarea>{value()}</textarea> }.into_any()
}}
}
}
fn main() {
leptos::mount_to_body(App)
}
Обработка ошибок
В предыдущей главе, мы рассмотрели, что можно рендерить Option<T>
:
в случае None
, ничего не будет выведено, а в случае Some(T)
, будет выведен T
(если T
реализует IntoView
). С Result<T, E>
можно обойтись весьма схожим образом.
В случае Err(_)
, ничего не будет выведено. В случае Ok(T)
будет выведен T
.
Давайте начнем с простого компонента, осуществляющего захват числового поля ввода.
#[component]
fn NumericInput() -> impl IntoView {
let (value, set_value) = create_signal(Ok(0));
// when input changes, try to parse a number from the input
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
view! {
<label>
"Type an integer (or not!)"
<input type="number" on:input=on_input/>
<p>
"You entered "
<strong>{value}</strong>
</p>
</label>
}
}
Каждый раз когда значение поля ввода меняется, on_input
попытается превратить это значение в 32-битное число (i32
)
и сохранить его в наш сигнал value
с типом Result<i32, _>
. Если ввести число 42
, UI отобразит
You entered 42
Но если введете строку foo
, он отобразит
You entered
Выглядит так себе. Это экономит нам вызов .unwrap_or_default()
или чего-то подобного, но было бы намного лучше, если
мы могли бы поймать эту ошибку и что-нибудь с ней сделать.
Это можно сделать, используя компонент <ErrorBoundary/>
Люди часто пытаются указать на то, что `<input type="number>` не даст вам написать строку как `foo` или что-либо ещё,
что не является числом. Это справедливо в каких-то браузерах, но не во всех! Более того, есть множество вещей, которые
можно напечатать в обычный числовое поле ввода и которые не являются `i32`: число с плавающей точкой, число больше
чем позволяют 32 бита, буква `e` и так далее. Браузеру можно сказать чтоб он поддерживал некоторые из этих инвариантов,
однако поведение браузера всё же вариативно: Важно парсить самостоятельно!
<ErrorBoundary/>
<ErrorBoundary/>
немного сродни компоненту <Show/>
, рассмотренному нами в предыдущей главе.
Если всё окей —точнее сказать, если всё Ok(_)
— он выводит дочерние элементы.
Но если среди потомков будет выведен Err(_)
, это вызовет отображение fallback
в <ErrorBoundary/>
.
Давайте добавим <ErrorBoundary/>
в наш пример.
#[component]
fn NumericInput() -> impl IntoView {
let (value, set_value) = create_signal(Ok(0));
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
view! {
<h1>"Error Handling"</h1>
<label>
"Type a number (or something that's not a number!)"
<input type="number" on:input=on_input/>
<ErrorBoundary
// the fallback receives a signal containing current errors
fallback=|errors| view! {
<div class="error">
<p>"Not a number! Errors: "</p>
// we can render a list of errors as strings, if we'd like
<ul>
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li>})
.collect_view()
}
</ul>
</div>
}
>
<p>"You entered " <strong>{value}</strong></p>
</ErrorBoundary>
</label>
}
}
Теперь если ввести 42
, value
примет значение Ok(42)
и вы увидите
You entered 42
Если ввести foo
, value
будет Err(_)
и отобразится fallback
.
Мы вывели список ошибок в виде String
, так что вы увидите что-то вроде
Not a number! Errors:
- cannot parse integer from empty string
Если исправить эту ошибку, сообщение об ошибке исчезнет и контент обёрнутый в <ErrorBoundary/>
появится снова.
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/7-errors-0-5-5mptv9?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/7-errors-0-5-5mptv9?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::*;
#[component]
fn App() -> impl IntoView {
let (value, set_value) = create_signal(Ok(0));
// when input changes, try to parse a number from the input
let on_input = move |ev| set_value(event_target_value(&ev).parse::<i32>());
view! {
<h1>"Error Handling"</h1>
<label>
"Type a number (or something that's not a number!)"
<input type="number" on:input=on_input/>
// If an `Err(_) had been rendered inside the <ErrorBoundary/>,
// the fallback will be displayed. Otherwise, the children of the
// <ErrorBoundary/> will be displayed.
<ErrorBoundary
// the fallback receives a signal containing current errors
fallback=|errors| view! {
<div class="error">
<p>"Not a number! Errors: "</p>
// we can render a list of errors
// as strings, if we'd like
<ul>
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li>})
.collect::<Vec<_>>()
}
</ul>
</div>
}
>
<p>
"You entered "
// because `value` is `Result<i32, _>`,
// it will render the `i32` if it is `Ok`,
// and render nothing and trigger the error boundary
// if it is `Err`. It's a signal, so this will dynamically
// update when `value` changes
<strong>{value}</strong>
</p>
</ErrorBoundary>
</label>
}
}
fn main() {
leptos::mount_to_body(App)
}
Коммуникация Родитель-Потомок
Приложение можно представить как вложенное дерево компонентов. Каждый компонент управляется со собственным локальным состоянием и управляет разделом пользовательского интерфейса, так что компоненты склонны быть относительно самодостаточными.
Хотя иногда вам захочется установить коммуникацию между родительным компонентом и его дочерними компонентами.
Представьте ситуацию: объявили компонент <FancyButton/>
, добавляющий какие-то стили, логирование или что-то ещё к <button/>
.
Хочется использовать <FancyButton/>
в компоненте <App/>
. Но как установить коммуникацию между ими двумя?
Передать состояние из родителя в дочерний компонент легко. Мы частично рассматривали это в материале про компоненты и свойства.
Попросту говоря если хочется, чтобы родитель общался с дочерним компонентом, можно передавать
ReadSignal
,
Signal
, или
MaybeSignal
в качестве свойства.
Но как быть с обратным направлением? Как дочерний элемент может уведомлять родителя о событиях или изменениях состояния?
Есть четыре простых паттерна коммуникации Родитель-Потомок в Leptos.
1. Передавать WriteSignal
Один из подходов это просто передавать WriteSignal
из родителя в дочерний компонент и в нём обновлять сигнал.
Это позволяет вам управлять состоянием родителя из дочернего компонента.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = create_signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<ButtonA setter=set_toggled/>
}
}
#[component]
pub fn ButtonA(setter: WriteSignal<bool>) -> impl IntoView {
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle"
</button>
}
}
Этот паттерн прост, но будьте с ним осторожны: передача WriteSignal
может усложить понимание вашего кода.
Читая <App/>
из этого примера, вполне ясно, что даёте возможность изменять значение toggled
,
но совершенно неясно когда и как это происходит. В этом простом, локальном примере это легко понять, но если вы
видите, что передаёте сигналы WriteSignal
как этот по всему коду, вам следует всерьёз задуматься о том не
упрощает ли этот подход донельзя написание спагетти-кода.
2. Использовать Callback
(функцию обратного вызова)
Ещё один подход это передавать Callback
в дочерний компонент: скажем, on_click
.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = create_signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
}
}
#[component]
pub fn ButtonB(#[prop(into)] on_click: Callback<MouseEvent>) -> impl IntoView
{
view! {
<button on:click=on_click>
"Toggle"
</button>
}
}
Вы заметите что когда как <ButtonA/>
получил WriteSignal
и решает как его мутировать,
<ButtonB/>
просто порождает событие: мутация происходит в <App/>
. Преимущество этого в том, что мы локальное состояние
остаётся локальным, предотвращая проблему спагетти мутации. Но это также означает, что логика мутации сигнала должна находиться в
<App/>
, а не в <ButtonB/>
. Это настоящие компромиссы, а не простой выбор между правильно/неправильно.
Обратите внимание на способ, которым мы используем тип
Callback<In, Out>
. Это просто обёртка вокруг замыканияFn(In) -> Out
, которая реализуетCopy
, что упрощает её передачу.Мы также использовали атрибут
#[prop(into)]
чтобы мы могли передавать обычное замыкание вon_click
. Пожалуйста, посмотрите главу "Свойства сinto
" для более подробной информации.
2.1 Использование замыкание вместо Callback
Rust замыкание Fn(MouseEvent)
можно использовать напрямую вместо Callback
:
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = create_signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
}
}
#[component]
pub fn ButtonB<F>(on_click: F) -> impl IntoView
where
F: Fn(MouseEvent) + 'static
{
view! {
<button on:click=on_click>
"Toggle"
</button>
}
}
В данном случае код почти не изменился. В более продвинутых случаях использование замыкания может потребовать клонирования,
в отличие от варианта с использованием Callback
.
Обратите внимание, что мы объявили обобщенный тип
F
ради функции обратного вызова. Если вас это смутило, вернитесь к разделу Свойства с обобщенными типами главы о компонентах.
3. Использование слушателя событий (англ. Event Listener)
По правде говоря, Вариант 2 можно написать немного другим способом.
Если функция обратного вызова напрямую накладывается на нативный DOM элементом,
можно добавить on:
слушатель прямо в то место, этот компонент используется в макросе view
в <App/>
.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = create_signal(false);
view! {
<p>"Toggled? " {toggled}</p>
// note the on:click instead of on_click
// this is the same syntax as an HTML element event listener
<ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
}
}
#[component]
pub fn ButtonC() -> impl IntoView {
view! {
<button>"Toggle"</button>
}
}
Это позволяет вам написать намного меньше кода для <ButtonC/>
, чем вы написали для <ButtonB/>
, и всё даёт слушателю
правильно типизированное событие. Это работает так, что слушатель событий on:
добавляется к каждому возвращаемому <ButtonC/>
:
в данном случае только <button>
.
Конечно, это работает только для настоящих событий DOM которые вы передаёте напрямую в элементы, которые вы рендерите в компоненте.
Для более сложной логики, которая не накладывается напрямую на элемент (скажем вы создали <ValidatedForm/>
и хотите функцию обратного вызова on_valid_form_submit
), вам следует использовать Вариант 2.
4. Предоставление Контекста
Эта версия по факту является разновидностью Варинта 1. Скажем у вас дерево компонентов глубокой вложенности:
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = create_signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<Layout/>
}
}
#[component]
pub fn Layout() -> impl IntoView {
view! {
<header>
<h1>"My Page"</h1>
</header>
<main>
<Content/>
</main>
}
}
#[component]
pub fn Content() -> impl IntoView {
view! {
<div class="content">
<ButtonD/>
</div>
}
}
#[component]
pub fn ButtonD<F>() -> impl IntoView {
todo!()
}
<ButtonD/>
теперь уже не дочерний элемент <App/>
, так что в его свойства нельзя просто передать WriteSignal
.
Можно сделать то, что иногда называют “пробурить свойство”, добавив свойство в каждый слой между ими двумя:
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = create_signal(false);
view! {
<p>"Toggled? " {toggled}</p>
<Layout set_toggled/>
}
}
#[component]
pub fn Layout(set_toggled: WriteSignal<bool>) -> impl IntoView {
view! {
<header>
<h1>"My Page"</h1>
</header>
<main>
<Content set_toggled/>
</main>
}
}
#[component]
pub fn Content(set_toggled: WriteSignal<bool>) -> impl IntoView {
view! {
<div class="content">
<ButtonD set_toggled/>
</div>
}
}
#[component]
pub fn ButtonD<F>(set_toggled: WriteSignal<bool>) -> impl IntoView {
todo!()
}
Ну и бардак. set_toggled
не нужен ни<Layout/>
, ни <Content/>
; они просто передают его в <ButtonD/>
. Но мне приходится
объявлять свойство трижды. Помимо того, что это раздражает, такое ещё и сложно поддерживать: только вообразите, если мы добавим
"среднее положение" переключателя, тип set_toggled
придётся поменять на enum
. Наам придётся менять его в трёх местах!
Нет ли какого-то способа перепрыгнуть эти уровни?
Есть!
4.1 API контекстов
Используя provide_context
и use_context
.
можно создавать контексты данных, которые будут перепрыгивать через уровни.
Контексты идентифицируются по типу данных, которые вы предоставляете (в этом примере, WriteSignal<bool>
),
и они существуют в нисходящем дереве, которое следует контурам дерева UI. В этом примере мы можем использовать
контекст, чтобы избежать ненужного бурения свойств.
#[component]
pub fn App() -> impl IntoView {
let (toggled, set_toggled) = create_signal(false);
// share `set_toggled` with all children of this component
provide_context(set_toggled);
view! {
<p>"Toggled? " {toggled}</p>
<Layout/>
}
}
// <Layout/> and <Content/> omitted
// To work in this version, drop their references to set_toggled
#[component]
pub fn ButtonD() -> impl IntoView {
// use_context searches up the context tree, hoping to
// find a `WriteSignal<bool>`
// in this case, I .expect() because I know I provided it
let setter = use_context::<WriteSignal<bool>>()
.expect("to have found the setter provided");
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle"
</button>
}
}
Эти же предостережения относятся к данному коду в той же мере, что и к <ButtonA/>
: передавать WriteSignal
нужно с осторожностью,
так как это позволяет мутировать состояние из произвольных мест в коде. Но при осторожном обращении, это одна из
самых эффективных техник управления глобальным состоянием в Leptos: просто предоставьте состояние на самом высоком из
уровней, на которых вам оно требуется и используйте где нужно на уровнях ниже.
Заметим, что у этого подхода нет недостатков в части производительности. Поскольку вы передаете мелкозернистый реактивный сигнал,
ничего не происходит в промежуточных компонентах (<Layout/>
и <Content/>
) когда вы его обновляете.
Можно установить прямую коммуникацию между <ButtonD/>
и <App/>
.
Фактически — и в этом состоит сила мелкозернистой реактивности — коммуникация идёт напрямую между нажатием на кнопку <ButtonD/>
и единственным текстовым узлом в <App/>
. Это как если бы самих компонентов вовсе не существовало. И, ну... в среде выполнения они не существуют.
Лишь сигналы и эффекты до самого низа.
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/8-parent-child-0-5-7rz7qd?file=%2Fsrc%2Fmain.rs%3A1%2C2)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/8-parent-child-0-5-7rz7qd?file=%2Fsrc%2Fmain.rs%3A1%2C2" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::{ev::MouseEvent, *};
// This highlights four different ways that child components can communicate
// with their parent:
// 1) <ButtonA/>: passing a WriteSignal as one of the child component props,
// for the child component to write into and the parent to read
// 2) <ButtonB/>: passing a closure as one of the child component props, for
// the child component to call
// 3) <ButtonC/>: adding an `on:` event listener to a component
// 4) <ButtonD/>: providing a context that is used in the component (rather than prop drilling)
#[derive(Copy, Clone)]
struct SmallcapsContext(WriteSignal<bool>);
#[component]
pub fn App() -> impl IntoView {
// just some signals to toggle three classes on our <p>
let (red, set_red) = create_signal(false);
let (right, set_right) = create_signal(false);
let (italics, set_italics) = create_signal(false);
let (smallcaps, set_smallcaps) = create_signal(false);
// the newtype pattern isn't *necessary* here but is a good practice
// it avoids confusion with other possible future `WriteSignal<bool>` contexts
// and makes it easier to refer to it in ButtonC
provide_context(SmallcapsContext(set_smallcaps));
view! {
<main>
<p
// class: attributes take F: Fn() => bool, and these signals all implement Fn()
class:red=red
class:right=right
class:italics=italics
class:smallcaps=smallcaps
>
"Lorem ipsum sit dolor amet."
</p>
// Button A: pass the signal setter
<ButtonA setter=set_red/>
// Button B: pass a closure
<ButtonB on_click=move |_| set_right.update(|value| *value = !*value)/>
// Button B: use a regular event listener
// setting an event listener on a component like this applies it
// to each of the top-level elements the component returns
<ButtonC on:click=move |_| set_italics.update(|value| *value = !*value)/>
// Button D gets its setter from context rather than props
<ButtonD/>
</main>
}
}
/// Button A receives a signal setter and updates the signal itself
#[component]
pub fn ButtonA(
/// Signal that will be toggled when the button is clicked.
setter: WriteSignal<bool>,
) -> impl IntoView {
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle Red"
</button>
}
}
/// Button B receives a closure
#[component]
pub fn ButtonB<F>(
/// Callback that will be invoked when the button is clicked.
on_click: F,
) -> impl IntoView
where
F: Fn(MouseEvent) + 'static,
{
view! {
<button
on:click=on_click
>
"Toggle Right"
</button>
}
// just a note: in an ordinary function ButtonB could take on_click: impl Fn(MouseEvent) + 'static
// and save you from typing out the generic
// the component macro actually expands to define a
//
// struct ButtonBProps<F> where F: Fn(MouseEvent) + 'static {
// on_click: F
// }
//
// this is what allows us to have named props in our component invocation,
// instead of an ordered list of function arguments
// if Rust ever had named function arguments we could drop this requirement
}
/// Button C is a dummy: it renders a button but doesn't handle
/// its click. Instead, the parent component adds an event listener.
#[component]
pub fn ButtonC() -> impl IntoView {
view! {
<button>
"Toggle Italics"
</button>
}
}
/// Button D is very similar to Button A, but instead of passing the setter as a prop
/// we get it from the context
#[component]
pub fn ButtonD() -> impl IntoView {
let setter = use_context::<SmallcapsContext>().unwrap().0;
view! {
<button
on:click=move |_| setter.update(|value| *value = !*value)
>
"Toggle Small Caps"
</button>
}
}
fn main() {
leptos::mount_to_body(App)
}
Дочерние элементы компонентов
Достаточно часто люди хотят передавать дочерние элементы в компонент как в обычный элемент HTML.
Представьте, к примеру, компонент <FancyForm/>
, усовершенствующий <form>
. Нужен какой-то способ
передать в него все поля ввода.
view! {
<FancyForm>
<fieldset>
<label>
"Some Input"
<input type="text" name="something"/>
</label>
</fieldset>
<button>"Submit"</button>
</FancyForm>
}
Как это сделать в Leptos? Есть два способа передать компоненты в другие компоненты:
- render-свойства: свойства-функции, возвращающие
view
- свойство
children
: специальное свойство компонента, включающее всё, что вы передаёте в качестве дочерних элементов компонента.
Фактически вы уже видели оба этих способа в действии в описании компонента <Show/>
:
view! {
<Show
// `when` is a normal prop
when=move || value() > 5
// `fallback` is a "render prop": a function that returns a view
fallback=|| view! { <Small/> }
>
// `<Big/>` (and anything else here)
// will be given to the `children` prop
<Big/>
</Show>
}
Давайте объявим компонент, который принимает дочерние элементы и render-свойство.
#[component]
pub fn TakesChildren<F, IV>(
/// Takes a function (type F) that returns anything that can be
/// converted into a View (type IV)
render_prop: F,
/// `children` takes the `Children` type
children: Children,
) -> impl IntoView
where
F: Fn() -> IV,
IV: IntoView,
{
view! {
<h2>"Render Prop"</h2>
{render_prop()}
<h2>"Children"</h2>
{children()}
}
}
И render_prop
и children
это функции, так что они могут быть вызываться, чтобы сгенерировать подходящие view
children
, в частности, это алиас для Box<dyn FnOnce() -> Fragment>
. (Разве вы не рады, что мы назвали его Children
вместо этого?)
Если вам тут понадобится
Fn
илиFnMut
, чтобы вызыватьchildren
больше одного раза, мы также добавили алиасыChildrenFn
иChildrenMut
.
Использовать этот компонент мы можем вот так:
view! {
<TakesChildren render_prop=|| view! { <p>"Hi, there!"</p> }>
// these get passed to `children`
"Some text"
<span>"A span"</span>
</TakesChildren>
}
Воздействие на дочерние элементы
Тип Fragment
это просто способ обернуть Vec<View>
Его можно вставлять куда угодно внутри view
.
Но мы можем также получить доступ к этим внутренним view
чтобы воздействовать на них.
К примеру, вот компонент, принимающий дочерние элементы и превращающий их в неупорядоченный список.
#[component]
pub fn WrapsChildren(children: Children) -> impl IntoView {
// Fragment has `nodes` field that contains a Vec<View>
let children = children()
.nodes
.into_iter()
.map(|child| view! { <li>{child}</li> })
.collect_view();
view! {
<ul>{children}</ul>
}
}
Вызов его вот так создаст список:
view! {
<WrapsChildren>
"A"
"B"
"C"
</WrapsChildren>
}
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/9-component-children-0-5-m4jwhp?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/9-component-children-0-5-m4jwhp?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::*;
// Often, you want to pass some kind of child view to another
// component. There are two basic patterns for doing this:
// - "render props": creating a component prop that takes a function
// that creates a view
// - the `children` prop: a special property that contains content
// passed as the children of a component in your view, not as a
// property
#[component]
pub fn App() -> impl IntoView {
let (items, set_items) = create_signal(vec![0, 1, 2]);
let render_prop = move || {
// items.with(...) reacts to the value without cloning
// by applying a function. Here, we pass the `len` method
// on a `Vec<_>` directly
let len = move || items.with(Vec::len);
view! {
<p>"Length: " {len}</p>
}
};
view! {
// This component just displays the two kinds of children,
// embedding them in some other markup
<TakesChildren
// for component props, you can shorthand
// `render_prop=render_prop` => `render_prop`
// (this doesn't work for HTML element attributes)
render_prop
>
// these look just like the children of an HTML element
<p>"Here's a child."</p>
<p>"Here's another child."</p>
</TakesChildren>
<hr/>
// This component actually iterates over and wraps the children
<WrapsChildren>
<p>"Here's a child."</p>
<p>"Here's another child."</p>
</WrapsChildren>
}
}
/// Displays a `render_prop` and some children within markup.
#[component]
pub fn TakesChildren<F, IV>(
/// Takes a function (type F) that returns anything that can be
/// converted into a View (type IV)
render_prop: F,
/// `children` takes the `Children` type
/// this is an alias for `Box<dyn FnOnce() -> Fragment>`
/// ... aren't you glad we named it `Children` instead?
children: Children,
) -> impl IntoView
where
F: Fn() -> IV,
IV: IntoView,
{
view! {
<h1><code>"<TakesChildren/>"</code></h1>
<h2>"Render Prop"</h2>
{render_prop()}
<hr/>
<h2>"Children"</h2>
{children()}
}
}
/// Wraps each child in an `<li>` and embeds them in a `<ul>`.
#[component]
pub fn WrapsChildren(children: Children) -> impl IntoView {
// children() returns a `Fragment`, which has a
// `nodes` field that contains a Vec<View>
// this means we can iterate over the children
// to create something new!
let children = children()
.nodes
.into_iter()
.map(|child| view! { <li>{child}</li> })
.collect::<Vec<_>>();
view! {
<h1><code>"<WrapsChildren/>"</code></h1>
// wrap our wrapped children in a UL
<ul>{children}</ul>
}
}
fn main() {
leptos::mount_to_body(App)
}
Без Макросов: синтаксис строителя View
Если вы и так счастливы с синтаксисом макросом
view!
описываемым до сих пор, мы можете пропустить эту главу. синтаксис описанный в этом разделе всегда доступен, но никогда не обязателен.
По тем или иным причинам, многие разработчики предпочитают избегать макросов. Быть может вам не нравится ограниченная
поддержка со стороны rustfmt
. (Хотя. вам стоит посмотреть на leptosfmt
, это прекрасный инструмент!)
А может, вы беспокоитесь по поводу влияния макросов на время компиляции. Может быть, вы предпочитаете эстетику
чистого синтаксиса Rust, или переключение контекста между HTML-подобным синтаксисом и кодом на Rust вызывает у вас трудности.
Или может вы хотите больше гибкости в том как вы создаёте и манипулируете HTML элементами, чем даёт макрос view
.
Если вы относитесь к одной из этих категорий, синтаксис строителя может вам подойти.
Макрос view
преобразует HTML-подобный синтаксис в набор Rust функций и вызовов методов. Если не хотите
не использовать макрос view
, этот синтаксис можно использовать самостоятельно. А он весьма хорош!
Во-первых, если хотите, можно даже убрать макрос #[component]
: компонент это просто установочная функция,
создающая view
, так что можно просто объявить компонент как простой вызов функции:
pub fn counter(initial_value: i32, step: u32) -> impl IntoView { }
Элементы создаются путём вызова функции одноименной с создаваемым элементом HTML:
p()
Дочерние элементы можно добавлять с помощью .child()
, который принимает одиночный элемент, массив
или кортеж с типами, реализующими IntoView
.
p().child((em().child("Big, "), strong().child("bold "), "text"))
Атрибуты добавляются при помощи метода .attr()
. Он может принимать любые типы, которые можно передавать в
качестве атрибута внутри макроса view
(типы, реализующие IntoAttribute
).
p().attr("id", "foo").attr("data-count", move || count().to_string())
Аналогично, class:
, prop:
, и style:
соотносятся с методами .class()
, .prop()
, и .style()
.
Слушатели событий могут быть добавлены через .on()
. Типизированные события из leptos::ev
предотвращают опечатки
в названиях событий и обеспечивают правильный вывод типов (англ. type inference) в функции обратного вызова.
button()
.on(ev::click, move |_| set_count.update(|count| *count = 0))
.child("Clear")
Много дополнительных методов можно найти в документации к
HtmlElement
, включая некоторые методы, недоступные напрямую в макросеview
.
Всё это складывается в очень Rust'овый синтаксис для построения полнофункциональных представлений, если вы предпочитаете этот стиль.
/// A simple counter view.
// A component is really just a function call: it runs once to create the DOM and reactive system
pub fn counter(initial_value: i32, step: u32) -> impl IntoView {
let (count, set_count) = create_signal(0);
div().child((
button()
// typed events found in leptos::ev
// 1) prevent typos in event names
// 2) allow for correct type inference in callbacks
.on(ev::click, move |_| set_count.update(|count| *count = 0))
.child("Clear"),
button()
.on(ev::click, move |_| set_count.update(|count| *count -= 1))
.child("-1"),
span().child(("Value: ", move || count.get(), "!")),
button()
.on(ev::click, move |_| set_count.update(|count| *count += 1))
.child("+1"),
))
}
У этого также есть преимущество большей гибкости: поскольку всё это простые Rust функции и методы, их проще использовать в таких вещах, как адаптеры итераторов без какой-либо дополнительной "магии': it’s easier to use them in things like iterator adapters without any additional “magic”:
// take some set of attribute names and values
let attrs: Vec<(&str, AttributeValue)> = todo!();
// you can use the builder syntax to “spread” these onto the
// element in a way that’s not possible with the view macro
let p = attrs
.into_iter()
.fold(p(), |el, (name, value)| el.attr(name, value));
Примечание о производительности
Одно предостережение: макрос
view
применяет значительные оптимизации в режиме сервере рендеринга (SSR), чтобы значительно улучшить производительность рендеринга HTML (в 2-4 раза быстрее, зависит от характеристик приложения). Он делает это путём анализаview
во время компиляции и превращения статических частей в простые HTML строки, вместо синтаксиса строителя.Из этого следуют две вещи:
- Синтаксис строителя и макрос
view
не стоит мешать вовсе или мешать, но очень осторожно: по меньшей мере в режиме SSR c выводом макросаview
стоит обходиться как с "черным ящиком", к которому нельзя применять дополнительные методы строителя не вызывая несоответствий.- Использование синтаксиса строителя выльется в производительность SSR ниже оптимальной. Медленно не будет ни в коем случае (всё равно стоит сделать собственные замеры производительные), но медленнее чем версия оптимизированная макросом
view
.
Реактивность
Leptos построен на системе мелкозернистой реактивности, разработанной так, чтобы выполнять затратные побочные эффекты (такие как рендеринг чего-нибудь в браузере или отправка сетевого запроса) как можно реже, в ответ на изменения реактивных значений.
Пока мы видели в действии сигналы. В этих главах мы опустимся немного глубже, посмотрим на эффекты — вторую половину всей истории.
Работа с Сигналами
Пока что мы использовали простые примеры с create_signal
, возвращающие геттер ReadSignal
и сеттер WriteSignal
.
Чтение и запись данных
Есть четыре простые операции с сигналами:
.get()
клонирует текущее значение сигнала и реактивно отслеживает будущие изменения..with()
принимает функцию, получающую текущее значеие сигнала по ссылке (&T
) и отслеживает будущие изменения..set()
заменяет текущее значение сигнала и уведомляет об этом подписчиков..update()
принимает функцию, которая принимает мутабельную ссылку на текущее значение сигнала (&mut T
) и уведомляет подписчиков. (.update()
не возвращает значение возвращённое замыканием; для этого можно использовать.try_update()
, если, например, при удалении элемента изVec<_>
нужно его вернуть.)
Вызов ReadSignal
как функции это синтаксический сахар для.get()
. Вызов WriteSignal
как функции это синтаксический сахар для .set()
.
Так что
let (count, set_count) = create_signal(0);
set_count(1);
logging::log!(count());
это то же самое, что
let (count, set_count) = create_signal(0);
set_count.set(1);
logging::log!(count.get());
Можно заметить, что и .get()
и .set()
могут быть реализованы при помощи .with()
и .update()
.
Другими словами, count.get()
идентично count.with(|n| n.clone())
, а count.set(1)
реализуется посредством count.update(|n| *n = 1)
.
Но конечно же, .get()
и .set()
(или простые вызовы функций!) это намного более приятный синтаксис.
Однако, и для .with()
с .update()
есть очень хорошее применение.
Например, возьмём сигнала с типом Vec<String>
.
let (names, set_names) = create_signal(Vec::new());
if names().is_empty() {
set_names(vec!["Alice".to_string()]);
}
С точки зрения логики, этот пример достаточно прост, но в нём скрываются существенные недостатки, влияющие на производительности.
Помните, что names().is_empty()
это сахар для names.get().is_empty()
, который клонирует значение (it’s names.with(|n| n.clone()).is_empty()
).
Это значит мы клонируем значение Vec<String>
целиком, выполняем is_empty()
, и тут же выбрасываем клонированное значение.
Аналогично и set_names
заменяет значение новым Vec<_>
. В общем-то ничего страшного, но мы можем просто мутировать исходный Vec<_>
там где он лежит.
let (names, set_names) = create_signal(Vec::new());
if names.with(|names| names.is_empty()) {
set_names.update(|names| names.push("Alice".to_string()));
}
Теперь наша функция просто принимает names
по ссылке и запускает is_empty()
, избегая клонирования.
Пользователи Clippy и те, у кого острый глаз, могли заметить, что можно сделать ещё лучше:
if names.with(Vec::is_empty) {
// ...
}
В конце концов, .with()
просто принимает функцию, которая принимает значение по ссылке.
Поскольку Vec::is_empty
принимает &self
, мы можем передать её напрямую и избавиться от ненужного замыкания.
Есть вспомогательные макросы, упрощающие использование .with()
и .update()
, особенно при работе с несколькими сигналами сразу.
let (first, _) = create_signal("Bob".to_string());
let (middle, _) = create_signal("J.".to_string());
let (last, _) = create_signal("Smith".to_string());
Если хочется конкатенировать эти 3 сигнала вместе без ненужного клонирования, пришлось бы написать что-то вроде:
let name = move || {
first.with(|first| {
middle.with(|middle| last.with(|last| format!("{first} {middle} {last}")))
})
};
Очень длинно и писать неприятно.
Вместо этого можно использовать макрос with!
, чтобы получить ссылки на все эти сигналы сразу.
let name = move || with!(|first, middle, last| format!("{first} {middle} {last}"));
Это превращается в то, что было выше. Посмотрите документацию к with!
для дополнительной информации,
и макросам update!
, with_value!
и update_value!
.
Делаем так, чтобы сигналы зависели друг от друга
Люди часто спрашивают о ситуациях, когда какой-то сигнал должен меняться в зависимости от значения другого сигнала. Для этого есть три хороших способа и один неидеальный, но сносный в контролируемых обстоятельства.
Хорошие варианты
1) Б это функция от А. Создадим сигнал для А и производный сигнал или memo для Б.
let (count, set_count) = create_signal(1);
let derived_signal_double_count = move || count() * 2;
let memoized_double_count = create_memo(move |_| count() * 2);
Рекомендации о том как выбирать между производным сигналом и memo можно найти в документации к
create_memo
2) В это функция от А и Б. Создадим сигналы для А и Б, а также производный сигнал или memo для В.
let (first_name, set_first_name) = create_signal("Bridget".to_string());
let (last_name, set_last_name) = create_signal("Jones".to_string());
let full_name = move || with!(|first_name, last_name| format!("{first_name} {last_name}"));
3) А и Б — независимые сигналы, но иногда они обновляются одновременно.. Когда вы обновляете A, отдельно вызываете и обновление Б.
let (age, set_age) = create_signal(32);
let (favorite_number, set_favorite_number) = create_signal(42);
// use this to handle a click on a `Clear` button
let clear_handler = move |_| {
set_age(0);
set_favorite_number(0);
};
Если без этого никак...
4) Создайте эффект, чтобы писать в Б каждый раз когда А меняется. Так делать официально не рекомендуется по нескольким причинам:
a) Это всегда будет более затратно, так как это значит, что каждый раз, когда А обновляется, будет два полных прохода через реактивный процесс.
(устанавливается значение А, это вызывает запуск этого эффекта, наряду с остальными, зависящими от А. Затем меняется Б, что вызовет выполнение всех зависящих от Б эффектов.)
b) Это повышает вероятность нечаянно сделать бесконечный цикл или эффекты, выполняющиеся слишком часто.
Это тот самый пинг-понг, происходивший в реактивном спагетти-коде в начале 2010-х годов, которого мы стараемся избежать
посредством разделения чтения и записи, а также не рекомендуя писать в сигналы из эффектов.
В большинстве ситуаций лучше переписать всё таким образом, чтобы был чёткий поток данных сверху вниз, основанный на производных сигналах или мемоизированных значениях. Но это не конец света.
Я намеренно не привожу здесь пример кода. Прочтите документацию к
create_effect
понять как это должно работать.
Реагирование на изменения с помощью create_effect
Мы добрались до сюда, не упомянув половины реактивной системы: эффектов.
У реактивности две половины: обновление отдельных реактивных значений ("сигналов") уведомляет куски кода, зависящие от них ("эффекты") о том, что они должны запуститься снова. Эти две половины (сигналы и эффекты) взаимозависимы. Без эффектов сигналы могут меняться в рамках реактивной системы, но за ними нельзя наблюдать, взаимодействуя с внешним миром. Без сигналов эффекты выполняются только раз, поскольку нет наблюдаемого значения, на которое можно подписаться. Эффекты это вполне буквально "побочные эффекты" реактивной системы: они существуют, что синхронизировать реактивному систему с нереактивным миром вокруг неё.
За всем реактивным рендерингом DOM, что мы на данный момент видели, скрывается функция под названием create_effect
.
create_effect
принимает в качестве аргумента функцию, которая сразу же выполняется.
Если функция обращается к реактивному сигналу, [create_effect
] в реактивной среде выполнения запоминает,
что данный эффект зависит от этого сигнала. Какой бы сигнал ни изменился, среди тех, от которых зависит наш эффект,
он будет выполнен снова.
let (a, set_a) = create_signal(0);
let (b, set_b) = create_signal(0);
create_effect(move |_| {
// immediately prints "Value: 0" and subscribes to `a`
log::debug!("Value: {}", a());
});
Функция эффекта вызывается с аргументом, содержащим значение, возвращённое ей при предыдущем вызове. При первом вызове оно None
.
По-умолчанию эффекты не выполняются на сервере. Это значит, что можно без проблем обращаться к браузерные API внутри функции эффекта.
Если вам нужно запускать эффект на сервере, используйте create_isomorphic_effect
.
Авто-отслеживание и Динамические Зависимости
Знакомые с фреймворком как React, могут заметить одно ключевое отличие. React и схожие фреймворки обычно требуют от вас передавать "массив зависимостей", явный набор переменных, определяющих когда эффекту выполняться снова.
Поскольку Leptos происходит из традиции синхронного реактивного программирования, нам не нужен этот явный список зависимостей. Вместо этого, мы автоматически отслеживаем зависимости, в зависимости от того какие сигналы вызываются внутри эффекта.
У этого есть два следствия. Зависимости являются:
- Автоматическими: Вам не нужно поддерживать список зависимостей или беспокоиться о том что он должен включать, а что не должен. Фреймворк просто отслеживает какие сигналы могут повлечь перезапуск эффекта и делает всё за вас.
- Динамическими: Список зависимостей очищается и заполняется каждый раз когда эффект выполняется. Если эффект содержит условие (как пример), будут отслеживаться только сигналы, использованные в текущей ветке. Это значит что эффекты перезапускаются самое минимальное количество раз.
Если это звучит как магия, и если хочется глубоко погрузиться в то, как устроено автоматическое отслеживание зависимостей, посмотрите это видео (англ.). (Простите за тихий звук!)
Эффекты как почти бесплатная абстракция
Несмотря на то, что не являясь "бесплатной абстракцией" в строгом техническом смысле — они требуют памяти, существуют во среде выполнения, и т.д. — на более высоком уровне, с точки зрения каких бы то ни было дорогих вызовов API или иной работы, которая делается с их помощью, эффекты это бесплатная абстракция. Они перезапускаются самое минимальное количество раз, зависящее от того, как вы их описали.
Представьте, что я пишу ПО для чата и хочу, чтобы люди могли отображать свои ФИО или просто имя, и уведомлять сервер всякий раз, когда их имя меняется.
let (first, set_first) = create_signal(String::new());
let (last, set_last) = create_signal(String::new());
let (use_last, set_use_last) = create_signal(true);
// this will add the name to the log
// any time one of the source signals changes
create_effect(move |_| {
log(
if use_last() {
format!("{} {}", first(), last())
} else {
first()
},
)
});
Если use_last
равно true
, эффект будет перезапускаться всегда когда first
, last
, или use_last
меняется.
Но если переключу use_last
в false
, изменение last
никогда не повлечет изменение ФИО.
Фактически, last
будет удалён из списка зависимостей пока use_last
не переключится вновь.
Это избавляет нас от необходимости отправлять ненужные запросы к API если меняю last
несколько раз пока use_last
всё ещё false
.
Быть create_effect
или не быть?
Эффекты предназначены для синхронизации реактивной системы с нереактивным внешним миром, а для не синхронизации
реактивных значений между собой. Другими словами: использование эффекта, чтобы прочесть значение одного сигнала,
и записать его в другой — всегда неоптимально.
Если вам нужно объявить сигнал, зависящий от значения другого сигнала, используйте производный сигнал
или create_memo
.
Запись в сигнал изнутри эффекта это не конец света и ваш компьютер от этого не загорится огнём, но
производный сигнал или мемоизированное значение всегда лучше — не только потому что движение данных будет ясным, но и потому что
производительность будет лучше.
let (a, set_a) = create_signal(0);
// ⚠️ not great
let (b, set_b) = create_signal(0);
create_effect(move |_| {
set_b(a() * 2);
});
// ✅ woo-hoo!
let b = move || a() * 2;
If you need to synchronize some reactive value with the non-reactive world outside—like a web API, the console, the filesystem, or the DOM—writing to a signal in an effect is a fine way to do that. In many cases, though, you’ll find that you’re really writing to a signal inside an event listener or something else, not inside an effect. In these cases, you should check out leptos-use
to see if it already provides a reactive wrapping primitive to do that!
If you’re curious for more information about when you should and shouldn’t use
create_effect
, check out this video for a more in-depth consideration!
Эффекты и Рендеринг
Мы смогли добраться до сюда не упоминая эффекты, поскольку они встроены в Leptos DOM рендерер.
Мы уже видели, что можно создавать сигнал и передавать его в макрос view
и он будет обновлять связанный узел DOM всякий раз, когда сигнал меняется:
let (count, set_count) = create_signal(0);
view! {
<p>{count}</p>
}
Это работает потому, что фреймворк в сущности создает эффект оборачивающий это обновление. Для понимания, Leptos переводит этот view в нечто вроде этого:
let (count, set_count) = create_signal(0);
// create a DOM element
let document = leptos::document();
let p = document.create_element("p").unwrap();
// create an effect to reactively update the text
create_effect(move |prev_value| {
// first, access the signal’s value and convert it to a string
let text = count().to_string();
// if this is different from the previous value, update the node
if prev_value != Some(text) {
p.set_text_content(&text);
}
// return this value so we can memoize the next update
text
});
Всякий раз когда count
меняется, этот эффект повторно выполняется. Это делает возможными реактивные мелкозернистые обновления DOM.
Явное, Отменяемое отслеживание через watch
Вдобавок к create_effect
, Leptos предлагает функцию watch
, которая может быть использована для двух основных вещей:
- Отделение отслеживания от реагирования на изменения путём явной передачи набора значений для отслеживания.
- Отмена отслеживания путём вызова stop-функции.
Как и create_resource
, watch
принимает первый аргумент (deps
), отслеживаемый реактивно, и второй (callback
), который не отслеживается.
Всякий раз, когда реактивное значение в аргументе deps
меняется, вызывается callback
. watch
возвращает функцию, которую
можно вызвать, чтобы прекратить отслеживание зависимостей.
let (num, set_num) = create_signal(0);
let stop = watch(
move || num.get(),
move |num, prev_num, _| {
log::debug!("Number: {}; Prev: {:?}", num, prev_num);
},
false,
);
set_num.set(1); // > "Number: 1; Prev: Some(0)"
stop(); // stop watching
set_num.set(2); // (nothing happens)
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/14-effect-0-5-d6hkch?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/14-effect-0-5-d6hkch?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use leptos::html::Input;
use leptos::*;
#[derive(Copy, Clone)]
struct LogContext(RwSignal<Vec<String>>);
#[component]
fn App() -> impl IntoView {
// Just making a visible log here
// You can ignore this...
let log = create_rw_signal::<Vec<String>>(vec![]);
let logged = move || log().join("\n");
// the newtype pattern isn't *necessary* here but is a good practice
// it avoids confusion with other possible future `RwSignal<Vec<String>>` contexts
// and makes it easier to refer to it
provide_context(LogContext(log));
view! {
<CreateAnEffect/>
<pre>{logged}</pre>
}
}
#[component]
fn CreateAnEffect() -> impl IntoView {
let (first, set_first) = create_signal(String::new());
let (last, set_last) = create_signal(String::new());
let (use_last, set_use_last) = create_signal(true);
// this will add the name to the log
// any time one of the source signals changes
create_effect(move |_| {
log(if use_last() {
with!(|first, last| format!("{first} {last}"))
} else {
first()
})
});
view! {
<h1>
<code>"create_effect"</code>
" Version"
</h1>
<form>
<label>
"First Name"
<input
type="text"
name="first"
prop:value=first
on:change=move |ev| set_first(event_target_value(&ev))
/>
</label>
<label>
"Last Name"
<input
type="text"
name="last"
prop:value=last
on:change=move |ev| set_last(event_target_value(&ev))
/>
</label>
<label>
"Show Last Name"
<input
type="checkbox"
name="use_last"
prop:checked=use_last
on:change=move |ev| set_use_last(event_target_checked(&ev))
/>
</label>
</form>
}
}
#[component]
fn ManualVersion() -> impl IntoView {
let first = create_node_ref::<Input>();
let last = create_node_ref::<Input>();
let use_last = create_node_ref::<Input>();
let mut prev_name = String::new();
let on_change = move |_| {
log(" listener");
let first = first.get().unwrap();
let last = last.get().unwrap();
let use_last = use_last.get().unwrap();
let this_one = if use_last.checked() {
format!("{} {}", first.value(), last.value())
} else {
first.value()
};
if this_one != prev_name {
log(&this_one);
prev_name = this_one;
}
};
view! {
<h1>"Manual Version"</h1>
<form on:change=on_change>
<label>"First Name" <input type="text" name="first" node_ref=first/></label>
<label>"Last Name" <input type="text" name="last" node_ref=last/></label>
<label>
"Show Last Name" <input type="checkbox" name="use_last" checked node_ref=use_last/>
</label>
</form>
}
}
#[component]
fn EffectVsDerivedSignal() -> impl IntoView {
let (my_value, set_my_value) = create_signal(String::new());
// Don't do this.
/*let (my_optional_value, set_optional_my_value) = create_signal(Option::<String>::None);
create_effect(move |_| {
if !my_value.get().is_empty() {
set_optional_my_value(Some(my_value.get()));
} else {
set_optional_my_value(None);
}
});*/
// Do this
let my_optional_value =
move || (!my_value.with(String::is_empty)).then(|| Some(my_value.get()));
view! {
<input prop:value=my_value on:input=move |ev| set_my_value(event_target_value(&ev))/>
<p>
<code>"my_optional_value"</code>
" is "
<code>
<Show when=move || my_optional_value().is_some() fallback=|| view! { "None" }>
"Some(\""
{my_optional_value().unwrap()}
"\")"
</Show>
</code>
</p>
}
}
#[component]
pub fn Show<F, W, IV>(
/// The components Show wraps
children: Box<dyn Fn() -> Fragment>,
/// A closure that returns a bool that determines whether this thing runs
when: W,
/// A closure that returns what gets rendered if the when statement is false
fallback: F,
) -> impl IntoView
where
W: Fn() -> bool + 'static,
F: Fn() -> IV + 'static,
IV: IntoView,
{
let memoized_when = create_memo(move |_| when());
move || match memoized_when.get() {
true => children().into_view(),
false => fallback().into_view(),
}
}
fn log(msg: impl std::fmt::Display) {
let log = use_context::<LogContext>().unwrap().0;
log.update(|log| log.push(msg.to_string()));
}
fn main() {
leptos::mount_to_body(App)
}
Примечание: Реактивность и Функции
Один из наших контрибьютеров в ядро сказал мне недавно: — Я никогда не использовал замыкания так часто, пока не начал
использовать Leptos.
И это правда. Замыкания это сердце любого Leptos приложения.
Иногда это выглядит немного глупо:
// a signal holds a value, and can be updated
let (count, set_count) = create_signal(0);
// a derived signal is a function that accesses other signals
let double_count = move || count() * 2;
let count_is_odd = move || count() & 1 == 1;
let text = move || if count_is_odd() {
"odd"
} else {
"even"
};
// an effect automatically tracks the signals it depends on
// and reruns when they change
create_effect(move |_| {
logging::log!("text = {}", text());
});
view! {
<p>{move || text().to_uppercase()}</p>
}
Замыкания, кругом замыкания!
Но почему?
Функции и UI фреймворки
Функции это сердце любого UI фреймворка. И немудрено. Создание пользовательского интерфейса по сути можно разбить на две части:
- первоначальный рендеринг
- обновления
При использовании Веб-фреймворка, именно фреймворк какой-то первоначальный рендеринг. Затем он передаёт контроль обратно браузеру. Когда срабатывают определенные события (такие, как нажатие мыши) или завершаются асинхронные задачи (например, завершается HTTP запрос), браузер будит фреймворк, чтобы обновить что-то. Фреймворк выполняет какой-то код, чтобы обновить интерфейс пользователя, и снова засыпает до тех пор, пока браузер не разбудит его снова.
Ключевой фразой здесь является "выполняет какой-то код". Естественный способ "вызвать какой-то код" в произвольный момент (в Rust или в любом другом языке программирования) это вызвать функцию. И фактически каждый UI фреймворк основан на повторном выполнении некоего рода функции снова и снова:
- фреймворки с виртуальным DOM (VDOM) такие, как React, Yew или Dioxus перезапускают компонент или рендерную функцию снова и снова чтобы сгенерировать виртуальное дерево DOM, которое может быть сверено с предыдущим результатом, чтобы внести правки в DOM
- компилирующие фреймворки как Angular и Svelte разделяют шаблоны компонента на функции "создать" и "обновить", повторно выполняя функцию "обновить" всякий раз, когда они обнаруживают, что состояние компонента изменилось
- во фреймворках с мелкозернистой реактивностью, таких как SolidJS, Sycamore, или Leptos, вы задаёте функции, которые выполняются повторно.
Это то, что все наши компоненты делают.
Возьмём привычный нам пример <SimpleCounter/>
в его простейшей форме:
#[component]
pub fn SimpleCounter() -> impl IntoView {
let (value, set_value) = create_signal(0);
let increment = move |_| set_value.update(|value| *value += 1);
view! {
<button on:click=increment>
{value}
</button>
}
}
Сама по себе функция SimpleCounter
выполняется единожды. Сигнал value
создается один раз. Фреймворк
передаёт функцию increment
браузеру в качестве слушателя событий. При нажатии на кнопку, браузер вызывает increment
,
которая обновляет value
через set_value
. И это обновляет единственный текстовый узел представленный в нашем view
выражением {value}
.
Замыкания это ключ к реактивности. Они дают возможность фреймворку повторно выполнить самый маленький элемент в приложении в ответ на изменение.
Так что помните две вещи:
- Функция компонента это установочная функция, не рендерная функция: она выполняется лишь раз.
- Чтобы значения в шаблоне
view
были реактивными, они должны быть функциями: либо сигналами (они реализуют типажиFn
) или замыканиями.
Тестирование компонентов
Тестирование пользовательских интерфейсов может быть сравнительно замысловатым, но очень важным делом. В этой статье мы обсудим пару принципов и подходов к тестированию Leptos-приложений.
1. Тестирование бизнес-логики с помощью обычных тестов Rust
Во многих случаях, имеет смысл вынести логику за пределы ваших компонентов и тестировать её отдельно.
Какие-то простые компоненты может не содержать логику, подлежащую тестированию, но для остальных стоит использовать
тестируемый тип-обёртку и использовать обычные Rust блоки impl
.
К примеру, вместо встраивания логики в компонент напрямую как здесь:
#[component]
pub fn TodoApp() -> impl IntoView {
let (todos, set_todos) = create_signal(vec![Todo { /* ... */ }]);
// ⚠️ this is hard to test because it's embedded in the component
let num_remaining = move || todos.with(|todos| {
todos.iter().filter(|todo| !todo.completed).sum()
});
}
Можно внести логику в отдельную структуру и тестировать её:
pub struct Todos(Vec<Todo>);
impl Todos {
pub fn num_remaining(&self) -> usize {
self.0.iter().filter(|todo| !todo.completed).sum()
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_remaining() {
// ...
}
}
#[component]
pub fn TodoApp() -> impl IntoView {
let (todos, set_todos) = create_signal(Todos(vec![Todo { /* ... */ }]));
// ✅ this has a test associated with it
let num_remaining = move || todos.with(Todos::num_remaining);
}
В целом, чем меньше логики завернуто в сами компоненты, тем более идиоматичным ощущается код и тем проще его тестировать.
2. Сквозное (e2e
) тестирование компонентов
В директории examples
) есть несколько подробных примеров сквозного тестирования с использованием различных инструментов.
Проще всего разобраться как использовать эти инструменты это посмотреть на сами примеров тестов:
wasm-bindgen-test
с counter
Вот достаточно простой сетап для ручного тестирования, использующий команду wasm-pack test
.
Образец Теста
#[wasm_bindgen_test]
fn clear() {
let document = leptos::document();
let test_wrapper = document.create_element("section").unwrap();
let _ = document.body().unwrap().append_child(&test_wrapper);
mount_to(
test_wrapper.clone().unchecked_into(),
|| view! { <SimpleCounter initial_value=10 step=1/> },
);
let div = test_wrapper.query_selector("div").unwrap().unwrap();
let clear = test_wrapper
.query_selector("button")
.unwrap()
.unwrap()
.unchecked_into::<web_sys::HtmlElement>();
clear.click();
assert_eq!(
div.outer_html(),
// here we spawn a mini reactive system to render the test case
run_scope(create_runtime(), || {
// it's as if we're creating it with a value of 0, right?
let (value, set_value) = create_signal(0);
// we can remove the event listeners because they're not rendered to HTML
view! {
<div>
<button>"Clear"</button>
<button>"-1"</button>
<span>"Value: " {value} "!"</span>
<button>"+1"</button>
</div>
}
// the view returned an HtmlElement<Div>, which is a smart pointer for
// a DOM element. So we can still just call .outer_html()
.outer_html()
})
);
}
wasm-bindgen-test
с counters_stable
А вот более развернутый набор тестов, использующий систему фикстур, чтобы заменить ручную манипуляцию DOM в тестах counter
и легко тестировать широкий диапазон кейсов.
Sample Test
use super::*;
use crate::counters_page as ui;
use pretty_assertions::assert_eq;
#[wasm_bindgen_test]
fn should_increase_the_total_count() {
// Given
ui::view_counters();
ui::add_counter();
// When
ui::increment_counter(1);
ui::increment_counter(1);
ui::increment_counter(1);
// Then
assert_eq!(ui::total(), 3);
}
Playwright и counters_stable
Эти тесты используют обычный инструмент тестирования JavaScript — Playwright для выполнения сквозных тестов в том же примере, используя библиотеку и подход к тестированию, знакомые многим, кто ранее занимался разработкой frontend.
Образец Теста
import { test, expect } from "@playwright/test";
import { CountersPage } from "./fixtures/counters_page";
test.describe("Increment Count", () => {
test("should increase the total count", async ({ page }) => {
const ui = new CountersPage(page);
await ui.goto();
await ui.addCounter();
await ui.incrementCount();
await ui.incrementCount();
await ui.incrementCount();
await expect(ui.total).toHaveText("3");
});
});
Gherkin/Cucumber тесты с todo_app_sqlite
Можно интегрировать какой угодно инструмент тестирования. Вот пример использования Cucumber, тестового фреймворка основанного на естественном языке.
@add_todo
Feature: Add Todo
Background:
Given I see the app
@add_todo-see
Scenario: Should see the todo
Given I set the todo as Buy Bread
When I click the Add button
Then I see the todo named Buy Bread
# @allow.skipped
@add_todo-style
Scenario: Should see the pending todo
When I add a todo as Buy Oranges
Then I see the pending todo
Определения для этих действий даны в Rust-коде.
use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};
#[given("I see the app")]
#[when("I open the app")]
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
let client = &world.client;
action::goto_path(client, "").await?;
Ok(())
}
#[given(regex = "^I add a todo as (.*)$")]
#[when(regex = "^I add a todo as (.*)$")]
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
let client = &world.client;
action::add_todo(client, text.as_str()).await?;
Ok(())
}
// etc.
Узнать Больше
Вы можете ознакомиться с настройками CI в репозитории Leptos, чтобы узнать больше о том, как использовать эти инструменты в вашем собственном приложении. Примеры приложений Leptos регулярно тестируются всеми этими способами.
Работа с async
На текущий момент мы успели поработать лишь с синхронными пользовательскими интерфейсами. Вы предоставляете некий ввод, приложение немедленно его обрабатывает и обновляет интерфейс. Это здорово, но так делает лишь маленькая часть веб-приложений. В частности, большинство веб-приложений должны иметь дело с какой-либо асинхронной подгрузкой данных, обычно это загрузка чего-то из API.
Синхронизация асинхронных данных с синхронными частями кода — известная проблема.
Leptos предоставляет кросс-платформенную функцию spawn_local
, которая упрощает выполнение Future
,
но это лишь малая часть решения.
В этой главе мы разберём как Leptos помогает облегчить этот процесс.
Подгрузка данных с помощью ресурсов
Ресурс (Resource) это реактивная структура данных, отражающая текущее состояние асинхронной задачи, и
позволяющая интегрировать асинхронные футуры (Future
) в синхронную реактивную систему. Вместо ожидания данных через .await
,
мы превращаем футуру в сигнал, возвращающий Some(T)
если она уже разрешилась и None
если она ещё в ожидании.
Это делается с помощью функции create_resource
. Она принимает два аргумента:
- сигнал-источник, порождающий новую футуру при любом изменении
- функция, принимающая как аргумент данные из сигнала и возвращающая футуру
Вот пример:
// our source signal: some synchronous, local state
let (count, set_count) = create_signal(0);
// our resource
let async_data = create_resource(
count,
// every time `count` changes, this will run
|value| async move {
logging::log!("loading data from API");
load_data(value).await
},
);
Для создания ресурса, который выполняется единожды, можно передать нереактивный, пустой сигнал-источник:
let once = create_resource(|| (), |_| async move { load_data().await });
Для доступа к значению можно использовать .get()
или .with(|data| /* */)
. Они работают так же как .get()
и .with()
у сигнала: get
возвращает клонированное значение, а with
применяет замыкание. Однако для всякого Resource<_, T>
,
они возвращают Option<T>
, а не T
, поскольку всегда возможно, что ресурс всё ещё грузится.
Так можно отобразить текущее состояние ресурса во view
:
let once = create_resource(|| (), |_| async move { load_data().await });
view! {
<h1>"My Data"</h1>
{move || match once.get() {
None => view! { <p>"Loading..."</p> }.into_view(),
Some(data) => view! { <ShowData data/> }.into_view()
}}
}
Ресурсы также предоставляют метод refetch()
, позволяющий вручную перезапросить данные (например, в ответ на нажатие кнопки)
и метод loading()
, возвращающий ReadSignal<bool>
— индикатор того загружается ли в данный момент сигнал.
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/10-resources-0-5-x6h5j6?file=%2Fsrc%2Fmain.rs%3A2%2C3)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/10-resources-0-5-9jq86q?file=%2Fsrc%2Fmain.rs%3A2%2C3" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use gloo_timers::future::TimeoutFuture;
use leptos::*;
// Here we define an async function
// This could be anything: a network request, database read, etc.
// Here, we just multiply a number by 10
async fn load_data(value: i32) -> i32 {
// fake a one-second delay
TimeoutFuture::new(1_000).await;
value * 10
}
#[component]
fn App() -> impl IntoView {
// this count is our synchronous, local state
let (count, set_count) = create_signal(0);
// create_resource takes two arguments after its scope
let async_data = create_resource(
// the first is the "source signal"
count,
// the second is the loader
// it takes the source signal's value as its argument
// and does some async work
|value| async move { load_data(value).await },
);
// whenever the source signal changes, the loader reloads
// you can also create resources that only load once
// just return the unit type () from the source signal
// that doesn't depend on anything: we just load it once
let stable = create_resource(|| (), |_| async move { load_data(1).await });
// we can access the resource values with .get()
// this will reactively return None before the Future has resolved
// and update to Some(T) when it has resolved
let async_result = move || {
async_data
.get()
.map(|value| format!("Server returned {value:?}"))
// This loading state will only show before the first load
.unwrap_or_else(|| "Loading...".into())
};
// the resource's loading() method gives us a
// signal to indicate whether it's currently loading
let loading = async_data.loading();
let is_loading = move || if loading() { "Loading..." } else { "Idle." };
view! {
<button
on:click=move |_| {
set_count.update(|n| *n += 1);
}
>
"Click me"
</button>
<p>
<code>"stable"</code>": " {move || stable.get()}
</p>
<p>
<code>"count"</code>": " {count}
</p>
<p>
<code>"async_value"</code>": "
{async_result}
<br/>
{is_loading}
</p>
}
}
fn main() {
leptos::mount_to_body(App)
}
<Suspense/>
В предыдущей главе было рассмотрено создание простого экрана загрузки, чтобы показывать некий fallback
пока ресурс грузится.
let (count, set_count) = create_signal(0);
let once = create_resource(count, |count| async move { load_a(count).await });
view! {
<h1>"My Data"</h1>
{move || match once.get() {
None => view! { <p>"Loading..."</p> }.into_view(),
Some(data) => view! { <ShowData data/> }.into_view()
}}
}
Но что если есть два ресурса и хочется ожидать оба?
let (count, set_count) = create_signal(0);
let (count2, set_count2) = create_signal(0);
let a = create_resource(count, |count| async move { load_a(count).await });
let b = create_resource(count2, |count| async move { load_b(count).await });
view! {
<h1>"My Data"</h1>
{move || match (a.get(), b.get()) {
(Some(a), Some(b)) => view! {
<ShowA a/>
<ShowA b/>
}.into_view(),
_ => view! { <p>"Loading..."</p> }.into_view()
}}
}
Выглядит не очень плохо, но всё же несколько раздражает. Что если использовать инверсию управления (IoC)?
Компонент <Suspense/>
позволяет сделать именно это. Вы передаёте ему свойство fallback
и дочерние элементы,
один или более из которых обычно включает в себя чтение из ресурса. Чтение из ресурса “внутри” a <Suspense/>
(т.е. в одном из элементов-потомков) регистрирует ресурс в <Suspense/>
. Если загрузка хотя бы одного из ресурсов всё ещё идет,
отображается fallback
. Когда они все загружены, отображаются дочерние элементы.
let (count, set_count) = create_signal(0);
let (count2, set_count2) = create_signal(0);
let a = create_resource(count, |count| async move { load_a(count).await });
let b = create_resource(count2, |count| async move { load_b(count).await });
view! {
<h1>"My Data"</h1>
<Suspense
fallback=move || view! { <p>"Loading..."</p> }
>
<h2>"My Data"</h2>
<h3>"A"</h3>
{move || {
a.get()
.map(|a| view! { <ShowA a/> })
}}
<h3>"B"</h3>
{move || {
b.get()
.map(|b| view! { <ShowB b/> })
}}
</Suspense>
}
Каждый раз когда любой из ресурсов перезагружается, fallback
с "Loading..." отображается снова.
Инверсия управления упрощает добавление и удаление отдельных ресурсов, избавляя от необходимости самостоятельно делать сопоставление с шаблоном. Это также открывает значительные оптимизации производительности во время серверного рендеринга (SSR), о которых мы поговорим в одной из следующих глав.
<Await/>
Если хочется просто дождаться какого-то футуры перед рендерингом, компонент <Await/>
может быть полезен в уменьшении шаблонного кода.
<Await/>
по сути сочетает в себе ресурс с источником || ()
и <Suspense/>
без fallback
.
Другими словами:
- Он poll'ит
Future
лишь раз и не реагирует ни на какие реактивные изменения. - Он ничего не рендерит пока
Future
не разрешится. - После разрешения футуры, он кладёт её данные в выбранную переменную и рендерит дочерние элементы с этой переменной в области видимости.
async fn fetch_monkeys(monkey: i32) -> i32 {
// maybe this didn't need to be async
monkey * 2
}
view! {
<Await
// `future` provides the `Future` to be resolved
future=|| fetch_monkeys(3)
// the data is bound to whatever variable name you provide
let:data
>
// you receive the data by reference and can use it in your view here
<p>{*data} " little monkeys, jumping on the bed."</p>
</Await>
}
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/11-suspense-0-5-qzpgqs?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/11-suspense-0-5-qzpgqs?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use gloo_timers::future::TimeoutFuture;
use leptos::*;
async fn important_api_call(name: String) -> String {
TimeoutFuture::new(1_000).await;
name.to_ascii_uppercase()
}
#[component]
fn App() -> impl IntoView {
let (name, set_name) = create_signal("Bill".to_string());
// this will reload every time `name` changes
let async_data = create_resource(
name,
|name| async move { important_api_call(name).await },
);
view! {
<input
on:input=move |ev| {
set_name(event_target_value(&ev));
}
prop:value=name
/>
<p><code>"name:"</code> {name}</p>
<Suspense
// the fallback will show whenever a resource
// read "under" the suspense is loading
fallback=move || view! { <p>"Loading..."</p> }
>
// the children will be rendered once initially,
// and then whenever any resources has been resolved
<p>
"Your shouting name is "
{move || async_data.get()}
</p>
</Suspense>
}
}
fn main() {
leptos::mount_to_body(App)
}
<Transition/>
Как можно заметить из примера с <Suspense/>
, если вы будете перезагружать данные, будет снова и снова мигать "Loading..."
.
Иногда это нормально. Для остальных случаев есть <Transition/>
.
<Transition/>
ведёт себя точно так же как <Suspense/>
, но вместо показа fallback
каждый раз,
fallback
покажется только при первой загрузке. При всех последующих, старые данные будут продолжать показываться,
до тех пор пока новые данные не будут готовы. Это может быть очень полезно, чтобы избежать мигающего эффекта
и позволить пользователям продолжить взаимодействие с приложением.
Данный пример показывает как создать простой список контактов со вкладками с помощью <Transition/>
.
Когда вы выбираете новую вкладку, текущий контакт продолжает показываться пока не загрузятся новые данные.
Так пользовательский опыт получается намного лучше чем с постоянным откатом к экрану загрузки.
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/12-transition-0-5-2jg5lz?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/12-transition-0-5-2jg5lz?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use gloo_timers::future::TimeoutFuture;
use leptos::*;
async fn important_api_call(id: usize) -> String {
TimeoutFuture::new(1_000).await;
match id {
0 => "Alice",
1 => "Bob",
2 => "Carol",
_ => "User not found",
}
.to_string()
}
#[component]
fn App() -> impl IntoView {
let (tab, set_tab) = create_signal(0);
// this will reload every time `tab` changes
let user_data = create_resource(tab, |tab| async move { important_api_call(tab).await });
view! {
<div class="buttons">
<button
on:click=move |_| set_tab(0)
class:selected=move || tab() == 0
>
"Tab A"
</button>
<button
on:click=move |_| set_tab(1)
class:selected=move || tab() == 1
>
"Tab B"
</button>
<button
on:click=move |_| set_tab(2)
class:selected=move || tab() == 2
>
"Tab C"
</button>
{move || if user_data.loading().get() {
"Loading..."
} else {
""
}}
</div>
<Transition
// the fallback will show initially
// on subsequent reloads, the current child will
// continue showing
fallback=move || view! { <p>"Loading..."</p> }
>
<p>
{move || user_data.read()}
</p>
</Transition>
}
}
fn main() {
leptos::mount_to_body(App)
}
Мутация данных через действия (Actions)
Мы обсудили как подгружать асинхронные данные с помощью ресурсов. Ресурсы немедленно загружают данные, они тесно связаны с
компонентами <Suspense/>
и <Transition/>
, которые показывают пользователю загружаются ли данные.
Но что если хочется просто вызвать произвольную async
функцию и следить за тем, что происходит?
Ну, всегда можно использовать spawn_local
. Это позволяет породить async
задачу в синхронном окружении,
передав Future
в браузер (или, на сервере, в Tokio или в другую используемую асинхронную среду выполнения).
Но как узнать выполняется ли она всё ещё или нет? Ну, можно сделать сигнал, показывающий идёт ли загрузка, и ещё один
для показа результата...
Всё это правда. Или можно просто последний async
примитив: create_action
.
Действия и ресурсы с виду похожи, но они представляют фундаментально разные вещи. Если нужно загрузить данные через
вызов async
функции, единожды или когда какое-то другое значение меняется, то наверно стоит использовать create_resource
.
Если же нужно время от времени запускать async
функцию в ответ на, например, нажатие на кнопку, наверно стоит использовать
create_action
.
Предположим есть async
функцию, которую нужно выполнить.
async fn add_todo_request(new_title: &str) -> Uuid {
/* do some stuff on the server to add a new todo */
}
create_action
принимает в качестве аргумента async
функцию, которая в качестве аргумента принимает ссылку на
то, что можно назвать “вводный тип”.
Вводный тип всегда один. Если нужно передать несколько аргументов, это можно сделать через структуру или кортеж.
// if there's a single argument, just use that let action1 = create_action(|input: &String| { let input = input.clone(); async move { todo!() } }); // if there are no arguments, use the unit type `()` let action2 = create_action(|input: &()| async { todo!() }); // if there are multiple arguments, use a tuple let action3 = create_action( |input: &(usize, String)| async { todo!() } );
Поскольку функция действия принимает ссылку, а футуре нужно время жизни
'static
, обычно требуется клонировать значение, чтобы передать его в футуру. Это, признаться, неуклюже, но это открывает такие мощные возможности, как оптимистичный UI. Подробнее об этом в будущих главах.
Так что в данном случае всё что нужно сделать это создать действие:
let add_todo_action = create_action(|input: &String| {
let input = input.to_owned();
async move { add_todo_request(&input).await }
});
Вместо прямого вызова add_todo_action
, вызовём его через .dispatch()
add_todo_action.dispatch("Some value".to_string());
Вы можете сделать из слушателя событий, таймаута, или откуда угодно; поскольку .dispatch()
это не async
функция,
она может быть вызвана из синхронного контекста.
Действия дают доступ к нескольким сигналам, синхронизирующим асинхронное действия и синхронную реактивную систему:
let submitted = add_todo_action.input(); // RwSignal<Option<String>>
let pending = add_todo_action.pending(); // ReadSignal<bool>
let todo_id = add_todo_action.value(); // RwSignal<Option<Uuid>>
Это позволяет легко отслеживать текущее состояние вашего запроса, показать индикатор загрузки, или сделать “оптимистичный UI”, основанный на допущении, что отправка увенчается успехом.
let input_ref = create_node_ref::<Input>();
view! {
<form
on:submit=move |ev| {
ev.prevent_default(); // don't reload the page...
let input = input_ref.get().expect("input to exist");
add_todo_action.dispatch(input.value());
}
>
<label>
"What do you need to do?"
<input type="text"
node_ref=input_ref
/>
</label>
<button type="submit">"Add Todo"</button>
</form>
// use our loading state
<p>{move || pending().then("Loading...")}</p>
}
Что ж, всё это может показаться чуточку переусложненным или, быть может, слишком ограничивающим.
Я захотел включить сюда действия, вместе с сигналами, как недостающий пазл. В реальном приложении на Leptos,
вы действительно чаще всего будете использовать действия вместе с серверными функциями, компоненты create_server_action
,
и <ActionForm/>
для создания действительно мощных форм с прогрессивным улучшением.
Так что, если этот примитив кажется бесполезным, не переживайте! Возможно понимание придёт позже. (Или ознакомьтесь с нашим todo_app_sqlite
примером прямо сейчас.)
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/13-actions-0-5-8xk35v?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/13-actions-0-5-8xk35v?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
Код примера CodeSandbox
use gloo_timers::future::TimeoutFuture;
use leptos::{html::Input, *};
use uuid::Uuid;
// Here we define an async function
// This could be anything: a network request, database read, etc.
// Think of it as a mutation: some imperative async action you run,
// whereas a resource would be some async data you load
async fn add_todo(text: &str) -> Uuid {
_ = text;
// fake a one-second delay
TimeoutFuture::new(1_000).await;
// pretend this is a post ID or something
Uuid::new_v4()
}
#[component]
fn App() -> impl IntoView {
// an action takes an async function with single argument
// it can be a simple type, a struct, or ()
let add_todo = create_action(|input: &String| {
// the input is a reference, but we need the Future to own it
// this is important: we need to clone and move into the Future
// so it has a 'static lifetime
let input = input.to_owned();
async move { add_todo(&input).await }
});
// actions provide a bunch of synchronous, reactive variables
// that tell us different things about the state of the action
let submitted = add_todo.input();
let pending = add_todo.pending();
let todo_id = add_todo.value();
let input_ref = create_node_ref::<Input>();
view! {
<form
on:submit=move |ev| {
ev.prevent_default(); // don't reload the page...
let input = input_ref.get().expect("input to exist");
add_todo.dispatch(input.value());
}
>
<label>
"What do you need to do?"
<input type="text"
node_ref=input_ref
/>
</label>
<button type="submit">"Add Todo"</button>
</form>
<p>{move || pending().then(|| "Loading...")}</p>
<p>
"Submitted: "
<code>{move || format!("{:#?}", submitted())}</code>
</p>
<p>
"Pending: "
<code>{move || format!("{:#?}", pending())}</code>
</p>
<p>
"Todo ID: "
<code>{move || format!("{:#?}", todo_id())}</code>
</p>
}
}
fn main() {
leptos::mount_to_body(App)
}
Проброс дочерних элементов
При написании компонентов можно время от времени ловить себя на желании "пробросить" через несколько уровней компонентов.
Задача
Рассмотрим следующий пример:
pub fn LoggedIn<F, IV>(fallback: F, children: ChildrenFn) -> impl IntoView
where
F: Fn() -> IV + 'static,
IV: IntoView,
{
view! {
<Suspense
fallback=|| ()
>
<Show
// check whether user is verified
// by reading from the resource
when=move || todo!()
fallback=fallback
>
{children()}
</Show>
</Suspense>
}
}
Он достаточно прост: когда пользователь авторизован, выводится children
. Когда нет — выводится fallback
.
А пока информация не пришла, выводится ()
, т.е. ничего.
Другими словами, дочерние элементы <LoggedIn/>
хочется передать через компонент <Suspense/>
,
чтобы они стали дочерними элементами <Show/>
. Это то, что имеется в виду под "пробросом".
Это не скомпилируется.
error[E0507]: cannot move out of `fallback`, a captured variable in an `Fn` closure
error[E0507]: cannot move out of `children`, a captured variable in an `Fn` closure
Проблема в том, что компонентам <Suspense/>
и <Show/>
нужна возможность строить свои children
несколько раз.
При первом построении дочерних элементов <Suspense>
владение fallback
и children
требуется,
чтобы переместить эти значения внутрь вызова <Show/>
, но тогда они недоступны для последующих построений дочерних элементов <Suspense/>
.
Подробности
Можете смело перейти сразу к решению.
Если хотите по-настоящему понять проблему, стоит посмотреть на расширенный макрос view
. Вот подчищенный вариант:
Suspense(
::leptos::component_props_builder(&Suspense)
.fallback(|| ())
.children({
// fallback and children are moved into this closure
Box::new(move || {
{
// fallback and children captured here
leptos::Fragment::lazy(|| {
vec![
(Show(
::leptos::component_props_builder(&Show)
.when(|| true)
// but fallback is moved into Show here
.fallback(fallback)
// and children is moved into Show here
.children(children)
.build(),
)
.into_view()),
]
})
}
})
})
.build(),
)
Все компоненты владеют своими свойствами; так что <Show/>
в данном случае не может быть вызван поскольку он лишь
захватил ссылки на fallback
и children
.
Решение
Однако, и <Suspense/>
и <Show/>
принимают ChildrenFn
в качестве аргумента, т.е. их children
должна реализовывать
тип Fn
, чтобы они могли вызваться несколько раз с лишь иммутабельной ссылкой. Это означает, что владеть
children
или fallback
не нужно; нужно просто передать 'static'
ссылки на них.
Эту проблему можно решить через примитив store_value
.
Он по сути сохраняет значение в реактивной системе, передавая его во владение фреймворку, в обмен на ссылку,
которая, подобно сигналу, реализует Copy, имеет время жизни 'static, и которую с помощью определённых методов можно изменить или получить к ней доступ.
В данном примере это очень просто:
pub fn LoggedIn<F, IV>(fallback: F, children: ChildrenFn) -> impl IntoView
where
F: Fn() -> IV + 'static,
IV: IntoView,
{
let fallback = store_value(fallback);
let children = store_value(children);
view! {
<Suspense
fallback=|| ()
>
<Show
when=|| todo!()
fallback=move || fallback.with_value(|fallback| fallback())
>
{children.with_value(|children| children())}
</Show>
</Suspense>
}
}
На верхнем уровне fallback
и children
сохраняется в реактивной области видимости, которой владеет LoggedIn
.
Теперь можно просто переместить эти ссылки через другие уровни в компонент<Show/>
и вызывать их там.
В заключение
Учтите, что это работает потому, что компонентам <Show/>
и <Suspense/>
нужна иммутабельная ссылка на их дочерние элементы
(которую им может дать .with_value
), а не владение ими.
В иных случаях, может понадобиться пробрасывать свойства с владением через функцию, которая принимает ChildrenFn
и которая таким образом должна вызываться более одного раза.
В этом случае может быть полезна вспомогательный синтаксис clone:
в макросе view
.
Рассмотрим пример
#[component]
pub fn App() -> impl IntoView {
let name = "Alice".to_string();
view! {
<Outer>
<Inner>
<Inmost name=name.clone()/>
</Inner>
</Outer>
}
}
#[component]
pub fn Outer(children: ChildrenFn) -> impl IntoView {
children()
}
#[component]
pub fn Inner(children: ChildrenFn) -> impl IntoView {
children()
}
#[component]
pub fn Inmost(name: String) -> impl IntoView {
view! {
<p>{name}</p>
}
}
Даже с name=name.clone()
, возникает ошибка
cannot move out of `name`, a captured variable in an `Fn` closure
Переменная захватывается на нескольких уровнях элементов-потомков, которые должны выполняться более одного раза и нет очевидного способа клонировать её в них.
Здесь пригождается синтаксис clone:
. Вызов clone:name
клонирует name
перед перемещением в дочерние элементы <Inner/>
,
что решает проблему с владением.
view! {
<Outer>
<Inner clone:name>
<Inmost name=name.clone()/>
</Inner>
</Outer>
}
Эти проблемы могут быть немного сложны для понимания и отладки, из-за непрозрачности макроса view
.
Но в целом, их всегда можно решить.
Управление глобальным состоянием
Пока что мы работали только с локальным состоянием в компонентах и мы узнали как координировать состояние между родительным и дочерними компонентами. Время от времени требуется более общее решение для управление глобальным состоянием, которое может работать по всему приложению.
Вообще, вам не требуется эта глава. Обычно приложение строят из компонентов, каждый из которых управляет собственным локальным состоянием, чтобы не хранить всё состояние в одной глобальной структуре. Однако, бывают такие случаи (темы интерфейсы, сохранение настроек пользователя или общий доступ к данным между разными частями UI), когда может захотеться хранить какое-то глобальное состояние.
Вот три лучших подход к глобальному состоянию:
- Использовать Роутер для управлять глобальным состоянием через URL
- Передача сигналов через контексты
- Создание глобальной структуры состояния и создания направленных на её линз через
create_slice
Вариант №1: URL как глобальное состояние
Во многом URL это действительно лучший способ хранить глобальное состояние. К нему можно получить доступ из любого компонента,
из любой части дерева. Есть такие нативные элементы как <form>
и <a>
, существующие исключительно для обновления URL.
И он сохраняется при перезагрузке страницы и при смене устройства; можно поделиться ссылкой с другом или отправить
её с телефона на свой же ноутбук и сохраненное в ней состояние будет восстановлено.
Несколько следующих разделов руководства будут о маршрутизаторе, в них эти темы будут разобраны куда более глубоко.
А пока мы лишь посмотрим на варианты №2 и №3.
Вариант №2: Передача Сигналов через Контекст
В разделе о коммуникации Родитель-Потомок мы рассмотрели, что с помощью provide_context
передать сигнал
из родительского компонента потомку, а с помощью use_context
получить его в потомке.
Но provide_context
работает на любом расстоянии. Можно создать глобальный сигнал, хранящий кусочек состояния, вызвать
provide_context
и иметь к нему доступ отовсюду в потомках компонента, в котором была вызвана функция provide_context
.
Сигнал предоставляемый с помощью контекста вызывает реактивные обновление лишь там, где он из него читают, а не во всех компонентах между родителем и читающщим потомком, так что сила мелкозернистых реактивных обновлений сохраняется, даже на расстоянии.
Начнём с создания сигнала в корне приложения и предоставления его всем потомкам через provide_context
.
#[component]
fn App() -> impl IntoView {
// here we create a signal in the root that can be consumed
// anywhere in the app.
let (count, set_count) = create_signal(0);
// we'll pass the setter to specific components,
// but provide the count itself to the whole app via context
provide_context(count);
view! {
// SetterButton is allowed to modify the count
<SetterButton set_count/>
// These consumers can only read from it
// But we could give them write access by passing `set_count` if we wanted
<FancyMath/>
<ListItems/>
}
}
<SetterButton/>
это счетчик, который мы уже несколько раз писали.
(См. песочницу ниже если не понимаете о чём я)
<FancyMath/>
и <ListItems/>
оба получают предоставляемый сигнал с помощью use_context
и что-то с ним делают.
/// A component that does some "fancy" math with the global count
#[component]
fn FancyMath() -> impl IntoView {
// here we consume the global count signal with `use_context`
let count = use_context::<ReadSignal<u32>>()
// we know we just provided this in the parent component
.expect("there to be a `count` signal provided");
let is_even = move || count() & 1 == 0;
view! {
<div class="consumer blue">
"The number "
<strong>{count}</strong>
{move || if is_even() {
" is"
} else {
" is not"
}}
" even."
</div>
}
}
Отметим, что этот же паттерн можно применить и к более сложным состояниям. Если хочется независимо обновлять сразу несколько полей, этого можно добиться, предоставляя некую структуру с сигналами:
#[derive(Copy, Clone, Debug)]
struct GlobalState {
count: RwSignal<i32>,
name: RwSignal<String>
}
impl GlobalState {
pub fn new() -> Self {
Self {
count: create_rw_signal(0),
name: create_rw_signal("Bob".to_string())
}
}
}
#[component]
fn App() -> impl IntoView {
provide_context(GlobalState::new());
// etc.
}
Варианта №3: Создание Глобальной Структуры Состояния и Срезы
Оборачивание каждого поля структуры в отдельный сигнал может показаться громоздким. В некоторых случаях полезно создать обычную структуру с нереактивными полями, а затем обернуть её в сигнал.
#[derive(Copy, Clone, Debug, Default)]
struct GlobalState {
count: i32,
name: String
}
#[component]
fn App() -> impl IntoView {
provide_context(create_rw_signal(GlobalState::default()));
// etc.
}
Но с этим есть проблема: поскольку всё наше состояние обёрнуто в единственный сигнал, обновление значения одного поля приведёт к реактивным обновлениям в частях UI, которые зависят лишь от других полей.
let state = expect_context::<RwSignal<GlobalState>>();
view! {
<button on:click=move |_| state.update(|state| state.count += 1)>"+1"</button>
<p>{move || state.with(|state| state.name.clone())}</p>
}
В данном примере нажатие на кнопку вызовет обновление текста внутри <p>
, с повторным клонированием state.name
!
Поскольку сигналы это мельчайшие составные части реактивности, обновление любого поля данного сигнала вызывает обновление
всего, что от него зависит.
Есть способ получше. Можно брать мелкозернистые, реактивные срезы используя create_memo
или create_slice
(которая использует create_memo
, но также представляет сеттер).
“Мемоизация” значения означает создание нового реактивного значения, которое обновляется лишь тогда, когда исходное меняется.
“Мемоизация среза” означает создание новое реактивного значения, которое обновляется лишь тогда, когда определенное поле структуры обновляется.
Вот, вместо чтения из сигнала состояния напрямую мы берём "срезы" этого состояния с мелкозернистыми обновлениями через create_slice
.
Каждый сигнал-срез обновляется лишь когда определенная часть структуры меняется. Это значит можно сделать единый корневой сигнал,
а затем брать от него независимые мелкозернистые срезы в различных компонентах, каждый из которых обновляется
не уведомляя других об изменениях.
/// A component that updates the count in the global state.
#[component]
fn GlobalStateCounter() -> impl IntoView {
let state = expect_context::<RwSignal<GlobalState>>();
// `create_slice` lets us create a "lens" into the data
let (count, set_count) = create_slice(
// we take a slice *from* `state`
state,
// our getter returns a "slice" of the data
|state| state.count,
// our setter describes how to mutate that slice, given a new value
|state, n| state.count = n,
);
view! {
<div class="consumer blue">
<button
on:click=move |_| {
set_count(count() + 1);
}
>
"Increment Global Count"
</button>
<br/>
<span>"Count is: " {count}</span>
</div>
}
}
Нажатие на эту кнопку обновляет лишь state.count
, так что если создать где-то ещё срез, берущий лишь state.name
,
нажатие на кнопку не приведет к обновление этого дополнительного среза. Это позволяет объединить преимущества
течения данных сверху вниз и мелкозернистых реактивных обновлений.
Примечание: У этого подхода есть существенные недостатки. И сигналам и мемоизированным значениям нужно владеть их значениями, так что мемоизированное значение будет клонировать значение поля при каждом изменении. Самый естественный путь управлять состоянием в фреймворке как Leptos это всегда предоставлять сигналы настолько локальные и мелкозернистые насколько это возможно, а не поднимать всё что ни попадя в глобальное состояние. Но когда нужно какое-то глобальное состояние,
create_slice
может быть полезна.
[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/15-global-state-0-5-8c2ff6?file=%2Fsrc%2Fmain.rs%3A1%2C2)
<noscript>
Пожалуйста, включите Javascript для просмотра примеров.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/15-global-state-0-5-8c2ff6?file=%2Fsrc%2Fmain.rs%3A1%2C2" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
CodeSandbox Source
use leptos::*;
// So far, we've only been working with local state in components
// We've only seen how to communicate between parent and child components
// But there are also more general ways to manage global state
//
// The three best approaches to global state are
// 1. Using the router to drive global state via the URL
// 2. Passing signals through context
// 3. Creating a global state struct and creating lenses into it with `create_slice`
//
// Option #1: URL as Global State
// The next few sections of the tutorial will be about the router.
// So for now, we'll just look at options #2 and #3.
// Option #2: Pass Signals through Context
//
// In virtual DOM libraries like React, using the Context API to manage global
// state is a bad idea: because the entire app exists in a tree, changing
// some value provided high up in the tree can cause the whole app to render.
//
// In fine-grained reactive libraries like Leptos, this is simply not the case.
// You can create a signal in the root of your app and pass it down to other
// components using provide_context(). Changing it will only cause rerendering
// in the specific places it is actually used, not the whole app.
#[component]
fn Option2() -> impl IntoView {
// here we create a signal in the root that can be consumed
// anywhere in the app.
let (count, set_count) = create_signal(0);
// we'll pass the setter to specific components,
// but provide the count itself to the whole app via context
provide_context(count);
view! {
<h1>"Option 2: Passing Signals"</h1>
// SetterButton is allowed to modify the count
<SetterButton set_count/>
// These consumers can only read from it
// But we could give them write access by passing `set_count` if we wanted
<div style="display: flex">
<FancyMath/>
<ListItems/>
</div>
}
}
/// A button that increments our global counter.
#[component]
fn SetterButton(set_count: WriteSignal<u32>) -> impl IntoView {
view! {
<div class="provider red">
<button on:click=move |_| set_count.update(|count| *count += 1)>
"Increment Global Count"
</button>
</div>
}
}
/// A component that does some "fancy" math with the global count
#[component]
fn FancyMath() -> impl IntoView {
// here we consume the global count signal with `use_context`
let count = use_context::<ReadSignal<u32>>()
// we know we just provided this in the parent component
.expect("there to be a `count` signal provided");
let is_even = move || count() & 1 == 0;
view! {
<div class="consumer blue">
"The number "
<strong>{count}</strong>
{move || if is_even() {
" is"
} else {
" is not"
}}
" even."
</div>
}
}
/// A component that shows a list of items generated from the global count.
#[component]
fn ListItems() -> impl IntoView {
// again, consume the global count signal with `use_context`
let count = use_context::<ReadSignal<u32>>().expect("there to be a `count` signal provided");
let squares = move || {
(0..count())
.map(|n| view! { <li>{n}<sup>"2"</sup> " is " {n * n}</li> })
.collect::<Vec<_>>()
};
view! {
<div class="consumer green">
<ul>{squares}</ul>
</div>
}
}
// Option #3: Create a Global State Struct
//
// You can use this approach to build a single global data structure
// that holds the state for your whole app, and then access it by
// taking fine-grained slices using `create_slice` or `create_memo`,
// so that changing one part of the state doesn't cause parts of your
// app that depend on other parts of the state to change.
#[derive(Default, Clone, Debug)]
struct GlobalState {
count: u32,
name: String,
}
#[component]
fn Option3() -> impl IntoView {
// we'll provide a single signal that holds the whole state
// each component will be responsible for creating its own "lens" into it
let state = create_rw_signal(GlobalState::default());
provide_context(state);
view! {
<h1>"Option 3: Passing Signals"</h1>
<div class="red consumer" style="width: 100%">
<h2>"Current Global State"</h2>
<pre>
{move || {
format!("{:#?}", state.get())
}}
</pre>
</div>
<div style="display: flex">
<GlobalStateCounter/>
<GlobalStateInput/>
</div>
}
}
/// A component that updates the count in the global state.
#[component]
fn GlobalStateCounter() -> impl IntoView {
let state = use_context::<RwSignal<GlobalState>>().expect("state to have been provided");
// `create_slice` lets us create a "lens" into the data
let (count, set_count) = create_slice(
// we take a slice *from* `state`
state,
// our getter returns a "slice" of the data
|state| state.count,
// our setter describes how to mutate that slice, given a new value
|state, n| state.count = n,
);
view! {
<div class="consumer blue">
<button
on:click=move |_| {
set_count(count() + 1);
}
>
"Increment Global Count"
</button>
<br/>
<span>"Count is: " {count}</span>
</div>
}
}
/// A component that updates the count in the global state.
#[component]
fn GlobalStateInput() -> impl IntoView {
let state = use_context::<RwSignal<GlobalState>>().expect("state to have been provided");
// this slice is completely independent of the `count` slice
// that we created in the other component
// neither of them will cause the other to rerun
let (name, set_name) = create_slice(
// we take a slice *from* `state`
state,
// our getter returns a "slice" of the data
|state| state.name.clone(),
// our setter describes how to mutate that slice, given a new value
|state, n| state.name = n,
);
view! {
<div class="consumer green">
<input
type="text"
prop:value=name
on:input=move |ev| {
set_name(event_target_value(&ev));
}
/>
<br/>
<span>"Name is: " {name}</span>
</div>
}
}
// This `main` function is the entry point into the app
// It just mounts our component to the <body>
// Because we defined it as `fn App`, we can now use it in a
// template as <App/>
fn main() {
leptos::mount_to_body(|| view! { <Option2/><Option3/> })
}
Маршрутизация
Основы
Маршрутизация это то, что управляет большинством Веб-сайтов. Маршрутизатор это ответ на вопрос, "Что должно быть на странице при таком URL?"
URL состоит из множества частей. Например, URL https://my-cool-blog.com/blog/search?q=Search#results
состоит из
- протокол (scheme):
https
- _ домен (domain)_:
my-cool-blog.com
- путь (path):
/blog/search
- запрос (query) (или search):
?q=Search
- hash:
#results
Маршрутизатор Leptos работает с путём и запросом (/blog/search?q=Search
). Что приложение должно показать на странице
при таком пути и запросе?
Философия
В большинстве случаев путь определяет что будет отображено на странице. С точки зрения пользователя, в большинстве приложений, большинство значительных изменений состояния приложения должна быть отражена в URL. Если cкопировать URL и открыть его в новой вкладке, то пользователь должен оказаться более или менее в том же месте.
В этом смысле маршрутизатор это сердце управления глобальным состоянием приложения. Больше чем что-либо другое он влияет на то, что будет отображено на странице.
Маршрутизатор берёт на себя большую часть работы, преобразуя текущий URL в соответствующие компоненты.
Описывание маршрутов
Начало работы
Начать работу с маршрутизатором легко.
Перво-наперво нужно убедиться, что пакет leptos_router
добавлен в зависимости проекта.
Как и leptos
, маршрутизатор полагается на активацию особенности csr
, hydrate
или ssr
.
К примеру, для добавления маршрутизатора в приложение с рендерингом на стороне клиента (CSR), можно вызвать:
cargo add leptos_router --features=csr
Важно отметить, что
leptos_router
это пакет отдельный от самогоleptos
. Это означает, что всё, что есть в маршрутизаторе может быть задано в пользовательском пространстве (англ. userland). Можно без проблем создать свой собственный маршрутизатор или вовсе обойтись без оного.
И импортировать сопутствующие типы из leptos_router
, или как-то так:
use leptos_router::{Route, RouteProps, Router, RouterProps, Routes, RoutesProps};
или просто
use leptos_router::*;
Объявление <Router/>
Поведение маршрутизатора определяется компонентом <Router/>
. Обычно он вызывается где-то рядом с корнем приложения.
Не следует вызывать
<Router/>
более одного раза. Помните, что маршрутизатор управляет глобальным состоянием: если у вас несколько маршрутизаторов, то какой из них будет решать когда менять URL?
Начнём с простого компонента <App/>
, использующего маршрутизатор:
use leptos::*;
use leptos_router::*;
#[component]
pub fn App() -> impl IntoView {
view! {
<Router>
<nav>
/* ... */
</nav>
<main>
/* ... */
</main>
</Router>
}
}
Объявление <Routes/>
Компонент <Routes/>
это то, где задаются все маршруты по которым может переходить пользователь приложения.
Каждый из доступных маршрутов задается компонентом <Route/>
.
Компонент <Routes/>
должен находиться там, где содержимое должно меняться в зависимости от маршрута.
Всё что вне <Routes/>
будет отображаться всегда, так что такие вещи как панель навигации или меню можно оставить за пределами <Routes/>
.
use leptos::*;
use leptos_router::*;
#[component]
pub fn App() -> impl IntoView {
view! {
<Router>
<nav>
/* ... */
</nav>
<main>
// all our routes will appear inside <main>
<Routes>
/* ... */
</Routes>
</main>
</Router>
}
}
Отдельные маршруты задаются добавлением в <Routes/>
дочерних компонентов <Route/>
. <Route/>
принимает свойства path
и view
.
Когда текущий URL удовлетворяет path
, view
будет создано и показано.
Свойство path
может быть:
- статическим путём (
/users
), - динамическим путём, именованные параметры начинаются с двоеточия (
/:id
), - и/или шаблоном начинающийся со звёздочки (
/user/*any
)
Свойство view
это функция, возвращающая представление. Тут подходит любой компонент без свойств, так же как и замыкание, возвращающее какой-то view.
<Routes>
<Route path="/" view=Home/>
<Route path="/users" view=Users/>
<Route path="/users/:id" view=UserProfile/>
<Route path="/*any" view=|| view! { <h1>"Not Found"</h1> }/>
</Routes>
view
принимает в качестве аргументаFn() -> impl IntoView
. Если у компонента нет свойств, его можно напрямую передать в свойствоview
. В данном примере,view=Home
это сокращенная форма|| view! { <Home/> }
.
Если перейти по адресу /
или /users
, то можно увидеть главную страницу или <Users/>
.
Если перейти на /users/3
или /blahblah
, то будет профиль пользователя or страница ошибки 404 (<NotFound/>
).
При каждом переходе, маршрутизатор определяет какой <Route/>
подходит, и как следствие, какое содержимое должно быть
отображено там, где объявлен компонент <Routes/>
.
Следует отметить, что маршруты можно задавать в любом порядке. Маршрутизатор использует систему баллов, чтобы определить какой маршрут лучше подходит, а не просто берёт первый подходящий идя по списку сверху вниз.
Достаточно просто?
Маршруты с условиями
leptos_router
основан на том допущении, что в приложении один и только один компонент <Routes/>
.
Он использует это, чтобы сгенерировать маршруты на стороне сервера, оптимизировать сопоставление маршрутов путём кеширование
вычисленных ветвей и отрендерить приложение.
Нельзя рендерить <Routes/>
внутри условий используя другие компоненты как <Show/>
или <Suspense/>
.
// ❌ не делайте так!
view! {
<Show when=|| is_loaded() fallback=|| view! { <p>"Loading"</p> }>
<Routes>
<Route path="/" view=Home/>
</Routes>
</Show>
}
Вместо этого можно использовать вложенные маршруты, чтобы отрендерить <Routes/>
единожды, и условно рендерить <Outlet/>
:
// ✅ делайте так!
view! {
<Routes>
// parent route
<Route path="/" view=move || {
view! {
// only show the outlet if data have loaded
<Show when=|| is_loaded() fallback=|| view! { <p>"Loading"</p> }>
<Outlet/>
</Show>
}
}>
// nested child route
<Route path="/" view=Home/>
</Route>
</Routes>
}
Если это выглядит причудливо, не стоит беспокоиться! Следующий раздел этой книги посвящен такой вложенной маршрутизации.
Вложенная Маршрутизации
Мы только что задали следующий набор маршрутов:
<Routes>
<Route path="/" view=Home/>
<Route path="/users" view=Users/>
<Route path="/users/:id" view=UserProfile/>
<Route path="/*any" view=NotFound/>
</Routes>
Тут есть некоторое дублирование: /users
и /users/:id
. Это нормально для мелкого приложения, но как вы наверное уже поняли, это плохо масштабируется.
Вот было бы классно если бы маршруты могли быть вложенными?
Барабанная дробь... они могут!
<Routes>
<Route path="/" view=Home/>
<Route path="/users" view=Users>
<Route path=":id" view=UserProfile/>
</Route>
<Route path="/*any" view=NotFound/>
</Routes>
Но подождите. Поведение приложения немного изменилось.
Следующий подраздел один из самых важных во всём руководстве по маршрутизации. Прочтите его внимательно и смело задавайте вопросы если чего-то не поняли.
Вложенные маршруты как макет
Вложенные маршруты это одна из форм макета, а не способ задания маршрутов.
Скажем иначе: Цель задания вложенных маршрутов главным образом не в том, чтобы избежать повторений при наборе путей в
определениях маршрутов. Она в действительности в том, чтобы сказать маршрутизатору отображать несколько <Route/>
на странице одновременно, бок о бок.
Давайте оглянёмся на наш практический пример.
<Routes>
<Route path="/users" view=Users/>
<Route path="/users/:id" view=UserProfile/>
</Routes>
Это значит:
- Если зайдешь на
/users
, получишь компонент<Users/>
. - Если зайдёшь на
/users/3
, получишь компонент<UserProfile/>
(с параметромid
равным3
; об этом позже)
Скажем мы используем вместо этого вложенные маршруты:
<Routes>
<Route path="/users" view=Users>
<Route path=":id" view=UserProfile/>
</Route>
</Routes>
Это значит:
- Если зайти на
/users/3
, путь подходит к двум<Route/>
:<Users/>
и<UserProfile/>
. - Если зайти на
/users
, путь ни к чему не подходит.
Нужно добавить fallback-маршрут.
<Routes>
<Route path="/users" view=Users>
<Route path=":id" view=UserProfile/>
<Route path="" view=NoUser/>
</Route>
</Routes>
Теперь:
- Если зайти на
/users/3
, путь подходит к<Users/>
и<UserProfile/>
. - Если зайти на
/users
, путь подходит к<Users/>
and<NoUser/>
.
Другими словами, использовании вложенных маршрутов, каждый путь может подходить к нескольким маршрутам:
каждый URL может рендерить view
предоставленные разными компонентами <Route/>
одновременно, на одной странице.
Это может показаться контринтуитивным, но этот подход очень силён, в силу причин, продемонстрированных далее.
Зачем нужна Вложенная Маршрутизация?
Зачем этим заморачиваться?
Большинство Веб-приложений содержат уровни навигации, соответствующие разным частям макета.
К примеру, в почтовом приложении может быть URL типа /contacts/greg
, показывающий список контактов в левой части экрана,
и детали контакта Greg в правой части. Список контактов и детали контакта должны всегда быть одновременно видны на экране.
Если контакт не выбран, возможно стоит показать небольшой текст с инструкцией.
Этого легко добиться с помощь вложенных маршрутов
<Routes>
<Route path="/contacts" view=ContactList>
<Route path=":id" view=ContactInfo/>
<Route path="" view=|| view! {
<p>"Select a contact to view more info."</p>
}/>
</Route>
</Routes>
Можно пойти дальше вглубь.
Предположим хочется, чтобы были вкладки для адреса контакта, почты/телефона, и переписки с ним.
Можно добавить ещё один набор вложенных маршрутов внутри :id
:
<Routes>
<Route path="/contacts" view=ContactList>
<Route path=":id" view=ContactInfo>
<Route path="" view=EmailAndPhone/>
<Route path="address" view=Address/>
<Route path="messages" view=Messages/>
</Route>
<Route path="" view=|| view! {
<p>"Select a contact to view more info."</p>
}/>
</Route>
</Routes>
На главной страница веб-сайта Remix, фреймворкa на React от создателей React Router, есть прекрасный визуальный пример если промотать вниз, с тремя уровнями вложенной маршрутизации: Sales > Invoices > an invoice.
<Outlet/>
Родительные маршруты автоматически не рендерят свои вложенные маршруты. В конце концов, это обычные компоненты; они не знают точно где им следует рендерить дочерние элементы и "просто присобачим их в конец родительного компонента" это плохой ответ.
Вместо этого, вы говорите родительному компоненту где рендерить любые вложенные компоненты используя компонент <Outlet/>
.
<Outlet/>
попросту показывает одно из двух:
- если нет подходящего вложенного маршрута, он ничего не показывает
- если есть подходящий, он показывает его
view
Вот и всё! Но важно знать и помнить, что это частая причина вопроса “Почему же это не работает?”
Если не предоставить <Outlet/>
, то вложенный маршрут не будет отображён.
#[component]
pub fn ContactList() -> impl IntoView {
let contacts = todo!();
view! {
<div style="display: flex">
// the contact list
<For each=contacts
key=|contact| contact.id
children=|contact| todo!()
/>
// the nested child, if any
// don’t forget this!
<Outlet/>
</div>
}
}
Рефакторинг Определений Маршрутов
Можно не задавать все маршруты в одном месте если не хочется. Любой <Route/>
и его дочерние элементы можно перенести в отдельный компонент.
Вышеуказанный пример можно разбить на два отдельных компонента:
#[component]
fn App() -> impl IntoView {
view! {
<Router>
<Routes>
<Route path="/contacts" view=ContactList>
<ContactInfoRoutes/>
<Route path="" view=|| view! {
<p>"Select a contact to view more info."</p>
}/>
</Route>
</Routes>
</Router>
}
}
#[component(transparent)]
fn ContactInfoRoutes() -> impl IntoView {
view! {
<Route path=":id" view=ContactInfo>
<Route path="" view=EmailAndPhone/>
<Route path="address" view=Address/>
<Route path="messages" view=Messages/>
</Route>
}
}
Второй компонент имеет пометку #[component(transparent)]
, это означит, что он просто возвращает данные, а не view
:
в данном случае это структура RouteDefinition
,
которую возвращает <Route/>
. Пока стоит пометка #[component(transparent)]
, этот под-маршрут может быть объявлен где угодно,
и при этом вставлен как компонент в дерево маршрутов.
Вложенная Маршрутизация и Производительность
Задумка хорошая, но всё же, в чём соль?
Производительность.
В библиотеке с мелкозернистой реактивностью, такой как Leptos, всегда важно делать как можно меньшее количества рендеринга. Поскольку мы работаем с реальными узлами DOM, а не сравнивам изменения через виртуальный DOM, мы хотим "перерендривать" компоненты как можно реже. Со вложенной маршрутизацией этого чрезвычайно просто достичь.
Представьте наш пример со списком контактов. Если перейти от Greg к Alice, затем к Bob и обратно к Greg, контактная информация
должна меняться при каждом переходе. Но <ContactList/>
вовсе не должен повторно рендериться.
Это не только экономит производительность, но и поддерживает состояние UI. К примеру, если вверху <ContactList/>
расположена поисковая строка,
то переход от Greg к Alice и к Bob не сбросит строку поиска.
Фактически, в данном случае даже не нужно повторно рендерить компонент <Contact/>
при переходе между контактами.
Роутер будет лишь реактивно обновлять параметр :id
по мере навигации, позволяя делать мелкозернистые обновления.
При навигации по контактам одиночные текстовые узлы будут обновляться: имя контакта, адрес и так далее, без какого-либо
дополнительного ререндеринга.
Эта песочница содержит пару This sandbox includes a couple features (like nested routing) discussed in this section and the previous one, and a couple we’ll cover in the rest of this chapter. The router is such an integrated system that it makes sense to provide a single example, so don’t be surprised if there’s anything you don’t understand.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2)
<noscript>
Please enable JavaScript to view examples.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
CodeSandbox Source
use leptos::*;
use leptos_router::*;
#[component]
fn App() -> impl IntoView {
view! {
<Router>
<h1>"Contact App"</h1>
// this <nav> will show on every routes,
// because it's outside the <Routes/>
// note: we can just use normal <a> tags
// and the router will use client-side navigation
<nav>
<h2>"Navigation"</h2>
<a href="/">"Home"</a>
<a href="/contacts">"Contacts"</a>
</nav>
<main>
<Routes>
// / just has an un-nested "Home"
<Route path="/" view=|| view! {
<h3>"Home"</h3>
}/>
// /contacts has nested routes
<Route
path="/contacts"
view=ContactList
>
// if no id specified, fall back
<Route path=":id" view=ContactInfo>
<Route path="" view=|| view! {
<div class="tab">
"(Contact Info)"
</div>
}/>
<Route path="conversations" view=|| view! {
<div class="tab">
"(Conversations)"
</div>
}/>
</Route>
// if no id specified, fall back
<Route path="" view=|| view! {
<div class="select-user">
"Select a user to view contact info."
</div>
}/>
</Route>
</Routes>
</main>
</Router>
}
}
#[component]
fn ContactList() -> impl IntoView {
view! {
<div class="contact-list">
// here's our contact list component itself
<div class="contact-list-contacts">
<h3>"Contacts"</h3>
<A href="alice">"Alice"</A>
<A href="bob">"Bob"</A>
<A href="steve">"Steve"</A>
</div>
// <Outlet/> will show the nested child route
// we can position this outlet wherever we want
// within the layout
<Outlet/>
</div>
}
}
#[component]
fn ContactInfo() -> impl IntoView {
// we can access the :id param reactively with `use_params_map`
let params = use_params_map();
let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default());
// imagine we're loading data from an API here
let name = move || match id().as_str() {
"alice" => "Alice",
"bob" => "Bob",
"steve" => "Steve",
_ => "User not found.",
};
view! {
<div class="contact-info">
<h4>{name}</h4>
<div class="tabs">
<A href="" exact=true>"Contact Info"</A>
<A href="conversations">"Conversations"</A>
</div>
// <Outlet/> here is the tabs that are nested
// underneath the /contacts/:id route
<Outlet/>
</div>
}
}
fn main() {
leptos::mount_to_body(App)
}
Params and Queries
Static paths are useful for distinguishing between different pages, but almost every application wants to pass data through the URL at some point.
There are two ways you can do this:
- named route params like
id
in/users/:id
- named route queries like
q
in/search?q=Foo
Because of the way URLs are built, you can access the query from any <Route/>
view. You can access route params from the <Route/>
that defines them or any of its nested children.
Accessing params and queries is pretty simple with a couple of hooks:
Each of these comes with a typed option (use_query
and use_params
) and an untyped option (use_query_map
and use_params_map
).
The untyped versions hold a simple key-value map. To use the typed versions, derive the Params
trait on a struct.
Params
is a very lightweight trait to convert a flat key-value map of strings into a struct by applyingFromStr
to each field. Because of the flat structure of route params and URL queries, it’s significantly less flexible than something likeserde
; it also adds much less weight to your binary.
use leptos::*;
use leptos_router::*;
#[derive(Params, PartialEq)]
struct ContactParams {
id: usize
}
#[derive(Params, PartialEq)]
struct ContactSearch {
q: String
}
Note: The
Params
derive macro is located atleptos::Params
, and theParams
trait is atleptos_router::Params
. If you avoid using glob imports likeuse leptos::*;
, make sure you’re importing the right one for the derive macro.If you are not using the
nightly
feature, you will get the errorno function or associated item named `into_param` found for struct `std::string::String` in the current scope
At the moment, supporting both
T: FromStr
andOption<T>
for typed params requires a nightly feature. You can fix this by simply changing the struct to useq: Option<String>
instead ofq: String
.
Now we can use them in a component. Imagine a URL that has both params and a query, like /contacts/:id?q=Search
.
The typed versions return Memo<Result<T, _>>
. It’s a Memo so it reacts to changes in the URL. It’s a Result
because the params or query need to be parsed from the URL, and may or may not be valid.
let params = use_params::<ContactParams>();
let query = use_query::<ContactSearch>();
// id: || -> usize
let id = move || {
params.with(|params| {
params.as_ref()
.map(|params| params.id)
.unwrap_or_default()
})
};
The untyped versions return Memo<ParamsMap>
. Again, it’s memo to react to changes in the URL. ParamsMap
behaves a lot like any other map type, with a .get()
method that returns Option<&String>
.
let params = use_params_map();
let query = use_query_map();
// id: || -> Option<String>
let id = move || {
params.with(|params| params.get("id").cloned())
};
This can get a little messy: deriving a signal that wraps an Option<_>
or Result<_>
can involve a couple steps. But it’s worth doing this for two reasons:
- It’s correct, i.e., it forces you to consider the cases, “What if the user doesn’t pass a value for this query field? What if they pass an invalid value?”
- It’s performant. Specifically, when you navigate between different paths that match the same
<Route/>
with only params or the query changing, you can get fine-grained updates to different parts of your app without rerendering. For example, navigating between different contacts in our contact-list example does a targeted update to the name field (and eventually contact info) without needing to replace or rerender the wrapping<Contact/>
. This is what fine-grained reactivity is for.
This is the same example from the previous section. The router is such an integrated system that it makes sense to provide a single example highlighting multiple features, even if we haven’t explained them all yet.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2)
<noscript>
Please enable JavaScript to view examples.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
CodeSandbox Source
use leptos::*;
use leptos_router::*;
#[component]
fn App() -> impl IntoView {
view! {
<Router>
<h1>"Contact App"</h1>
// this <nav> will show on every routes,
// because it's outside the <Routes/>
// note: we can just use normal <a> tags
// and the router will use client-side navigation
<nav>
<h2>"Navigation"</h2>
<a href="/">"Home"</a>
<a href="/contacts">"Contacts"</a>
</nav>
<main>
<Routes>
// / just has an un-nested "Home"
<Route path="/" view=|| view! {
<h3>"Home"</h3>
}/>
// /contacts has nested routes
<Route
path="/contacts"
view=ContactList
>
// if no id specified, fall back
<Route path=":id" view=ContactInfo>
<Route path="" view=|| view! {
<div class="tab">
"(Contact Info)"
</div>
}/>
<Route path="conversations" view=|| view! {
<div class="tab">
"(Conversations)"
</div>
}/>
</Route>
// if no id specified, fall back
<Route path="" view=|| view! {
<div class="select-user">
"Select a user to view contact info."
</div>
}/>
</Route>
</Routes>
</main>
</Router>
}
}
#[component]
fn ContactList() -> impl IntoView {
view! {
<div class="contact-list">
// here's our contact list component itself
<div class="contact-list-contacts">
<h3>"Contacts"</h3>
<A href="alice">"Alice"</A>
<A href="bob">"Bob"</A>
<A href="steve">"Steve"</A>
</div>
// <Outlet/> will show the nested child route
// we can position this outlet wherever we want
// within the layout
<Outlet/>
</div>
}
}
#[component]
fn ContactInfo() -> impl IntoView {
// we can access the :id param reactively with `use_params_map`
let params = use_params_map();
let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default());
// imagine we're loading data from an API here
let name = move || match id().as_str() {
"alice" => "Alice",
"bob" => "Bob",
"steve" => "Steve",
_ => "User not found.",
};
view! {
<div class="contact-info">
<h4>{name}</h4>
<div class="tabs">
<A href="" exact=true>"Contact Info"</A>
<A href="conversations">"Conversations"</A>
</div>
// <Outlet/> here is the tabs that are nested
// underneath the /contacts/:id route
<Outlet/>
</div>
}
}
fn main() {
leptos::mount_to_body(App)
}
The <A/>
Component
Client-side navigation works perfectly fine with ordinary HTML <a>
elements. The router adds a listener that handles every click on a <a>
element and tries to handle it on the client side, i.e., without doing another round trip to the server to request HTML. This is what enables the snappy “single-page app” navigations you’re probably familiar with from most modern web apps.
The router will bail out of handling an <a>
click under a number of situations
- the click event has had
prevent_default()
called on it - the Meta, Alt, Ctrl, or Shift keys were held during click
- the
<a>
has atarget
ordownload
attribute, orrel="external"
- the link has a different origin from the current location
In other words, the router will only try to do a client-side navigation when it’s pretty sure it can handle it, and it will upgrade every <a>
element to get this special behavior.
This also means that if you need to opt out of client-side routing, you can do so easily. For example, if you have a link to another page on the same domain, but which isn’t part of your Leptos app, you can just use
<a rel="external">
to tell the router it isn’t something it can handle.
The router also provides an <A>
component, which does two additional things:
- Correctly resolves relative nested routes. Relative routing with ordinary
<a>
tags can be tricky. For example, if you have a route like/post/:id
,<A href="1">
will generate the correct relative route, but<a href="1">
likely will not (depending on where it appears in your view.)<A/>
resolves routes relative to the path of the nested route within which it appears. - Sets the
aria-current
attribute topage
if this link is the active link (i.e., it’s a link to the page you’re on). This is helpful for accessibility and for styling. For example, if you want to set the link a different color if it’s a link to the page you’re currently on, you can match this attribute with a CSS selector.
Navigating Programmatically
Your most-used methods of navigating between pages should be with <a>
and <form>
elements or with the enhanced <A/>
and <Form/>
components. Using links and forms to navigate is the best solution for accessibility and graceful degradation.
On occasion, though, you’ll want to navigate programmatically, i.e., call a function that can navigate to a new page. In that case, you should use the use_navigate
function.
let navigate = leptos_router::use_navigate();
navigate("/somewhere", Default::default());
You should almost never do something like
<button on:click=move |_| navigate(/* ... */)>
. Anyon:click
that navigates should be an<a>
, for reasons of accessibility.
The second argument here is a set of NavigateOptions
, which includes options to resolve the navigation relative to the current route as the <A/>
component does, replace it in the navigation stack, include some navigation state, and maintain the current scroll state on navigation.
Once again, this is the same example. Check out the relative
<A/>
components, and take a look at the CSS inindex.html
to see the ARIA-based styling.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2)
<noscript>
Please enable JavaScript to view examples.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/16-router-0-5-4xp4zz?file=%2Fsrc%2Fmain.rs%3A102%2C2" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
CodeSandbox Source
use leptos::*;
use leptos_router::*;
#[component]
fn App() -> impl IntoView {
view! {
<Router>
<h1>"Contact App"</h1>
// this <nav> will show on every routes,
// because it's outside the <Routes/>
// note: we can just use normal <a> tags
// and the router will use client-side navigation
<nav>
<h2>"Navigation"</h2>
<a href="/">"Home"</a>
<a href="/contacts">"Contacts"</a>
</nav>
<main>
<Routes>
// / just has an un-nested "Home"
<Route path="/" view=|| view! {
<h3>"Home"</h3>
}/>
// /contacts has nested routes
<Route
path="/contacts"
view=ContactList
>
// if no id specified, fall back
<Route path=":id" view=ContactInfo>
<Route path="" view=|| view! {
<div class="tab">
"(Contact Info)"
</div>
}/>
<Route path="conversations" view=|| view! {
<div class="tab">
"(Conversations)"
</div>
}/>
</Route>
// if no id specified, fall back
<Route path="" view=|| view! {
<div class="select-user">
"Select a user to view contact info."
</div>
}/>
</Route>
</Routes>
</main>
</Router>
}
}
#[component]
fn ContactList() -> impl IntoView {
view! {
<div class="contact-list">
// here's our contact list component itself
<div class="contact-list-contacts">
<h3>"Contacts"</h3>
<A href="alice">"Alice"</A>
<A href="bob">"Bob"</A>
<A href="steve">"Steve"</A>
</div>
// <Outlet/> will show the nested child route
// we can position this outlet wherever we want
// within the layout
<Outlet/>
</div>
}
}
#[component]
fn ContactInfo() -> impl IntoView {
// we can access the :id param reactively with `use_params_map`
let params = use_params_map();
let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default());
// imagine we're loading data from an API here
let name = move || match id().as_str() {
"alice" => "Alice",
"bob" => "Bob",
"steve" => "Steve",
_ => "User not found.",
};
view! {
<div class="contact-info">
<h4>{name}</h4>
<div class="tabs">
<A href="" exact=true>"Contact Info"</A>
<A href="conversations">"Conversations"</A>
</div>
// <Outlet/> here is the tabs that are nested
// underneath the /contacts/:id route
<Outlet/>
</div>
}
}
fn main() {
leptos::mount_to_body(App)
}
The <Form/>
Component
Links and forms sometimes seem completely unrelated. But, in fact, they work in very similar ways.
In plain HTML, there are three ways to navigate to another page:
- An
<a>
element that links to another page: Navigates to the URL in itshref
attribute with theGET
HTTP method. - A
<form method="GET">
: Navigates to the URL in itsaction
attribute with theGET
HTTP method and the form data from its inputs encoded in the URL query string. - A
<form method="POST">
: Navigates to the URL in itsaction
attribute with thePOST
HTTP method and the form data from its inputs encoded in the body of the request.
Since we have a client-side router, we can do client-side link navigations without reloading the page, i.e., without a full round-trip to the server and back. It makes sense that we can do client-side form navigations in the same way.
The router provides a <Form>
component, which works like the HTML <form>
element, but uses client-side navigations instead of full page reloads. <Form/>
works with both GET
and POST
requests. With method="GET"
, it will navigate to the URL encoded in the form data. With method="POST"
it will make a POST
request and handle the server’s response.
<Form/>
provides the basis for some components like <ActionForm/>
and <MultiActionForm/>
that we’ll see in later chapters. But it also enables some powerful patterns of its own.
For example, imagine that you want to create a search field that updates search results in real time as the user searches, without a page reload, but that also stores the search in the URL so a user can copy and paste it to share results with someone else.
It turns out that the patterns we’ve learned so far make this easy to implement.
async fn fetch_results() {
// some async function to fetch our search results
}
#[component]
pub fn FormExample() -> impl IntoView {
// reactive access to URL query strings
let query = use_query_map();
// search stored as ?q=
let search = move || query().get("q").cloned().unwrap_or_default();
// a resource driven by the search string
let search_results = create_resource(search, fetch_results);
view! {
<Form method="GET" action="">
<input type="search" name="q" value=search/>
<input type="submit"/>
</Form>
<Transition fallback=move || ()>
/* render search results */
</Transition>
}
}
Whenever you click Submit
, the <Form/>
will “navigate” to ?q={search}
. But because this navigation is done on the client side, there’s no page flicker or reload. The URL query string changes, which triggers search
to update. Because search
is the source signal for the search_results
resource, this triggers search_results
to reload its resource. The <Transition/>
continues displaying the current search results until the new ones have loaded. When they are complete, it switches to displaying the new result.
This is a great pattern. The data flow is extremely clear: all data flows from the URL to the resource into the UI. The current state of the application is stored in the URL, which means you can refresh the page or text the link to a friend and it will show exactly what you’re expecting. And once we introduce server rendering, this pattern will prove to be really fault-tolerant, too: because it uses a <form>
element and URLs under the hood, it actually works really well without even loading your WASM on the client.
We can actually take it a step further and do something kind of clever:
view! {
<Form method="GET" action="">
<input type="search" name="q" value=search
oninput="this.form.requestSubmit()"
/>
</Form>
}
You’ll notice that this version drops the Submit
button. Instead, we add an oninput
attribute to the input. Note that this is not on:input
, which would listen for the input
event and run some Rust code. Without the colon, oninput
is the plain HTML attribute. So the string is actually a JavaScript string. this.form
gives us the form the input is attached to. requestSubmit()
fires the submit
event on the <form>
, which is caught by <Form/>
just as if we had clicked a Submit
button. Now the form will “navigate” on every keystroke or input to keep the URL (and therefore the search) perfectly in sync with the user’s input as they type.
[Click to open CodeSandbox.](https://codesandbox.io/p/sandbox/20-form-0-5-9g7v9p?file=%2Fsrc%2Fmain.rs%3A1%2C1)
<noscript>
Please enable JavaScript to view examples.
</noscript>
<template>
<iframe src="https://codesandbox.io/p/sandbox/20-form-0-5-9g7v9p?file=%2Fsrc%2Fmain.rs%3A1%2C1" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>
CodeSandbox Source
use leptos::*;
use leptos_router::*;
#[component]
fn App() -> impl IntoView {
view! {
<Router>
<h1><code>"<Form/>"</code></h1>
<main>
<Routes>
<Route path="" view=FormExample/>
</Routes>
</main>
</Router>
}
}
#[component]
pub fn FormExample() -> impl IntoView {
// reactive access to URL query
let query = use_query_map();
let name = move || query().get("name").cloned().unwrap_or_default();
let number = move || query().get("number").cloned().unwrap_or_default();
let select = move || query().get("select").cloned().unwrap_or_default();
view! {
// read out the URL query strings
<table>
<tr>
<td><code>"name"</code></td>
<td>{name}</td>
</tr>
<tr>
<td><code>"number"</code></td>
<td>{number}</td>
</tr>
<tr>
<td><code>"select"</code></td>
<td>{select}</td>
</tr>
</table>
// <Form/> will navigate whenever submitted
<h2>"Manual Submission"</h2>
<Form method="GET" action="">
// input names determine query string key
<input type="text" name="name" value=name/>
<input type="number" name="number" value=number/>
<select name="select">
// `selected` will set which starts as selected
<option selected=move || select() == "A">
"A"
</option>
<option selected=move || select() == "B">
"B"
</option>
<option selected=move || select() == "C">
"C"
</option>
</select>
// submitting should cause a client-side
// navigation, not a full reload
<input type="submit"/>
</Form>
// This <Form/> uses some JavaScript to submit
// on every input
<h2>"Automatic Submission"</h2>
<Form method="GET" action="">
<input
type="text"
name="name"
value=name
// this oninput attribute will cause the
// form to submit on every input to the field
oninput="this.form.requestSubmit()"
/>
<input
type="number"
name="number"
value=number
oninput="this.form.requestSubmit()"
/>
<select name="select"
onchange="this.form.requestSubmit()"
>
<option selected=move || select() == "A">
"A"
</option>
<option selected=move || select() == "B">
"B"
</option>
<option selected=move || select() == "C">
"C"
</option>
</select>
// submitting should cause a client-side
// navigation, not a full reload
<input type="submit"/>
</Form>
}
}
fn main() {
leptos::mount_to_body(App)
}
Interlude: Styling
Anyone creating a website or application soon runs into the question of styling. For a small app, a single CSS file is probably plenty to style your user interface. But as an application grows, many developers find that plain CSS becomes increasingly hard to manage.
Some frontend frameworks (like Angular, Vue, and Svelte) provide built-in ways to scope your CSS to particular components, making it easier to manage styles across a whole application without styles meant to modify one small component having a global effect. Other frameworks (like React or Solid) don’t provide built-in CSS scoping, but rely on libraries in the ecosystem to do it for them. Leptos is in this latter camp: the framework itself has no opinions about CSS at all, but provides a few tools and primitives that allow others to build styling libraries.
Here are a few different approaches to styling your Leptos app, other than plain CSS.
TailwindCSS: Utility-first CSS
TailwindCSS is a popular utility-first CSS library. It allows you to style your application by using inline utility classes, with a custom CLI tool that scans your files for Tailwind class names and bundles the necessary CSS.
This allows you to write components like this:
#[component]
fn Home() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<main class="my-0 mx-auto max-w-3xl text-center">
<h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
<p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
<button
class="bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
on:click=move |_| set_count.update(|count| *count += 1)
>
{move || if count() == 0 {
"Click me!".to_string()
} else {
count().to_string()
}}
</button>
</main>
}
}
It can be a little complicated to set up the Tailwind integration at first, but you can check out our two examples of how to use Tailwind with a client-side-rendered trunk
application or with a server-rendered cargo-leptos
application. cargo-leptos
also has some built-in Tailwind support that you can use as an alternative to Tailwind’s CLI.
Stylers: Compile-time CSS Extraction
Stylers is a compile-time scoped CSS library that lets you declare scoped CSS in the body of your component. Stylers will extract this CSS at compile time into CSS files that you can then import into your app, which means that it doesn’t add anything to the WASM binary size of your application.
This allows you to write components like this:
use stylers::style;
#[component]
pub fn App() -> impl IntoView {
let styler_class = style! { "App",
#two{
color: blue;
}
div.one{
color: red;
content: raw_str(r#"\hello"#);
font: "1.3em/1.2" Arial, Helvetica, sans-serif;
}
div {
border: 1px solid black;
margin: 25px 50px 75px 100px;
background-color: lightblue;
}
h2 {
color: purple;
}
@media only screen and (max-width: 1000px) {
h3 {
background-color: lightblue;
color: blue
}
}
};
view! { class = styler_class,
<div class="one">
<h1 id="two">"Hello"</h1>
<h2>"World"</h2>
<h2>"and"</h2>
<h3>"friends!"</h3>
</div>
}
}
Stylance: Scoped CSS Written in CSS Files
Stylers lets you write CSS inline in your Rust code, extracts it at compile time, and scopes it. Stylance allows you to write your CSS in CSS files alongside your components, import those files into your components, and scope the CSS classes to your components.
This works well with the live-reloading features of trunk
and cargo-leptos
because edited CSS files can be updated immediately in the browser.
import_style!(style, "app.module.scss");
#[component]
fn HomePage() -> impl IntoView {
view! {
<div class=style::jumbotron/>
}
}
You can edit the CSS directly without causing a Rust recompile.
.jumbotron {
background: blue;
}
Styled: Runtime CSS Scoping
Styled is a runtime scoped CSS library that integrates well with Leptos. It lets you declare scoped CSS in the body of your component function, and then applies those styles at runtime.
use styled::style;
#[component]
pub fn MyComponent() -> impl IntoView {
let styles = style!(
div {
background-color: red;
color: white;
}
);
styled::view! { styles,
<div>"This text should be red with white text."</div>
}
}
Contributions Welcome
Leptos has no opinions on how you style your website or app, but we’re very happy to provide support to any tools you’re trying to create to make it easier. If you’re working on a CSS or styling approach that you’d like to add to this list, please let us know!
Metadata
So far, everything we’ve rendered has been inside the <body>
of the HTML document. And this makes sense. After all, everything you can see on a web page lives inside the <body>
.
However, there are plenty of occasions where you might want to update something inside the <head>
of the document using the same reactive primitives and component patterns you use for your UI.
That’s where the leptos_meta
package comes in.
Metadata Components
leptos_meta
provides special components that let you inject data from inside components anywhere in your application into the <head>
:
<Title/>
allows you to set the document’s title from any component. It also takes a formatter
function that can be used to apply the same format to the title set by other pages. So, for example, if you put <Title formatter=|text| format!("{text} — My Awesome Site")/>
in your <App/>
component, and then <Title text="Page 1"/>
and <Title text="Page 2"/>
on your routes, you’ll get Page 1 — My Awesome Site
and Page 2 — My Awesome Site
.
<Link/>
takes the standard attributes of the <link>
element.
<Stylesheet/>
creates a <link rel="stylesheet">
with the href
you give.
<Style/>
creates a <style>
with the children you pass in (usually a string). You can use this to import some custom CSS from another file at compile time <Style>{include_str!("my_route.css")}</Style>
.
<Meta/>
lets you set <meta>
tags with descriptions and other metadata.
<Script/>
and <script>
leptos_meta
also provides a <Script/>
component, and it’s worth pausing here for a second. All of the other components we’ve considered inject <head>
-only elements in the <head>
. But a <script>
can also be included in the body.
There’s a very simple way to determine whether you should use a capital-S <Script/>
component or a lowercase-s <script>
element: the <Script/>
component will be rendered in the <head>
, and the <script>
element will be rendered wherever in the <body>
of your user interface you put it in, alongside other normal HTML elements. These cause JavaScript to load and run at different times, so use whichever is appropriate to your needs.
<Body/>
and <Html/>
There are even a couple elements designed to make semantic HTML and styling easier. <Html/>
lets you set the lang
and dir
on your <html>
tag from your application code. <Html/>
and <Body/>
both have class
props that let you set their respective class
attributes, which is sometimes needed by CSS frameworks for styling.
<Body/>
and <Html/>
both also have attributes
props which can be used to set any number of additional attributes on them via the attr:
syntax:
<Html
lang="he"
dir="rtl"
attr:data-theme="dark"
/>
Metadata and Server Rendering
Now, some of this is useful in any scenario, but some of it is especially important for search-engine optimization (SEO). Making sure you have things like appropriate <title>
and <meta>
tags is crucial. Modern search engine crawlers do handle client-side rendering, i.e., apps that are shipped as an empty index.html
and rendered entirely in JS/WASM. But they prefer to receive pages in which your app has been rendered to actual HTML, with metadata in the <head>
.
This is exactly what leptos_meta
is for. And in fact, during server rendering, this is exactly what it does: collect all the <head>
content you’ve declared by using its components throughout your application, and then inject it into the actual <head>
.
But I’m getting ahead of myself. We haven’t actually talked about server-side rendering yet. The next chapter will talk about integrating with JavaScript libraries. Then we’ll wrap up the discussion of the client side, and move onto server side rendering.
Wrapping Up Part 1: Client-Side Rendering
So far, everything we’ve written has been rendered almost entirely in the browser. When we create an app using Trunk, it’s served using a local development server. If you build it for production and deploy it, it’s served by whatever server or CDN you’re using. In either case, what’s served is an HTML page with
- the URL of your Leptos app, which has been compiled to WebAssembly (WASM)
- the URL of the JavaScript used to initialize this WASM blob
- an empty
<body>
element
When the JS and WASM have loaded, Leptos will render your app into the <body>
. This means that nothing appears on the screen until JS/WASM have loaded and run. This has some drawbacks:
- It increases load time, as your user’s screen is blank until additional resources have been downloaded.
- It’s bad for SEO, as load times are longer and the HTML you serve has no meaningful content.
- It’s broken for users for whom JS/WASM don’t load for some reason (e.g., they’re on a train and just went into a tunnel before WASM finished loading; they’re using an older device that doesn’t support WASM; they have JavaScript or WASM turned off for some reason; etc.)
These downsides apply across the web ecosystem, but especially to WASM apps.
However, depending on the requirements of your project, you may be fine with these limitations.
If you just want to deploy your Client-Side Rendered website, skip ahead to the chapter on "Deployment" - there, you'll find directions on how best to deploy your Leptos CSR site.
But what do you do if you want to return more than just an empty <body>
tag in your index.html
page? Use “Server-Side Rendering”!
Whole books could be (and probably have been) written about this topic, but at its core, it’s really simple: rather than returning an empty <body>
tag, with SSR, you'll return an initial HTML page that reflects the actual starting state of your app or site, so that while JS/WASM are loading, and until they load, the user can access the plain HTML version.
Part 2 of this book, on Leptos SSR, will cover this topic in some detail!
Part 2: Server Side Rendering
The second part of the book is all about how to turn your beautiful UIs into full-stack Rust + Leptos powered websites and applications.
As you read in the last chapter, there are some limitations to using client-side rendered Leptos apps - over the next few chapters, you'll see how we can overcome those limitations and get the best performance and SEO out of your Leptos apps.
When working with Leptos on the server side, you're free to choose either the Actix-web or the Axum integrations - the full feature set of Leptos is available with either option.
If, however, you need deploy to a WinterCG-compatible runtime like Deno, Cloudflare, etc., then choose the Axum integration as this deployment option is only available with Axum on the server. Lastly, if you'd like to go full-stack WASM/WASI and deploy to WASM-based serverless runtimes, then Axum is your go-to choice here too.
NB: this is a limitation of the web frameworks themselves, not Leptos.
Introducing cargo-leptos
So far, we’ve just been running code in the browser and using Trunk to coordinate the build process and run a local development process. If we’re going to add server-side rendering, we’ll need to run our application code on the server as well. This means we’ll need to build two separate binaries, one compiled to native code and running the server, the other compiled to WebAssembly (WASM) and running in the user’s browser. Additionally, the server needs to know how to serve this WASM version (and the JavaScript required to initialize it) to the browser.
This is not an insurmountable task but it adds some complication. For convenience and an easier developer experience, we built the cargo-leptos
build tool. cargo-leptos
basically exists to coordinate the build process for your app, handling recompiling the server and client halves when you make changes, and adding some built-in support for things like Tailwind, SASS, and testing.
Getting started is pretty easy. Just run
cargo install cargo-leptos
And then to create a new project, you can run either
# for an Actix template
cargo leptos new --git leptos-rs/start
or
# for an Axum template
cargo leptos new --git leptos-rs/start-axum
Make sure you've added the wasm32-unknown-unknown target so that Rust can compile your code to WebAssembly to run in the browser.
rustup target add wasm32-unknown-unknown
Now cd
into the directory you’ve created and run
cargo leptos watch
Note: Remember that Leptos has a
nightly
feature, which each of these starters use. If you're using the stable Rust compiler, that’s fine; just remove thenightly
feature from each of the Leptos dependencies in your newCargo.toml
and you should be all set.
Once your app has compiled you can open up your browser to http://localhost:3000
to see it.
cargo-leptos
has lots of additional features and built in tools. You can learn more in its README
.
But what exactly is happening when you open our browser to localhost:3000
? Well, read on to find out.
The Life of a Page Load
Before we get into the weeds it might be helpful to have a higher-level overview. What exactly happens between the moment you type in the URL of a server-rendered Leptos app, and the moment you click a button and a counter increases?
I’m assuming some basic knowledge of how the Internet works here, and won’t get into the weeds about HTTP or whatever. Instead, I’ll try to show how different parts of the Leptos APIs map onto each part of the process.
This description also starts from the premise that your app is being compiled for two separate targets:
- A server version, often running on Actix or Axum, compiled with the Leptos
ssr
feature - A browser version, compiled to WebAssembly (WASM) with the Leptos
hydrate
feature
The cargo-leptos
build tool exists to coordinate the process of compiling your app for these two different targets.
On the Server
- Your browser makes a
GET
request for that URL to your server. At this point, the browser knows almost nothing about the page that’s going to be rendered. (The question “How does the browser know where to ask for the page?” is an interesting one, but out of the scope of this tutorial!) - The server receives that request, and checks whether it has a way to handle a
GET
request at that path. This is what the.leptos_routes()
methods inleptos_axum
andleptos_actix
are for. When the server starts up, these methods walk over the routing structure you provide in<Routes/>
, generating a list of all possible routes your app can handle and telling the server’s router “for each of these routes, if you get a request... hand it off to Leptos.” - The server sees that this route can be handled by Leptos. So it renders your root component (often called something like
<App/>
), providing it with the URL that’s being requested and some other data like the HTTP headers and request metadata. - Your application runs once on the server, building up an HTML version of the component tree that will be rendered at that route. (There’s more to be said here about resources and
<Suspense/>
in the next chapter.) - The server returns this HTML page, also injecting information on how to load the version of your app that has been compiled to WASM so that it can run in the browser.
The HTML page that’s returned is essentially your app, “dehydrated” or “freeze-dried”: it is HTML without any of the reactivity or event listeners you’ve added. The browser will “rehydrate” this HTML page by adding the reactive system and attaching event listeners to that server-rendered HTML. Hence the two feature flags that apply to the two halves of this process:
ssr
on the server for “server-side rendering”, andhydrate
in the browser for that process of rehydration.
In the Browser
- The browser receives this HTML page from the server. It immediately goes back to the server to begin loading the JS and WASM necessary to run the interactive, client side version of the app.
- In the meantime, it renders the HTML version.
- When the WASM version has reloaded, it does the same route-matching process that the server did. Because the
<Routes/>
component is identical on the server and in the client, the browser version will read the URL and render the same page that was already returned by the server. - During this initial “hydration” phase, the WASM version of your app doesn’t re-create the DOM nodes that make up your application. Instead, it walks over the existing HTML tree, “picking up” existing elements and adding the necessary interactivity.
Note that there are some trade-offs here. Before this hydration process is complete, the page will appear interactive but won’t actually respond to interactions. For example, if you have a counter button and click it before WASM has loaded, the count will not increment, because the necessary event listeners and reactivity have not been added yet. We’ll look at some ways to build in “graceful degradation” in future chapters.
Client-Side Navigation
The next step is very important. Imagine that the user now clicks a link to navigate to another page in your application.
The browser will not make another round trip to the server, reloading the full page as it would for navigating between plain HTML pages or an application that uses server rendering (for example with PHP) but without a client-side half.
Instead, the WASM version of your app will load the new page, right there in the browser, without requesting another page from the server. Essentially, your app upgrades itself from a server-loaded “multi-page app” into a browser-rendered “single-page app.” This yields the best of both worlds: a fast initial load time due to the server-rendered HTML, and fast secondary navigations because of the client-side routing.
Some of what will be described in the following chapters—like the interactions between server functions, resources, and <Suspense/>
—may seem overly complicated. You might find yourself asking, “If my page is being rendered to HTML on the server, why can’t I just .await
this on the server? If I can just call library X in a server function, why can’t I call it in my component?” The reason is pretty simple: to enable the upgrade from server rendering to client rendering, everything in your application must be able to run either on the server or in the browser.
This is not the only way to create a website or web framework, of course. But it’s the most common way, and we happen to think it’s quite a good way, to create the smoothest possible experience for your users.
Async Rendering and SSR “Modes”
Server-rendering a page that uses only synchronous data is pretty simple: You just walk down the component tree, rendering each element to an HTML string. But this is a pretty big caveat: it doesn’t answer the question of what we should do with pages that includes asynchronous data, i.e., the sort of stuff that would be rendered under a <Suspense/>
node on the client.
When a page loads async data that it needs to render, what should we do? Should we wait for all the async data to load, and then render everything at once? (Let’s call this “async” rendering) Should we go all the way in the opposite direction, just sending the HTML we have immediately down to the client and letting the client load the resources and fill them in? (Let’s call this “synchronous” rendering) Or is there some middle-ground solution that somehow beats them both? (Hint: There is.)
If you’ve ever listened to streaming music or watched a video online, I’m sure you realize that HTTP supports streaming, allowing a single connection to send chunks of data one after another without waiting for the full content to load. You may not realize that browsers are also really good at rendering partial HTML pages. Taken together, this means that you can actually enhance your users’ experience by streaming HTML: and this is something that Leptos supports out of the box, with no configuration at all. And there’s actually more than one way to stream HTML: you can stream the chunks of HTML that make up your page in order, like frames of a video, or you can stream them... well, out of order.
Let me say a little more about what I mean.
Leptos supports all the major ways of rendering HTML that includes asynchronous data:
- Synchronous Rendering
- Async Rendering
- In-Order streaming
- Out-of-Order Streaming (and a partially-blocked variant)
Synchronous Rendering
- Synchronous: Serve an HTML shell that includes
fallback
for any<Suspense/>
. Load data on the client usingcreate_local_resource
, replacingfallback
once resources are loaded.
- Pros: App shell appears very quickly: great TTFB (time to first byte).
- Cons
- Resources load relatively slowly; you need to wait for JS + WASM to load before even making a request.
- No ability to include data from async resources in the
<title>
or other<meta>
tags, hurting SEO and things like social media link previews.
If you’re using server-side rendering, the synchronous mode is almost never what you actually want, from a performance perspective. This is because it misses out on an important optimization. If you’re loading async resources during server rendering, you can actually begin loading the data on the server. Rather than waiting for the client to receive the HTML response, then loading its JS + WASM, then realize it needs the resources and begin loading them, server rendering can actually begin loading the resources when the client first makes the response. In this sense, during server rendering an async resource is like a Future
that begins loading on the server and resolves on the client. As long as the resources are actually serializable, this will always lead to a faster total load time.
This is why
create_resource
requires resources data to be serializable by default, and why you need to explicitly usecreate_local_resource
for any async data that is not serializable and should therefore only be loaded in the browser itself. Creating a local resource when you could create a serializable resource is always a deoptimization.
Async Rendering
async
: Load all resources on the server. Wait until all data are loaded, and render HTML in one sweep.
- Pros: Better handling for meta tags (because you know async data even before you render the
<head>
). Faster complete load than synchronous because async resources begin loading on server. - Cons: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client. The page is totally blank until everything is loaded.
In-Order Streaming
- In-order streaming: Walk through the component tree, rendering HTML until you hit a
<Suspense/>
. Send down all the HTML you’ve got so far as a chunk in the stream, wait for all the resources accessed under the<Suspense/>
to load, then render it to HTML and keep walking until you hit another<Suspense/>
or the end of the page.
- Pros: Rather than a blank screen, shows at least something before the data are ready.
- Cons
- Loads the shell more slowly than synchronous rendering (or out-of-order streaming) because it needs to pause at every
<Suspense/>
. - Unable to show fallback states for
<Suspense/>
. - Can’t begin hydration until the entire page has loaded, so earlier pieces of the page will not be interactive until the suspended chunks have loaded.
- Loads the shell more slowly than synchronous rendering (or out-of-order streaming) because it needs to pause at every
Out-of-Order Streaming
- Out-of-order streaming: Like synchronous rendering, serve an HTML shell that includes
fallback
for any<Suspense/>
. But load data on the server, streaming it down to the client as it resolves, and streaming down HTML for<Suspense/>
nodes, which is swapped in to replace the fallback.
- Pros: Combines the best of synchronous and
async
.- Fast initial response/TTFB because it immediately sends the whole synchronous shell
- Fast total time because resources begin loading on the server.
- Able to show the fallback loading state and dynamically replace it, instead of showing blank sections for un-loaded data.
- Cons: Requires JavaScript to be enabled for suspended fragments to appear in correct order. (This small chunk of JS streamed down in a
<script>
tag alongside the<template>
tag that contains the rendered<Suspense/>
fragment, so it does not need to load any additional JS files.)
- Partially-blocked streaming: “Partially-blocked” streaming is useful when you have multiple separate
<Suspense/>
components on the page. It is triggered by settingssr=SsrMode::PartiallyBlocked
on a route, and depending on blocking resources within the view. If one of the<Suspense/>
components reads from one or more “blocking resources” (see below), the fallback will not be sent; rather, the server will wait until that<Suspense/>
has resolved and then replace the fallback with the resolved fragment on the server, which means that it is included in the initial HTML response and appears even if JavaScript is disabled or not supported. Other<Suspense/>
stream in out of order, similar to theSsrMode::OutOfOrder
default.
This is useful when you have multiple <Suspense/>
on the page, and one is more important than the other: think of a blog post and comments, or product information and reviews. It is not useful if there’s only one <Suspense/>
, or if every <Suspense/>
reads from blocking resources. In those cases it is a slower form of async
rendering.
- Pros: Works if JavaScript is disabled or not supported on the user’s device.
- Cons
- Slower initial response time than out-of-order.
- Marginally overall response due to additional work on the server.
- No fallback state shown.
Using SSR Modes
Because it offers the best blend of performance characteristics, Leptos defaults to out-of-order streaming. But it’s really simple to opt into these different modes. You do it by adding an ssr
property onto one or more of your <Route/>
components, like in the ssr_modes
example.
<Routes>
// We’ll load the home page with out-of-order streaming and <Suspense/>
<Route path="" view=HomePage/>
// We'll load the posts with async rendering, so they can set
// the title and metadata *after* loading the data
<Route
path="/post/:id"
view=Post
ssr=SsrMode::Async
/>
</Routes>
For a path that includes multiple nested routes, the most restrictive mode will be used: i.e., if even a single nested route asks for async
rendering, the whole initial request will be rendered async
. async
is the most restricted requirement, followed by in-order, and then out-of-order. (This probably makes sense if you think about it for a few minutes.)
Blocking Resources
Any Leptos versions later than 0.2.5
(i.e., git main and 0.3.x
or later) introduce a new resource primitive with create_blocking_resource
. A blocking resource still loads asynchronously like any other async
/.await
in Rust; it doesn’t block a server thread or anything. Instead, reading from a blocking resource under a <Suspense/>
blocks the HTML stream from returning anything, including its initial synchronous shell, until that <Suspense/>
has resolved.
Now from a performance perspective, this is not ideal. None of the synchronous shell for your page will load until that resource is ready. However, rendering nothing means that you can do things like set the <title>
or <meta>
tags in your <head>
in actual HTML. This sounds a lot like async
rendering, but there’s one big difference: if you have multiple <Suspense/>
sections, you can block on one of them but still render a placeholder and then stream in the other.
For example, think about a blog post. For SEO and for social sharing, I definitely want my blog post’s title and metadata in the initial HTML <head>
. But I really don’t care whether comments have loaded yet or not; I’d like to load those as lazily as possible.
With blocking resources, I can do something like this:
#[component]
pub fn BlogPost() -> impl IntoView {
let post_data = create_blocking_resource(/* load blog post */);
let comments_data = create_resource(/* load blog comments */);
view! {
<Suspense fallback=|| ()>
{move || {
post_data.with(|data| {
view! {
<Title text=data.title/>
<Meta name="description" content=data.excerpt/>
<article>
/* render the post content */
</article>
}
})
}}
</Suspense>
<Suspense fallback=|| "Loading comments...">
/* render comments data here */
</Suspense>
}
}
The first <Suspense/>
, with the body of the blog post, will block my HTML stream, because it reads from a blocking resource. Meta tags and other head elements awaiting the blocking resource will be rendered before the stream is sent.
Combined with the following route definition, which uses SsrMode::PartiallyBlocked
, the blocking resource will be fully rendered on the server side, making it accessible to users who disable WebAssembly or JavaScript.
<Routes>
// We’ll load the home page with out-of-order streaming and <Suspense/>
<Route path="" view=HomePage/>
// We'll load the posts with async rendering, so they can set
// the title and metadata *after* loading the data
<Route
path="/post/:id"
view=Post
ssr=SsrMode::PartiallyBlocked
/>
</Routes>
The second <Suspense/>
, with the comments, will not block the stream. Blocking resources gave me exactly the power and granularity I needed to optimize my page for SEO and user experience.
Hydration Bugs (and how to avoid them)
A Thought Experiment
Let’s try an experiment to test your intuitions. Open up an app you’re server-rendering with cargo-leptos
. (If you’ve just been using trunk
so far to play with examples, go clone a cargo-leptos
template just for the sake of this exercise.)
Put a log somewhere in your root component. (I usually call mine <App/>
, but anything will do.)
#[component]
pub fn App() -> impl IntoView {
logging::log!("where do I run?");
// ... whatever
}
And let’s fire it up
cargo leptos watch
Where do you expect where do I run?
to log?
- In the command line where you’re running the server?
- In the browser console when you load the page?
- Neither?
- Both?
Try it out.
...
...
...
Okay, consider the spoiler alerted.
You’ll notice of course that it logs in both places, assuming everything goes according to plan. In fact on the server it logs twice—first during the initial server startup, when Leptos renders your app once to extract the route tree, then a second time when you make a request. Each time you reload the page, where do I run?
should log once on the server and once on the client.
If you think about the description in the last couple sections, hopefully this makes sense. Your application runs once on the server, where it builds up a tree of HTML which is sent to the client. During this initial render, where do I run?
logs on the server.
Once the WASM binary has loaded in the browser, your application runs a second time, walking over the same user interface tree and adding interactivity.
Does that sound like a waste? It is, in a sense. But reducing that waste is a genuinely hard problem. It’s what some JS frameworks like Qwik are intended to solve, although it’s probably too early to tell whether it’s a net performance gain as opposed to other approaches.
The Potential for Bugs
Okay, hopefully all of that made sense. But what does it have to do with the title of this chapter, which is “Hydration bugs (and how to avoid them)”?
Remember that the application needs to run on both the server and the client. This generates a few different sets of potential issues you need to know how to avoid.
Mismatches between server and client code
One way to create a bug is by creating a mismatch between the HTML that’s sent down by the server and what’s rendered on the client. It’s actually fairly hard to do this unintentionally, I think (at least judging by the bug reports I get from people.) But imagine I do something like this
#[component]
pub fn App() -> impl IntoView {
let data = if cfg!(target_arch = "wasm32") {
vec![0, 1, 2]
} else {
vec![]
};
data.into_iter()
.map(|value| view! { <span>{value}</span> })
.collect_view()
}
In other words, if this is being compiled to WASM, it has three items; otherwise it’s empty.
When I load the page in the browser, I see nothing. If I open the console I see a bunch of warnings:
element with id 0-3 not found, ignoring it for hydration
element with id 0-4 not found, ignoring it for hydration
element with id 0-5 not found, ignoring it for hydration
component with id _0-6c not found, ignoring it for hydration
component with id _0-6o not found, ignoring it for hydration
The WASM version of your app, running in the browser, expects to find three items; but the HTML has none.
Solution
It’s pretty rare that you do this intentionally, but it could happen from somehow running different logic on the server and in the browser. If you’re seeing warnings like this and you don’t think it’s your fault, it’s much more likely that it’s a bug with <Suspense/>
or something. Feel free to go ahead and open an issue or discussion on GitHub for help.
Not all client code can run on the server
Imagine you happily import a dependency like gloo-net
that you’ve been used to using to make requests in the browser, and use it in a create_resource
in a server-rendered app.
You’ll probably instantly see the dreaded message
panicked at 'cannot call wasm-bindgen imported functions on non-wasm targets'
Uh-oh.
But of course this makes sense. We’ve just said that your app needs to run on the client and the server.
Solution
There are a few ways to avoid this:
- Only use libraries that can run on both the server and the client.
reqwest
, for example, works for making HTTP requests in both settings. - Use different libraries on the server and the client, and gate them using the
#[cfg]
macro. (Click here for an example.) - Wrap client-only code in
create_effect
. Becausecreate_effect
only runs on the client, this can be an effective way to access browser APIs that are not needed for initial rendering.
For example, say that I want to store something in the browser’s localStorage
whenever a signal changes.
#[component]
pub fn App() -> impl IntoView {
use gloo_storage::Storage;
let storage = gloo_storage::LocalStorage::raw();
logging::log!("{storage:?}");
}
This panics because I can’t access LocalStorage
during server rendering.
But if I wrap it in an effect...
#[component]
pub fn App() -> impl IntoView {
use gloo_storage::Storage;
create_effect(move |_| {
let storage = gloo_storage::LocalStorage::raw();
logging::log!("{storage:?}");
});
}
It’s fine! This will render appropriately on the server, ignoring the client-only code, and then access the storage and log a message on the browser.
Not all server code can run on the client
WebAssembly running in the browser is a pretty limited environment. You don’t have access to a file-system or to many of the other things the standard library may be used to having. Not every crate can even be compiled to WASM, let alone run in a WASM environment.
In particular, you’ll sometimes see errors about the crate mio
or missing things from core
. This is generally a sign that you are trying to compile something to WASM that can’t be compiled to WASM. If you’re adding server-only dependencies, you’ll want to mark them optional = true
in your Cargo.toml
and then enable them in the ssr
feature definition. (Check out one of the template Cargo.toml
files to see more details.)
You can use create_effect
to specify that something should only run on the client, and not in the server. Is there a way to specify that something should run only on the server, and not the client?
In fact, there is. The next chapter will cover the topic of server functions in some detail. (In the meantime, you can check out their docs here.)
Working with the Server
The previous section described the process of server-side rendering, using the server to generate an HTML version of the page that will become interactive in the browser. So far, everything has been “isomorphic”; in other words, your app has had the “same (iso) shape (morphe)” on the client and the server.
But a server can do a lot more than just render HTML! In fact, a server can do a whole bunch of things your browser can’t, like reading from and writing to a SQL database.
If you’re used to building JavaScript frontend apps, you’re probably used to calling out to some kind of REST API to do this sort of server work. If you’re used to building sites with PHP or Python or Ruby (or Java or C# or...), this server-side work is your bread and butter, and it’s the client-side interactivity that tends to be an afterthought.
With Leptos, you can do both: not only in the same language, not only sharing the same types, but even in the same files!
This section will talk about how to build the uniquely-server-side parts of your application.
Server Functions
If you’re creating anything beyond a toy app, you’ll need to run code on the server all the time: reading from or writing to a database that only runs on the server, running expensive computations using libraries you don’t want to ship down to the client, accessing APIs that need to be called from the server rather than the client for CORS reasons or because you need a secret API key that’s stored on the server and definitely shouldn’t be shipped down to a user’s browser.
Traditionally, this is done by separating your server and client code, and by setting up something like a REST API or GraphQL API to allow your client to fetch and mutate data on the server. This is fine, but it requires you to write and maintain your code in multiple separate places (client-side code for fetching, server-side functions to run), as well as creating a third thing to manage, which is the API contract between the two.
Leptos is one of a number of modern frameworks that introduce the concept of server functions. Server functions have two key characteristics:
- Server functions are co-located with your component code, so that you can organize your work by feature, not by technology. For example, you might have a “dark mode” feature that should persist a user’s dark/light mode preference across sessions, and be applied during server rendering so there’s no flicker. This requires a component that needs to be interactive on the client, and some work to be done on the server (setting a cookie, maybe even storing a user in a database.) Traditionally, this feature might end up being split between two different locations in your code, one in your “frontend” and one in your “backend.” With server functions, you’ll probably just write them both in one
dark_mode.rs
and forget about it. - Server functions are isomorphic, i.e., they can be called either from the server or the browser. This is done by generating code differently for the two platforms. On the server, a server function simply runs. In the browser, the server function’s body is replaced with a stub that actually makes a fetch request to the server, serializing the arguments into the request and deserializing the return value from the response. But on either end, the function can simply be called: you can create an
add_todo
function that writes to your database, and simply call it from a click handler on a button in the browser!
Using Server Functions
Actually, I kind of like that example. What would it look like? It’s pretty simple, actually.
// todo.rs
#[server(AddTodo, "/api")]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
let mut conn = db().await?;
match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
.bind(title)
.execute(&mut conn)
.await
{
Ok(_row) => Ok(()),
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[component]
pub fn BusyButton() -> impl IntoView {
view! {
<button on:click=move |_| {
spawn_local(async {
add_todo("So much to do!".to_string()).await;
});
}>
"Add Todo"
</button>
}
}
You’ll notice a couple things here right away:
- Server functions can use server-only dependencies, like
sqlx
, and can access server-only resources, like our database. - Server functions are
async
. Even if they only did synchronous work on the server, the function signature would still need to beasync
, because calling them from the browser must be asynchronous. - Server functions return
Result<T, ServerFnError>
. Again, even if they only do infallible work on the server, this is true, becauseServerFnError
’s variants include the various things that can be wrong during the process of making a network request. - Server functions can be called from the client. Take a look at our click handler. This is code that will only ever run on the client. But it can call the function
add_todo
(usingspawn_local
to run theFuture
) as if it were an ordinary async function:
move |_| {
spawn_local(async {
add_todo("So much to do!".to_string()).await;
});
}
- Server functions are top-level functions defined with
fn
. Unlike event listeners, derived signals, and most everything else in Leptos, they are not closures! Asfn
calls, they have no access to the reactive state of your app or anything else that is not passed in as an argument. And again, this makes perfect sense: When you make a request to the server, the server doesn’t have access to client state unless you send it explicitly. (Otherwise we’d have to serialize the whole reactive system and send it across the wire with every request, which—while it served classic ASP for a while—is a really bad idea.) - Server function arguments and return values both need to be serializable with
serde
. Again, hopefully this makes sense: while function arguments in general don’t need to be serialized, calling a server function from the browser means serializing the arguments and sending them over HTTP.
There are a few things to note about the way you define a server function, too.
- Server functions are created by using the
#[server]
macro to annotate a top-level function, which can be defined anywhere. - We provide the macro a type name. The type name is used internally as a container to hold, serialize, and deserialize the arguments.
- We provide the macro a path. This is a prefix for the path at which we’ll mount a server function handler on our server. (See examples for Actix and Axum.)
- You’ll need to have
serde
as a dependency with thederive
featured enabled for the macro to work properly. You can easily add it toCargo.toml
withcargo add serde --features=derive
.
Server Function URL Prefixes
You can optionally define a specific URL prefix to be used in the definition of the server function.
This is done by providing an optional 2nd argument to the #[server]
macro.
By default the URL prefix will be /api
, if not specified.
Here are some examples:
#[server(AddTodo)] // will use the default URL prefix of `/api`
#[server(AddTodo, "/foo")] // will use the URL prefix of `/foo`
Server Function Encodings
By default, the server function call is a POST
request that serializes the arguments as URL-encoded form data in the body of the request. (This means that server functions can be called from HTML forms, which we’ll see in a future chapter.) But there are a few other methods supported. Optionally, we can provide another argument to the #[server]
macro to specify an alternate encoding:
#[server(AddTodo, "/api", "Url")]
#[server(AddTodo, "/api", "GetJson")]
#[server(AddTodo, "/api", "Cbor")]
#[server(AddTodo, "/api", "GetCbor")]
The four options use different combinations of HTTP verbs and encoding methods:
Name | Method | Request | Response |
---|---|---|---|
Url (default) | POST | URL encoded | JSON |
GetJson | GET | URL encoded | JSON |
Cbor | POST | CBOR | CBOR |
GetCbor | GET | URL encoded | CBOR |
In other words, you have two choices:
GET
orPOST
? This has implications for things like browser or CDN caching; whilePOST
requests should not be cached,GET
requests can be.- Plain text (arguments sent with URL/form encoding, results sent as JSON) or a binary format (CBOR, encoded as a base64 string)?
But remember: Leptos will handle all the details of this encoding and decoding for you. When you use a server function, it looks just like calling any other asynchronous function!
Why not
PUT
orDELETE
? Why URL/form encoding, and not JSON?These are reasonable questions. Much of the web is built on REST API patterns that encourage the use of semantic HTTP methods like
DELETE
to delete an item from a database, and many devs are accustomed to sending data to APIs in the JSON format.The reason we use
POST
orGET
with URL-encoded data by default is the<form>
support. For better or for worse, HTML forms don’t supportPUT
orDELETE
, and they don’t support sending JSON. This means that if you use anything but aGET
orPOST
request with URL-encoded data, it can only work once WASM has loaded. As we’ll see in a later chapter, this isn’t always a great idea.The CBOR encoding is supported for historical reasons; an earlier version of server functions used a URL encoding that didn’t support nested objects like structs or vectors as server function arguments, which CBOR did. But note that the CBOR forms encounter the same issue as
PUT
,DELETE
, or JSON: they do not degrade gracefully if the WASM version of your app is not available.
Server Functions Endpoint Paths
By default, a unique path will be generated. You can optionally define a specific endpoint path to be used in the URL. This is done by providing an optional 4th argument to the #[server]
macro. Leptos will generate the complete path by concatenating the URL prefix (2nd argument) and the endpoint path (4th argument).
For example,
#[server(MyServerFnType, "/api", "Url", "hello")]
will generate a server function endpoint at /api/hello
that accepts a POST request.
Can I use the same server function endpoint path with multiple encodings?
No. Different server functions must have unique paths. The
#[server]
macro automatically generates unique paths, but you need to be careful if you choose to specify the complete path manually, as the server looks up server functions by their path.
An Important Note on Security
Server functions are a cool technology, but it’s very important to remember. Server functions are not magic; they’re syntax sugar for defining a public API. The body of a server function is never made public; it’s just part of your server binary. But the server function is a publicly accessible API endpoint, and its return value is just a JSON or similar blob. Do not return information from a server function unless it is public, or you've implemented proper security procedures. These procedures might include authenticating incoming requests, ensuring proper encryption, rate limiting access, and more.
Integrating Server Functions with Leptos
So far, everything I’ve said is actually framework agnostic. (And in fact, the Leptos server function crate has been integrated into Dioxus as well!) Server functions are simply a way of defining a function-like RPC call that leans on Web standards like HTTP requests and URL encoding.
But in a way, they also provide the last missing primitive in our story so far. Because a server function is just a plain Rust async function, it integrates perfectly with the async Leptos primitives we discussed earlier. So you can easily integrate your server functions with the rest of your applications:
- Create resources that call the server function to load data from the server
- Read these resources under
<Suspense/>
or<Transition/>
to enable streaming SSR and fallback states while data loads. - Create actions that call the server function to mutate data on the server
The final section of this book will make this a little more concrete by introducing patterns that use progressively-enhanced HTML forms to run these server actions.
But in the next few chapters, we’ll actually take a look at some of the details of what you might want to do with your server functions, including the best ways to integrate with the powerful extractors provided by the Actix and Axum server frameworks.
Extractors
The server functions we looked at in the last chapter showed how to run code on the server, and integrate it with the user interface you’re rendering in the browser. But they didn’t show you much about how to actually use your server to its full potential.
Server Frameworks
We call Leptos a “full-stack” framework, but “full-stack” is always a misnomer (after all, it never means everything from the browser to your power company.) For us, “full stack” means that your Leptos app can run in the browser, and can run on the server, and can integrate the two, drawing together the unique features available in each; as we’ve seen in the book so far, a button click on the browser can drive a database read on the server, both written in the same Rust module. But Leptos itself doesn’t provide the server (or the database, or the operating system, or the firmware, or the electrical cables...)
Instead, Leptos provides integrations for the two most popular Rust web server frameworks, Actix Web (leptos_actix
) and Axum (leptos_axum
). We’ve built integrations with each server’s router so that you can simply plug your Leptos app into an existing server with .leptos_routes()
, and easily handle server function calls.
If you haven’t seen our Actix and Axum templates, now’s a good time to check them out.
Using Extractors
Both Actix and Axum handlers are built on the same powerful idea of extractors. Extractors “extract” typed data from an HTTP request, allowing you to access server-specific data easily.
Leptos provides extract
helper functions to let you use these extractors directly in your server functions, with a convenient syntax very similar to handlers for each framework.
Actix Extractors
The extract
function in leptos_actix
takes a handler function as its argument. The handler follows similar rules to an Actix handler: it is an async function that receives arguments that will be extracted from the request and returns some value. The handler function receives that extracted data as its arguments, and can do further async
work on them inside the body of the async move
block. It returns whatever value you return back out into the server function.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct MyQuery {
foo: String,
}
#[server]
pub async fn actix_extract() -> Result<String, ServerFnError> {
use actix_web::dev::ConnectionInfo;
use actix_web::web::{Data, Query};
use leptos_actix::extract;
let (Query(search), connection): (Query<MyQuery>, ConnectionInfo) = extract().await?;
Ok(format!("search = {search:?}\nconnection = {connection:?}",))
}
Axum Extractors
The syntax for the leptos_axum::extract
function is very similar.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct MyQuery {
foo: String,
}
#[server]
pub async fn axum_extract() -> Result<String, ServerFnError> {
use axum::{extract::Query, http::Method};
use leptos_axum::extract;
let (method, query): (Method, Query<MyQuery>) = extract().await?;
Ok(format!("{method:?} and {query:?}"))
}
These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same extract()
pattern.
The Axum extract
function only supports extractors for which the state is ()
. If you need an extractor that uses State
, you should use extract_with_state
. This requires you to provide the state. You can do this by extending the existing LeptosOptions
state using the Axum FromRef
pattern, which providing the state as context during render and server functions with custom handlers.
use axum::extract::FromRef;
/// Derive FromRef to allow multiple items in state, using Axum’s
/// SubStates pattern.
#[derive(FromRef, Debug, Clone)]
pub struct AppState{
pub leptos_options: LeptosOptions,
pub pool: SqlitePool
}
Click here for an example of providing context in custom handlers.
Axum State
Axum's typical pattern for dependency injection is to provide a State
, which can then be extracted in your route handler. Leptos provides its own method of dependency injection via context. Context can often be used instead of State
to provide shared server data (for example, a database connection pool).
let connection_pool = /* some shared state here */;
let app = Router::new()
.leptos_routes_with_context(
&app_state,
routes,
move || provide_context(connection_pool.clone()),
App,
)
// etc.
This context can then be accessed with a simple use_context::<T>()
inside your server functions.
If you need to use State
in a server function—for example, if you have an existing Axum extractor that requires State
—that is also possible using Axum's FromRef
pattern and extract_with_state
. Essentially you'll need to provide the state both via context and via Axum router state:
#[derive(FromRef, Debug, Clone)]
pub struct MyData {
pub value: usize,
pub leptos_options: LeptosOptions,
}
let app_state = MyData {
value: 42,
leptos_options,
};
// build our application with a route
let app = Router::new()
.leptos_routes_with_context(
&app_state,
routes,
{
let app_state = app_state.clone();
move || provide_context(app_state.clone())
},
App,
)
.fallback(file_and_error_handler)
.with_state(app_state);
// ...
#[server]
pub async fn uses_state() -> Result<(), ServerFnError> {
let state = expect_context::<AppState>();
let SomeStateExtractor(data) = extract_with_state(&state).await?;
// todo
}
A Note about Data-Loading Patterns
Because Actix and (especially) Axum are built on the idea of a single round-trip HTTP request and response, you typically run extractors near the “top” of your application (i.e., before you start rendering) and use the extracted data to determine how that should be rendered. Before you render a <button>
, you load all the data your app could need. And any given route handler needs to know all the data that will need to be extracted by that route.
But Leptos integrates both the client and the server, and it’s important to be able to refresh small pieces of your UI with new data from the server without forcing a full reload of all the data. So Leptos likes to push data loading “down” in your application, as far towards the leaves of your user interface as possible. When you click a <button>
, it can refresh just the data it needs. This is exactly what server functions are for: they give you granular access to data to be loaded and reloaded.
The extract()
functions let you combine both models by using extractors in your server functions. You get access to the full power of route extractors, while decentralizing knowledge of what needs to be extracted down to your individual components. This makes it easier to refactor and reorganize routes: you don’t need to specify all the data a route needs up front.
Responses and Redirects
Extractors provide an easy way to access request data inside server functions. Leptos also provides a way to modify the HTTP response, using the ResponseOptions
type (see docs for Actix or Axum) types and the redirect
helper function (see docs for Actix or Axum).
ResponseOptions
ResponseOptions
is provided via context during the initial server rendering response and during any subsequent server function call. It allows you to easily set the status code for the HTTP response, or to add headers to the HTTP response, e.g., to set cookies.
#[server(TeaAndCookies)]
pub async fn tea_and_cookies() -> Result<(), ServerFnError> {
use actix_web::{cookie::Cookie, http::header, http::header::HeaderValue};
use leptos_actix::ResponseOptions;
// pull ResponseOptions from context
let response = expect_context::<ResponseOptions>();
// set the HTTP status code
response.set_status(StatusCode::IM_A_TEAPOT);
// set a cookie in the HTTP response
let mut cookie = Cookie::build("biscuits", "yes").finish();
if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
response.insert_header(header::SET_COOKIE, cookie);
}
}
redirect
One common modification to an HTTP response is to redirect to another page. The Actix and Axum integrations provide a redirect
function to make this easy to do. redirect
simply sets an HTTP status code of 302 Found
and sets the Location
header.
Here’s a simplified example from our session_auth_axum
example.
#[server(Login, "/api")]
pub async fn login(
username: String,
password: String,
remember: Option<String>,
) -> Result<(), ServerFnError> {
// pull the DB pool and auth provider from context
let pool = pool()?;
let auth = auth()?;
// check whether the user exists
let user: User = User::get_from_username(username, &pool)
.await
.ok_or_else(|| {
ServerFnError::ServerError("User does not exist.".into())
})?;
// check whether the user has provided the correct password
match verify(password, &user.password)? {
// if the password is correct...
true => {
// log the user in
auth.login_user(user.id);
auth.remember_user(remember.is_some());
// and redirect to the home page
leptos_axum::redirect("/");
Ok(())
}
// if not, return an error
false => Err(ServerFnError::ServerError(
"Password does not match.".to_string(),
)),
}
}
This server function can then be used from your application. This redirect
works well with the progressively-enhanced <ActionForm/>
component: without JS/WASM, the server response will redirect because of the status code and header. With JS/WASM, the <ActionForm/>
will detect the redirect in the server function response, and use client-side navigation to redirect to the new page.
Progressive Enhancement (and Graceful Degradation)
I’ve been driving around Boston for about fifteen years. If you don’t know Boston, let me tell you: Massachusetts has some of the most aggressive drivers(and pedestrians!) in the world. I’ve learned to practice what’s sometimes called “defensive driving”: assuming that someone’s about to swerve in front of you at an intersection when you have the right of way, preparing for a pedestrian to cross into the street at any moment, and driving accordingly.
“Progressive enhancement” is the “defensive driving” of web design. Or really, that’s “graceful degradation,” although they’re two sides of the same coin, or the same process, from two different directions.
Progressive enhancement, in this context, means beginning with a simple HTML site or application that works for any user who arrives at your page, and gradually enhancing it with layers of additional features: CSS for styling, JavaScript for interactivity, WebAssembly for Rust-powered interactivity; using particular Web APIs for a richer experience if they’re available and as needed.
Graceful degradation means handling failure gracefully when parts of that stack of enhancement aren’t available. Here are some sources of failure your users might encounter in your app:
- Their browser doesn’t support WebAssembly because it needs to be updated.
- Their browser can’t support WebAssembly because browser updates are limited to newer OS versions, which can’t be installed on the device. (Looking at you, Apple.)
- They have WASM turned off for security or privacy reasons.
- They have JavaScript turned off for security or privacy reasons.
- JavaScript isn’t supported on their device (for example, some accessibility devices only support HTML browsing)
- The JavaScript (or WASM) never arrived at their device because they walked outside and lost WiFi.
- They stepped onto a subway car after loading the initial page and subsequent navigations can’t load data.
- ... and so on.
How much of your app still works if one of these holds true? Two of them? Three?
If the answer is something like “95%... okay, then 90%... okay, then 75%,” that’s graceful degradation. If the answer is “my app shows a blank screen unless everything works correctly,” that’s... rapid unscheduled disassembly.
Graceful degradation is especially important for WASM apps, because WASM is the newest and least-likely-to-be-supported of the four languages that run in the browser (HTML, CSS, JS, WASM).
Luckily, we’ve got some tools to help.
Defensive Design
There are a few practices that can help your apps degrade more gracefully:
- Server-side rendering. Without SSR, your app simply doesn’t work without both JS and WASM loading. In some cases this may be appropriate (think internal apps gated behind a login) but in others it’s simply broken.
- Native HTML elements. Use HTML elements that do the things that you want, without additional code:
<a>
for navigation (including to hashes within the page),<details>
for an accordion,<form>
to persist information in the URL, etc. - URL-driven state. The more of your global state is stored in the URL (as a route param or part of the query string), the more of the page can be generated during server rendering and updated by an
<a>
or a<form>
, which means that not only navigations but state changes can work without JS/WASM. SsrMode::PartiallyBlocked
orSsrMode::InOrder
. Out-of-order streaming requires a small amount of inline JS, but can fail if 1) the connection is broken halfway through the response or 2) the client’s device doesn’t support JS. Async streaming will give a complete HTML page, but only after all resources load. In-order streaming begins showing pieces of the page sooner, in top-down order. “Partially-blocked” SSR builds on out-of-order streaming by replacing<Suspense/>
fragments that read from blocking resources on the server. This adds marginally to the initial response time (because of theO(n)
string replacement work), in exchange for a more complete initial HTML response. This can be a good choice for situations in which there’s a clear distinction between “more important” and “less important” content, e.g., blog post vs. comments, or product info vs. reviews. If you choose to block on all the content, you’ve essentially recreated async rendering.- Leaning on
<form>
s. There’s been a bit of a<form>
renaissance recently, and it’s no surprise. The ability of a<form>
to manage complicatedPOST
orGET
requests in an easily-enhanced way makes it a powerful tool for graceful degradation. The example in the<Form/>
chapter, for example, would work fine with no JS/WASM: because it uses a<form method="GET">
to persist state in the URL, it works with pure HTML by making normal HTTP requests and then progressively enhances to use client-side navigations instead.
There’s one final feature of the framework that we haven’t seen yet, and which builds on this characteristic of forms to build powerful applications: the <ActionForm/>
.
<ActionForm/>
<ActionForm/>
is a specialized <Form/>
that takes a server action, and automatically dispatches it on form submission. This allows you to call a server function directly from a <form>
, even without JS/WASM.
The process is simple:
- Define a server function using the
#[server]
macro (see Server Functions.) - Create an action using
create_server_action
, specifying the type of the server function you’ve defined. - Create an
<ActionForm/>
, providing the server action in theaction
prop. - Pass the named arguments to the server function as form fields with the same names.
Note:
<ActionForm/>
only works with the default URL-encodedPOST
encoding for server functions, to ensure graceful degradation/correct behavior as an HTML form.
#[server(AddTodo, "/api")]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
todo!()
}
#[component]
fn AddTodo() -> impl IntoView {
let add_todo = create_server_action::<AddTodo>();
// holds the latest *returned* value from the server
let value = add_todo.value();
// check if the server has returned an error
let has_error = move || value.with(|val| matches!(val, Some(Err(_))));
view! {
<ActionForm action=add_todo>
<label>
"Add a Todo"
// `title` matches the `title` argument to `add_todo`
<input type="text" name="title"/>
</label>
<input type="submit" value="Add"/>
</ActionForm>
}
}
It’s really that easy. With JS/WASM, your form will submit without a page reload, storing its most recent submission in the .input()
signal of the action, its pending status in .pending()
, and so on. (See the Action
docs for a refresher, if you need.) Without JS/WASM, your form will submit with a page reload. If you call a redirect
function (from leptos_axum
or leptos_actix
) it will redirect to the correct page. By default, it will redirect back to the page you’re currently on. The power of HTML, HTTP, and isomorphic rendering mean that your <ActionForm/>
simply works, even with no JS/WASM.
Client-Side Validation
Because the <ActionForm/>
is just a <form>
, it fires a submit
event. You can use either HTML validation, or your own client-side validation logic in an on:submit
. Just call ev.prevent_default()
to prevent submission.
The FromFormData
trait can be helpful here, for attempting to parse your server function’s data type from the submitted form.
let on_submit = move |ev| {
let data = AddTodo::from_event(&ev);
// silly example of validation: if the todo is "nope!", nope it
if data.is_err() || data.unwrap().title == "nope!" {
// ev.prevent_default() will prevent form submission
ev.prevent_default();
}
}
Complex Inputs
Server function arguments that are structs with nested serializable fields should make use of indexing notation of serde_qs
.
use leptos::*;
use leptos_router::*;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct HeftyData {
first_name: String,
last_name: String,
}
#[component]
fn ComplexInput() -> impl IntoView {
let submit = Action::<VeryImportantFn, _>::server();
view! {
<ActionForm action=submit>
<input type="text" name="hefty_arg[first_name]" value="leptos"/>
<input
type="text"
name="hefty_arg[last_name]"
value="closures-everywhere"
/>
<input type="submit"/>
</ActionForm>
}
}
#[server]
async fn very_important_fn(
hefty_arg: HeftyData,
) -> Result<(), ServerFnError> {
assert_eq!(hefty_arg.first_name.as_str(), "leptos");
assert_eq!(hefty_arg.last_name.as_str(), "closures-everywhere");
Ok(())
}
Deployment
There are as many ways to deploy a web application as there are developers, let alone applications. But there are a couple useful tips to keep in mind when deploying an app.
General Advice
- Remember: Always deploy Rust apps built in
--release
mode, not debug mode. This has a huge effect on both performance and binary size. - Test locally in release mode as well. The framework applies certain optimizations in release mode that it does not apply in debug mode, so it’s possible for bugs to surface at this point. (If your app behaves differently or you do encounter a bug, it’s likely a framework-level bug and you should open a GitHub issue with a reproduction.)
- See the chapter on "Optimizing WASM Binary Size" for additional tips and tricks to further improve the time-to-interactive metric for your WASM app on first load.
We asked users to submit their deployment setups to help with this chapter. I’ll quote from them below, but you can read the full thread here.
Optimizing WASM Binary Size
One of the primary downsides of deploying a Rust/WebAssembly frontend app is that splitting a WASM file into smaller chunks to be dynamically loaded is significantly more difficult than splitting a JavaScript bundle. There have been experiments like wasm-split
in the Emscripten ecosystem but at present there’s no way to split and dynamically load a Rust/wasm-bindgen
binary. This means that the whole WASM binary needs to be loaded before your app becomes interactive. Because the WASM format is designed for streaming compilation, WASM files are much faster to compile per kilobyte than JavaScript files. (For a deeper look, you can read this great article from the Mozilla team on streaming WASM compilation.)
Still, it’s important to ship the smallest WASM binary to users that you can, as it will reduce their network usage and make your app interactive as quickly as possible.
So what are some practical steps?
Things to Do
- Make sure you’re looking at a release build. (Debug builds are much, much larger.)
- Add a release profile for WASM that optimizes for size, not speed.
For a cargo-leptos
project, for example, you can add this to your Cargo.toml
:
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
# ....
[package.metadata.leptos]
# ....
lib-profile-release = "wasm-release"
This will hyper-optimize the WASM for your release build for size, while keeping your server build optimized for speed. (For a pure client-rendered app without server considerations, just use the [profile.wasm-release]
block as your [profile.release]
.)
-
Always serve compressed WASM in production. WASM tends to compress very well, typically shrinking to less than 50% its uncompressed size, and it’s trivial to enable compression for static files being served from Actix or Axum.
-
If you’re using nightly Rust, you can rebuild the standard library with this same profile rather than the prebuilt standard library that’s distributed with the
wasm32-unknown-unknown
target.
To do this, create a file in your project at .cargo/config.toml
[unstable]
build-std = ["std", "panic_abort", "core", "alloc"]
build-std-features = ["panic_immediate_abort"]
Note that if you're using this with SSR too, the same Cargo profile will be applied. You'll need to explicitly specify your target:
[build]
target = "x86_64-unknown-linux-gnu" # or whatever
Also note that in some cases, the cfg feature has_std
will not be set, which may cause build errors with some dependencies which check for has_std
. You may fix any build errors due to this by adding:
[build]
rustflags = ["--cfg=has_std"]
And you'll need to add panic = "abort"
to [profile.release]
in Cargo.toml
. Note that this applies the same build-std
and panic settings to your server binary, which may not be desirable. Some further exploration is probably needed here.
- One of the sources of binary size in WASM binaries can be
serde
serialization/deserialization code. Leptos usesserde
by default to serialize and deserialize resources created withcreate_resource
. You might try experimenting with theminiserde
andserde-lite
features, which allow you to use those crates for serialization and deserialization instead; each only implements a subset ofserde
’s functionality, but typically optimizes for size over speed.
Things to Avoid
There are certain crates that tend to inflate binary sizes. For example, the regex
crate with its default features adds about 500kb to a WASM binary (largely because it has to pull in Unicode table data!). In a size-conscious setting, you might consider avoiding regexes in general, or even dropping down and calling browser APIs to use the built-in regex engine instead. (This is what leptos_router
does on the few occasions it needs a regular expression.)
In general, Rust’s commitment to runtime performance is sometimes at odds with a commitment to a small binary. For example, Rust monomorphizes generic functions, meaning it creates a distinct copy of the function for each generic type it’s called with. This is significantly faster than dynamic dispatch, but increases binary size. Leptos tries to balance runtime performance with binary size considerations pretty carefully; but you might find that writing code that uses many generics tends to increase binary size. For example, if you have a generic component with a lot of code in its body and call it with four different types, remember that the compiler could include four copies of that same code. Refactoring to use a concrete inner function or helper can often maintain performance and ergonomics while reducing binary size.
A Final Thought
Remember that in a server-rendered app, JS bundle size/WASM binary size affects only one thing: time to interactivity on the first load. This is very important to a good user experience: nobody wants to click a button three times and have it do nothing because the interactive code is still loading — but it's not the only important measure.
It’s especially worth remembering that streaming in a single WASM binary means all subsequent navigations are nearly instantaneous, depending only on any additional data loading. Precisely because your WASM binary is not bundle split, navigating to a new route does not require loading additional JS/WASM, as it does in nearly every JavaScript framework. Is this copium? Maybe. Or maybe it’s just an honest trade-off between the two approaches!
Always take the opportunity to optimize the low-hanging fruit in your application. And always test your app under real circumstances with real user network speeds and devices before making any heroic efforts.
Guide: Islands
Leptos 0.5 introduces the new experimental-islands
feature. This guide will walk through the islands feature and core concepts, while implementing a demo app using the islands architecture.
The Islands Architecture
The dominant JavaScript frontend frameworks (React, Vue, Svelte, Solid, Angular) all originated as frameworks for building client-rendered single-page apps (SPAs). The initial page load is rendered to HTML, then hydrated, and subsequent navigations are handled directly in the client. (Hence “single page”: everything happens from a single page load from the server, even if there is client-side routing later.) Each of these frameworks later added server-side rendering to improve initial load times, SEO, and user experience.
This means that by default, the entire app is interactive. It also means that the entire app has to be shipped to the client as JavaScript in order to be hydrated. Leptos has followed this same pattern.
You can read more in the chapters on server-side rendering.
But it’s also possible to work in the opposite direction. Rather than taking an entirely-interactive app, rendering it to HTML on the server, and then hydrating it in the browser, you can begin with a plain HTML page and add small areas of interactivity. This is the traditional format for any website or app before the 2010s: your browser makes a series of requests to the server and returns the HTML for each new page in response. After the rise of “single-page apps” (SPA), this approach has sometimes become known as a “multi-page app” (MPA) by comparison.
The phrase “islands architecture” has emerged recently to describe the approach of beginning with a “sea” of server-rendered HTML pages, and adding “islands” of interactivity throughout the page.
Additional Reading
The rest of this guide will look at how to use islands with Leptos. For more background on the approach in general, check out some of the articles below:
- Jason Miller, “Islands Architecture”, Jason Miller
- Ryan Carniato, “Islands & Server Components & Resumability, Oh My!”
- “Islands Architectures” on patterns.dev
- Astro Islands
Activating Islands Mode
Let’s start with a fresh cargo-leptos
app:
cargo leptos new --git leptos-rs/start
I’m using Actix because I like it. Feel free to use Axum; there should be approximately no server-specific differences in this guide.
I’m just going to run
cargo leptos build
in the background while I fire up my editor and keep writing.
The first thing I’ll do is to add the experimental-islands
feature in my Cargo.toml
. I need to add this to both leptos
and leptos_actix
:
leptos = { version = "0.5", features = ["nightly", "experimental-islands"] }
leptos_actix = { version = "0.5", optional = true, features = [
"experimental-islands",
] }
Next I’m going to modify the hydrate
function exported from src/lib.rs
. I’m going to remove the line that calls leptos::mount_to_body(App)
and replace it with
leptos::leptos_dom::HydrationCtx::stop_hydrating();
Each “island” we create will actually act as its own entrypoint, so our hydrate()
function just says “okay, hydration’s done now.”
Okay, now fire up your cargo leptos watch
and go to http://localhost:3000
(or wherever).
Click the button, and...
Nothing happens!
Perfect.
The starter templates include `use app::*;` in their `hydrate()` function definitions. Once you've switched over to islands mode, you are no longer using the imported main `App` function, so you might think you can delete this. (And in fact, Rust lint tools might issue warnings if you don't!)
However, this can cause issues if you are using a workspace setup. We use `wasm-bindgen` to independently export an entrypoint for each function. In my experience, if you are using a workspace setup and nothing in your `frontend` crate actually uses the `app` crate, those bindings will not be generated correctly. [See this discussion for more](https://github.com/leptos-rs/leptos/issues/2083#issuecomment-1868053733).
Using Islands
Nothing happens because we’ve just totally inverted the mental model of our app. Rather than being interactive by default and hydrating everything, the app is now plain HTML by default, and we need to opt into interactivity.
This has a big effect on WASM binary sizes: if I compile in release mode, this app is a measly 24kb of WASM (uncompressed), compared to 355kb in non-islands mode. (355kb is quite large for a “Hello, world!” It’s really just all the code related to client-side routing, which isn’t being used in the demo.)
When we click the button, nothing happens, because our whole page is static.
So how do we make something happen?
Let’s turn the HomePage
component into an island!
Here was the non-interactive version:
#[component]
fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let (count, set_count) = create_signal(0);
let on_click = move |_| set_count.update(|count| *count += 1);
view! {
<h1>"Welcome to Leptos!"</h1>
<button on:click=on_click>"Click Me: " {count}</button>
}
}
Here’s the interactive version:
#[island]
fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let (count, set_count) = create_signal(0);
let on_click = move |_| set_count.update(|count| *count += 1);
view! {
<h1>"Welcome to Leptos!"</h1>
<button on:click=on_click>"Click Me: " {count}</button>
}
}
Now when I click the button, it works!
The #[island]
macro works exactly like the #[component]
macro, except that in islands mode, it designates this as an interactive island. If we check the binary size again, this is 166kb uncompressed in release mode; much larger than the 24kb totally static version, but much smaller than the 355kb fully-hydrated version.
If you open up the source for the page now, you’ll see that your HomePage
island has been rendered as a special <leptos-island>
HTML element which specifies which component should be used to hydrate it:
<leptos-island data-component="HomePage" data-hkc="0-0-0">
<h1 data-hk="0-0-2">Welcome to Leptos!</h1>
<button data-hk="0-0-3">
Click Me:
<!-- <DynChild> -->11<!-- </DynChild> -->
</button>
</leptos-island>
The typical Leptos hydration keys and markers are only present inside the island, only the island is hydrated.
Using Islands Effectively
Remember that only code within an #[island]
needs to be compiled to WASM and shipped to the browser. This means that islands should be as small and specific as possible. My HomePage
, for example, would be better broken apart into a regular component and an island:
#[component]
fn HomePage() -> impl IntoView {
view! {
<h1>"Welcome to Leptos!"</h1>
<Counter/>
}
}
#[island]
fn Counter() -> impl IntoView {
// Creates a reactive value to update the button
let (count, set_count) = create_signal(0);
let on_click = move |_| set_count.update(|count| *count += 1);
view! {
<button on:click=on_click>"Click Me: " {count}</button>
}
}
Now the <h1>
doesn’t need to be included in the client bundle, or hydrated. This seems like a silly distinction now; but note that you can now add as much inert HTML content as you want to the HomePage
itself, and the WASM binary size will remain exactly the same.
In regular hydration mode, your WASM binary size grows as a function of the size/complexity of your app. In islands mode, your WASM binary grows as a function of the amount of interactivity in your app. You can add as much non-interactive content as you want, outside islands, and it will not increase that binary size.
Unlocking Superpowers
So, this 50% reduction in WASM binary size is nice. But really, what’s the point?
The point comes when you combine two key facts:
- Code inside
#[component]
functions now only runs on the server. - Children and props can be passed from the server to islands, without being included in the WASM binary.
This means you can run server-only code directly in the body of a component, and pass it directly into the children. Certain tasks that take a complex blend of server functions and Suspense in fully-hydrated apps can be done inline in islands.
We’re going to rely on a third fact in the rest of this demo:
- Context can be passed between otherwise-independent islands.
So, instead of our counter demo, let’s make something a little more fun: a tabbed interface that reads data from files on the server.
Passing Server Children to Islands
One of the most powerful things about islands is that you can pass server-rendered children into an island, without the island needing to know anything about them. Islands hydrate their own content, but not children that are passed to them.
As Dan Abramov of React put it (in the very similar context of RSCs), islands aren’t really islands: they’re donuts. You can pass server-only content directly into the “donut hole,” as it were, allowing you to create tiny atolls of interactivity, surrounded on both sides by the sea of inert server HTML.
In the demo code included below, I added some styles to show all server content as a light-blue “sea,” and all islands as light-green “land.” Hopefully that will help picture what I’m talking about!
To continue with the demo: I’m going to create a Tabs
component. Switching between tabs will require some interactivity, so of course this will be an island. Let’s start simple for now:
#[island]
fn Tabs(labels: Vec<String>) -> impl IntoView {
let buttons = labels
.into_iter()
.map(|label| view! { <button>{label}</button> })
.collect_view();
view! {
<div style="display: flex; width: 100%; justify-content: space-between;">
{buttons}
</div>
}
}
Oops. This gives me an error
error[E0463]: can't find crate for `serde`
--> src/app.rs:43:1
|
43 | #[island]
| ^^^^^^^^^ can't find crate
Easy fix: let’s cargo add serde --features=derive
. The #[island]
macro wants to pull in serde
here because it needs to serialize and deserialize the labels
prop.
Now let’s update the HomePage
to use Tabs
.
#[component]
fn HomePage() -> impl IntoView {
// these are the files we’re going to read
let files = ["a.txt", "b.txt", "c.txt"];
// the tab labels will just be the file names
let labels = files.iter().copied().map(Into::into).collect();
view! {
<h1>"Welcome to Leptos!"</h1>
<p>"Click any of the tabs below to read a recipe."</p>
<Tabs labels/>
}
}
If you take a look in the DOM inspector, you’ll see the island is now something like
<leptos-island
data-component="Tabs"
data-hkc="0-0-0"
data-props='{"labels":["a.txt","b.txt","c.txt"]}'
></leptos-island>
Our labels
prop is getting serialized to JSON and stored in an HTML attribute so it can be used to hydrate the island.
Now let’s add some tabs. For the moment, a Tab
island will be really simple:
#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
view! {
<div>{children()}</div>
}
}
Each tab, for now will just be a <div>
wrapping its children.
Our Tabs
component will also get some children: for now, let’s just show them all.
#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
let buttons = labels
.into_iter()
.map(|label| view! { <button>{label}</button> })
.collect_view();
view! {
<div style="display: flex; width: 100%; justify-content: space-around;">
{buttons}
</div>
{children()}
}
}
Okay, now let’s go back into the HomePage
. We’re going to create the list of tabs to put into our tab box.
#[component]
fn HomePage() -> impl IntoView {
let files = ["a.txt", "b.txt", "c.txt"];
let labels = files.iter().copied().map(Into::into).collect();
let tabs = move || {
files
.into_iter()
.enumerate()
.map(|(index, filename)| {
let content = std::fs::read_to_string(filename).unwrap();
view! {
<Tab index>
<h2>{filename.to_string()}</h2>
<p>{content}</p>
</Tab>
}
})
.collect_view()
};
view! {
<h1>"Welcome to Leptos!"</h1>
<p>"Click any of the tabs below to read a recipe."</p>
<Tabs labels>
<div>{tabs()}</div>
</Tabs>
}
}
Uh... What?
If you’re used to using Leptos, you know that you just can’t do this. All code in the body of components has to run on the server (to be rendered to HTML) and in the browser (to hydrate), so you can’t just call std::fs
; it will panic, because there’s no access to the local filesystem (and certainly not to the server filesystem!) in the browser. This would be a security nightmare!
Except... wait. We’re in islands mode. This HomePage
component really does only run on the server. So we can, in fact, just use ordinary server code like this.
Is this a dumb example? Yes! Synchronously reading from three different local files in a
.map()
is not a good choice in real life. The point here is just to demonstrate that this is, definitely, server-only content.
Go ahead and create three files in the root of the project called a.txt
, b.txt
, and c.txt
, and fill them in with whatever content you’d like.
Refresh the page and you should see the content in the browser. Edit the files and refresh again; it will be updated.
You can pass server-only content from a #[component]
into the children of an #[island]
, without the island needing to know anything about how to access that data or render that content.
This is really important. Passing server children
to islands means that you can keep islands small. Ideally, you don’t want to slap and #[island]
around a whole chunk of your page. You want to break that chunk out into an interactive piece, which can be an #[island]
, and a bunch of additional server content that can be passed to that island as children
, so that the non-interactive subsections of an interactive part of the page can be kept out of the WASM binary.
Passing Context Between Islands
These aren’t really “tabs” yet: they just show every tab, all the time. So let’s add some simple logic to our Tabs
and Tab
components.
We’ll modify Tabs
to create a simple selected
signal. We provide the read half via context, and set the value of the signal whenever someone clicks one of our buttons.
#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
let (selected, set_selected) = create_signal(0);
provide_context(selected);
let buttons = labels
.into_iter()
.enumerate()
.map(|(index, label)| view! {
<button on:click=move |_| set_selected(index)>
{label}
</button>
})
.collect_view();
// ...
And let’s modify the Tab
island to use that context to show or hide itself:
#[island]
fn Tab(children: Children) -> impl IntoView {
let selected = expect_context::<ReadSignal<usize>>();
view! {
<div style:display=move || if selected() == index {
"block"
} else {
"none"
}>
// ...
Now the tabs behave exactly as I’d expect. Tabs
passes the signal via context to each Tab
, which uses it to determine whether it should be open or not.
That’s why in
HomePage
, I madelet tabs = move ||
a function, and called it like{tabs()}
: creating the tabs lazily this way meant that theTabs
island would already have provided theselected
context by the time eachTab
went looking for it.
Our complete tabs demo is about 220kb uncompressed: not the smallest demo in the world, but still about a third smaller than the counter button! Just for kicks, I built the same demo without islands mode, using #[server]
functions and Suspense
. and it was 429kb. So again, this was about a 50% savings in binary size. And this app includes quite minimal server-only content: remember that as we add additional server-only components and pages, this 220 will not grow.
Overview
This demo may seem pretty basic. It is. But there are a number of immediate takeaways:
- 50% WASM binary size reduction, which means measurable improvements in time to interactivity and initial load times for clients.
- Reduced HTML page size. This one is less obvious, but it’s true and important: HTML generated from
#[component]
s doesn’t need all the hydration IDs and other boilerplate added. - Reduced data serialization costs. Creating a resource and reading it on the client means you need to serialize the data, so it can be used for hydration. If you’ve also read that data to create HTML in a
Suspense
, you end up with “double data,” i.e., the same exact data is both rendered to HTML and serialized as JSON, increasing the size of responses, and therefore slowing them down. - Easily use server-only APIs inside a
#[component]
as if it were a normal, native Rust function running on the server—which, in islands mode, it is! - Reduced
#[server]
/create_resource
/Suspense
boilerplate for loading server data.
Future Exploration
The experimental-islands
feature included in 0.5 reflects work at the cutting edge of what frontend web frameworks are exploring right now. As it stands, our islands approach is very similar to Astro (before its recent View Transitions support): it allows you to build a traditional server-rendered, multi-page app and pretty seamlessly integrate islands of interactivity.
There are some small improvements that will be easy to add. For example, we can do something very much like Astro's View Transitions approach:
- add client-side routing for islands apps by fetching subsequent navigations from the server and replacing the HTML document with the new one
- add animated transitions between the old and new document using the View Transitions API
- support explicit persistent islands, i.e., islands that you can mark with unique IDs (something like
persist:searchbar
on the component in the view), which can be copied over from the old to the new document without losing their current state
There are other, larger architectural changes that I’m not sold on yet.
Additional Information
Check out the islands PR, roadmap, and Hackernews demo for additional discussion.
Demo Code
use leptos::*;
use leptos_router::*;
#[component]
pub fn App() -> impl IntoView {
view! {
<Router>
<main style="background-color: lightblue; padding: 10px">
<Routes>
<Route path="" view=HomePage/>
</Routes>
</main>
</Router>
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
let files = ["a.txt", "b.txt", "c.txt"];
let labels = files.iter().copied().map(Into::into).collect();
let tabs = move || {
files
.into_iter()
.enumerate()
.map(|(index, filename)| {
let content = std::fs::read_to_string(filename).unwrap();
view! {
<Tab index>
<div style="background-color: lightblue; padding: 10px">
<h2>{filename.to_string()}</h2>
<p>{content}</p>
</div>
</Tab>
}
})
.collect_view()
};
view! {
<h1>"Welcome to Leptos!"</h1>
<p>"Click any of the tabs below to read a recipe."</p>
<Tabs labels>
<div>{tabs()}</div>
</Tabs>
}
}
#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
let (selected, set_selected) = create_signal(0);
provide_context(selected);
let buttons = labels
.into_iter()
.enumerate()
.map(|(index, label)| {
view! {
<button on:click=move |_| set_selected(index)>
{label}
</button>
}
})
.collect_view();
view! {
<div
style="display: flex; width: 100%; justify-content: space-around;\
background-color: lightgreen; padding: 10px;"
>
{buttons}
</div>
{children()}
}
}
#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
let selected = expect_context::<ReadSignal<usize>>();
view! {
<div
style:background-color="lightgreen"
style:padding="10px"
style:display=move || if selected() == index {
"block"
} else {
"none"
}
>
{children()}
</div>
}
}
Appendix: How does the Reactive System Work?
You don’t need to know very much about how the reactive system actually works in order to use the library successfully. But it’s always useful to understand what’s going on behind the scenes once you start working with the framework at an advanced level.
The reactive primitives you use are divided into three sets:
- Signals (
ReadSignal
/WriteSignal
,RwSignal
,Resource
,Trigger
) Values you can actively change to trigger reactive updates. - Computations (
Memo
s) Values that depend on signals (or other computations) and derive a new reactive value through some pure computation. - Effects Observers that listen to changes in some signals or computations and run a function, causing some side effect.
Derived signals are a kind of non-primitive computation: as plain closures, they simply allow you to refactor some repeated signal-based computation into a reusable function that can be called in multiple places, but they are not represented in the reactive system itself.
All the other primitives actually exist in the reactive system as nodes in a reactive graph.
Most of the work of the reactive system consists of propagating changes from signals to effects, possibly through some intervening memos.
The assumption of the reactive system is that effects (like rendering to the DOM or making a network request) are orders of magnitude more expensive than things like updating a Rust data structure inside your app.
So the primary goal of the reactive system is to run effects as infrequently as possible.
Leptos does this through the construction of a reactive graph.
Leptos’s current reactive system is based heavily on the Reactively library for JavaScript. You can read Milo’s article “Super-Charging Fine-Grained Reactivity” for an excellent account of its algorithm, as well as fine-grained reactivity in general—including some beautiful diagrams!
The Reactive Graph
Signals, memos, and effects all share three characteristics:
- Value They have a current value: either the signal’s value, or (for memos and effects) the value returned by the previous run, if any.
- Sources Any other reactive primitives they depend on. (For signals, this is an empty set.)
- Subscribers Any other reactive primitives that depend on them. (For effects, this is an empty set.)
In reality then, signals, memos, and effects are just conventional names for one generic concept of a “node” in a reactive graph. Signals are always “root nodes,” with no sources/parents. Effects are always “leaf nodes,” with no subscribers. Memos typically have both sources and subscribers.
Simple Dependencies
So imagine the following code:
// A
let (name, set_name) = create_signal("Alice");
// B
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
// C
create_effect(move |_| {
log!("{}", name_upper());
});
set_name("Bob");
You can easily imagine the reactive graph here: name
is the only signal/origin node, the create_effect
is the only effect/terminal node, and there’s one intervening memo.
A (name)
|
B (name_upper)
|
C (the effect)
Splitting Branches
Let’s make it a little more complex.
// A
let (name, set_name) = create_signal("Alice");
// B
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
// C
let name_len = create_memo(move |_| name.len());
// D
create_effect(move |_| {
log!("len = {}", name_len());
});
// E
create_effect(move |_| {
log!("name = {}", name_upper());
});
This is also pretty straightforward: a signal source signal (name
/A
) divides into two parallel tracks: name_upper
/B
and name_len
/C
, each of which has an effect that depends on it.
__A__
| |
B C
| |
E D
Now let’s update the signal.
set_name("Bob");
We immediately log
len = 3
name = BOB
Let’s do it again.
set_name("Tim");
The log should shows
name = TIM
len = 3
does not log again.
Remember: the goal of the reactive system is to run effects as infrequently as possible. Changing name
from "Bob"
to "Tim"
will cause each of the memos to re-run. But they will only notify their subscribers if their value has actually changed. "BOB"
and "TIM"
are different, so that effect runs again. But both names have the length 3
, so they do not run again.
Reuniting Branches
One more example, of what’s sometimes called the diamond problem.
// A
let (name, set_name) = create_signal("Alice");
// B
let name_upper = create_memo(move |_| name.with(|n| n.to_uppercase()));
// C
let name_len = create_memo(move |_| name.len());
// D
create_effect(move |_| {
log!("{} is {} characters long", name_upper(), name_len());
});
What does the graph look like for this?
__A__
| |
B C
| |
|__D__|
You can see why it's called the “diamond problem.” If I’d connected the nodes with straight lines instead of bad ASCII art, it would form a diamond: two memos, each of which depend on a signal, which feed into the same effect.
A naive, push-based reactive implementation would cause this effect to run twice, which would be bad. (Remember, our goal is to run effects as infrequently as we can.) For example, you could implement a reactive system such that signals and memos immediately propagate their changes all the way down the graph, through each dependency, essentially traversing the graph depth-first. In other words, updating A
would notify B
, which would notify D
; then A
would notify C
, which would notify D
again. This is both inefficient (D
runs twice) and glitchy (D
actually runs with the incorrect value for the second memo during its first run.)
Solving the Diamond Problem
Any reactive implementation worth its salt is dedicated to solving this issue. There are a number of different approaches (again, see Milo’s article for an excellent overview).
Here’s how ours works, in brief.
A reactive node is always in one of three states:
Clean
: it is known not to have changedCheck
: it is possible it has changedDirty
: it has definitely changed
Updating a signal Dirty
marks that signal Dirty
, and marks all its descendants Check
, recursively. Any of its descendants that are effects are added to a queue to be re-run.
____A (DIRTY)___
| |
B (CHECK) C (CHECK)
| |
|____D (CHECK)__|
Now those effects are run. (All of the effects will be marked Check
at this point.) Before re-running its computation, the effect checks its parents to see if they are dirty. So
- So
D
goes toB
and checks if it isDirty
. - But
B
is also markedCheck
. SoB
does the same thing:B
goes toA
, and finds that it isDirty
.- This means
B
needs to re-run, because one of its sources has changed. B
re-runs, generating a new value, and marks itselfClean
- Because
B
is a memo, it then checks its prior value against the new value. - If they are the same,
B
returns "no change." Otherwise, it returns "yes, I changed."
- If
B
returned “yes, I changed,”D
knows that it definitely needs to run and re-runs immediately before checking any other sources. - If
B
returned “no, I didn’t change,”D
continues on to checkC
(see process above forB
.) - If neither
B
norC
has changed, the effect does not need to re-run. - If either
B
orC
did change, the effect now re-runs.
Because the effect is only marked Check
once and only queued once, it only runs once.
If the naive version was a “push-based” reactive system, simply pushing reactive changes all the way down the graph and therefore running the effect twice, this version could be called “push-pull.” It pushes the Check
status all the way down the graph, but then “pulls” its way back up. In fact, for large graphs it may end up bouncing back up and down and left and right on the graph as it tries to determine exactly which nodes need to re-run.
Note this important trade-off: Push-based reactivity propagates signal changes more quickly, at the expense of over-re-running memos and effects. Remember: the reactive system is designed to minimize how often you re-run effects, on the (accurate) assumption that side effects are orders of magnitude more expensive than this kind of cache-friendly graph traversal happening entirely inside the library’s Rust code. The measurement of a good reactive system is not how quickly it propagates changes, but how quickly it propagates changes without over-notifying.
Memos vs. Signals
Note that signals always notify their children; i.e., a signal is always marked Dirty
when it updates, even if its new value is the same as the old value. Otherwise, we’d have to require PartialEq
on signals, and this is actually quite an expensive check on some types. (For example, add an unnecessary equality check to something like some_vec_signal.update(|n| n.pop())
when it’s clear that it has in fact changed.)
Memos, on the other hand, check whether they change before notifying their children. They only run their calculation once, no matter how many times you .get()
the result, but they run whenever their signal sources change. This means that if the memo’s computation is very expensive, you may actually want to memoize its inputs as well, so that the memo only re-calculates when it is sure its inputs have changed.
Memos vs. Derived Signals
All of this is cool, and memos are pretty great. But most actual applications have reactive graphs that are quite shallow and quite wide: you might have 100 source signals and 500 effects, but no memos or, in rare case, three or four memos between the signal and the effect. Memos are extremely good at what they do: limiting how often they notify their subscribers that they have changed. But as this description of the reactive system should show, they come with overhead in two forms:
- A
PartialEq
check, which may or may not be expensive. - Added memory cost of storing another node in the reactive system.
- Added computational cost of reactive graph traversal.
In cases in which the computation itself is cheaper than this reactive work, you should avoid “over-wrapping” with memos and simply use derived signals. Here’s a great example in which you should never use a memo:
let (a, set_a) = create_signal(1);
// none of these make sense as memos
let b = move || a() + 2;
let c = move || b() % 2 == 0;
let d = move || if c() { "even" } else { "odd" };
set_a(2);
set_a(3);
set_a(5);
Even though memoizing would technically save an extra calculation of d
between setting a
to 3
and 5
, these calculations are themselves cheaper than the reactive algorithm.
At the very most, you might consider memoizing the final node before running some expensive side effect:
let text = create_memo(move |_| {
d()
});
create_effect(move |_| {
engrave_text_into_bar_of_gold(&text());
});