前言
先说一个我自己刚上手 LiveView 时的真实感受:
它看起来像在写页面,实际是在写一个服务端进程。
这句话如果没转过来,后面会非常容易写出一堆"能跑,但是味儿不对"的代码。
我第一次写 LiveView 的时候,脑子里还是 React 那套模型:
- 页面初始化就是组件挂载
- 点击按钮就是前端事件
- URL 变化就是路由状态
- props 传进来,state 自己维护
结果一上手就踩坑。
我在 mount/3 里打日志,发现它竟然跑了两次;我在 mount/3 里订阅 PubSub,结果调试时一脸懵;我把各种参数解析、数据库查询、事件处理全塞进同一个地方,代码很快就开始发臭。
后来我才意识到,LiveView 的生命周期不是"前端组件生命周期"的 Elixir 版。
它真正的心智模型是:
一次 LiveView 页面,先经历普通 HTTP 渲染,再升级成一个有状态的服务端进程。浏览器负责发事件,服务端进程负责改状态,LiveView 负责把 DOM diff 推回浏览器。
这篇就把这条线掰开讲清楚。
1. 先把整条生命周期跑一遍
如果只记一句话,我建议记这个:
LiveView 不是一上来就是 WebSocket,它先是一次普通 HTTP 请求。
一个通过 router 挂载的 LiveView,典型流程大概是这样:
text
浏览器发起 HTTP GET
|
v
mount/3 # 第一次,静态渲染阶段
|
v
handle_params/3 # 如果这个 LiveView 挂在 router 上
|
v
render/1 # 返回一份普通 HTML
|
v
浏览器加载页面,LiveSocket 建立连接
|
v
mount/3 # 第二次,connected 阶段
|
v
handle_params/3
|
v
render/1 # 之后通过 WebSocket 推 diff
|
+--> handle_event/3 # 客户端 phx-click/phx-submit/phx-change
|
+--> handle_info/2 # 服务端消息、PubSub、定时器
|
+--> handle_params/3 # live patch 导致 URL 参数变化
注意这里有几个重点:
mount/3通常会跑两次。handle_params/3在mount/3后面跑,也会因为 live patch 再跑。handle_event/3处理的是浏览器发来的事件。handle_info/2处理的是服务端进程收到的消息。- 每次你改了
socket.assigns,LiveView 都会重新 render,然后把变化推给浏览器。
所以 LiveView 的生命周期,本质上不是"页面生命周期",而是:
HTTP 初始渲染 + WebSocket 连接后的服务端进程事件循环。
这个理解一旦建立,很多 API 就突然合理了。
2. socket.assigns:别再把它当 props 了
很多前端同学看 LiveView 第一眼,会自然把 assigns 理解成 props。
这个类比能帮你入门,但不能一直这么想。
在 React 里,props 是父组件传给子组件的数据;state 是浏览器内存里的组件状态。
在 LiveView 里,socket.assigns 是服务端 LiveView 进程维护的一份状态 map。
举个最小例子:
elixir
defmodule DemoWeb.CounterLive do
use DemoWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def handle_event("inc", _params, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def render(assigns) do
~H"""
<div>
<p>count: <%= @count %></p>
<button phx-click="inc">+1</button>
</div>
"""
end
end
这里的 @count 来自 socket.assigns.count。
按钮点击以后,浏览器不是自己把 count + 1,而是发一个 "inc" 事件到服务端。服务端在 handle_event/3 里更新 socket.assigns,然后 LiveView 重新渲染,把 diff 推回浏览器。
所以我现在更愿意这么理解:
socket.assigns 是这张页面在服务端的状态快照。
它不是数据库,不是 session,也不是前端缓存。它的生命周期跟当前 LiveView 连接绑定。
这点很关键,因为它会直接影响你怎么写代码。
一个我踩过的坑:把 assigns 当成"万能仓库"
我一开始很容易把所有东西都塞进 assigns:
elixir
assign(socket,
current_user: user,
posts: posts,
all_tags: all_tags,
permissions: permissions,
raw_payload: payload,
debug_meta: meta
)
看起来省事,实际上后面会很难受。
LiveView 的 diff 很聪明,但不是魔法。你塞进 assigns 的东西越杂,模板依赖越乱,后面追踪"到底哪个状态导致页面变了"就越痛苦。
我的经验是:
- 模板真的要用的,放 assigns
- 事件处理临时要用的,尽量局部变量解决
- 大对象、原始 payload、调试信息,不要顺手塞进去
- 列表很大时,优先考虑后面会讲到的
streams
一句话:
assigns 要像页面状态,不要像垃圾桶。
3. mount/3:初始化状态,但别乱做副作用
mount/3 是 LiveView 的入口。
它有三个参数:
elixir
def mount(params, session, socket) do
{:ok, socket}
end
这三个参数分工很清楚:
params:URL 里的公开参数,用户可以改,不可信session:服务端放进 session 的私有数据,常用来拿当前用户信息socket:当前 LiveView 的 socket,里面会放 assigns
举个例子:一个用户详情页。
elixir
defmodule DemoWeb.ProfileLive do
use DemoWeb, :live_view
alias Demo.Accounts
def mount(%{"id" => id}, %{"user_token" => token}, socket) do
current_user = Accounts.get_user_by_session_token(token)
profile = Accounts.get_profile!(id)
{:ok,
socket
|> assign(:current_user, current_user)
|> assign(:profile, profile)}
end
end
这段能说明 mount/3 适合做什么:
- 初始化页面必须的数据
- 根据 session 恢复当前用户
- 给模板准备第一屏需要的 assigns
但这里有一个 LiveView 新手必踩坑:
mount/3 通常会跑两次。
第一次是静态 HTTP 渲染,第二次是 WebSocket 连接建立后的有状态渲染。
你可以用 connected?/1 判断当前 socket 是否已经连上:
elixir
def mount(_params, _session, socket) do
IO.inspect(connected?(socket), label: "connected?")
{:ok, assign(socket, count: 0)}
end
第一次通常是:
text
connected?: false
第二次是:
text
connected?: true
错误写法:在 mount/3 里无脑做副作用
比如你写一个实时订单页,想订阅订单状态变化:
elixir
def mount(%{"id" => id}, _session, socket) do
Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}")
{:ok, assign(socket, order_id: id)}
end
这段代码的问题不是"完全不能跑",而是心智不对。
静态渲染阶段的进程很快就结束了,你在这个阶段做订阅没有意义。更麻烦的是,如果这里换成外部 API 调用、发消息、写日志、打点、创建任务,就可能出现重复执行或无意义执行。
正确写法一般是:
elixir
def mount(%{"id" => id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}")
end
order = Orders.get_order!(id)
{:ok,
socket
|> assign(:order_id, id)
|> assign(:order, order)}
end
我的判断标准很简单:
- 第一屏必须要有的数据,可以在
mount/3里取 - PubSub 订阅、定时器、只对 WebSocket 连接有意义的事情,放进
connected?(socket)判断里 - 会产生外部副作用的操作,不要随手放
mount/3
比如定时器就是典型例子:
elixir
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(1_000, self(), :tick)
end
{:ok, assign(socket, now: DateTime.utc_now())}
end
def handle_info(:tick, socket) do
{:noreply, assign(socket, now: DateTime.utc_now())}
end
这就是 connected?/1 最常见的价值:
区分"静态 HTML 首屏"与"真正活起来的 LiveView 进程"。
4. handle_params/3:URL 状态归它管,别塞给 mount/3
handle_params/3 是我觉得最容易被低估的回调。
它会在 mount/3 之后调用,并且当你用 live patch 改变当前 LiveView 的 URL 参数时,它还会再次调用。
它的签名是:
elixir
def handle_params(params, uri, socket) do
{:noreply, socket}
end
适合它处理的东西有:
- 搜索关键词
- 分页页码
- tab
- 排序字段
- 筛选条件
- 当前详情 ID
也就是一句话:
凡是应该体现在 URL 里的页面状态,优先考虑放到 handle_params/3。
举个例子:文章列表页支持搜索和分页。
elixir
defmodule DemoWeb.PostIndexLive do
use DemoWeb, :live_view
alias Demo.Blog
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Posts")
|> assign(:posts, [])
|> assign(:filters, %{})}
end
def handle_params(params, _uri, socket) do
filters = %{
"q" => Map.get(params, "q", ""),
"page" => Map.get(params, "page", "1")
}
posts = Blog.list_posts(filters)
{:noreply,
socket
|> assign(:filters, filters)
|> assign(:posts, posts)}
end
end
模板里可以这样触发 patch:
elixir
def render(assigns) do
~H"""
<div>
<.link patch={~p"/posts?q=elixir&page=1"}>Elixir</.link>
<.link patch={~p"/posts?q=liveview&page=1"}>LiveView</.link>
<ul>
<li :for={post <- @posts}>
<%= post.title %>
</li>
</ul>
</div>
"""
end
点击链接以后,不是整页刷新,而是在同一个 LiveView 里触发 URL 参数变化,然后进入 handle_params/3。
我以前的错误:把 URL 参数只在 mount/3 里处理
错误写法大概是这样:
elixir
def mount(params, _session, socket) do
posts = Blog.list_posts(params)
{:ok, assign(socket, posts: posts)}
end
这在首次进入页面时没问题。
但如果后面你做了 <.link patch={...}> 或 push_patch/2,你会发现 URL 变了,页面状态却没有按预期更新。
因为 mount/3 不是给每次 URL 参数变化准备的。这个职责应该交给 handle_params/3。
我的经验是:
mount/3管"这个 LiveView 活起来前,需要准备什么基础状态"handle_params/3管"当前 URL 对页面状态有什么影响"handle_event/3管"用户操作导致了什么变化"handle_info/2管"服务端消息导致了什么变化"
这四个边界分清楚,LiveView 代码会清爽很多。
5. handle_event/3:处理客户端事件,但参数永远别信
handle_event/3 处理的是客户端通过 phx- 绑定发来的事件。
比如按钮:
elixir
def render(assigns) do
~H"""
<button phx-click="inc">+1</button>
"""
end
def handle_event("inc", _params, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
再比如带参数:
elixir
def render(assigns) do
~H"""
<button phx-click="delete" phx-value-id={@post.id}>
删除
</button>
"""
end
def handle_event("delete", %{"id" => id}, socket) do
Blog.delete_post!(id)
{:noreply, put_flash(socket, :info, "删除成功")}
end
这里有个非常重要的安全点:
handle_event/3 收到的 params 来自客户端,不可信。
别因为它是 LiveView,就以为不用做权限校验了。
错误写法:
elixir
def handle_event("delete", %{"id" => id}, socket) do
Blog.delete_post!(id)
{:noreply, socket}
end
这个问题很明显:用户可以在浏览器里伪造事件参数。你不能只看按钮上渲染了哪个 ID,就默认这个 ID 一定合法。
更稳一点的写法:
elixir
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.current_user
post = Blog.get_post!(id)
if Blog.can_delete?(user, post) do
{:ok, _post} = Blog.delete_post(post)
{:noreply,
socket
|> put_flash(:info, "删除成功")
|> push_patch(to: ~p"/posts")}
else
{:noreply, put_flash(socket, :error, "没有权限删除这篇文章")}
end
end
LiveView 省掉的是前后端状态同步成本,不是安全校验。
这个坑我觉得必须反复说:
LiveView 让你少写 API,不代表用户输入突然可信了。
handle_event/3 的返回值怎么理解
最常见返回值是:
elixir
{:noreply, socket}
意思不是"不回复浏览器页面更新",而是"不额外回复一个事件结果"。只要你更新了 socket assigns,LiveView 仍然会重新 render 并把 diff 推给客户端。
比如:
elixir
def handle_event("toggle", _params, socket) do
{:noreply, update(socket, :open?, &(!&1))}
end
这段会更新页面。
我刚开始看到 :noreply 时还愣过一下:不 reply 那页面怎么变?
后来才明白,LiveView 的页面更新不靠你手动 response,它靠 socket 状态变化后的 render/diff 流程。
6. handle_info/2:真正体现 BEAM 味道的地方
如果说 handle_event/3 是浏览器驱动,那 handle_info/2 就是服务端驱动。
它处理的是发给 LiveView 进程的普通 Elixir 消息。
这就是 LiveView 很不一样的地方:
你的页面是一个进程,所以它可以接收消息。
举个定时刷新例子:
elixir
def mount(_params, _session, socket) do
if connected?(socket) do
Process.send_after(self(), :refresh, 5_000)
end
{:ok, assign(socket, stats: load_stats())}
end
def handle_info(:refresh, socket) do
Process.send_after(self(), :refresh, 5_000)
{:noreply, assign(socket, stats: load_stats())}
end
再举个 PubSub 的例子。比如聊天室里,有人发了一条新消息。
elixir
def mount(%{"room_id" => room_id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Demo.PubSub, "room:#{room_id}")
end
{:ok,
socket
|> assign(:room_id, room_id)
|> assign(:messages, Chat.list_messages(room_id))}
end
def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do
room_id = socket.assigns.room_id
user = socket.assigns.current_user
{:ok, message} = Chat.create_message(room_id, user, body)
Phoenix.PubSub.broadcast(
Demo.PubSub,
"room:#{room_id}",
{:new_message, message}
)
{:noreply, socket}
end
def handle_info({:new_message, message}, socket) do
{:noreply, update(socket, :messages, fn messages -> messages ++ [message] end)}
end
这段代码很能体现 LiveView 的味道:
- 用户提交表单,进入
handle_event/3 - 服务端写数据库
- 服务端广播消息
- 所有订阅了这个房间的 LiveView 进程收到消息
- 每个进程进入自己的
handle_info/2 - 每个浏览器收到对应的 DOM diff
如果换成传统 SPA,你大概率要写:
- 发送消息 API
- WebSocket 订阅客户端逻辑
- 前端消息状态合并
- 后端广播逻辑
- 断线重连和重复消息处理
LiveView 不是说这些复杂性完全消失了,但它把很多东西收回到服务端同一种语言、同一个进程模型里。
这个体验非常不一样。
这里也有坑:别把 handle_info/2 写成万能入口
我见过一种写法,所有事情都先 send(self(), xxx),然后丢给 handle_info/2 做。
少量异步解耦没问题,但如果滥用,很快就会变成"消息面条":
elixir
def handle_event("save", params, socket) do
send(self(), {:save_later, params})
{:noreply, assign(socket, saving?: true)}
end
def handle_info({:save_later, params}, socket) do
# 这里又发消息,又改状态,又查数据库
{:noreply, socket}
end
我的建议是:
- 用户事件能同步处理清楚,就放
handle_event/3 - 外部进程、PubSub、定时器、后台任务结果,放
handle_info/2 - 真正耗时的任务,不要阻塞 LiveView 进程,考虑
assign_async/3、start_async/3或业务层任务进程
LiveView 是进程,但它不是让你把所有业务都塞进页面进程。
7. render/1:你以为它只是模板,其实它吃的是 assigns
render/1 很容易被忽略,因为大家觉得"模板嘛,没什么好讲"。
但 LiveView 里,render/1 的关键点是:
它应该尽量是 assigns 的纯展示结果。
也就是说,复杂业务逻辑不要写在模板里。
错误味道:
elixir
def render(assigns) do
~H"""
<div :for={post <- Enum.filter(@posts, &(&1.published))}>
<%= post.title %>
</div>
"""
end
更好的做法是提前在回调里准备好:
elixir
def handle_params(params, _uri, socket) do
posts = Blog.list_posts(params)
published_posts = Enum.filter(posts, & &1.published)
{:noreply,
socket
|> assign(:posts, posts)
|> assign(:published_posts, published_posts)}
end
模板只负责展示:
elixir
def render(assigns) do
~H"""
<div :for={post <- @published_posts}>
<%= post.title %>
</div>
"""
end
当然,不是说模板里完全不能写逻辑。简单判断、循环、展示格式化都很正常。
我的边界是:
如果这段逻辑需要单独测试、会查数据库、会影响业务分支,就不要写进 render。
8. 生命周期分工:我的个人口诀
把上面这些合起来,我现在写 LiveView 会先问自己四个问题:
8.1 这是页面第一次起来就要有的吗?
是,就看 mount/3。
比如:
- 当前用户
- 页面标题
- 首屏基础数据
- 默认表单
但如果是订阅、定时器、后台消息,就加 connected?(socket)。
8.2 这是 URL 决定的吗?
是,就看 handle_params/3。
比如:
/posts?page=2/posts?q=liveview/settings?tab=billing/orders/123
只要你希望用户刷新、分享链接、浏览器前进后退还能保留状态,就别只放在 handle_event/3 里。
8.3 这是用户在页面上操作触发的吗?
是,就看 handle_event/3。
比如:
- 点击按钮
- 提交表单
- 输入框变化
- 拖拽、选择、删除
但记住:参数来自客户端,不可信。
8.4 这是服务端主动来的消息吗?
是,就看 handle_info/2。
比如:
- PubSub 广播
- 定时器 tick
- 后台任务完成
- 其他进程发来的消息
这四个问题问完,大部分 LiveView 代码该放哪就很清楚了。
9. 一个完整一点的例子:订单详情页
最后我们把几个回调放到一个场景里。
假设有一个订单详情页:
- 打开页面时加载订单
- URL 里可以切 tab:
?tab=timeline、?tab=payment - 点击按钮可以取消订单
- 其他系统更新订单状态后,当前页面要实时刷新
代码可以这样组织:
下面代码默认你的认证层已经通过 on_mount 或类似方式把 current_user 放进了 assigns,这也是 Phoenix 项目里比较常见的做法。
elixir
defmodule DemoWeb.OrderLive.Show do
use DemoWeb, :live_view
alias Demo.Orders
@tabs ~w(summary timeline payment)
def mount(%{"id" => id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}")
end
{:ok,
socket
|> assign(:order_id, id)
|> assign(:order, Orders.get_order!(id))
|> assign(:tab, "summary")}
end
def handle_params(params, _uri, socket) do
tab =
params
|> Map.get("tab", "summary")
|> normalize_tab()
{:noreply, assign(socket, :tab, tab)}
end
def handle_event("cancel", _params, socket) do
order = socket.assigns.order
user = socket.assigns.current_user
case Orders.cancel_order(order, user) do
{:ok, updated_order} ->
Phoenix.PubSub.broadcast(
Demo.PubSub,
"order:#{updated_order.id}",
{:order_updated, updated_order}
)
{:noreply, put_flash(socket, :info, "订单已取消")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, error_message(reason))}
end
end
def handle_info({:order_updated, order}, socket) do
{:noreply, assign(socket, :order, order)}
end
defp normalize_tab(tab) when tab in @tabs, do: tab
defp normalize_tab(_tab), do: "summary"
defp error_message(:not_allowed), do: "没有权限取消这个订单"
defp error_message(:already_finished), do: "订单已完成,不能取消"
defp error_message(_reason), do: "操作失败,请稍后再试"
end
对应模板大概是:
elixir
def render(assigns) do
~H"""
<div>
<h1>订单 #<%= @order.id %></h1>
<nav>
<.link patch={~p"/orders/#{@order_id}?tab=summary"}>概览</.link>
<.link patch={~p"/orders/#{@order_id}?tab=timeline"}>动态</.link>
<.link patch={~p"/orders/#{@order_id}?tab=payment"}>支付</.link>
</nav>
<section :if={@tab == "summary"}>
<p>状态:<%= @order.status %></p>
<button phx-click="cancel">取消订单</button>
</section>
<section :if={@tab == "timeline"}>
<!-- 这里展示订单动态 -->
</section>
<section :if={@tab == "payment"}>
<!-- 这里展示支付信息 -->
</section>
</div>
"""
end
这段例子的分工就比较舒服:
mount/3:准备订单基础状态,连接后订阅订单主题handle_params/3:处理 URL 里的 tabhandle_event/3:处理用户点击取消订单handle_info/2:处理其他地方广播来的订单更新render/1:根据 assigns 展示 UI
我觉得这就是 LiveView 写顺手之后的感觉:
不是到处找"该发哪个 API",而是在问"这个状态变化来自哪里"。
10. 再强调几个实战坑
10.1 mount/3 跑两次,不要大惊小怪
这是设计,不是 bug。
第一次给用户一份普通 HTML,第二次建立 WebSocket 后让页面活起来。你要做的是区分哪些事情应该两次都做,哪些事情只该 connected 后做。
10.2 params 不可信,哪怕它来自 LiveView
mount/3、handle_params/3、handle_event/3 里的 params 都可能被用户改。
该校验校验,该鉴权鉴权,该查数据库查数据库。
10.3 socket.assigns 不是长期存储
连接断了会重连,进程崩了会重新 mount。
真正重要的数据要落数据库,至少也要有业务层状态来源。assigns 只是当前页面进程的状态。
10.4 不要在回调里堆业务大泥球
LiveView 回调应该负责"接事件、改状态、调业务层",不要把所有业务规则都写进 LiveView。
我的习惯是:
elixir
def handle_event("publish", %{"id" => id}, socket) do
user = socket.assigns.current_user
case Blog.publish_post(user, id) do
{:ok, post} ->
{:noreply, assign(socket, :post, post)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, humanize(reason))}
end
end
业务规则放 Blog.publish_post/2,LiveView 只处理页面状态。
这比在 handle_event/3 里塞几十行权限、状态机、数据库操作要稳得多。
总结
这期我们把 LiveView 的生命周期主线走了一遍。
我自己的核心结论是:
写 LiveView,不要先想"组件怎么更新",要先想"这次状态变化从哪里来"。
来自页面初始化,看 mount/3;来自 URL,看 handle_params/3;来自用户操作,看 handle_event/3;来自服务端消息,看 handle_info/2;最后统一落到 socket.assigns,由 render/diff 推给浏览器。
这个心智模型转过来以后,LiveView 就不再是"不会写 JS 的替代品",而是一套非常清楚的服务端交互模型。
当然,这篇只是把生命周期讲清楚。真正开始写业务后,最容易上头的地方其实是表单:实时校验、错误展示、phx-change、phx-submit、Ecto changeset 怎么配合。