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 看起来像"分布式系统味儿很重"的东西。

但真要说,subscribebroadcast 本身一点都不复杂。

真正复杂的,其实是下面这些决策:

  • 你的 topic 怎么分
  • 你的消息命名是否贴近业务
  • 当前页面要不要自己先更新
  • 收到消息后是增量改,还是全量重查
  • 这个变化应该广播给谁,不该广播给谁

所以我现在对这块的理解是:

PubSub 的门槛不在 API,而在建模。

你把 topic、消息语义、页面状态边界设计对了,它会非常顺。

你如果一开始就用"全局广播 + 收到就重刷 + 消息名全叫 refresh"这种写法,那后面基本注定要还债。

这也是为什么我觉得,LiveView 的实时通信很适合认真写一篇单独文章。

因为它不是一个"记几个函数名就完了"的点,而是一个很典型的:

代码不多,但非常考验你有没有想清楚系统怎么流动。

总结

如果只让我压缩成几句话,那我会这么说:

LiveView 里的实时通信,核心不是 WebSocket 有多酷,而是页面本身就是服务端进程,所以它天然适合接收消息、更新状态、再把结果推给浏览器。

再说得更直白一点:

  • handle_event/3 处理用户动作
  • broadcast/3 传播业务变化
  • handle_info/2 消化系统消息
  • socket.assigns 始终是页面自己的状态真相

这套东西一旦顺起来,多用户同步这类以前很容易写脏的需求,会突然变得非常"工程朴素"。

当然,坑也是真的有:

  • 订阅时机别乱放
  • topic 粒度别偷懒
  • 别一广播就把自己重复更新了
  • 别收到消息就无脑全量重查
  • 更别把 PubSub 当成数据库替身

下一篇我大概率会继续写 LiveComponent。那个东西也很有意思,因为它会把"页面是进程"这个思路,再往组件层面推进一步。

如果你也在写 LiveView,或者你刚好对"服务端主导的实时 UI"这条路线感兴趣,欢迎在评论区聊聊你最烦的一个实时场景是什么。很多时候,真正难的不是功能做不出来,而是第一版特别容易做歪。

相关推荐
用户2930750976691 小时前
告别关键词匹配,拥抱向量语义 —— RAG 搜索从零到一
前端
独孤留白1 小时前
从C到Rust:告别 C 的"指针 + 长度"手动模式
前端·rust
掘金安东尼2 小时前
中小厂前端候选人简历面试拆解:从 HR 面、技术面到主管面的双赢提问法
前端·面试
天平11 小时前
油猴脚本创建webworker踩坑记录
前端·javascript·typescript
原则猫12 小时前
前端基础大厦
前端
陈随易13 小时前
编程语言级别的Skill市场,AI Agent 的未来形态
前端·后端·程序员
SoaringHeart14 小时前
Flutter进阶:基于 EasyRefresh 的下拉刷新封装 n_easy_refresh_mixin.dart
前端·flutter
IT_陈寒16 小时前
Vite的热更新突然不香了,排查三小时差点砸键盘
前端·人工智能·后端