前言
如果说 LiveView 前两篇内容,更多是在帮我建立心智模型,那 PubSub 这一块,就是我第一次真正感受到它"有业务杀伤力"的地方。
原因很简单。
很多 Web 项目,最后都会走到"实时"这个词上:
- 聊天室里,新消息要立刻出现
- 协作看板里,别人拖了一张卡片,我这边得同步
- 订单详情页里,支付状态更新了,页面不能还傻站着
- 后台列表里,一条记录被别人改了,我最好别还展示旧数据
以前写 SPA 的时候,遇到这类需求,我的第一反应通常是:
- 要不要上 WebSocket
- 前端状态怎么同步
- 后端推什么格式
- 客户端怎么局部更新缓存
- 当前页面和别的页面的数据冲突怎么处理
一套搞下来,不一定做不成,但脑子会明显变重。
而 LiveView 在这件事上的思路,非常直接:
页面本来就是服务端进程,那实时更新这件事,本质上就变成"让别的进程给这个页面发消息"。
这个味道一下就不一样了。
所以这篇我最想讲的,不只是 API 怎么写,而是这句话:
LiveView 里的实时通信,本质上不是"前端收到推送后更新状态",而是"服务端页面进程收到消息后改自己的 assigns"。
你如果把这层理解吃透,后面很多写法都会顺。
1. 先说人话:PubSub 到底是在干嘛
第一次看到 Phoenix.PubSub 的时候,我脑子里想的是:
"哦,广播系统。"
这个理解不算错,但有点太平了,不够指导代码。
我现在更喜欢一个更土、但更好记的说法:
PubSub 就像给一堆进程建了不同的聊天群。
- 你可以订阅某个 topic,等于进群
- 你可以往这个 topic 广播消息,等于群发
- 订阅这个 topic 的进程,都会收到这条消息
举个最小例子:
elixir
Phoenix.PubSub.subscribe(MyApp.PubSub, "room:lobby")
Phoenix.PubSub.broadcast(
MyApp.PubSub,
"room:lobby",
{:new_message, %{body: "hello"}}
)
只要当前进程订阅了 "room:lobby",它就能在自己的邮箱里收到这条消息。
这里有个特别关键、但新手很容易忽略的点:
PubSub 发消息的对象,不是浏览器,不是 DOM,也不是数据库,而是 Elixir 进程。
而 LiveView 页面,恰好就是一个服务端进程。
所以整个链路就变得非常自然了:
- LiveView 进程订阅某个 topic
- 别的地方发生业务操作
- 那个地方广播一条消息
- LiveView 在
handle_info/2里收到消息 - 改
socket.assigns - 页面自动重新渲染并推 diff 给浏览器
你看,压根不需要先把"实时通信"想成一个神秘的前端网络协议问题。
它首先是一个进程消息分发问题。
2. LiveView 里实时更新的完整链路,到底长什么样
我建议你把这条链路记熟,不然后面很容易写着写着就糊。
一个典型的"多用户实时更新"流程,大概长这样:
text
用户 A 点击"发送消息"
|
v
handle_event/3 处理表单提交
|
v
消息写入数据库
|
v
broadcast/3 向 topic 广播 {:message_created, message}
|
v
订阅该 topic 的 LiveView 进程收到消息
|
v
handle_info/2 更新 socket.assigns
|
v
LiveView 推送 DOM diff
|
v
用户 A / B / C 页面同时更新
这条链里最容易混的,是这两个回调:
handle_event/3:处理浏览器发来的事件handle_info/2:处理服务端进程收到的消息
我刚开始写的时候,经常把这两个东西混成一锅粥。
后来我强行给自己立了一个规矩:
用户手点出来的事,先进 handle_event/3;系统里别的进程告诉我的事,进 handle_info/2。
这个分层一旦稳住,代码就会清爽很多。
3. 先跑一个最小例子:聊天室为什么这么适合 LiveView
讲 PubSub,聊天室几乎是绕不过去的。不是因为它老套,而是因为它刚好能把整套思路讲透。
3.1 先订阅 topic
比如我有一个房间页,URL 是 /rooms/:id。
这个页面对应的 LiveView,一进来就应该订阅这个房间的话题:
elixir
defmodule MyAppWeb.RoomLive do
use MyAppWeb, :live_view
alias MyApp.Chat
def mount(%{"id" => room_id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, topic(room_id))
end
messages = Chat.list_messages(room_id)
{:ok,
socket
|> assign(:room_id, room_id)
|> assign(:messages, messages)
|> assign(:form, to_form(%{"body" => ""}, as: :message))}
end
defp topic(room_id), do: "room:#{room_id}"
end
这里先别急着看下面,先盯住一个细节:
订阅放在 if connected?(socket) 里面。
这个地方我自己真踩过坑。
因为 mount/3 通常会跑两次。第一次是静态 HTTP 渲染,第二次才是真正建立 WebSocket 连接后的 LiveView 进程。
如果你在没连上的阶段就订阅,轻则没意义,重则你调试的时候会开始怀疑人生:
- "我明明订阅了,怎么没收到消息?"
- "为什么这里日志打印两次?"
- "到底哪个进程活着,哪个已经没了?"
所以这条经验我觉得值得直接背下来:
凡是订阅、定时器、外部副作用这类事,先想想要不要放进 connected?(socket)。
3.2 用户发消息时,在 handle_event/3 里广播
用户提交消息时,可以这么写:
elixir
def handle_event("send", %{"message" => %{"body" => body}}, socket) do
room_id = socket.assigns.room_id
case Chat.create_message(room_id, body) do
{:ok, message} ->
Phoenix.PubSub.broadcast(
MyApp.PubSub,
topic(room_id),
{:message_created, message}
)
{:noreply, assign(socket, :form, to_form(%{"body" => ""}, as: :message))}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, as: :message))}
end
end
这段代码看起来很顺,但这里恰好藏着一个特别值得说的观点:
广播应该发生在"业务动作已经成立"之后,而不是"用户有意图"时。
什么意思?
就是你别在数据还没落稳的时候,先广播一个"我改了我改了"。不然别的页面收到了消息,结果数据库里还没有,或者后续又失败回滚,这就很尴尬。
所以更稳的做法一般是:
- 先完成业务动作,比如
Repo.insert - 拿到最终结果
- 再广播一个明确的领域事件
比如这里广播的是:
elixir
{:message_created, message}
这个就比:
elixir
{:refresh, :messages}
更有信息量,也更不容易把接收方写成"收到任何事都整页重刷"的祖传逻辑。
3.3 订阅方在 handle_info/2 里收消息
页面收到消息后,更新自己的状态:
elixir
def handle_info({:message_created, message}, socket) do
{:noreply, update(socket, :messages, fn messages -> messages ++ [message] end)}
end
这时候浏览器就会自动更新,不需要你手动操作 DOM。
这也是我觉得 LiveView 最省脑子的一点:
页面更新不是"前端监听 socket 后自己拼状态",而是 LiveView 进程像处理普通服务器消息一样处理它。
如果你之前写惯了 SPA,这个转变会很明显。
以前你可能会想:
- WebSocket 客户端在哪初始化
- 收到 JSON 怎么 parse
- 当前页面对应哪个 store slice
- 列表怎么 merge
现在你想的是:
- 这个页面该订阅哪个 topic
- 收到哪几种消息
- 每种消息怎么改 assigns
说白了,复杂度没有消失,但它被收回服务端了。
4. 我真正觉得好用的,不是"能实时",而是"业务和页面终于能说同一种话"
这个点我很想展开一下。
很多人介绍 LiveView 的实时能力时,会强调"代码少""不怎么写前端"。
这些当然是优点,但我觉得还没打到最值钱的地方。
真正值钱的是:
广播的内容可以直接长成业务语言,而不是前端协议语言。
比如一个协作看板里,卡片被移动了。
在很多前后端分离项目里,这条实时消息可能会长这样:
json
{
"type": "update_board",
"payload": {
"boardId": "b1",
"listId": "l2",
"cardId": "c9",
"position": 3
}
}
不是说这样不行,但它常常会越写越像"前端补丁协议"。
而在 Phoenix / LiveView 里,我更喜欢把消息命名成明确的领域事件:
elixir
{:card_moved, card, from_list_id, to_list_id}
或者:
elixir
{:board_updated, :card_moved, card}
这样做的好处是,发消息的人和收消息的人,都在讨论同一件业务事实,而不是讨论"界面该怎么打补丁"。
这不是小事。
因为系统一复杂,很多维护成本根本不在于 API 会不会写,而在于大家到底在用什么语言描述同一个世界。
我的体感是:
LiveView + PubSub 这套组合,最厉害的地方不是实时,而是它让"服务端业务事件"直接变成"页面更新来源"。
这个链路短了,出错点就会少很多。
5. 进项目之后,我最常踩的 4 个坑
下面这部分,我尽量不讲那种"看文档就知道"的事,主要讲几个我自己真容易写歪的点。
5.1 坑一:topic 粒度太粗,最后变成广播大喇叭
这是我最早犯的错误之一。
一开始图省事,很容易写出这种 topic:
elixir
"messages"
或者:
elixir
"board_updates"
然后所有页面全订阅它。
短期看很爽,长期看就会开始翻车:
- 某个房间发消息,所有房间页面都收到
- 某个看板改卡片,所有看板页面都要过滤一遍
- 广播一多,页面上到处是"跟我无关但我也收到了"的消息
这就是典型的 topic 设计偷懒,后面用条件判断还债。
我现在的习惯是:
- 跟具体实体绑定的,用实体级 topic
- 跟页面范围绑定的,用页面级 topic
- 真需要全局通知,再单独做全局 topic
举个例子:
elixir
def room_topic(room_id), do: "room:#{room_id}"
def board_topic(board_id), do: "board:#{board_id}"
def order_topic(order_id), do: "order:#{order_id}"
别小看这一步。
topic 粒度一旦设计对,后面很多"为什么页面老是在刷新无关数据"的问题,会直接少一大半。
5.2 坑二:广播之后自己也收到了,于是重复追加
这个坑在列表场景特别常见。
比如我提交一条消息时,自己先把消息 append 到列表里;然后又广播;广播回来后,自己又在 handle_info/2 里 append 一次。
结果就是:
我发一条,自己页面出现两条。
第一次遇到这个问题时,我盯着界面看了半天,还怀疑是不是数据库插入了两次。后来发现,根本不是数据层的问题,是我自己把页面更新做重了。
错误写法很像这样:
elixir
def handle_event("send", params, socket) do
{:ok, message} = Chat.create_message(...)
Phoenix.PubSub.broadcast(MyApp.PubSub, topic(socket.assigns.room_id), {:message_created, message})
{:noreply, update(socket, :messages, &(&1 ++ [message]))}
end
然后:
elixir
def handle_info({:message_created, message}, socket) do
{:noreply, update(socket, :messages, &(&1 ++ [message]))}
end
这样自己当然会重复。
两个常见解法:
- 只靠广播回流更新页面,本地不手动 append
- 用
broadcast_from/4排除当前进程,自己单独更新,别人走广播
比如第二种:
elixir
Phoenix.PubSub.broadcast_from(
MyApp.PubSub,
self(),
topic(room_id),
{:message_created, message}
)
这个 API 的意思很朴素:
给大家广播,但别再发回我自己。
它很适合"当前页面已经本地更新过,其他页面再同步"的场景。
5.3 坑三:收到任何消息都全量重查数据库
这也是很真实的坑。
写起来最省事的方式往往是:
elixir
def handle_info({:message_created, _message}, socket) do
messages = Chat.list_messages(socket.assigns.room_id)
{:noreply, assign(socket, :messages, messages)}
end
这段不是错,甚至很多后台页面、小数据量页面这么干都完全能跑。
但问题是,一旦消息频率高一点,或者列表大一点,你就会开始感受到:
- 数据库查询次数明显上来了
- 每次都全量 assign,diff 也会变重
- 用户越多,所有订阅方都在做重复工作
我自己的经验是:
先分清"业务真相要不要重查"和"页面显示要不要全刷",这两件事不是一个问题。
举个例子:
- 如果广播里已经带了完整的新消息,那就没必要全量重查
- 如果只是某条记录状态变了,也许只更新那一项就够了
- 如果排序规则复杂、关联很多、局部更新很难维护,那再考虑重查
也就是说,别把"收到广播"默认等于"全表再查一遍"。
能增量更新,就尽量增量更新。
5.4 坑四:把 PubSub 当成最终一致性的唯一来源
这个坑更偏设计层面,但我觉得很重要。
我刚开始写实时页面的时候,有一阵子会下意识觉得:
"既然大家都能收到广播,那页面状态是不是只靠广播就行了?"
后来发现,这个想法很危险。
因为 PubSub 更像是变化通知机制,不是你的持久化真相源。
什么意思?
- 真正的业务事实,还是数据库里的数据
- PubSub 负责把"发生了变化"尽快告诉订阅方
- 新进来的用户,不能指望靠历史广播把页面拼完整
- 页面断线重连之后,也应该能从持久层恢复状态
所以更稳的思路一般是:
mount/3先从数据库拿当前快照connected?(socket)后订阅 topic- 后续靠广播吃增量更新
这套组合才是完整的。
如果你把 PubSub 当成"唯一数据源",早晚会在重连、漏消息、初始化时机这些地方踩坑。
6. 一个更像真实项目的例子:订单状态页为什么特别适合这套模型
聊天室好懂,但有人会说:聊天室当然实时,正常业务系统没那么典型。
还真不是。
我反而觉得,很多"状态变化由后台任务驱动"的页面,更适合 LiveView + PubSub。
比如订单详情页。
用户打开订单页面后,可能会看到:
- 待支付
- 支付中
- 支付成功
- 发货中
- 已完成
这里最烦人的点在于,状态不一定是用户自己点击页面触发的,也可能是:
- 支付回调改的
- 后台任务改的
- 管理后台人工改的
这种场景如果还是纯"前端轮询接口",能做,但总有点笨。
而 LiveView 的写法就很顺:
6.1 页面订阅订单 topic
elixir
def mount(%{"id" => order_id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, order_topic(order_id))
end
order = Orders.get_order!(order_id)
{:ok, assign(socket, :order, order)}
end
6.2 订单状态变化时广播
比如支付回调处理成功后:
elixir
def mark_order_paid(order) do
order = Orders.update_status!(order, :paid)
Phoenix.PubSub.broadcast(
MyApp.PubSub,
order_topic(order.id),
{:order_updated, order}
)
{:ok, order}
end
6.3 页面收到消息后局部更新
elixir
def handle_info({:order_updated, order}, socket) do
{:noreply, assign(socket, :order, order)}
end
这时候你会发现,所谓"实时订单状态页",在 LiveView 里一点都不玄学。
它甚至有点朴素。
因为你根本不是在想"客户端怎么保持 socket 连接并同步 store",你想的是:
这个订单页是个进程,订单状态变了,就通知它一下。
这种建模方式,真的很顺手。
7. handle_info/2 其实是 LiveView 实时能力的灵魂接口
这一节我想单独说一下 handle_info/2。
因为我觉得,很多人第一次学 LiveView 时,注意力都放在 handle_event/3 上,觉得用户点击、表单提交这些最显眼。
但你一旦开始做实时协同、多来源状态更新,就会意识到:
handle_info/2 才是 LiveView 从"动态页面"变成"实时系统界面"的关键。
为什么这么说?
因为它让页面可以处理任何服务端消息:
- PubSub 广播
send(self(), msg)的内部消息Process.send_after/3的定时消息- 后台任务结果通知
换句话说,LiveView 页面不是只能响应用户点击。
它也能响应系统世界正在发生的变化。
这个能力一出来,很多页面的味道就变了。
以前你可能把页面理解成"用户来操作,我再响应"。
现在你会开始把页面理解成:
一个有状态的服务端 actor,它既接收用户事件,也接收系统消息。
说得稍微装一点,就是:
LiveView 最有意思的地方,不是模板会动,而是页面开始"活"起来了。
8. 我现在对 PubSub 的一个很主观判断:它不复杂,复杂的是你广播什么
写到这里,我想给一个比较主观、但我自己越来越笃定的观点。
很多人觉得 PubSub 难,是因为 API 看起来像"分布式系统味儿很重"的东西。
但真要说,subscribe 和 broadcast 本身一点都不复杂。
真正复杂的,其实是下面这些决策:
- 你的 topic 怎么分
- 你的消息命名是否贴近业务
- 当前页面要不要自己先更新
- 收到消息后是增量改,还是全量重查
- 这个变化应该广播给谁,不该广播给谁
所以我现在对这块的理解是:
PubSub 的门槛不在 API,而在建模。
你把 topic、消息语义、页面状态边界设计对了,它会非常顺。
你如果一开始就用"全局广播 + 收到就重刷 + 消息名全叫 refresh"这种写法,那后面基本注定要还债。
这也是为什么我觉得,LiveView 的实时通信很适合认真写一篇单独文章。
因为它不是一个"记几个函数名就完了"的点,而是一个很典型的:
代码不多,但非常考验你有没有想清楚系统怎么流动。
总结
如果只让我压缩成几句话,那我会这么说:
LiveView 里的实时通信,核心不是 WebSocket 有多酷,而是页面本身就是服务端进程,所以它天然适合接收消息、更新状态、再把结果推给浏览器。
再说得更直白一点:
handle_event/3处理用户动作broadcast/3传播业务变化handle_info/2消化系统消息socket.assigns始终是页面自己的状态真相
这套东西一旦顺起来,多用户同步这类以前很容易写脏的需求,会突然变得非常"工程朴素"。
当然,坑也是真的有:
- 订阅时机别乱放
- topic 粒度别偷懒
- 别一广播就把自己重复更新了
- 别收到消息就无脑全量重查
- 更别把 PubSub 当成数据库替身
下一篇我大概率会继续写 LiveComponent。那个东西也很有意思,因为它会把"页面是进程"这个思路,再往组件层面推进一步。
如果你也在写 LiveView,或者你刚好对"服务端主导的实时 UI"这条路线感兴趣,欢迎在评论区聊聊你最烦的一个实时场景是什么。很多时候,真正难的不是功能做不出来,而是第一版特别容易做歪。