LiveView 的实时通信,爽是爽,但 PubSub 和广播也最容易把自己绕晕

📅 2026/7/5 7:56:15
LiveView 的实时通信,爽是爽,但 PubSub 和广播也最容易把自己绕晕
前言如果说 LiveView 前两篇内容更多是在帮我建立心智模型那 PubSub 这一块就是我第一次真正感受到它“有业务杀伤力”的地方。原因很简单。很多 Web 项目最后都会走到“实时”这个词上聊天室里新消息要立刻出现协作看板里别人拖了一张卡片我这边得同步订单详情页里支付状态更新了页面不能还傻站着后台列表里一条记录被别人改了我最好别还展示旧数据以前写 SPA 的时候遇到这类需求我的第一反应通常是要不要上 WebSocket前端状态怎么同步后端推什么格式客户端怎么局部更新缓存当前页面和别的页面的数据冲突怎么处理一套搞下来不一定做不成但脑子会明显变重。而 LiveView 在这件事上的思路非常直接页面本来就是服务端进程那实时更新这件事本质上就变成“让别的进程给这个页面发消息”。这个味道一下就不一样了。所以这篇我最想讲的不只是 API 怎么写而是这句话LiveView 里的实时通信本质上不是“前端收到推送后更新状态”而是“服务端页面进程收到消息后改自己的 assigns”。你如果把这层理解吃透后面很多写法都会顺。1. 先说人话PubSub 到底是在干嘛第一次看到Phoenix.PubSub的时候我脑子里想的是“哦广播系统。”这个理解不算错但有点太平了不够指导代码。我现在更喜欢一个更土、但更好记的说法PubSub 就像给一堆进程建了不同的聊天群。你可以订阅某个 topic等于进群你可以往这个 topic 广播消息等于群发订阅这个 topic 的进程都会收到这条消息举个最小例子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 里实时更新的完整链路到底长什么样我建议你把这条链路记熟不然后面很容易写着写着就糊。一个典型的“多用户实时更新”流程大概长这样用户 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一进来就应该订阅这个房间的话题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里广播用户提交消息时可以这么写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拿到最终结果再广播一个明确的领域事件比如这里广播的是{:message_created, message}这个就比{:refresh, :messages}更有信息量也更不容易把接收方写成“收到任何事都整页重刷”的祖传逻辑。3.3 订阅方在handle_info/2里收消息页面收到消息后更新自己的状态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 的实时能力时会强调“代码少”“不怎么写前端”。这些当然是优点但我觉得还没打到最值钱的地方。真正值钱的是广播的内容可以直接长成业务语言而不是前端协议语言。比如一个协作看板里卡片被移动了。在很多前后端分离项目里这条实时消息可能会长这样{type:update_board,payload:{boardId:b1,listId:l2,cardId:c9,position:3}}不是说这样不行但它常常会越写越像“前端补丁协议”。而在 Phoenix / LiveView 里我更喜欢把消息命名成明确的领域事件{:card_moved, card, from_list_id, to_list_id}或者{:board_updated, :card_moved, card}这样做的好处是发消息的人和收消息的人都在讨论同一件业务事实而不是讨论“界面该怎么打补丁”。这不是小事。因为系统一复杂很多维护成本根本不在于 API 会不会写而在于大家到底在用什么语言描述同一个世界。我的体感是LiveView PubSub 这套组合最厉害的地方不是实时而是它让“服务端业务事件”直接变成“页面更新来源”。这个链路短了出错点就会少很多。5. 进项目之后我最常踩的 4 个坑下面这部分我尽量不讲那种“看文档就知道”的事主要讲几个我自己真容易写歪的点。5.1 坑一topic 粒度太粗最后变成广播大喇叭这是我最早犯的错误之一。一开始图省事很容易写出这种 topicmessages或者board_updates然后所有页面全订阅它。短期看很爽长期看就会开始翻车某个房间发消息所有房间页面都收到某个看板改卡片所有看板页面都要过滤一遍广播一多页面上到处是“跟我无关但我也收到了”的消息这就是典型的topic 设计偷懒后面用条件判断还债。我现在的习惯是跟具体实体绑定的用实体级 topic跟页面范围绑定的用页面级 topic真需要全局通知再单独做全局 topic举个例子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 一次。结果就是我发一条自己页面出现两条。第一次遇到这个问题时我盯着界面看了半天还怀疑是不是数据库插入了两次。后来发现根本不是数据层的问题是我自己把页面更新做重了。错误写法很像这样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然后def handle_info({:message_created, message}, socket) do {:noreply, update(socket, :messages, (1 [message]))} end这样自己当然会重复。两个常见解法只靠广播回流更新页面本地不手动 append用broadcast_from/4排除当前进程自己单独更新别人走广播比如第二种Phoenix.PubSub.broadcast_from( MyApp.PubSub, self(), topic(room_id), {:message_created, message} )这个 API 的意思很朴素给大家广播但别再发回我自己。它很适合“当前页面已经本地更新过其他页面再同步”的场景。5.3 坑三收到任何消息都全量重查数据库这也是很真实的坑。写起来最省事的方式往往是def handle_info({:message_created, _message}, socket) do messages Chat.list_messages(socket.assigns.room_id) {:noreply, assign(socket, :messages, messages)} end这段不是错甚至很多后台页面、小数据量页面这么干都完全能跑。但问题是一旦消息频率高一点或者列表大一点你就会开始感受到数据库查询次数明显上来了每次都全量 assigndiff 也会变重用户越多所有订阅方都在做重复工作我自己的经验是先分清“业务真相要不要重查”和“页面显示要不要全刷”这两件事不是一个问题。举个例子如果广播里已经带了完整的新消息那就没必要全量重查如果只是某条记录状态变了也许只更新那一项就够了如果排序规则复杂、关联很多、局部更新很难维护那再考虑重查也就是说别把“收到广播”默认等于“全表再查一遍”。能增量更新就尽量增量更新。5.4 坑四把 PubSub 当成最终一致性的唯一来源这个坑更偏设计层面但我觉得很重要。我刚开始写实时页面的时候有一阵子会下意识觉得“既然大家都能收到广播那页面状态是不是只靠广播就行了”后来发现这个想法很危险。因为 PubSub 更像是变化通知机制不是你的持久化真相源。什么意思真正的业务事实还是数据库里的数据PubSub 负责把“发生了变化”尽快告诉订阅方新进来的用户不能指望靠历史广播把页面拼完整页面断线重连之后也应该能从持久层恢复状态所以更稳的思路一般是mount/3先从数据库拿当前快照connected?(socket)后订阅 topic后续靠广播吃增量更新这套组合才是完整的。如果你把 PubSub 当成“唯一数据源”早晚会在重连、漏消息、初始化时机这些地方踩坑。6. 一个更像真实项目的例子订单状态页为什么特别适合这套模型聊天室好懂但有人会说聊天室当然实时正常业务系统没那么典型。还真不是。我反而觉得很多“状态变化由后台任务驱动”的页面更适合 LiveView PubSub。比如订单详情页。用户打开订单页面后可能会看到待支付支付中支付成功发货中已完成这里最烦人的点在于状态不一定是用户自己点击页面触发的也可能是支付回调改的后台任务改的管理后台人工改的这种场景如果还是纯“前端轮询接口”能做但总有点笨。而 LiveView 的写法就很顺6.1 页面订阅订单 topicdef 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)} end6.2 订单状态变化时广播比如支付回调处理成功后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} end6.3 页面收到消息后局部更新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”这条路线感兴趣欢迎在评论区聊聊你最烦的一个实时场景是什么。很多时候真正难的不是功能做不出来而是第一版特别容易做歪。