Phoenix LiveView: UI, живущий в процессе
Обычный веб-фреймворк рендерит HTML и забывает о клиенте. SPA переносит состояние в браузер и платит за это дублированием логики, отдельным API и целым фронтенд-стеком. LiveView предлагает третий путь: состояние UI живёт на сервере в отдельном процессе BEAM, а по сети ходят только диффы разметки.
Такое решение возможно ровно потому, что процесс BEAM стоит копейки (см. главу Конкурентность и OTP). Десять тысяч открытых вкладок — это десять тысяч процессов по несколько килобайт, а не десять тысяч потоков ОС. Технология, которая в любом другом рантайме была бы безумием, здесь оказывается дешёвой.
Как это работает: два рендера и один процесс
Ключевая деталь, на которой спотыкаются все новички: mount/3 вызывается дважды.
(обычный Plug) participant LV as Процесс LiveView participant DB as Контекст / БД B->>HTTP: GET /orders HTTP->>DB: mount (disconnected) — грузим данные DB-->>HTTP: данные HTTP-->>B: полноценный HTML (SEO, быстрый first paint) B->>LV: WebSocket connect + join LV->>DB: mount (connected) — грузим ЗАНОВО, подписываемся на PubSub DB-->>LV: данные LV-->>B: полный HTML-«скелет» + структура шаблона Note over B,LV: дальше по сети ходят только изменившиеся куски B->>LV: phx-click "sort" (событие) LV->>LV: handle_event -> новые assigns LV-->>B: дифф: {"2": "по дате"} — сотни байт
Первый mount — внутри обычного HTTP-запроса, без сокета. Он нужен, чтобы отдать полноценный HTML: это и SEO, и мгновенный first paint. Второй mount — уже в постоянном процессе, после установки WebSocket. Отсюда практическое правило:
def mount(_params, _session, socket) do
# дорогое делаем ТОЛЬКО в подключённом рендере, иначе платим дважды
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "orders")
:timer.send_interval(30_000, self(), :refresh)
end
{:ok, assign(socket, orders: Sales.list_orders(), filter: :all)}
end
Подписываться на PubSub или заводить таймеры в отключённом рендере бессмысленно: тот процесс умрёт сразу после ответа. А вот загрузку данных обычно делают в обоих — иначе первый HTML будет пустым.
assigns и отслеживание изменений
socket.assigns — это состояние LiveView, ровно как состояние GenServer (потому что LiveView и есть GenServer). Но у него есть суперспособность: HEEx на этапе компиляции разбирает шаблон на статические куски и динамические дырки и знает, от каких assigns зависит каждая дырка.
<h1>Заказы: <%= @filter %></h1>
<p>Всего: <%= length(@orders) %></p>
Компилируется примерно в структуру ["<h1>Заказы: ", dyn0, "</h1>\n<p>Всего: ", dyn1, "</p>"]. При первом рендере клиент получает и статику, и динамику. Дальше, если изменился только :filter, по проводу уедет %{"0" => "оплаченные"} — несколько десятков байт вместо всей страницы.
Отсюда два правила, определяющих производительность LiveView:
- Меняйте assigns через
assign/3, а не пересобирайте socket руками. LiveView сравнивает старое и новое значение и помечает assign изменившимся. Если вы каждый раз кладёте новый список с теми же данными — дифф будет полным. - Дорогие вычисления заворачивайте в
assign_new/3— оно вычисляет значение, только если его ещё нет (например,current_userуже положен в отключённом рендере и не должен грузиться заново).
socket
|> assign_new(:current_user, fn -> Accounts.get_user!(user_id) end)
|> assign(:filter, filter)
|> assign(:orders, Sales.list_orders(filter))
События: handle_event, handle_info, handle_params
LiveView — GenServer, и колбэки у него ровно те же по духу:
| Колбэк | Кто вызывает | Типичный повод |
|---|---|---|
handle_event/3 |
браузер (phx-click, phx-change, phx-submit) |
клик, ввод, отправка формы |
handle_info/2 |
любой процесс (PubSub, таймер, Task) | «данные изменились у кого-то ещё» |
handle_params/3 |
навигация внутри LiveView (~p + push_patch) |
смена фильтра/страницы через URL |
handle_async/3 |
завершение start_async/3 |
долгий запрос к внешнему API |
defmodule MyAppWeb.OrderLive.Index do
use MyAppWeb, :live_view
alias MyApp.Sales
@impl true
def handle_event("filter", %{"status" => status}, socket) do
# push_patch меняет URL без перезагрузки — состояние фильтра становится
# частью адреса, его можно скопировать и переслать
{:noreply, push_patch(socket, to: ~p"/orders?#{[status: status]}")}
end
@impl true
def handle_params(params, _uri, socket) do
status = params["status"] || "all"
{:noreply, assign(socket, status: status, orders: Sales.list_orders(status))}
end
# Кто-то в другом процессе создал заказ и разослал событие
@impl true
def handle_info({:order_created, order}, socket) do
{:noreply, stream_insert(socket, :orders, order, at: 0)}
end
end
Разделение handle_event → push_patch → handle_params выглядит окольным, но даёт важное свойство: состояние UI отражено в URL. Кнопка «назад», перезагрузка страницы и отправленная коллеге ссылка работают сами собой.
Долгие операции: start_async
Пока handle_event выполняется, LiveView не отвечает на другие события этого же клиента — он GenServer. Поэтому любой поход во внешний API оборачивают в асинхронную операцию:
def handle_event("check", _params, socket) do
{:noreply, start_async(socket, :credit_check, fn -> Billing.check_credit(socket.assigns.user) end)}
end
def handle_async(:credit_check, {:ok, result}, socket) do
{:noreply, assign(socket, credit: result, checking: false)}
end
def handle_async(:credit_check, {:exit, reason}, socket) do
{:noreply, put_flash(socket, :error, "Проверка не удалась: #{inspect(reason)}")}
end
start_async/3 запускает задачу под супервизором самого LiveView: если процесс LiveView умрёт (пользователь закрыл вкладку), задача будет убита вместе с ним. Это ровно то, что нужно: работа, которая никому уже не интересна, не должна продолжаться.
Streams: как не хранить коллекцию в памяти
Наивный LiveView со списком в assigns хранит весь список в состоянии процесса. Тысяча заказов на тысячу открытых вкладок — гигабайты на ноде. stream/4 решает это радикально: коллекция живёт в DOM браузера, а сервер помнит только идентификаторы операций.
def mount(_params, _session, socket) do
{:ok, stream(socket, :orders, Sales.list_orders())}
end
def handle_info({:order_created, order}, socket) do
{:noreply, stream_insert(socket, :orders, order, at: 0)} # вставить в начало
end
def handle_event("delete", %{"id" => id}, socket) do
order = Sales.get_order!(id)
{:ok, _} = Sales.delete_order(order)
{:noreply, stream_delete(socket, :orders, order)} # удалить из DOM
end
<tbody id="orders" phx-update="stream">
<tr :for={{dom_id, order} <- @streams.orders} id={dom_id}>
<td><%= order.number %></td>
<td><%= order.status %></td>
</tr>
</tbody>
Правило простое: любой список, который может вырасти, — стрим. Список из пяти статусов оставьте в assigns; ленту заказов, сообщений, уведомлений — только стримом. Это главный рычаг памяти в LiveView-приложении.
Промежуточный вариант — temporary_assigns: assign обнуляется сразу после рендера, поэтому не занимает память между событиями. Он старше стримов и подходит, когда данные только добавляются и не редактируются (лог, лента событий).
Компоненты: функциональные и живые
Есть два разных механизма, и путать их дорого.
- Функциональный компонент — просто функция
assigns -> HEEx. 90% вашего UI должно быть таким: кнопки, бейджи, строки таблицы, модалки без своей логики. live_component— состояние есть, процесса нет. Полезен, когда кусок UI имеет собственный цикл событий (сложная форма, редактируемая ячейка), но не должен переживать родителя. Обязателенid— по нему LiveView отслеживает экземпляр.- Вложенный
live_render— отдельный процесс со своей изоляцией. Оправдан, когда часть страницы дорогая или обновляется независимо (виджет с тикером, чат сбоку). Плата: отдельное состояние, отдельный mount, отдельное падение.
Практическое правило: начинайте с функционального компонента; повышайте «уровень» только когда упёрлись.
attr :status, :atom, required: true
attr :rest, :global
def status_badge(assigns) do
~H"""
<span class={["badge", badge_class(@status)]} {@rest}>
<%= label_for(@status) %>
</span>
"""
end
defp badge_class(:paid), do: "badge--green"
defp badge_class(:new), do: "badge--gray"
defp badge_class(:failed), do: "badge--red"
Формы: валидация без единой строки JavaScript
Форма в LiveView — это to_form/1 поверх changeset из главы Архитектура и продакшн. Одни и те же правила валидации работают и при живом вводе, и при финальной вставке в БД. Дублирования валидации между фронтом и бэком просто не возникает.
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(Sales.change_order(%Order{})))}
end
def handle_event("validate", %{"order" => params}, socket) do
changeset =
%Order{}
|> Sales.change_order(params)
|> Map.put(:action, :validate) # action заставляет показать ошибки
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("save", %{"order" => params}, socket) do
case Sales.create_order(params) do
{:ok, order} ->
{:noreply,
socket
|> put_flash(:info, "Заказ #{order.number} создан")
|> push_navigate(to: ~p"/orders/#{order}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:number]} label="Номер" />
<.input field={@form[:comment]} type="textarea" label="Комментарий" />
<.button phx-disable-with="Сохраняем...">Создать</.button>
</.form>
phx-disable-with — маленькая, но важная деталь: кнопка блокируется на время обработки, что убирает целый класс багов с двойной отправкой.
Загрузка файлов
socket = allow_upload(socket, :avatar, accept: ~w(.jpg .png), max_entries: 1, max_file_size: 5_000_000)
def handle_event("save", _params, socket) do
paths =
consume_uploaded_entries(socket, :avatar, fn %{path: tmp}, _entry ->
dest = Path.join("priv/static/uploads", Path.basename(tmp))
File.cp!(tmp, dest)
{:ok, "/uploads/" <> Path.basename(dest)}
end)
{:noreply, assign(socket, avatar: List.first(paths))}
end
Файл едет по тому же WebSocket чанками, прогресс доступен в @uploads.avatar.entries без единой строчки JS. Ограничения (max_file_size, accept) проверяются и на клиенте, и на сервере.
Границы: JS-хуки и команды
LiveView не отменяет JavaScript — он его локализует.
JS-команды выполняются в браузере без похода на сервер: показать/скрыть, добавить класс, фокус. Идеальны для чистой анимации и мелкой интерактивности.
<button phx-click={JS.toggle(to: "#menu") |> JS.focus(to: "#search")}>Меню</button>
Хуки — точка интеграции с любой JS-библиотекой (карты, графики, редакторы):
// assets/js/app.js
let Hooks = {}
Hooks.Chart = {
mounted() {
this.chart = new Chart(this.el, JSON.parse(this.el.dataset.points))
// события в обе стороны
this.handleEvent("points_updated", ({points}) => this.chart.update(points))
this.el.addEventListener("click", () => this.pushEvent("chart_clicked", {}))
},
destroyed() { this.chart.destroy() }
}
Правило разделения ответственности: состояние — на сервере, эфемерное представление — в хуке. Если хук начинает хранить бизнес-данные, вы строите SPA внутри LiveView и получаете худшее из двух миров. Сравнение с полноценным клиентским подходом — в треке Фронтенд.
Реалтайм: PubSub как единственный источник обновлений
Классическая ошибка — рассылать обновления из контроллера или из LiveView. Правильное место — контекст, который единственный знает, что данные изменились:
defmodule MyApp.Sales do
def create_order(attrs) do
with {:ok, order} <- %Order{} |> Order.changeset(attrs) |> Repo.insert() do
Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_created, order})
{:ok, order}
end
end
end
Теперь любой подписчик — LiveView, канал, фоновый воркер, другая нода кластера — получает событие одинаково. Web-слой ничего не знает о том, кто слушает.
Три подводных камня:
- Эхо самому себе. Инициатор действия тоже получит broadcast и может обновить UI дважды. Либо делайте обработчик идемпотентным (
stream_insertпо тому же id просто заменит элемент), либо фильтруйте по отправителю. - Гроза сообщений. Тысяча LiveView, подписанных на одну тему, при массовом импорте получат тысячу событий на каждую запись. Агрегируйте: шлите «данные изменились», а не «вот все 10 000 записей», и подтягивайте изменения пачкой по таймеру.
- Порядок и потери. PubSub — это не очередь с гарантиями. При переподключении клиент обязан перечитать состояние, а не надеяться, что не пропустил ни одного события.
Тестирование
LiveViewTest кликает, вводит и проверяет DOM — без браузера и без драйвера:
defmodule MyAppWeb.OrderLive.IndexTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "фильтр меняет список", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/orders")
assert html =~ "Заказы"
view
|> element("#filter-paid")
|> render_click()
refute has_element?(view, "#orders tr", "новый")
end
test "валидация показывает ошибку", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/orders/new")
assert view
|> form("#order-form", order: %{number: ""})
|> render_change() =~ "не может быть пустым"
end
test "broadcast долетает до открытого LiveView", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/orders")
{:ok, order} = MyApp.Sales.create_order(%{number: "A-1"})
assert render(view) =~ order.number
end
end
Последний тест особенно ценен: он проверяет весь реалтайм-контур целиком — контекст, PubSub и рендер — за миллисекунды. В любом другом стеке это был бы медленный E2E с браузером. Про пирамиду тестов и что чем проверять — в главе Тестирование и в треке Тестирование ПО.
Жизненный цикл в проде
Из этой картинки следуют все продакшн-ограничения LiveView:
- Состояние процесса эфемерно. Переподключение = новый
mount. Всё, что должно пережить разрыв, обязано лежать в URL, сессии или БД. Это дисциплина, а не недостаток: она же спасает при деплое. - Деплой рвёт все соединения разом. Тысячи клиентов переподключатся одновременно и одновременно выполнят
mountс походом в базу — типичная причина «падения сразу после выката». Лечится джиттером на реконнекте, кэшем и постепенным rolling-выкатом (см. главу SDLC и ресурсы). - Задержка сети становится задержкой интерфейса. Каждый
phx-click— сетевой round-trip. Для пользователя в 200 мс от сервера это заметно. Мелкую интерактивность (открыть меню, подсветить) уводите в JS-команды; географически далёкую аудиторию — либо ближе к серверу, либо не в LiveView. - Память на соединение. Базовый LiveView — десятки килобайт; со списком в assigns — сколько угодно. Считайте:
число соединений × размер assigns. Именно поэтому существуют стримы. - Оценка мощности. Ориентир из публичных отчётов Phoenix — сотни тысяч простаивающих соединений на крупной ноде, но реальный предел задаёт частота обновлений и объём диффов, а не сам сокет. Мерить нужно свой сценарий: методология — в треке Производительность.
Когда LiveView — не тот инструмент
Честный список:
- Оффлайн-режим и работа при плохой связи. Нет соединения — нет UI. Для мобильного веба в метро нужен клиент с локальным состоянием.
- Интерфейсы с мгновенным откликом на каждое движение — редакторы кода, canvas-графика, drag-n-drop с превью. Round-trip убивает ощущение.
- Публичный клиент, где сервер не ваш — виджет на чужом сайте.
- Аудитория, размазанная по континентам, при одном регионе сервера.
Во всех остальных случаях — админки, дашборды, формы, ленты, чаты, мастера — LiveView убирает целый слой архитектуры: нет отдельного API, нет клиентского состояния, нет рассинхрона валидаций.
Типичные ошибки
- Подписка на PubSub без
connected?/1— двойные подписки и мёртвые таймеры в отключённом рендере. - Большой список в assigns вместо стрима — линейный рост памяти по числу вкладок.
- Тяжёлая работа прямо в
handle_event— LiveView перестаёт отвечать этому пользователю; нуженstart_async. - Состояние в хуке вместо сервера — рассинхрон и невозможность восстановиться после реконнекта.
- Забытый
idуlive_component— компонент теряет идентичность, состояние обнуляется на каждом рендере. push_navigateвместоpush_patchтам, где меняется только фильтр: полный ремоунт вместо диффа.- Отсутствие плана на реконнект после деплоя — одновременная лавина
mountв базу.
Мини-итог
LiveView — это GenServer, который рендерит HTML. Всё, что вы знаете про процессы, состояние и падения, применимо здесь один в один: mount — это init, handle_event — это handle_call, разрыв соединения — это смерть процесса, а восстановление состояния — ваша ответственность. Продуктивность даёт не «магия», а тот факт, что состояние и логика живут в одном месте, а по сети едут байты диффа.
Источники
- Phoenix LiveView docs — начните с
Phoenix.LiveViewиPhoenix.Component. - LiveView Uploads, JS Interoperability.
- Книга «Programming Phoenix LiveView» (Bruce Tate, Sophie DeBenedetto, PragProg).
- Блог fly.io/phoenix-files — много практических разборов LiveView-паттернов.
Что дальше
Метапрограммирование Elixir — как устроены use, ~H, schema и прочая «магия», которой на самом деле нет.