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

前言

如果说 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 页面,恰好就是一个服务端进程。

所以整个链路就变得非常自然了:

  1. LiveView 进程订阅某个 topic
  2. 别的地方发生业务操作
  3. 那个地方广播一条消息
  4. LiveView 在 handle_info/2 里收到消息
  5. 改 socket.assigns
  6. 页面自动重新渲染并推 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

这段代码看起来很顺,但这里恰好藏着一个特别值得说的观点:

广播应该发生在"业务动作已经成立"之后,而不是"用户有意图"时。

什么意思?

就是你别在数据还没落稳的时候,先广播一个"我改了我改了"。不然别的页面收到了消息,结果数据库里还没有,或者后续又失败回滚,这就很尴尬。

所以更稳的做法一般是:

  1. 先完成业务动作,比如 Repo.insert
  2. 拿到最终结果
  3. 再广播一个明确的领域事件

比如这里广播的是:

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

这样自己当然会重复。

两个常见解法:

  1. 只靠广播回流更新页面,本地不手动 append
  2. 用 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 负责把"发生了变化"尽快告诉订阅方
  • 新进来的用户,不能指望靠历史广播把页面拼完整
  • 页面断线重连之后,也应该能从持久层恢复状态

所以更稳的思路一般是:

  1. mount/3 先从数据库拿当前快照
  2. connected?(socket) 后订阅 topic
  3. 后续靠广播吃增量更新

这套组合才是完整的。

如果你把 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"这条路线感兴趣,欢迎在评论区聊聊你最烦的一个实时场景是什么。很多时候,真正难的不是功能做不出来,而是第一版特别容易做歪。

相关推荐
deli00733 分钟前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0072 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈9 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技10 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng13 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding14 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js