檢視原始碼 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} />

你務必傳遞 moduleid 屬性。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/3id 給定的 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。

回呼

連結至這個回呼

handle_async(名稱,異步_函數_結果,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,未簽署_參數,socket)

檢視原始碼 (選用)
@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()
連結至這個回呼

update(assigns,socket)

檢視原始碼 (選用)
@callback update(
  assigns :: Phoenix.LiveView.Socket.assigns(),
  socket :: Phoenix.LiveView.Socket.t()
) ::
  {:ok, Phoenix.LiveView.Socket.t()}
連結至這個回呼

update_many(清單)

檢視原始碼 (選用)

函數

連結至這個巨集

__using__(opts \\ [])

檢視原始碼 (巨集)

在當前模組中使用 LiveComponent。

use Phoenix.LiveComponent

選項

  • :global_prefixes - 要用於元件的整體前綴。更多資訊請參閱 Phoenix.Component 中的 整體屬性