Phoenix.LiveComponent поведение
Исходный кодLiveComponents — это механизм для инкапсуляции состояния, разметки и событий, позволяющий совместно использовать их в LiveViews.
LiveComponents определяются с помощью Phoenix.LiveComponent и вызываются в родительском LiveView с помощью Phoenix.Component.live_component/1. Они выполняются в процессе LiveView, но имеют собственные состояние и жизненный цикл. Поэтому их также часто называют «компонентами с состоянием». В отличие от них, Phoenix.Component, также известные как «функциональные компоненты», не имеют состояния и жизненного цикла.
Для определения простейшего LiveComponent достаточно объявить функцию render/1:
defmodule HeroComponent do
# In Phoenix apps, the line is typically: use MyAppWeb, :live_component
use Phoenix.LiveComponent
def render(assigns) do
~H"""
<div class="hero">{@content}</div>
"""
end
end
LiveComponent отображается следующим образом:
<.live_component module={HeroComponent} id="hero" content={@content} />
Всегда необходимо передавать атрибуты module и id. id будет доступен как assign, и его необходимо использовать для уникальной идентификации компонента. Все остальные атрибуты будут доступны в LiveComponent как assigns.
Функциональные компоненты или LiveComponents?
Как правило, отдавайте предпочтение функциональным компонентам, а не LiveComponents: они представляют собой более простую абстракцию с меньшей поверхностью взаимодействия. LiveComponents нужны только в тех случаях, когда требуется инкапсулировать и обработку событий, и дополнительное состояние.
Не используйте LiveComponents только ради организации кода.
Жизненный цикл
Жизненный цикл LiveComponents похож на жизненный цикл LiveViews:
- LiveViews:
mount/3->handle_params/3->render/1 - LiveComponents:
mount/1->update/2->render/1
Кроме того, в обоих случаях можно определить callback handle_event/3 для обработки событий клиента и использовать handle_async/3 для работы с асинхронными assigns. В следующих разделах мы подробно рассмотрим жизненный цикл LiveComponents на примерах и диаграммах.
Монтирование и обновление
LiveComponents идентифицируются по модулю компонента и его ID. Часто ID компонента связывают с каким-либо ID приложения:
<.live_component module={UserComponent} id={@user.id} user={@user} />
При вызове live_component/1 функция mount/1 вызывается один раз, когда компонент впервые добавляется на страницу. Функция mount/1 получает в качестве аргумента socket. Обратите внимание, что это не та же структура socket, что и у родительского LiveView. Она не содержит assigns родительского LiveView, и её обновление не повлияет на socket родительского LiveView.
Затем вызывается update/2 со всеми assigns, переданными в live_component/1. В assigns, полученных в качестве первого аргумента функции update/2, будут только assigns, переданные в live_component/1, а также socket в качестве второго аргумента.
Если функция update/2 не определена, все assigns, переданные в live_component/1, просто объединяются с socket.assigns.
И mount/1, и update/2 должны возвращать кортеж, первым элементом которого является :ok, а вторым — обновлённый socket.
После обновления компонента вызывается render/1 со всеми assigns. При первой отрисовке происходит следующее:
mount(socket) -> update(assigns, socket) -> render(assigns)
Теперь при каждом изменении родительского компонента функция mount/1 больше не вызывается — выполняются только update, а затем render:
update(assigns, socket) -> render(assigns)
Два LiveComponent с одинаковыми модулем и ID считаются одним и тем же компонентом независимо от их расположения на странице. Поэтому при перемещении компонента в другое место внутри родительского LiveView он не будет смонтирован заново. Это позволяет использовать LiveComponents для создания карточек и других элементов, которые можно перемещать, не теряя состояние. Компонент удаляется, только когда клиент обнаруживает, что он исчез со страницы.
Наконец, указанный id автоматически не используется в качестве DOM ID. Если вы хотите задать DOM ID, это необходимо сделать самостоятельно при отрисовке:
defmodule UserComponent do
# In Phoenix apps, the line is typically: use MyAppWeb, :live_component
use Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id={"user-#{@id}"} class="user">
{@user.name}
</div>
"""
end
end
События
В LiveComponents также можно реализовать callback handle_event/3, работающий точно так же, как в LiveView. Чтобы событие клиента дошло до компонента, у тега должен быть атрибут phx-target. Чтобы отправить событие самому компоненту, можно просто использовать assign @myself, представляющий собой внутреннюю уникальную ссылку на экземпляр компонента:
<a href="#" phx-click="say_hello" phx-target={@myself}>
Say hello!
</a>
Обратите внимание, что @myself не задаётся для компонентов без состояния, поскольку они не могут получать события.
Чтобы обратиться к другому компоненту, можно также передать селектор ID или класса любого элемента внутри целевого компонента. Например, если имеется UserComponent с DOM ID "user-13", событие можно отправить ему с помощью селектора запроса:
<a href="#" phx-click="say_hello" phx-target="#user-13"> Say hello! </a>
В обоих случаях будет вызвана функция handle_event/3 с событием "say_hello". При вызове handle_event/3 для компонента клиенту отправляется только diff этого компонента, что обеспечивает высокую эффективность.
Примечание о безопасности
Событие payload содержит недоверенные данные от клиента. Прежде чем использовать эти данные для получения или изменения ресурсов, необходимо проверить их и убедиться в наличии соответствующих прав. Подробнее см. в руководстве «Соображения безопасности».
Поддерживаются любые допустимые селекторы запроса для phx-target, если найденные узлы являются дочерними элементами LiveView или LiveComponent. Например, можно отправить событие close нескольким компонентам:
<a href="#" phx-click="close" phx-target="#modal, #sidebar"> Dismiss </a>
Массовое обновление
В LiveComponents можно использовать необязательный callback update_many/1 вместо update/2. Функция update/2 вызывается отдельно для каждого компонента, тогда как update_many/1 вызывается для всех LiveComponents одного модуля, которые в данный момент отрисовываются или обновляются. Это позволяет предварительно загружать данные из базы данных одним запросом для всех компонентов, а не выполнять отдельный запрос для каждого из них.
Чтобы нагляднее объяснить, зачем нужны оба callback, рассмотрим пример. Предположим, вы разрабатываете компоненту, которой необходимо загрузить некоторое состояние из базы данных. Например:
<.live_component module={UserComponent} id={user_id} />
Возможная реализация — загрузить пользователя в callback update/2:
def update(assigns, socket) do
user = Repo.get!(User, assigns.id)
{:ok, assign(socket, :user, user)}
end
Однако у этого подхода есть проблема: если на одной странице отображается несколько компонентов пользователя, возникает проблема N+1 запросов. Вместо update/2 можно использовать update_many/1. В этом случае мы получаем список всех assigns и сокетов, что позволяет обновить их все за один раз:
def update_many(assigns_sockets) do
list_of_ids = Enum.map(assigns_sockets, fn {assigns, _socket} -> assigns.id end)
users =
from(u in User, where: u.id in ^list_of_ids, select: {u.id, u})
|> Repo.all()
|> Map.new()
Enum.map(assigns_sockets, fn {assigns, socket} ->
assign(socket, :user, users[assigns.id])
end)
end
Теперь к базе данных будет выполнен только один запрос. Фактически алгоритм update_many/1 обходит дерево в ширину, благодаря чему количество запросов сводится к минимуму даже для вложенных компонентов.
Наконец, обратите внимание, что update_many/1 должен возвращать обновлённый список сокетов в том же порядке, в котором они были переданы. Если определён update_many/1, update/2 не вызывается.
Краткое описание
Все события жизненного цикла сведены в диаграмму ниже. Белые круги обозначают события, запускающие компонент. Фиолетовым цветом обозначены callback компонента; подчёркнутые названия указывают на обязательные callback.
flowchart LR
*((start)):::event-.->M
WE([wait for<br>parent changes]):::event-.->M
W([wait for<br>events]):::event-.->H
subgraph j__transparent[" "]
subgraph i[" "]
direction TB
M(mount/1<br><em>only once</em>):::callback
M-->U
M-->UM
end
U(update/2):::callback-->A
UM(update_many/1):::callback-->A
subgraph j[" "]
direction TB
A --> |yes| R
H(handle_event/3):::callback-->A{any<br>changes?}:::diamond
end
A --> |no| W
end
R(render/1):::callback_req-->W
classDef event fill:#fff,color:#000,stroke:#000
classDef diamond fill:#FFC28C,color:#000,stroke:#000
classDef callback fill:#B7ADFF,color:#000,stroke-width:0
classDef callback_req fill:#B7ADFF,color:#000,stroke-width:0,text-decoration:underline
Управление состоянием
Теперь, когда мы узнали, как определять и использовать компоненты, а также применять update_many/1 для оптимизации загрузки данных, важно поговорить об управлении состоянием в компонентах.
Как правило, следует избегать ситуации, когда родительский LiveView и LiveComponent работают с двумя разными копиями состояния. Вместо этого нужно выбрать только один из них в качестве источника истины. Рассмотрим подробнее два возможных подхода.
Представим, что LiveView отображает доску, а каждая карточка на ней является отдельным LiveComponent. У каждой карточки есть форма, позволяющая непосредственно в компоненте изменить её заголовок:
defmodule CardComponent do
use Phoenix.LiveComponent
def render(assigns) do
~H"""
<form phx-submit="..." phx-target={@myself}>
<input name="title">{@card.title}</input>
...
</form>
"""
end
...
end
Посмотрим, как организовать поток данных, чтобы источником истины был либо LiveView доски, либо LiveComponents карточек.
LiveView как источник истины
Если источником истины является LiveView доски, он отвечает за получение всех карточек на доске. Затем он вызывает live_component/1 для каждой карточки, передавая структуру карточки в качестве аргумента CardComponent:
<.live_component
:for={card <- @cards}
module={CardComponent}
card={card}
id={card.id}
board_id={@id}
/>
При отправке формы пользователем будет вызван CardComponent.handle_event/3. Однако, если обновление прошло успешно, не следует изменять структуру карточки внутри компонента. Иначе структура карточки в компоненте перестанет соответствовать данным LiveView. Поскольку источником истины является LiveView, нужно сообщить ему об обновлении карточки.
К счастью, компонент и представление выполняются в одном процессе, поэтому отправить сообщение из LiveComponent в родительский LiveView так же просто, как отправить сообщение в self():
defmodule CardComponent do
...
def handle_event("update_title", %{"title" => title}, socket) do
send self(), {:updated_card, %{socket.assigns.card | title: title}}
{:noreply, socket}
end
end
Затем LiveView получает это событие с помощью Phoenix.LiveView.handle_info/2:
defmodule BoardView do
...
def handle_info({:updated_card, card}, socket) do
# update the list of cards in the socket
{:noreply, updated_socket}
end
end
Поскольку список карточек в родительском сокете обновился, родительский LiveView будет отрисован повторно и отправит компоненту обновлённую карточку. Таким образом, компонент всё же получает обновлённую карточку, но обновление всегда инициируется родителем.
Вместо отправки сообщения непосредственно родительскому представлению компонент может транслировать обновление с помощью Phoenix.PubSub. Например:
defmodule CardComponent do
...
def handle_event("update_title", %{"title" => title}, socket) do
message = {:updated_card, %{socket.assigns.card | title: title}}
Phoenix.PubSub.broadcast(MyApp.PubSub, board_topic(socket), message)
{:noreply, socket}
end
defp board_topic(socket) do
"board:" <> socket.assigns.board_id
end
end
Если родительский LiveView подписан на топик board:<ID>, он будет получать обновления. Преимущество PubSub в том, что распределённые обновления поддерживаются «из коробки». Теперь, если любой подключённый к доске пользователь изменит карточку, это изменение увидят и все остальные пользователи.
LiveComponent как источник истины
Если источником истины является LiveComponent каждой карточки, LiveView доски больше не должен получать структуры карточек из базы данных. Вместо этого LiveView доски должен получать только ID карточек, а затем отрисовывать каждый компонент, передавая ему только ID:
<.live_component
:for={card_id <- @card_ids}
module={CardComponent}
id={card_id}
board_id={@id}
/>
Теперь каждая CardComponent будет загружать свою карточку. Конечно, загрузка данных для каждой карточки по отдельности может быть затратной и привести к N запросам, где N — число карточек, поэтому для повышения эффективности можно использовать callback update_many/1.
После запуска компоненты карточек могут самостоятельно управлять своими карточками, не завися от родительского LiveView.
Однако обратите внимание, что у компонентов нет callback Phoenix.LiveView.handle_info/2. Поэтому для отслеживания распределённых изменений карточки родительский LiveView должен получать эти события и перенаправлять их соответствующей карточке. Например, если обновления карточек отправляются в топик "board:ID", на который подписан LiveView доски, можно сделать следующее:
def handle_info({:updated_card, card}, socket) do
send_update CardComponent, id: card.id, board_id: socket.assigns.id
{:noreply, socket}
end
С помощью Phoenix.LiveView.send_update/3 будет вызван CardComponent, переданный в id. Это запустит callback update или update_many, который загрузит из базы данных самые актуальные данные.
Объединение взаимодействия LiveView и LiveComponent
В приведённых выше примерах для взаимодействия с LiveView мы использовали send/2, а для взаимодействия с компонентами — send_update/2. Это создаёт проблему: что делать, если компонент может быть смонтирован как внутри LiveView, так и внутри другого компонента? Поначалу это может показаться сложным, поскольку для обмена данными используются разные API, но элегантное решение — использовать анонимные функции в качестве callback. Рассмотрим пример.
В предыдущих разделах в CardComponent мы написали следующий код:
def handle_event("update_title", %{"title" => title}, socket) do
send(self(), {:updated_card, %{socket.assigns.card | title: title}})
{:noreply, socket}
end
Проблема этого кода в том, что, если CardComponent смонтирован внутри другого компонента, он всё равно отправит сообщение в LiveView. Кроме того, такой код сложно поддерживать, поскольку сообщение, отправляемое компонентом, определяется далеко от LiveView, который его получит.
Вместо этого определим callback, который будет вызываться CardComponent:
def handle_event("update_title", %{"title" => title}, socket) do
socket.assigns.on_card_update.(%{socket.assigns.card | title: title})
{:noreply, socket}
end
Теперь при инициализации CardComponent из LiveView можно написать:
<.live_component
module={CardComponent}
card={card}
id={card.id}
board_id={@id}
on_card_update={fn card -> send(self(), {:updated_card, card}) end} />
При инициализации внутри другого компонента можно написать:
<.live_component
module={CardComponent}
card={card}
id={card.id}
board_id={@id}
on_card_update={fn card -> send_update(@myself, card: card) end} />
Главное преимущество обоих подходов в том, что родитель явно контролирует получаемые сообщения.
Стоимость LiveComponents
Внутренняя инфраструктура, с помощью которой LiveView отслеживает LiveComponents, очень лёгкая. Однако учтите: для отслеживания изменений и отправки diff по сети assigns всех компонентов хранятся в памяти — точно так же, как и в самих LiveViews.
Поэтому вам следует оставлять в каждом компоненте только необходимые assigns. Например, не передавайте при отрисовке компонента все assigns LiveView:
<.live_component module={MyComponent} {assigns} />
Вместо этого передавайте только нужные ключи:
<.live_component module={MyComponent} user={@user} org={@org} />
К счастью, поскольку LiveViews и LiveComponents выполняются в одном процессе, они используют общие представления структур данных в памяти. Например, в приведённом выше коде представление и компонент будут совместно использовать одни и те же копии assigns @user и @org.
Также не следует использовать LiveComponents для создания абстрактных DOM-компонентов. Хороший LiveComponent должен инкапсулировать задачи приложения, а не функциональность DOM. Например, если на странице отображаются товары для продажи, отрисовку каждого товара можно инкапсулировать в компонент. Внутри такого компонента может быть множество кнопок и событий. И наоборот, не создавайте компонент, который лишь инкапсулирует универсальные DOM-компоненты. Например, не делайте так:
defmodule MyButton do
use Phoenix.LiveComponent
def render(assigns) do
~H"""
<button class="css-framework-class" phx-click="click">
{@text}
</button>
"""
end
def handle_event("click", _, socket) do
_ = socket.assigns.on_click.()
{:noreply, socket}
end
end
Вместо этого гораздо проще создать функциональный компонент:
def my_button(%{text: _, click: _} = assigns) do
~H"""
<button class="css-framework-class" phx-click={@click}>
{@text}
</button>
"""
end
Если компоненты в основном решают задачи приложения и содержат только необходимые assigns, проблем, связанных с LiveComponents, скорее всего, не возникнет.
Live-переходы и перенаправления
В шаблоне, отрисованном внутри компонента, можно использовать <.link patch={...}> и <.link navigate={...}>. Переходы всегда обрабатываются родительским LiveView, поскольку компоненты не предоставляют handle_params.
Слоты
LiveComponent также может принимать слоты, как и Phoenix.Component:
<.live_component module={MyComponent} id={@data.id} >
<div>Inner content here</div>
</.live_component>
Если в LiveComponent определён update/2, убедитесь, что возвращаемый им сокет содержит assign :inner_block, полученный функцией.
Дополнительную информацию см. в документации по Phoenix.Component слотам.
Ограничения
В корне Live Component должен находиться один HTML-тег. Компоненты, отображающие только текст или несколько тегов, создавать нельзя.
Краткое описание
Callback
- __using__(opts \\ [])
Использует LiveComponent в текущем модуле.
Функции
Callback
handle_async(name, async_fun_result, socket)Исходный код
@callback handle_async(
name :: term(),
async_fun_result :: {:ok, term()} | {:exit, term()},
socket :: Phoenix.LiveView.Socket.t()
) :: {:noreply, Phoenix.LiveView.Socket.t()} handle_event(event, payload, socket)Исходный код
@callback handle_event(
event :: binary(),
payload :: term(),
socket :: Phoenix.LiveView.Socket.t()
) ::
{:noreply, Phoenix.LiveView.Socket.t()}
| {:reply, map(), Phoenix.LiveView.Socket.t()} mount(socket)Исходный код
@callback mount(socket :: Phoenix.LiveView.Socket.t()) ::
{:ok, Phoenix.LiveView.Socket.t()}
| {:ok, Phoenix.LiveView.Socket.t(), keyword()} render(assigns)Исходный код
@callback render(assigns :: Phoenix.LiveView.Socket.assigns()) :: Phoenix.LiveView.Rendered.t()
update(assigns, socket)Исходный код
@callback update(
assigns :: Phoenix.LiveView.Socket.assigns(),
socket :: Phoenix.LiveView.Socket.t()
) ::
{:ok, Phoenix.LiveView.Socket.t()} update_many(list)Исходный код
@callback update_many([{Phoenix.LiveView.Socket.assigns(), Phoenix.LiveView.Socket.t()}]) ::
[
Phoenix.LiveView.Socket.t()
] Функции
__using__(opts \\ [])Исходный код
Использует LiveComponent в текущем модуле.
use Phoenix.LiveComponent
Параметры
-
:global_prefixes— глобальные префиксы, используемые для компонентов. Подробнее см.Global AttributesвPhoenix.Component.
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix-live-view.hexdocs.pm/Phoenix.LiveComponent.html