Создание интерактивных приложений на Phoenix LiveView
Создайте поиск на Phoenix LiveView с серверным состоянием, шаблонами HEEx, событиями и встроенным JavaScript-хуком. Узнайте, где LiveView подходит.
Phoenix LiveView рендерит HTML на сервере, хранит состояние каждой страницы в долгоживущем процессе Elixir и отправляет в браузер через постоянное соединение только изменившиеся части страницы.
Если вы создаёте CRUD-экраны на React или Vue, то знаете привычную схему: JSON-эндпоинт, клиентское хранилище, состояния загрузки и валидация, которую приходится писать отдельно для каждой стороны. Большая часть этого кода лишь перекладывает данные между двумя копиями одного и того же состояния.
В этой статье показано, как LiveView избавляет такие приложения от слоя API. Вы создадите поле живого поиска на синтаксисе LiveView 1.2, добавите один фрагмент клиентского JavaScript и увидите, где эта модель перестаёт подходить.
Ключевые выводы
- LiveView хранит состояние UI в серверном процессе. События браузера передаются по WebSocket, а LiveView возвращает только изменившийся HTML.
- Первый рендер LiveView — это обычный HTTP-ответ, а
mount/3выполняется повторно после установки WebSocket-соединения. - Базовому LiveView нужны три колбэка:
mount/3задаёт начальное состояние,render/1возвращает HEEx-шаблон, аhandle_event/3обновляет состояние в ответ на привязки (bindings), такие какphx-clickиphx-change. - Колоцированные хуки (colocated hooks) позволяют размещать JavaScript компонента в теге
<script>рядом с его разметкой. Для них требуется Phoenix 1.8 или новее. - Каждое событие, обрабатываемое на сервере, — это сетевой round trip. Для offline-first-приложений и интерфейсов с насыщенным клиентским взаимодействием React или Vue по-прежнему подходят лучше.
Что делает Phoenix LiveView?
Phoenix LiveView переносит состояние, обработку событий и рендеринг из браузера на сервер, а браузер держит открытым постоянное соединение для получения обновлений. В документации LiveView каждый LiveView описывается как процесс: он принимает события, изменяет своё состояние и отправляет странице только те части, которые отличаются.
Соответствие уже знакомой вам модели SPA:
| Концепция SPA | Аналог в LiveView |
|---|---|
Состояние компонента (useState, ref) | Assigns в серверном сокете |
Обработчик события (onClick) | Колбэк handle_event/3 |
| Патч виртуального DOM | HTML-дифф, отправляемый по WebSocket |
| API-запрос для получения данных | Вызов функции внутри того же процесса |
Жизненный цикл состоит из двух фаз. Первый рендер — обычный HTTP-ответ, поэтому страница видна и индексируется ещё до загрузки JavaScript. Затем браузер открывает WebSocket, и mount/3 выполняется второй раз — уже внутри процесса, который живёт, пока страница открыта. Следовательно, код в mount/3 выполняется дважды при каждой загрузке страницы.
Если вы работали с htmx, идея управления UI с помощью HTML, отрендеренного на сервере, покажется вам знакомой. Разница в том, что LiveView дополнительно держит на сервере stateful-процесс для каждой подключённой страницы.
Как создать проект Phoenix и добавить live-маршрут?
Новое приложение на Phoenix 1.8 поставляется с уже настроенным LiveView, поэтому одна команда генератора даёт всё необходимое для этого примера. После установки Elixir и инсталлятора Phoenix выполните:
mix phx.new my_app --no-ecto
cd my_app
mix phx.server
Флаг --no-ecto исключает слой базы данных, который демо-приложению не нужен. Сервер разработки слушает http://localhost:4000. Флаг --live не нужен: в старых руководствах его передают, но теперь LiveView входит в каждое сгенерированное приложение.
Откройте lib/my_app_web/router.ex. Внутри существующего блока scope "/", MyAppWeb do, под pipe_through :browser, добавьте:
live "/search", SearchLive
Макрос live сопоставляет путь с модулем LiveView — так же, как get сопоставляет путь с контроллером. Везде, где встречается MyApp, подставьте имя своего приложения. Phoenix из коробки включает генераторы, заготовки для аутентификации и LiveView, то есть это фреймворк по принципу «всё включено» (batteries included).
Как создать поле живого поиска на Phoenix LiveView?
Полю живого поиска нужны одна привязка формы, реагирующая на ввод, и одна привязка клика, сбрасывающая состояние. Создайте lib/my_app_web/live/search_live.ex:
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: frameworks())}
end
def render(assigns) do
~H"""
<form phx-change="search" phx-submit="search">
<input
id="search-input"
type="text"
name="q"
value={@query}
placeholder="Filter frameworks"
autocomplete="off"
phx-debounce="300"
/>
</form>
<button phx-click="clear">Clear</button>
<p>{length(@results)} matches</p>
<ul>
<li :for={name <- @results}>{name}</li>
</ul>
"""
end
def handle_event("search", %{"q" => q}, socket) do
{:noreply,
socket
|> assign(:query, q)
|> assign(:results, filter(q))}
end
def handle_event("clear", _params, socket) do
{:noreply, assign(socket, query: "", results: frameworks())}
end
defp frameworks do
["Phoenix", "Rails", "Laravel", "Django", "Next.js", "Nuxt", "SvelteKit", "Remix"]
end
defp filter(q) do
needle = String.downcase(q)
Enum.filter(frameworks(), &String.contains?(String.downcase(&1), needle))
end
end
mount/3 получает параметры, сессию и сокет именно в таком порядке и возвращает сокет с начальными assigns. render/1 возвращает HEEx-шаблон, построенный с помощью сигила ~H. Каждая из привязок phx-change, phx-submit и phx-click отправляет именованное событие в handle_event/3. Этот колбэк сопоставляет имя события, возвращает {:noreply, socket} с обновлёнными assigns, после чего LiveView отправляет дифф затронутого HTML.
phx-change срабатывает по мере ввода, поэтому каждое нажатие клавиши превращалось бы в round trip. phx-debounce ограничивает частоту событий и принимает значение в миллисекундах или "blur". Если направить phx-submit на то же событие, нажатие Enter обработает LiveView, а не браузер отправкой формы. Как показано в руководстве по привязкам форм, параметры формы приходят в виде map со строковыми ключами, названными по именам полей ввода, например %{"q" => "pho"}.
Интерполяция в шаблонах выполняется через {@query}. Руководства, где используется <%= @query %>, написаны до появления актуального синтаксиса. Атрибут :for — это сокращённая запись comprehension в HEEx.
Что нужно знать об Elixir для этого файла:
- Атомы, такие как
:ok,:noreplyи:query, — это именованные константы, похожие на строковые enum. - Кортежи, такие как
{:noreply, socket}, — это группы значений фиксированного размера. Колбэки возвращают их, чтобы сообщить о результате. - Заголовки функций с сопоставлением с образцом: два клауза
handle_event/3— отдельные определения, и Elixir выполняет тот, чей первый аргумент совпадает с именем события.%{"q" => q}деструктурирует параметры так же, какconst { q } = paramsв JavaScript. - Пайпы:
|>передаёт значение слева первым аргументом в следующий вызов. - Иммутабельность:
assignвозвращает новый сокет. Ничего не изменяется на месте.
Как добавить клиентский JavaScript с помощью колоцированного хука?
Колоцированный хук позволяет писать JavaScript компонента в теге <script> прямо рядом с его разметкой. Имя хука начинается с точки, а элемент, к которому он привязан, должен иметь уникальный DOM id. Полное описание API хуков приведено в руководстве по взаимодействию с JS.
В примере ниже добавляются горячие клавиши: / переводит фокус на поле поиска, а Escape очищает его. Замените <input> в render/1 на:
<input id="search-input" type="text" name="q" value={@query}
placeholder="Filter frameworks" autocomplete="off"
phx-debounce="300"
phx-hook=".SearchShortcuts" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".SearchShortcuts">
export default {
mounted() {
this.onKey = (e) => {
if (e.key === "/" && document.activeElement !== this.el) {
e.preventDefault()
this.el.focus()
}
if (e.key === "Escape" && document.activeElement === this.el) {
this.el.value = ""
this.el.blur()
this.pushEvent("clear", {})
}
}
window.addEventListener("keydown", this.onKey)
},
destroyed() {
window.removeEventListener("keydown", this.onKey)
}
}
</script>
mounted выполняется при добавлении элемента на страницу, а destroyed — при его удалении, поэтому обработчик подключается один раз и затем корректно удаляется. pushEvent("clear", {}) вызывает тот же клауз handle_event("clear", ...), что и кнопка, так что клиент и сервер используют один путь выполнения кода. Очистка поля и снятие с него фокуса до отправки события синхронизируют видимое значение с серверным состоянием.
Модуль ColocatedHook извлекает скрипт на этапе компиляции. Имя с префиксом-точкой помещается в пространство имён модуля, а сама функциональность требует Phoenix 1.8 или новее. Файл app.js, сгенерированный mix phx.new, уже регистрирует эти хуки:
import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
// ...
hooks: {...colocatedHooks}
Когда LiveView подходит, а когда нет?
Каждое событие, обрабатываемое handle_event/3, — это round trip до сервера. В быстрой сети LiveView ощущается мгновенным, в медленной — заторможенным. Чисто визуальные изменения, например открытие выпадающего списка, можно выполнять в браузере с помощью команд Phoenix.LiveView.JS, вообще не обращаясь к серверу.
На localhost эта задержка незаметна. Файл assets/js/app.js, сгенерированный mix phx.new, уже делает сокет доступным как window.liveSocket, поэтому для имитации медленного соединения выполните в консоли браузера:
liveSocket.enableLatencySim(500)
// later
liveSocket.disableLatencySim()
Симулятор задержки добавляет переданное число миллисекунд как одностороннюю задержку в каждом направлении — к серверу и от него. В продакшене записи пользовательских сессий (session replay) LiveView-приложений на медленных соединениях показывают ту же проблему со стороны пользователя: ввод отстаёт от набора текста, а при разрыве и восстановлении соединения страница мерцает.
У LiveView есть ещё две издержки. Офлайн-поддержку реализовать сложно, поскольку для обработки событий требуется активное соединение с сервером. Кроме того, в продакшене придётся эксплуатировать Elixir — с одним процессом на каждую подключённую страницу.
| Ситуация | Что подходит лучше | Почему |
|---|---|---|
| CRUD-экраны, дашборды, формы, админ-панели | LiveView | Нет отдельного слоя API; состояние и рендеринг остаются на сервере |
| Offline-first-приложения или плохое качество связи | React / Vue | LiveView нужно активное соединение для обработки событий |
| Насыщенное клиентское взаимодействие (редакторы, canvas, интерфейсы с активным drag-and-drop) | React / Vue | Каждое действие стоит round trip; хуки помогают, но не заменяют клиентский фреймворк |
| Команда не готова осваивать Elixir | React / Vue | Придётся запускать и поддерживать Elixir на сервере |
Более широкий взгляд на компромисс между серверным и клиентским управлением UI даёт это сравнение htmx с Vue и React.
Заключение
LiveView хорошо подходит для CRUD-приложений, которым иначе понадобились бы JSON API и клиентское хранилище. Вы пишете колбэки в одном модуле, сервер хранит состояние, а хуки или JS-команды покрывают то, что должно выполняться в браузере. Хороший следующий шаг — сгенерировать приложение на Phoenix 1.8, переписать одну форму из вашего текущего SPA на LiveView и включить симулятор задержки, чтобы до принятия окончательного решения увидеть, как она ведёт себя на медленном соединении.
Часто задаваемые вопросы
Что происходит, когда LiveView теряет WebSocket-соединение?
Клиент автоматически переподключается с экспоненциальной задержкой (exponential back-off), и mount/3 выполняется снова, поэтому все assigns, которые не восстанавливаются в mount/3, теряются. Формы, у которых есть и атрибут id, и привязка phx-change, восстанавливаются автоматически: после переподключения LiveView повторно отправляет текущие значения формы через событие phx-change. Добавьте phx-auto-recover, чтобы вызывать другое событие восстановления, или задайте ему значение 'ignore', чтобы отключить восстановление.
Как сделать так, чтобы код в mount/3 не выполнялся дважды?
Оберните его в проверку connected?(socket). Phoenix.LiveView.connected?/1 возвращает false во время статического HTTP-рендера и true после установки WebSocket-соединения. Поэтому подписки, таймеры и ресурсоёмкие запросы внутри блока if connected?(socket) выполняются только в live-процессе. Дешёвые assigns со значениями по умолчанию оставляйте вне проверки, чтобы статический рендер по-прежнему выдавал полный, индексируемый HTML.
Как тестировать LiveView без браузера?
Используйте Phoenix.LiveViewTest — он поставляется вместе с LiveView и работает под ExUnit. Вызовите live(conn, path), чтобы смонтировать представление и получить его дескриптор, затем управляйте им с помощью хелперов вроде render_click и render_change и проверяйте возвращаемый ими HTML. Такие тесты взаимодействуют с процессом LiveView напрямую, поэтому клиентский JavaScript хуков в них не выполняется.
Чем функциональный компонент отличается от LiveComponent?
Функциональный компонент — это функция без состояния, которая принимает assigns и возвращает HEEx, поэтому события из его разметки попадают в родительский LiveView. LiveComponent, определяемый через use Phoenix.LiveComponent, хранит собственное состояние и сам обрабатывает свои события, если в его разметке задано phx-target={@myself}. Оба выполняются внутри процесса родительского LiveView. По умолчанию используйте функциональные компоненты, а LiveComponent выбирайте только тогда, когда состояние и обработку событий нужно держать внутри компонента.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k