12k
All articles

Создание интерактивных приложений на Phoenix LiveView

Создайте поиск на Phoenix LiveView с серверным состоянием, шаблонами HEEx, событиями и встроенным JavaScript-хуком. Узнайте, где LiveView подходит.

OpenReplay Team
OpenReplay Team
Создание интерактивных приложений на Phoenix 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
Патч виртуального DOMHTML-дифф, отправляемый по 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 / VueLiveView нужно активное соединение для обработки событий
Насыщенное клиентское взаимодействие (редакторы, canvas, интерфейсы с активным drag-and-drop)React / VueКаждое действие стоит round trip; хуки помогают, но не заменяют клиентский фреймворк
Команда не готова осваивать ElixirReact / 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 выбирайте только тогда, когда состояние и обработку событий нужно держать внутри компонента.

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.