檢視原始碼 Phoenix.LiveComponent 行為 (Phoenix LiveView v0.20.17)
LiveComponents 是區隔 LiveView 中的狀態、標記和事件的機制。
使用 Phoenix.LiveComponent
定義 LiveComponents,並使用 Phoenix.Component.live_component/1
在父 LiveView 中呼叫。它們在 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 元件是由元件模組及其 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 的兩個動態元件將被視為相同的元件,無論它們在頁面中的任何位置。因此,如果您變更元件在其父級 LiveView 中呈現的位置,它不會重新掛載。這表示您可以使用動態元件,實作卡片和其他不會因遺失狀態而無法移動的元素。元件僅在客戶端觀測到從頁面中移除時才會被捨棄。
最後,所提供的 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
事件
動態元件也可以實作與在 LiveView 中執行方式完全相同的 handle_event/3
回呼。要讓客戶端事件到達元件,標籤必須註解有 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>
更新多個
動態元件也支援一個可選的 update_many/1
回呼,作為 update/2
的替代方案。雖然會個別呼叫每個元件的 update/2
,但是會在目前呈現/更新的所有相同模組的動態元件時,呼叫 update_many/1
。它的優點是可以使用一個查詢為所有元件從資料庫載入資料,而非為每個元件執行一個查詢。
為了讓您更完整地了解為什麼兩個回呼都是必要的,讓我們來看看一個範例。想像一下您正在實作一個元件,而元件需要從資料庫載入一些狀態。例如
<.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 或卡片 LiveComponent 為真實來源
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 卡片已更新。
很幸運的是,因為元件和檢視在同一個流程中執行,因此要從 Live 元件發送訊息至母 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
指定的相同副本。
您還應避免使用 Live Components 提供抽象 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,您不太會遇到與 Live Components 相關的問題。
限制
Live Components 需要有一個單一的 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
中的整體屬性
。