Chapter 07

Phoenix LiveView 实时 UI

革命性的服务端渲染范式——WebSocket + 差量更新,无需写一行 JavaScript 构建实时交互界面

LiveView 工作原理

Phoenix LiveView 是一种颠覆性的 Web 开发模式:

  1. 首次请求:服务端渲染完整 HTML(对 SEO 友好)
  2. 建立 WebSocket:页面加载后自动升级为持久 WebSocket 连接
  3. 用户交互:点击/输入等事件通过 WebSocket 发往服务端
  4. 服务端处理:更新 LiveView 进程的状态(assigns)
  5. 差量更新:只将变化的 HTML 片段推送到客户端
  6. DOM 更新:客户端 JS 库精确更新 DOM,无需刷新页面

每个 LiveView 连接是一个 BEAM 进程:10 万个并发用户 = 10 万个独立进程,每个进程有独立状态,彼此隔离。某个用户的连接崩溃,其他用户完全不受影响。这正是 BEAM VM 的威力所在。

第一个 LiveView:实时计数器

# lib/blog_web/live/counter_live.ex
defmodule BlogWeb.CounterLive do
  use BlogWeb, :live_view

  # mount:初始化状态(类似构造函数)
  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  # handle_event:处理用户事件
  @impl true
  def handle_event("increment", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("decrement", _params, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  def handle_event("reset", _params, socket) do
    {:noreply, assign(socket, count: 0)}
  end

  # render:HEEx 模板(注意 ~H 符号)
  @impl true
  def render(assigns) do
    ~H"""
    <div class="counter">
      <h1>计数器:<%= @count %></h1>

      <button phx-click="decrement">-</button>
      <button phx-click="reset">归零</button>
      <button phx-click="increment">+</button>
    </div>
    """
  end
end

# router.ex 注册路由
live "/counter", CounterLive

LiveView 生命周期

HEEx 模板:常用绑定

<!-- phx-click:点击事件 -->
<button phx-click="like" phx-value-post-id={@post.id}>
  点赞 <%= @post.likes %>
</button>

<!-- phx-change:表单字段变化 -->
<input type="text" phx-change="search" name="query" value={@query} />

<!-- phx-submit:表单提交 -->
<form phx-submit="save">
  <input name="title" value={@form[:title].value} />
  <button type="submit">保存</button>
</form>

<!-- phx-keydown:键盘事件 -->
<input phx-keydown="key_pressed" phx-key="Enter" />

<!-- phx-hook:JS Hook 扩展点 -->
<div id="chart" phx-hook="Chart" data-values={Jason.encode!(@data)}></div>

<!-- 条件渲染 -->
<%= if @loading do %>
  <div class="spinner">加载中...</div>
<% end %>

<!-- 列表渲染(:let 解构)-->
<%= for post <- @posts do %>
  <article id={"post-#{post.id}"}>
    <h2><%= post.title %></h2>
  </article>
<% end %>

实时 Todo 应用

defmodule BlogWeb.TodoLive do
  use BlogWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket,
      todos: [],
      input: "",
      filter: :all
    )}
  end

  @impl true
  def handle_event("add_todo", %{"text" => text}, socket) when text != "" do
    todo = %{id: System.unique_integer([:positive]), text: text, done: false}
    {:noreply, socket
      |> update(:todos, &[todo | &1])
      |> assign(:input, "")}
  end

  def handle_event("toggle", %{"id" => id}, socket) do
    todos = Enum.map(socket.assigns.todos, fn todo ->
      if todo.id == String.to_integer(id),
        do:   %{todo | done: !todo.done},
        else: todo
    end)
    {:noreply, assign(socket, :todos, todos)}
  end

  def handle_event("delete", %{"id" => id}, socket) do
    todos = Enum.reject(socket.assigns.todos,
      &(&1.id == String.to_integer(id)))
    {:noreply, assign(socket, :todos, todos)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <form phx-submit="add_todo">
        <input name="text" value={@input} placeholder="新增 Todo..." />
        <button type="submit">添加</button>
      </form>

      <%= for todo <- @todos do %>
        <div id={"todo-#{todo.id}"}>
          <input type="checkbox" phx-click="toggle"
                 phx-value-id={todo.id} checked={todo.done} />
          <span class={if todo.done, do: "done"}><%= todo.text %></span>
          <button phx-click="delete" phx-value-id={todo.id}>✕</button>
        </div>
      <% end %>
    </div>
    """
  end
end

handle_info:订阅实时推送

defmodule BlogWeb.DashboardLive do
  use BlogWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    # 订阅 PubSub 主题
    if connected?(socket) do
      BlogWeb.Endpoint.subscribe("metrics")
      # 每秒刷新
      Process.send_after(self(), :tick, 1000)
    end
    {:ok, assign(socket, metrics: %{}, online_count: 0)}
  end

  # 收到 PubSub 广播
  @impl true
  def handle_info(%{event: "metrics_update", payload: data}, socket) do
    {:noreply, assign(socket, :metrics, data)}
  end

  # 定时器消息
  def handle_info(:tick, socket) do
    count = Blog.Metrics.online_count()
    Process.send_after(self(), :tick, 1000)
    {:noreply, assign(socket, :online_count, count)}
  end
end

本章小结:LiveView 是 Phoenix 最具革命性的特性。每个连接对应一个 BEAM 进程,持有独立状态;mount 初始化,handle_event 响应用户交互,handle_info 响应服务端推送;HEEx 模板的差量算法极度高效;phx-click/phx-change 等绑定让实时 UI 无需 JavaScript。下一章学习 Channels 实时通信,处理多播场景。