檢視原始碼 Binding

Phoenix 支援 DOM 元素 binding,用於進行用戶端與伺服器互動。例如,若要回應按鈕的 click 事件,可以這樣渲染元素:

<button phx-click="inc_temperature">+</button>

然後在伺服器上,所有 LiveView binding 都以 handle_event callback 來處理,範例如下:

def handle_event("inc_temperature", _value, socket) do
  {:ok, new_temp} = Thermostat.inc_temperature(socket.assigns.id)
  {:noreply, assign(socket, :temperature, new_temp)}
end
Binding屬性
參數phx-value-*
Click Eventsphx-clickphx-click-away
Form Eventsphx-changephx-submitphx-feedback-forphx-feedback-groupphx-disable-withphx-trigger-actionphx-auto-recover
Focus Eventsphx-blurphx-focusphx-window-blurphx-window-focus
Key Eventsphx-keydownphx-keyupphx-window-keydownphx-window-keyupphx-key
Scroll Eventsphx-viewport-topphx-viewport-bottom
DOM 修補phx-mountedphx-updatephx-remove
JS Interopphx-hook
生命週期事件phx-connectedphx-disconnected
速率限制phx-debouncephx-throttle
靜態追蹤phx-track-static

Click Events

phx-click binding 用於將 click 事件傳送到伺服器。當任何用戶端事件,例如 phx-click click 觸發時,傳送到伺服器的值會依照下列優先權進行挑選:

  • Phoenix.LiveView.JS.push/3 中指定的 :value,例如:

    <div phx-click={JS.push("inc", value: %{myvar1: @val1})}>
  • 任何數量的自訂 phx-value- 的前綴屬性,例如:

    <div phx-click="inc" phx-value-myvar1="val1" phx-value-myvar2="val2">

    會將下列的參數映射傳送至伺服器:

    def handle_event("inc", %{"myvar1" => "val1", "myvar2" => "val2"}, socket) do

    如果使用 phx-value- 的前綴,如果元素的 value 屬性存在,伺服器 payload 也會包含 "value"

  • payload 也會包含用戶端事件的任何其他自訂 metadata。例如,下列 LiveSocket 用戶端選項會傳送所有 click 的座標和 altKey 資訊:

    let liveSocket = new LiveSocket("/live", Socket, {
      params: {_csrf_token: csrfToken},
      metadata: {
        click: (e, el) => {
          return {
            altKey: e.altKey,
            clientX: e.clientX,
            clientY: e.clientY
          }
        }
      }
    })

當 click 事件發生在元素外部時,會觸發 phx-click-away 事件。這對於隱藏下拉選單等已切換的容器很有用。

Focus and Blur Events

對應 DOM 元素可以使用 phx-blurphx-focus 繫結來繫結對應會觸發此類事件的事件,例如

<input name="email" phx-focus="myfocus" phx-blur="myblur"/>

若要偵測頁面本身何時已經收到焦點或失去焦點,可以指定 phx-window-focusphx-window-blur。如果考慮中的元素(最常是沒有標籤索引的 div)無法接收焦點,則可能需要使用這些視窗等級的事件。與其他繫結類似,可以在繫結的元素上提供 phx-value-*,而這些值會作為有效負載的一部分傳送。例如

<div class="container"
    phx-window-focus="page-active"
    phx-window-blur="page-inactive"
    phx-value-page="123">
  ...
</div>

按鍵事件

使用 phx-keydownphx-keyup 繫結支援 onkeydownonkeyup 事件。每個繫結都支援 phx-key 屬性,這個屬性會為特定按鍵觸發事件。如果沒有提供 phx-key,則會針對任何按鍵觸發事件。按一下時,傳送到伺服器的值會包含按下的 "key",以及任何使用者定義的元資料。例如,按下 Escape 鍵會看起來像這樣

%{"key" => "Escape"}

可以提供 keydown 事件的 metadata 選項給 LiveSocket 建構函數,以擷取其他使用者定義的元資料。例如

let liveSocket = new LiveSocket("/live", Socket, {
  params: {_csrf_token: csrfToken},
  metadata: {
    keydown: (e, el) => {
      return {
        key: e.key,
        metaKey: e.metaKey,
        repeat: e.repeat
      }
    }
  }
})

若要判斷按下了哪個按鍵,您應該使用 key 值。可用的選項可以在 MDN 上找到,或透過 按鍵事件檢視器 找到。

注意phx-keyupphx-keydown 不支援輸入。請使用表單繫結,例如 phx-changephx-submit 等。

注意:某些瀏覽器功能(如自動填寫)可能會觸發按鍵事件,且傳送到伺服器的值對應中沒有 "key" 欄位。因此,我們建議針對 LiveView 按鍵繫結始終有一個後備的萬用事件處理常式。預設情況下,繫結的元素會是事件聆聽器,但是可以透過 phx-window-keydownphx-window-keyup 等方式提供視窗等級繫結,例如

def render(assigns) do
  ~H"""
  <div id="thermostat" phx-window-keyup="update_temp">
    Current temperature: <%= @temperature %>
  </div>
  """
end

def handle_event("update_temp", %{"key" => "ArrowUp"}, socket) do
  {:ok, new_temp} = Thermostat.inc_temperature(socket.assigns.id)
  {:noreply, assign(socket, :temperature, new_temp)}
end

def handle_event("update_temp", %{"key" => "ArrowDown"}, socket) do
  {:ok, new_temp} = Thermostat.dec_temperature(socket.assigns.id)
  {:noreply, assign(socket, :temperature, new_temp)}
end

def handle_event("update_temp", _, socket) do
  {:noreply, socket}
end

使用防彈和節流來限制事件速率

除了 phx-blur 繫結(會立即觸發)之外,所有事件都可以在用戶端使用 phx-debouncephx-throttle 繫結來限制速率。

限制速率和防彈的事件有下列行為

  • phx-debounce - 可接受整數逾時值(以毫秒為單位)或 "blur"。當提供整數時,會延遲發射事件指定的毫秒數。當提供 "blur" 時,會延遲發射事件,直到使用者使得欄位模糊為止。當省略值時,將使用 300 毫秒的預設值。去除抖動通常用於輸入元素。

  • phx-throttle - 可接受以毫秒為單位調整事件的整數逾時值。與去除抖動不同,調整節流會立即發射事件,然後每提供一次逾時值限定一次速率。當省略值時,將使用 300 毫秒的預設值。調整節流通常用於限定按一下、滑鼠和鍵盤動作的速率。

例如,要避免驗證電子郵件,直到欄位模糊為止,同時在使用者變更欄位後最多每 2 秒驗證一次使用者名稱

<form phx-change="validate" phx-submit="save">
  <input type="text" name="user[email]" phx-debounce="blur"/>
  <input type="text" name="user[username]" phx-debounce="2000"/>
</form>

並將增加音量按一下的速率限定為每秒一次

<button phx-click="volume_up" phx-throttle="1000">+</button>

同樣地,你可以調整按住的鍵盤

<div phx-window-keydown="keydown" phx-throttle="500">
  ...
</div>

除非需要按住的按鍵,否則更好的作法通常是使用 phx-keyup 繫結,它只會觸發一次按鍵,因此會自限。然而,phx-keydown 對於遊戲和其他需要持續按下按鍵的用例是有用的。在這種情況下,應始終使用調整節流。

Debounce 和 Throttle 的特殊行為

對表單和鍵盤繫結執行以下特定行為

  • 觸發 phx-submit 或不同輸入的 phx-change 時,任何現有的去除抖動或調整節流計時器對現有輸入都會重設。

  • phx-keydown 繫結只對重複按鍵進行調整節流。連續按單個按鍵會發送已按按鍵的事件。

JS 指令

LiveView 繫結透過 Phoenix.LiveView.JS 模組支援 JavaScript 指令介面,這允許你在觸發 phx- 繫結事件(例如 phx-clickphx-change 等)時指定在用戶端執行的工具程式操作。指令可組成在一起,讓你發送事件、將類別新增至元素、將元素淡入淡出等等。請參閱 Phoenix.LiveView.JS 文件,以取得完整的用法。

對於可能發生的情況,一個小範例是想像你要在頁面上顯示和隱藏一個 modal,而不需要來回伺服器來呈現內容

<div id="modal" class="modal">
  My Modal
</div>

<button phx-click={JS.show(to: "#modal", transition: "fade-in")}>
  show modal
</button>

<button phx-click={JS.hide(to: "#modal", transition: "fade-out")}>
  hide modal
</button>

<button phx-click={JS.toggle(to: "#modal", in: "fade-in", out: "fade-out")}>
  toggle modal
</button>

或者如果你的 UI 程式庫依賴類別來執行顯示或隱藏

<div id="modal" class="modal">
  My Modal
</div>

<button phx-click={JS.add_class("show", to: "#modal", transition: "fade-in")}>
  show modal
</button>

<button phx-click={JS.remove_class("show", to: "#modal", transition: "fade-out")}>
  hide modal
</button>

指令可組成在一起。例如,你可以將一個事件推播到伺服器,並立即在用戶端隱藏 modal

<div id="modal" class="modal">
  My Modal
</div>

<button phx-click={JS.push("modal-closed") |> JS.remove_class("show", to: "#modal", transition: "fade-out")}>
  hide modal
</button>

將指令萃取到它們自己的函式中也很有用

alias Phoenix.LiveView.JS

def hide_modal(js \\ %JS{}, selector) do
  js
  |> JS.push("modal-closed")
  |> JS.remove_class("show", to: selector, transition: "fade-out")
end
<button phx-click={hide_modal("#modal")}>hide modal</button>

Phoenix.LiveView.JS.push/3 指令特別強大,能讓你自訂推送到伺服器的事件。例如,想像你從一個常見的 phx-click 開始,當按下後會推播訊息到伺服器

<button phx-click="clicked">click</button>

現在想像你想自訂在 "clicked" 事件被推播時會發生什麼,像是要針對哪個元件、哪個元素會接受 CSS 載入狀態類別等等。這可以使用 JS push 指令中的選項達成。例如

<button phx-click={JS.push("clicked", target: @myself, loading: ".container")}>click</button>

有關所有支援選項,請參閱 Phoenix.LiveView.JS.push/3

DOM 修補

容器可以用 phx-update 標記,用於設定 DOM 的更新方式。支援以下數值

  • replace - 預設操作。將元素替換為內容

  • stream - 支援串流操作。串流用於在 UI 中管理大型集合,而不需要將集合儲存在伺服器上

  • ignore - 無視 DOM 更新,無論新內容如何變更。這對於自訂用戶端與使用自有 DOM 操作的現有函式庫整合時很有用

使用 phx-update 時,必須在容器中設定獨特的 DOM ID。如果使用「串流」,也必須為每個子項目設定 DOM ID。當插入包含已存在於容器中的 ID 的串流元素時,LiveView 會用新內容取代現有元素。有關更多資訊,請參閱 Phoenix.LiveView.stream/3

當你需要與其他 JS 函式庫整合時,經常會使用「ignore」行為。伺服器對元素的內容和屬性的更新會被忽略,資料屬性除外。從服務端對資料屬性進行變更、新增和移除等動作會與被忽略的元素合併,可用於將資料傳遞給 JS 處理常式。

若要讓元素對掛載到 DOM,可以使用 phx-mounted 繫結。例如,在掛載時為元素新增動畫效果

<div phx-mounted={JS.transition("animate-ping", time: 500)}>

如果在初始頁面渲染時使用了 phx-mounted,則只有在建立初始 WebSocket 連接後才會呼叫它。

若要讓元素對從 DOM 中移除做出反應,可以指定 phx-remove 繫結,其中可以包含要執行的 Phoenix.LiveView.JS 指令。phx-remove 指令只會針對已移除的父元素執行。它不會遞迴到子項目。

生命週期事件

LiveView 支援 phx-connectedphx-disconnected 綁定,以使用 JS 命令對連線生命週期事件做出反應。舉例來說,當 LiveView 失去連線時顯示元素,當連線復原時隱藏元素

<div id="status" class="hidden" phx-disconnected={JS.show()} phx-connected={JS.hide()}>
  Attempting to reconnect...
</div>

phx-connectedphx-disconnected 僅在 LiveView 容器內運作時執行。對於靜態範本,它們不會有任何效果。

LiveView 專屬事件

lv: 事件字首支援 LiveView 專屬功能,由 LiveView 處理而不呼叫使用者的 handle_event/3 回呼。目前,支援以下事件

  • lv:clear-flash – 傳送給伺服器時清除閃光訊息。如果提供 phx-value-key,將從閃光訊息中移除特定金鑰。

舉例來說

<p class="alert" phx-click="lv:clear-flash" phx-value-key="info">
  <%= Phoenix.Flash.get(@flash, :info) %>
</p>

載入狀態與錯誤

所有 phx- 事件綁定在推播時套用它們自己的 CSS 類別。例如以下標記

<button phx-click="clicked" phx-window-keydown="key">...</button>

點擊時,會收到 phx-click-loading 類別,按下鍵時會收到 phx-keydown-loading 類別。CSS 載入類別會保留,直到在用戶端收到推播事件的確認為止。

對於表單,傳送 phx-change 給伺服器時,傳出變更的輸入元素會收到 phx-change-loading 類別,以及父層表單標籤。以下事件會收到 CSS 載入類別

  • phx-click - phx-click-loading
  • phx-change - phx-change-loading
  • phx-submit - phx-submit-loading
  • phx-focus - phx-focus-loading
  • phx-blur - phx-blur-loading
  • phx-window-keydown - phx-keydown-loading
  • phx-window-keyup - phx-keyup-loading

此外,會套用以下類別至 LiveView 的父層容器

  • "phx-connected" - 當檢視連線到伺服器時套用
  • "phx-loading" - 當檢視未連線到伺服器時套用
  • "phx-error" - 當伺服器發生錯誤時套用。請注意,如果失去與伺服器的連線,此類別將與 "phx-loading" 一併套用。

對於導覽相關的載入狀態(自動與手動),請參閱 JavaScript 互操作性:Live 導覽事件 中所述的 phx-page-loading

捲動事件與無限串流分頁

透過phx-viewport-topphx-viewport-bottom 繫結,可以偵測某個容器的第一個子項目何時抵達視窗頂端,或是最後一個子項目何時抵達視窗底端。這在無窮捲動中非常實用,因為你能在使用者上下捲動、抵達視窗頂部或底部時傳送分頁事件給下一組結果或前一組結果。

執行無窮捲動時,應用程式通常會在容器上方和下方加入內邊距,以便在載入結果時維持順暢捲動。搭配 Phoenix.LiveView.stream/3phx-viewport-topphx-viewport-bottom 能產生無窮虛擬清單,這個清單只會在 DOM 中保留一組很小的實際元素。例如

def mount(_, _, socket) do
  {:ok,
   socket
   |> assign(page: 1, per_page: 20)
   |> paginate_posts(1)}
end

defp paginate_posts(socket, new_page) when new_page >= 1 do
  %{per_page: per_page, page: cur_page} = socket.assigns
  posts = Blog.list_posts(offset: (new_page - 1) * per_page, limit: per_page)

  {posts, at, limit} =
    if new_page >= cur_page do
      {posts, -1, per_page * 3 * -1}
    else
      {Enum.reverse(posts), 0, per_page * 3}
    end

  case posts do
    [] ->
      assign(socket, end_of_timeline?: at == -1)

    [_ | _] = posts ->
      socket
      |> assign(end_of_timeline?: false)
      |> assign(:page, new_page)
      |> stream(:posts, posts, at: at, limit: limit)
  end
end

我們的 paginate_posts 函式會擷取一頁文章,再判斷使用者是否要分頁到前一頁或下一頁。根據分頁方向,串流可以前置,或分別附加至 0-1at。我們也會將串流的 limit 設為 per_page 的三倍,以確保使用者介面有足夠文章顯現成無窮清單,但又夠小以維持使用者介面效能。我們也會設定 @end_of_timeline? 指定變數,以追蹤使用者是否已抵達結果尾端。最後,我們更新 @page 指定變數和文章串流。然後,我們可以連結容器以支援視窗事件

<ul
  id="posts"
  phx-update="stream"
  phx-viewport-top={@page > 1 && "prev-page"}
  phx-viewport-bottom={!@end_of_timeline? && "next-page"}
  phx-page-loading
  class={[
    if(@end_of_timeline?, do: "pb-10", else: "pb-[calc(200vh)]"),
    if(@page == 1, do: "pt-10", else: "pt-[calc(200vh)]")
  ]}
>
  <li :for={{id, post} <- @streams.posts} id={id}>
    <.post_card post={post} />
  </li>
</ul>
<div :if={@end_of_timeline?} class="mt-5 text-[50px] text-center">
  🎉 You made it to the beginning of time 🎉
</div>

這段程式碼沒什麼,不過那正是重點!這個小小的使用者介面片段正在驅動一個全虛擬化的清單,配備雙向無窮捲動。我們使用 phx-viewport-top 繫結將 "prev-page" 事件傳送至 LiveView,但前提是使用者已超過第一頁。載入負頁面結果並沒有意義,因此我們在這些情況下會完全移除繫結。接下來,我們連結 phx-viewport-bottom 以傳送 "next-page" 事件,但前提是我們尚未抵達時間軸尾端。最後,我們視情況套用一些 CSS 類別,以根據目前的分页設定,將上、下內邊距設定為視窗高度兩倍的大小,以利順暢捲動。

若要完成我們的解決方案,我們只需要處理 LiveView 中的 "prev-page""next-page" 事件

def handle_event("next-page", _, socket) do
  {:noreply, paginate_posts(socket, socket.assigns.page + 1)}
end

def handle_event("prev-page", %{"_overran" => true}, socket) do
  {:noreply, paginate_posts(socket, 1)}
end

def handle_event("prev-page", _, socket) do
  if socket.assigns.page > 1 do
    {:noreply, paginate_posts(socket, socket.assigns.page - 1)}
  else
    {:noreply, socket}
  end
end

這個程式碼僅呼叫 `paginate_posts` 函式,這是我們在第一步時定義的,使用目前或下一個頁面來引導結果。請注意我們在 `prev-page` 事件中匹配特殊參數 `"_overran" => true`。當使用者「越過」視窗頂部或底部時,視窗事件會傳送此參數。想像一種狀況,使用者向上捲動瀏覽許多頁面的結果,但抓住捲軸並立即返回頁面頂部。這表示我們的容器 `<ul id="posts">` 被視窗頂部越過,我們需要將 UI 重設為第一個頁面。