Phoenix LiveView 1.0-rc 登場!

於 2024 年 5 月 8 日發布,作者為 Chris McCord


LiveView 1.0.0-rc.0 發布囉!

這是 LiveView 第一個提交時間點約六年的 1.0 里程碑。

為什麼選擇 LiveView

我開始使用 LiveView 是為了滿足我的好奇心。我想建立動態伺服器渲染應用程式,卻不想編寫 JavaScript。我受夠了 JavaScript 必然會帶來的複雜架構。

試想一下即時表單驗證、更新購物車中的數量,或即時串流更新。為什麼在傳統架構中要解決這些問題會需要大費周章?我們撰寫 HTTP 膠水或 GraphQL 規範和解析器,然後找出哪些驗證邏輯需要共用或複製。這會沒完沒了 – 我們如何將在地化資訊傳送給客戶端?我們需要哪些資料序列化器?我們如何將 WebSocket 和 IPC 連接回我們的程式碼?我們的 js 程式碼會不會太大?我想是時候調整 Webpack 或 Parcel 旋鈕了。等等,現在 Vite 是主流了嗎?或者我猜 Bun 組態才是我們想要的?我們都曾經歷過這種痛苦。

構想如下,如果我們完全移除這些問題會如何?HTTP 可以消失,而伺服器則可以處理所有渲染和動態更新的疑慮。這感覺起來很笨重,但我相信 Elixir 和 Phoenix 完全適合這種處理方式。

六年過去了,這個程式設計模型仍然感覺像作弊。處理速度都超級快。負載很小。延遲達到同類中的頂尖水準。你寫的程式碼變少了,而且在撰寫功能時,需要考慮的項目也變少了。

即時基礎架構讓超能力發揮

當你讓每個使用者和使用者介面都具備即時、雙向的基礎時,就會發生有趣的事。你突然間會獲得超能力。你幾乎不會注意到它。擺脫一般全端開發的所有世俗疑慮後,你可以專注在推出功能上。更棒的是,在 Elixir 的幫助下,你開始推出其他平台甚至無法想像的功能。

想在開發階段將即時伺服器記錄傳送到 js 主控台嗎?沒問題!

想支援生產環境的熱門程式碼升級,讓瀏覽器可以在 CSS 樣式表、圖片或範本變更時自動重新渲染,且不遺失狀態或中斷連線嗎?當然可以!

或者你有一個部署到全球各地的應用程式,你需要在叢集中工作,並將結果即時彙總回使用者介面。你相信嗎?包含範本標記和 RPC 呼叫在內的整個 LiveView 只有 350 行程式碼

這些是 LiveView 啟用的應用程式類型。可以運送此類應用程式是令人難以置信的,但需要時間建立,並有其充分的理由。要讓此程式設計模型真正優異,有很多問題需要解決。

如何開始的

就概念上而言,我真正想要的是類似於我們在 React 中所做的——變更一些狀態,我們的範本會自動重新顯示,然後 UI 會更新。但如果我們不在用戶端上執行一點 UI,而是改在伺服器上執行呢?LiveView 可以如下所示

defmodule ThermoLive do
  def render(assigns) do
    ~H"""
    <div id="thermostat">
      <p>Temperature: <%= @thermostat.temperature %></p>
      <p>Mode: <%= @thermostat.mode %></p>
      <button phx-click="inc">+</button>
      <button phx-click="dec">-</button>
    </div>
    """
  end

  def mount(%{"id" => id}, _session, socket) do
    thermostat = ThermoControl.get_thermostat!(id)
    :ok = ThermoControl.subscribe(thermostat)
    {:ok, assign(socket, thermostat: thermstat)}
  end

  def handle_info({ThermoControl, %ThermoStat{} = new_thermo}, _, socket) do
    {:noreply, assign(socket, thermostat: new_thermo)}
  end

  def handle_event("inc", _, socket) do
    thermostat = ThermoControl.inc(socket.assigns.thermostat)
    {:noreply, assign(socket, thermostat: thermostat)}
  end
end

就像 React 一樣,我們有一個渲染函數和一些在 Mount LiveView 時設定我們初始狀態的函數。當狀態變更時,我們會使用新狀態呼叫 render,然後 UI 會更新。

+- 按鈕上的互動(例如 phx-click)可以從用戶端發送成 RPC 到伺服器,伺服器可以使用新的網頁 HTML 回應。這些用戶端/伺服器訊息使用 Phoenix Channels,您可以透過該管道每個伺服器擴充至百萬連線

同樣地,如果伺服器想要傳送更新給用戶端(例如另一個使用者變更了恆溫器),用戶端可以使用相同的方式來聆聽,並取代網頁 HTML。phoenix_live_view.js 用戶端上的我天真第一次嘗試類似於以下方式。

let main = document.querySelector("[phx-main]")
let channel = new socket.channel("lv")
channel.join().receive("ok", ({html}) => main.innerHTML = html)
channel.on("update", ({html}) => main.innerHTML = html)

window.addEventListener("click", e => {
  let event = e.getAttribute("phx-click")
  if(!event){ return }
  channel.push("event", {event}).receive("ok", ({html}) => main.innerHTML = html)
})

這就是 LiveView 的起點。針對互動,我們前往伺服器、在狀態變更時重新渲染整個範本,並將整個網頁傳送給用戶端。然後,用戶端置換掉內部 HTML。

這樣做是有用的,但並不是很理想。部分狀態的變更需要重新執行整個範本,並傳送大量 HTML 下載非常微小的更新。

不過基本的程式設計模型卻正是我想要的。隨著 HTTP 不再是我的擔憂,考慮全堆疊的層級也隨之消失。

接下來的挑戰是要將這個模型真正做到優質。我們不大可能意外地優於一般的 SPA 範例。

如何最佳化程式設計模型

LiveView 的區分引擎使用單一機制解決了兩個問題。第一個問題僅執行範本中先前渲染時實際會變更的動態部分。第二個問題僅傳送更新用戶端所需要最少量資料。

它透過將範本分割成靜態與動態部分來解決這兩個問題。考量到以下的 LiveView 範本

~H"""
<p class={@mode}>Temperature: <%= format_unit(@temperature) %></p>
"""

在編譯時間時,我們將範本轉換成類似這樣的結構

%Phoenix.LiveView.Rendered{
  static: ["<p class=\"", \">Temperature:", "</p>"]
  dynamic: fn assigns ->
    [
      if changed?(assigns, :mode), do: assigns.mode,
      if changed?(assigns, :temperature), do: format_unit(assigns.temperature)
    ]
  end
}

我們知道靜態部分永遠不會變更,因此將它們從動態 Elixir 表達式中分割出來。接下來,我們會根據在每個表達式中存取的變數,使用變更追蹤來編譯每個表達式。在渲染時,我們會將以前的範本值和新值進行比較,而只有當值已變更時,才會執行範本表達式。

在變更時,我們可以針對 mount 傳送所有靜態和動態元件給客戶端,取代傳送整個範本。在 mount 後,我們僅會為每一次更新傳送動態值的局部差異。

為了了解此運作方式,我們可以想像在下方的範本中,傳送以下酬載來進行 mount

{
  s: ["<p class=\"", ">Temperature: ", "</p>"],
  0: "cooling",
  1: "68℉"
}

客戶端會收到一個靜態值的對應表,位於 s 鍵中,以及動態值,這些值以它們在靜態值中的索引為鍵。客戶端只需將靜態清單與動態值合併,即可呈現完整的範本字串。例如

["<p class=\"", "cooling", "\">Temperature: ", "68℉", "</p>"].join("")
"<p class=\"cooling\">Temperature: 68℉</p>"

客戶端握有一個靜態/動態快取,這使得最佳化網路更新變得容易。在 mount 之後的任何伺服器呈現,都會單純在既知的索引中傳回新的動態值。不變的動態值和靜態值將完全略過。

如果 LiveView 執行 assign(socket, :temperature, 70)render/1 函數會被呼叫,且會透過網路傳送以下酬載

{1: "70℉"}

就是這樣!為了更新 UI,客戶端只需將此物件與其靜態/動態快取合併

{                     {
                        s: ["<p class=\"", ">Temperature: ", "</p>"],
                        0: "cooling",
  1: "70F"     =>       1: "70℉"
}                     }

然後資料會在客戶端合併在一起,以製作 UI 的完整 HTML。

當然 innerHTML 更新會吹掉 UI 狀態,而且執行成本很高。因此,與任何用戶端框架一樣,我們計算最小的 DOM 差異,以有效率地更新 DOM。事實上,我們已經有使用者從 React 移轉到 Phoenix LiveView,原因在於 LiveView 用戶端呈現比他們的 React 應用程式能提供的還要快

最適化繼續進行。包括指紋辨識、對應式理解、樹狀結構共用,等等。您可以在 Dashbit 部落格中 了解每項最適化的所有資訊

由於我們有狀態的用戶端與伺服器連線,我們可以自動且免費套用這些最佳化功能。大多數的其他伺服器呈現 HTML 解決方案會在每次更新時傳送整個片段,或者是要求使用者手動微調更新。

同級最佳的延遲

我們已經看到 LiveView 酬載小於手寫的最佳 JSON API 或 GraphQL 查詢,但它甚至比那還要好。每個 LiveView 都持有與伺服器的連線,因此頁面導覽會透過即時導覽來進行。TLS 交握、目前的使用者驗證,等等,在使用者的拜訪期間只會發生一次。這表示頁面導覽可以透過單一的 WebSocket 框架來進行,且在任何用戶端動作中產生較少的資料庫查詢。結果是從使用者端減少來回次數,也讓伺服器的工作量減少。這與會從伺服器擷取資料或將變異傳送到伺服器的 SPA 相比,為終端使用者提供了較低的延遲。

維持有狀態連線的代價為伺服器記憶體,但這遠低於人們的預期。基本的設定下,給定一個頻道連線會耗用 40 kb 的記憶體。這表示一個 1 GB 的伺服器理論上可同時支援約 25,000 個 LiveView。當然,儲存的狀態越多,消耗的記憶體就越多,但你只需要保留你需要的狀態即可。我們還有 stream 原語,可以用於處理大型集合且不會影響記憶體。Elixir 和 Erlang VM 是專為此用途而設計的。將有狀態系統擴充到數百萬的同時使用者不是理論上的假設,我們隨時都能做到。你可以參考 WhatsApp、Discord 或 我們的基準測試 作為範例。

優化了客戶端和伺服器上的程式設計模型之後,我們便擴充到能善用我們獨特差動引擎的高階組成元素。

使用 HEEx 的可重複使用元件

變更追蹤和最小差動是劃時代的功能,但我們的 HTML 樣板仍然缺乏可組合性。我們所能提供的最佳作法是「部分」式的樣板呈現,一個函式可以封裝一些部分樣板內容。這有其作用,但組合性差,且與我們撰寫標記的方式不一致。所幸 Surface 專案 的 Marlus Saraiva 領導開發了一套 HTML 感知元件系統,並回饋給 LiveView 專案。有了 HEEx 元件,我們就能擁有宣告式元件系統、HTML 驗證以及元件屬性和插槽的編譯時間檢查。

HEEx 元件就是附註的函式,看起來像這樣

@doc """
Renders a button.

## Examples

    <.button>Send!</.button>
    <.button phx-click="go">Send!</.button>
"""
attr :type, :string, default: nil
attr :rest, :global, include: ~w(disabled form name value)

slot :inner_block, required: true

def button(assigns) do
  ~H"""
  <button
    type={@type}
    class="rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3 text-white"
    {@rest}
  >
    <%= render_slot(@inner_block) %>
  </button>
  """
end

對元件的無效呼叫,例如 <.button click="bad"> 會產生編譯時間警告

warning: undefined attribute "click" for component AppWeb.CoreComponents.button/1
  lib/app_web/live/page_live.ex:123: (file)

插槽允許元件接受來自呼叫者的任意內容。這能讓呼叫者更容易擴充元件,而無需建立一堆客製化部分樣板來處理每種狀況。

HEEx 標記附註

現在不再需要檢查瀏覽器的 HTML,然後搜尋該 HTML 在程式碼中是由何處產生。最終的瀏覽器標記會在巢狀元件呼叫中呈現。我們要如何快速追蹤是由誰呈現出什麼?

HEEx 使用 debug_heex_annotations 組態來解決此問題。設定後,所有呈現的標記都會附註函式元件定義的檔案:行號,以及元件呼叫者的檔案:行號。實際上,在瀏覽器檢查器中看到的開發人員 HTML 會像這樣

Debug HEEx annotations

它會在呼叫者位置和函式元件定義同時為文件加上附註。如果你覺得這種方式難以瀏覽,你可以使用新的 Phoenix.LiveReloader 功能,它會在使用你選擇的特殊按鍵順序按一下元素時,讓你的編輯器跳到該元素最接近的呼叫者或定義檔案:行號。

讓我們實際來看看

首先,我們可以看到按住 c,同時按一下跳轉到呼叫檔案的 <.button> 呼叫。接下來,我們看到按住 d 同時按一下按鈕跳轉到函式定義檔案:行。

這是一個很簡單的生活品質提升。只要試用看看,就會變成工作流程的關鍵部分。

互動上傳

幾年前,LiveView 解決了檔案上傳的問題。某些原本應該很簡單的東西,以前卻很困難。我們需要一個單一的抽象化來進行互動上傳,同時用於直接上傳到雲端和直接上傳到伺服器的用例。

透過幾行伺服器程式碼,你可以進行檔案上傳,包括拖曳放上、檔案進度、選擇修剪、檔案預覽等等。

最近,我們定義了一個 UploadWriter 行為。這讓你得以存取原始上傳串流,因為它會切塊由客戶端傳送。這讓你得以執行類似 用 LiveView 串流上傳到不同伺服器上傳影片時進行轉碼

由於上傳會透過現有的 LiveView 連線進行,反映上傳進度或進階檔案操作會很 容易實作

串流和非同步

在處理上傳的後續,我們提供了串流原始碼,用於有效處理大型集合,而無需在伺服器記憶體中保留那些集合。我們也引進了 assign_asyncstart_async 原始碼,這使得處理非同步操作和回傳非同步結果變得輕鬆容易。

例如,假設你有一項成本昂貴的操作呼叫外部服務。其結果可能會是延遲的或斷斷續續的,或兩者皆是。你的 LiveView 可以使用 assign_async/2 將此操作卸載到新程序,並使用 <.async_result> 回傳結果到每個載入、成功或失敗狀態。

def render(assigns) do
  ~H"""
  <.async_result :let={org} assign={@org}>
    <:loading>Loading organization <.spinner /></:loading>
    <:failed :let={_failure}>there was an error loading the organization</:failed>
    <%= org.name %>
  </.async_result>
  """
end

def mount(%{"slug" => slug}, _, socket) do
  {:ok, assign_async(:org, fn -> {:ok, %{org: fetch_org(slug)}} end)}
end

現在,你不必擔心非同步任務會讓 UI 崩潰,也無需再小心監控非同步操作,同時用一堆條件陳述式更新範本,你有一個單一的抽象化用於執行工作和回傳結果。當 LiveView 中斷時,非同步程序也會被清除,確保不再存在的 UI 沒有浪費資源。

這裡我們還可以看看 <.async_result> 函式組件的 <:loading><:failed> 插槽的動作。插槽讓呼叫程式得以使用自己的動態內容擴充元件,包括自己的標記和函式組件呼叫。

LiveView 成為主流

LiveView 和 .NET Blazor 幾乎在同一個時間開始。我想這兩個專案都有助於帶動這種程式設計模型的採用。

自從開始以來,這種模型已經在 Go、Rust、Java、PHP、JavaScript、Ruby 和 Haskell 社群中得到各種不同的採用。而且我敢肯定還有我尚未聽聞的其他情況。

大多數都沒有提供 LiveView 的宣告模型。相反地,開發人員必須註解個別元素如何更新和移除,導致應用程式脆弱,類似於 React 和其他宣告式架構問世前的客戶端應用程式。大多數也缺乏 LiveView 開發人員免費獲得的最佳化。大型有效負載會於每次事件時傳送,除非開發人員手動微調。

React 本身非常喜歡將 React 放到伺服器上,他們發行了自己的 React Server Components,處理與 LiveView 類似的目標橫切面。在 RSC 的情況下,推播即時事件留給外部處理。

React 與大多數一樣,選擇不同的折衷,因為他們別無選擇。大多數略過有狀態的雙向通訊層,因為大多數平台都無法妥善使用它。Elixir 和 Erlang VM 才真正讓這個程式設計模型發光發熱。而且我們僅簡略討論了我們的內建全球分散式叢集和 PubSub。這個平台內建了真正非凡的功能,且觸手可及。

下一步

我們鼓勵大家在應用程式中試用 1.0-rc,並回報任何問題或 bug。查看 變更日誌,以瞭解重大變更內容,讓你的現有應用程式升級。RC 階段之後,我們將持續努力處理置放的 JavaScript 勾子、Web 元件整合、導覽防護等事項,如我們問題追蹤器中所述。

特別感謝

沒有 Phoenix 團隊的協助,不可能走到這裡,特別是 Steffen Deusch,他在過去幾個月處理了無數的 LiveView 問題。

愉快開發!

–Chris