查看源代码 Phoenix.LiveComponent 行为 (Phoenix LiveView v0.20.17)
LiveComponents 是一个机制,用于在 LiveView 中对状态、标记和事件进行分隔。
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
将作为分配可用,并且必须用于唯一标识组件。所有其他属性都将作为分配在 LiveComponent 内可用。
函数组件还是 Live 组件?
一般来说,您应该优先使用函数组件而不是 Live 组件,因为它们是一种更简单的抽象,具有更小的表面积。Live 组件的使用案例只出现在需要封装事件处理和额外状态时。
生命周期
挂载和更新
Live 组件由组件模块及其 ID 标识。我们通常将组件 ID 绑定到某个基于应用程序的 ID
<.live_component module={UserComponent} id={@user.id} user={@user} />
当 live_component/1
被调用时, mount/1
在组件首次添加到页面时被调用一次。 mount/1
接收 socket
作为参数。然后 update/2
会被调用,其中包含传递给 live_component/1
的所有分配。如果 update/2
未定义,则所有分配将简单地合并到 socket 中。作为 update/2
回调的第一个参数接收的分配将只包含从该函数传递的新的分配。可以在 socket.assigns
中找到预先存在的分配。
组件更新后, render/1
会被调用,其中包含所有分配。在第一次渲染时,我们得到
mount(socket) -> update(assigns, socket) -> render(assigns)
在进一步渲染时
update(assigns, socket) -> render(assigns)
具有相同模块和 ID 的两个 Live 组件被视为同一个组件,无论它们在页面中的位置如何。因此,如果您更改了组件在其父 LiveView 中渲染的位置,它不会重新挂载。这意味着您可以使用 Live 组件来实现卡片和其他可以在不丢失状态的情况下四处移动的元素。组件只会在客户端观察到它从页面中移除时被丢弃。
最后,给定的 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 还可以实现 handle_event/3
回调,其工作方式与 LiveView 中完全相同。为了让客户端事件到达组件,标签必须用 phx-target
进行注释。如果您想将事件发送给自己,则可以简单地使用 @myself
分配,它是一个对组件实例的内部唯一引用
<a href="#" phx-click="say_hello" phx-target={@myself}>
Say hello!
</a>
请注意,无状态组件没有设置 @myself
,因为它们无法接收事件。
如果您想将目标设置为另一个组件,您还可以将 ID 或类选择器传递给目标组件内的任何元素。例如,如果有一个 DOM ID 为 "user-13"
的 UserComponent
,使用查询选择器,我们可以用以下方法将事件发送给它
<a href="#" phx-click="say_hello" phx-target="#user-13">
Say hello!
</a>
在这两种情况下, handle_event/3
都会被调用,其中包含“say_hello”事件。当 handle_event/3
被组件调用时,只有组件的差异会被发送到客户端,这使得它们非常高效。
任何有效的 phx-target
查询选择器都受支持,前提是匹配的节点是 LiveView 或 LiveComponent 的子节点,例如要将 close
事件发送到多个组件
<a href="#" phx-click="close" phx-target="#modal, #sidebar">
Dismiss
</a>
批量更新
Live 组件还支持一个可选的 update_many/1
回调,作为 update/2
的替代方案。虽然 update/2
是针对每个组件单独调用的,但 update_many/1
是针对当前正在渲染/更新的相同模块的所有 LiveComponents 调用的。优势在于,您可以使用单个查询为所有组件预加载数据,而不是为每个组件运行一个查询。
为了更全面地了解为什么这两个回调都是必要的,让我们来看一个例子。假设您正在实现一个组件,并且组件需要从数据库加载一些状态。例如
<.live_component module={UserComponent} id={user_id} />
一种可能的实现是在 update/2
回调中加载用户
def update(assigns, socket) do
user = Repo.get!(User, assigns.id)
{:ok, assign(socket, :user, user)}
end
但是,这种方法的问题在于,如果您在同一个页面中渲染了多个用户组件,您会遇到 N+1 查询问题。通过使用 update_many/1
而不是 update/2
,我们接收所有分配和 socket 的列表,允许我们一次更新多个组件
def update_many(assigns_sockets) do
list_of_ids = Enum.map(assigns_sockets, fn {assigns, _sockets} -> 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
必须以与它们给出的相同顺序返回更新后的 socket 列表。如果定义了 update_many/1
,则不会调用 update/2
。
总结
所有生命周期事件都在下图中总结。白色气泡事件是触发组件的触发器。蓝色代表组件回调,下划线名称代表必需的回调
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
<%= for card <- @cards do %>
<.live_component module={CardComponent} card={card} id={card.id} board_id={@id} />
<% end %>
现在,当用户提交表单时,将触发 CardComponent.handle_event/3
。但是,如果更新成功,您不能更改组件中的卡片结构体。如果您这样做,组件中的卡片结构体将与 LiveView 不同步。由于 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
由于父 socket 中的卡片列表已更新,父 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 来渲染每个组件
<%= for card_id <- @card_ids do %>
<.live_component module={CardComponent} id={card_id} board_id={@id} />
<% end %>
现在,每个 CardComponent 将加载自己的卡片。当然,这样做会对每个卡片进行昂贵的操作,并会导致 N 个查询,其中 N 是卡片数量,因此我们可以使用 update_many/1
回调来提高效率。
卡片组件启动后,它们可以分别管理自己的卡片,而不必关心父 LiveView。
但是,请注意,组件没有 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
,由 id
给出的 CardComponent
将被调用,触发更新或 update_many 回调,这将从数据库中加载最新的数据。
统一 LiveView 和 LiveComponent 通信
在上面的示例中,我们使用了 send/2
与 LiveView 通信,使用 send_update/2
与组件通信。这带来了一个问题:如果您的组件可能同时在 LiveView 和另一个组件内挂载,会怎么样?鉴于每个组件使用不同的 API 来交换数据,这在开始时可能看起来很棘手,但一个优雅的解决方案是使用匿名函数作为回调。让我们来看一个例子。
在上面的部分中,我们在 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 远处。
相反,让我们定义一个由 CardComponent 调用的回调函数
def handle_event("update_title", %{"title" => title}, socket) do
socket.assigns.on_card_update.(%{socket.assigns.card | title: title})
{:noreply, socket}
end
现在,当从 LiveView 初始化 CardComponent 时,我们可以写成
<.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} />
这两种情况下,主要的优势是父组件可以显式地控制它将接收到的消息。
插槽
LiveComponent 也可以接收插槽,就像 Phoenix.Component
一样。
<.live_component module={MyComponent} id={@data.id} >
<div>Inner content here</div>
</.live_component>
如果 LiveComponent 定义了一个 update/2
,请确保它返回的 socket 包含它接收到的 :inner_block
赋值。
有关更多信息,请参阅 文档,了解 Phoenix.Component
。
实时补丁和实时重定向
在组件内部渲染的模板可以使用 <.link patch={...}>
和 <.link navigate={...}>
。补丁始终由父 LiveView 处理,因为组件不提供 handle_params
。
实时组件的成本
LiveView 用于跟踪实时组件的内部基础设施非常轻量级。但是,请注意,为了提供变更跟踪并在网络上传送差异,所有组件的赋值都将保存在内存中 - 就像在 LiveView 本身中一样。
因此,您有责任仅保留每个组件中必要的赋值。例如,避免在渲染组件时传递所有 LiveView 的赋值
<.live_component module={MyComponent} {assigns} />
相反,只传递您需要的键
<.live_component module={MyComponent} user={@user} org={@org} />
幸运的是,由于 LiveView 和 LiveComponent 在同一个进程中,它们共享内存中的数据结构表示。例如,在上面的代码中,视图和组件将共享 @user
和 @org
赋值的相同副本。
您还应该避免使用实时组件来提供抽象的 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
如果您将组件主要作为应用程序问题处理,只包含必要的赋值,则不太可能遇到与实时组件相关的问题。
局限性
实时组件需要一个根节点上的单个 HTML 标签。不可能拥有仅渲染文本或多个标签的组件。
总结
函数
在当前模块中使用 LiveComponent。
回调函数
@callback handle_async( name :: term(), async_fun_result :: {:ok, term()} | {:exit, term()}, socket :: Phoenix.LiveView.Socket.t() ) :: {:noreply, Phoenix.LiveView.Socket.t()}
@callback handle_event( event :: binary(), unsigned_params :: Phoenix.LiveView.unsigned_params(), socket :: Phoenix.LiveView.Socket.t() ) :: {:noreply, Phoenix.LiveView.Socket.t()} | {:reply, map(), Phoenix.LiveView.Socket.t()}
@callback mount(socket :: Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} | {:ok, Phoenix.LiveView.Socket.t(), keyword()}
@callback render(assigns :: Phoenix.LiveView.Socket.assigns()) :: Phoenix.LiveView.Rendered.t()
@callback update( assigns :: Phoenix.LiveView.Socket.assigns(), socket :: Phoenix.LiveView.Socket.t() ) :: {:ok, Phoenix.LiveView.Socket.t()}
@callback update_many([{Phoenix.LiveView.Socket.assigns(), Phoenix.LiveView.Socket.t()}]) :: [ Phoenix.LiveView.Socket.t() ]
函数
在当前模块中使用 LiveComponent。
use Phoenix.LiveComponent
选项
:global_prefixes
- 用于组件的全局前缀。有关更多信息,请参阅Phoenix.Component
中的全局属性
。