Elixir Phoenix LiveView: интерактивный UI с состоянием на сервере
0%

Phoenix LiveView: интерактивный UI с состоянием на сервере

Phoenix LiveView: UI, живущий в процессе

Обычный веб-фреймворк рендерит HTML и забывает о клиенте. SPA переносит состояние в браузер и платит за это дублированием логики, отдельным API и целым фронтенд-стеком. LiveView предлагает третий путь: состояние UI живёт на сервере в отдельном процессе BEAM, а по сети ходят только диффы разметки.

Такое решение возможно ровно потому, что процесс BEAM стоит копейки (см. главу Конкурентность и OTP). Десять тысяч открытых вкладок — это десять тысяч процессов по несколько килобайт, а не десять тысяч потоков ОС. Технология, которая в любом другом рантайме была бы безумием, здесь оказывается дешёвой.

Как это работает: два рендера и один процесс

Ключевая деталь, на которой спотыкаются все новички: mount/3 вызывается дважды.

Первый 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:

  1. Меняйте assigns через assign/3, а не пересобирайте socket руками. LiveView сравнивает старое и новое значение и помечает assign изменившимся. Если вы каждый раз кладёте новый список с теми же данными — дифф будет полным.
  2. Дорогие вычисления заворачивайте в 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_eventpush_patchhandle_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, разрыв соединения — это смерть процесса, а восстановление состояния — ваша ответственность. Продуктивность даёт не «магия», а тот факт, что состояние и логика живут в одном месте, а по сети едут байты диффа.

Источники

Что дальше

Метапрограммирование Elixir — как устроены use, ~H, schema и прочая «магия», которой на самом деле нет.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов