LiveView 工作原理
Phoenix LiveView 是一种颠覆性的 Web 开发模式:
- 首次请求:服务端渲染完整 HTML(对 SEO 友好)
- 建立 WebSocket:页面加载后自动升级为持久 WebSocket 连接
- 用户交互:点击/输入等事件通过 WebSocket 发往服务端
- 服务端处理:更新 LiveView 进程的状态(assigns)
- 差量更新:只将变化的 HTML 片段推送到客户端
- 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 生命周期
-
mount/3
连接建立时调用(HTTP 和 WebSocket 各调用一次)。初始化 assigns(状态)。参数:路由参数、会话数据、socket。返回
{:ok, socket}或{:ok, socket, opts}。 - handle_params/3 URL 参数变化时调用(配合 live_patch 使用)。用于响应 URL 查询参数的变化,如翻页、筛选。
-
handle_event/3
用户触发前端事件时调用。事件由 HEEx 模板中的
phx-click、phx-change等绑定。返回{:noreply, socket}。 -
handle_info/2
收到 Elixir 消息时调用(如 PubSub 广播)。用于实现跨进程通信驱动的实时更新。返回
{:noreply, socket}。 - render/1 每次 assigns 变化后调用,返回 HEEx 模板。LiveView 的差量算法会比较前后两次 render 结果,只发送变化的部分。
- terminate/2 连接断开时调用,用于清理资源(如取消订阅、关闭文件等)。
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 实时通信,处理多播场景。