当前位置: 首页 > news >正文

用 LiveView 做一个实时聊天室:我以为省的是 JS,结果省的是一整套状态同步

前言

我写这篇,不是因为聊天室有多新鲜,恰恰相反,是因为它太老了。

老到什么程度?老到几乎每个框架在证明“我能做实时”的时候,都会把它拎出来遛一圈。结果轮到我自己上手时,我还是翻车了几次。不是那种宏大翻车,不是服务挂了数据库炸了,而是那种更烦人的小翻车:

  • 消息明明发成功了,自己这边怎么冒出两条
  • 列表是能更新,但更新得像在偷偷全表重刷
  • 代码跑得通,可我越看越觉得这条链路心里发虚

这种时刻很像你组装了一把椅子,四条腿都装上了,坐也能坐,但你总感觉它下一秒就会当场散架。LiveView 做实时页面时,最折磨我的就是这种“能跑,但不踏实”。

subscribebroadcasthandle_info/2我都认识,单拎出来谁也不吓人。可一旦它们真的开始排队进一个页面里,问题就会从“函数怎么写”变成“责任到底怎么分”。

所以我后来不想空聊了,干脆拿聊天室正面硬碰一下。

原因很简单,聊天室这东西虽然老,但它对 LiveView 太友好了:

  • 有高频实时更新
  • 有多用户同步
  • 有表单提交
  • 有列表渲染
  • 稍微写歪一点,问题会立刻露馅

说白了,它特别适合拿来检验一句话到底是不是实话:

LiveView 的爽点,到底是不是“不写 JS”,还是“状态终于不用在两边来回搬了”。

我这次没有把摊子铺得很大,第一版就只盯着一个最短闭环:

  • 先有房间和消息
  • 能看到历史消息
  • 能发送消息
  • 一个用户发出去,另一个用户能立刻看到

在线用户列表、多房间切换、部署和生产环境细节,我放到下篇再接着聊。不然第一篇就全塞进去,很容易写成一种很熟悉的技术文章形态:看着挺全,读着挺累,最后谁也记不住重点。

1. 我为什么宁可写聊天室,也不想再写一个 Todo

这个判断我现在越来越主观了:

要体现 LiveView 的优势,聊天室比 Todo 强太多。

Todo 当然也能写,而且很适合入门。但它的问题也很明显:它更多是在证明“LiveView 能做 CRUD”,而不是证明“LiveView 的实时模型到底值不值钱”。

聊天室就不一样了。

它一上来就把几个最核心的问题同时摆在桌上:

  • 消息发出后,别的用户什么时候看到
  • 当前用户自己的页面怎么更新
  • 页面状态和数据库状态谁先谁后
  • 消息列表变长以后,还能不能继续优雅

这些问题在 SPA 里也不是做不了,但一般都会开始长出这些东西:

  • WebSocket 客户端状态
  • 本地消息列表缓存
  • 乐观更新
  • 回滚
  • 接口和推送消息的双轨同步

而 LiveView 处理它的方式特别朴素,甚至朴素到有点像在侮辱问题:

用户发事件给服务端,服务端完成业务动作,再把变化广播给订阅这个房间的页面进程。

你会发现聊天室一旦跑起来,LiveView 的那股“服务端统一状态”的味道会特别明显。

2. 我把第一版收得很死,不是克制,是怕自己写成“小型 IM 宇宙”

我一开始也有过那种冲动:

  • 要不要一上来就做私聊
  • 要不要做已读未读
  • 要不要支持图片
  • 要不要把在线状态顺手也带上

后来我忍住了。因为这些需求每加一个,都会把第一版重心拉偏。

所以我后来给自己划了一条很死的线:

  1. 先只有房间消息,不做私聊
  2. 先只处理文本消息
  3. 不引入认证系统,昵称先跟消息一起提交
  4. 不做前端乐观更新,一切以服务端落库成功为准
  5. 列表更新要走增量,不要每次都全量重查

这里面我最不肯松口的是第 4 条和第 5 条。

因为我后来越来越觉得,聊天室第一版最容易歪掉的,不是页面丑,而是:

  • 还没落库就先假装成功了
  • 每来一条消息就把整个列表再查一遍

这两个坑,前者会让状态开始发虚,后者会让你后面性能和 diff 都难受。

3. 我第一版把数据模型压到只剩两张表,反而一下清醒了

这一版我只保留两个核心实体:roomsmessages

3.1Room这张表,我一开始就没打算给它太多戏份

Room这张表我没加什么花活,几乎就是一个“给消息找个窝”的存在。字段也很克制,无非就是房间名,加上和消息的关联。它在第一版里最大的价值,不是承担复杂业务,而是帮我拒绝复杂业务。

这里我顺手把timestamps配成了:utc_datetime_usec。这个细节我一开始真没太在意,后来做消息排序时才开始后知后觉:

实时消息这种东西,时间精度不够的时候,排序很容易看着就别扭。

尤其你本地开两个窗口自己压消息的时候,很容易出现“怎么前后顺序像在抽签”的错觉。用微秒时间戳,至少这类问题会少很多。

3.2 第一版真正有存在感的,其实只有Message

聊天室第一版我没有先上用户系统,所以直接把昵称和消息内容放进messages表里。很土,但很诚实。

schema "messages" do field :username, :string field :body, :string belongs_to :room, MyApp.Chat.Room timestamps(type: :utc_datetime_usec) end

这里我其实做了一个看起来不那么“完整”、但我自己很认同的选择:

先别把认证系统绑进来。

不是说认证不重要,而是如果一上来就把userscurrent_user、路由守卫、房间权限全捆进来,整个讨论会立刻跑偏。原本想聊的是聊天室链路,最后很可能写成一篇“顺便再解释三套东西”的大杂烩。

等到后面专门聊认证时,再把username这层替换成user_id,思路反而更顺。

3.3 我后来最认的一次收手,是把广播从 LiveView 里请了出去

这一点算是我这次最想说的观点之一。

我第一次写聊天室时,脑子里的默认剧本其实特别直白:

  1. LiveView 收到表单提交
  2. LiveView 调Repo.insert
  3. LiveView 自己broadcast

这套写法当然能跑,但我写着写着就开始别扭。

因为它默认了一个前提:

“消息创建”这件事只会发生在当前这个页面里。

但真实项目里未必。

后面你很可能还会有:

  • HTTP 接口创建消息
  • 后台任务导入消息
  • 管理员工具代发系统消息

如果广播只写在 LiveView 里,那“消息创建”这件事就会被偷偷绑定到页面层。短期看不出问题,后面来源一多,链路立刻开始散。

所以我后来干脆把它收回 Context。代码也没必要摆成一整面墙,关键其实就下面这几行:

def create_message(attrs) do %Message{} |> Message.changeset(attrs) |> Repo.insert() |> case do {:ok, message} -> Phoenix.PubSub.broadcast(MyApp.PubSub, room_topic(message.room_id), {:message_created, message}) {:ok, message} {:error, changeset} -> {:error, changeset} end end

这里最大的变化,不是“代码更优雅”这种虚词,而是:

消息创建和消息广播终于是同一件业务动作的两个连续步骤。

以后谁来创建消息不重要,只要它走Chat.create_message/1,广播就不会漏。

这个边界一旦站稳,后面不管谁来发消息,味道都会统一很多。

4. 页面这块,我一开始故意没往“像不像 Slack”上使劲

我自己做这种东西有个老毛病:功能还没跑顺,脑子先开始想滚动区域、气泡样式、头像圆角,甚至已经在幻想“这个渐变能不能更高级一点”。

后来我把自己按住了。因为聊天室第一版最值钱的,不是“像不像 Slack”,而是这条链先别散:

进入页面 -> 订阅房间 -> 拉历史消息 -> 发送消息 -> 收到广播 -> 列表更新

只要这条链是稳的,UI 后面慢慢修都来得及。

最后我落下来的页面骨架其实挺薄的。不是那种“功能齐全、讲解到位”的薄,而是“刚够用,但责任分得清”的薄:

def mount(%{"id" => room_id}, _session, socket) do if connected?(socket), do: Chat.subscribe_to_room(room_id) {:ok, socket |> assign(:room_id, room_id) |> assign_form(%{"username" => "匿名但认真", "body" => ""}) |> stream(:messages, Chat.list_messages(room_id))} end def handle_info({:message_created, message}, socket) do {:noreply, stream_insert(socket, :messages, message)} end

这段代码我现在回头看,真正让我觉得“顺”的,不是它多短,而是它没乱抢活。页面负责接消息和展示状态,业务负责让消息成立,广播负责把动静传出去。谁都别顺手多干一点,系统味道就会正很多。

4.1 我第一次把订阅放错地方时,日志看起来像闹鬼

这个坑我前面提过,但放到聊天室里体感会更强。因为mount/3真不是“页面进来一次就完事”那么简单,它要经历静态渲染和连接建立两个阶段。你要是一上来就订阅,很容易进入经典怀疑人生模式:

  • 为什么日志打了两次
  • 为什么我以为订阅成功了,结果消息没进来
  • 为什么这个页面进程像薛定谔的猫

所以我现在只要看到下面这类动作,脑子里都会自动拉警报:

  • 订阅
  • 定时器
  • 外部副作用

后来我基本形成了条件反射:这类事先别急着写,先问一句,它是不是应该等connected?(socket)之后再发生。

4.2 我为什么很早就倒向了stream

这个选择挺主观,但我现在是真心觉得聊天室和stream的气质特别搭。

最直接的写法当然也有:

{:ok, assign(socket, :messages, Chat.list_messages(room_id))}

然后收到广播后:

{:noreply, update(socket, :messages, &(&1 ++ [message]))}

这在 demo 阶段没毛病,但聊天室这个场景天生有个特点:消息不会老老实实停在 10 条。

也就是说,这不是一个“列表偶尔变一下”的页面,而是一个“列表一直在变、还会越来越长”的页面。

所以我后来越来越喜欢stream的地方,不是“它新”,而是它很像在替我提前挡债:

  • 初始加载可以直接塞一批历史消息
  • 新消息来了只增量插入
  • 页面 diff 更克制
  • 后面做删除、替换、截断也更顺

我自己的感受很直接:聊天室不用stream不是不行,但用了以后,那种“先拿数组糊一下再说”的草台味会淡很多。

5. 模板我也没想耍花活,我只是很怕它长成一整坨

HEEx 模板我最后也写得挺朴素。老实说,这里我已经不太想展示整段模板了,因为它本身没什么玄学,核心就一个判断:

我一开始就把“消息显示区”和“输入操作区”拆开了。

因为后面你很可能会继续加这些东西:

  • 在线用户列表
  • 房间切换
  • 顶部房间信息
  • 输入中的状态提示

如果一开始模板就是一整坨,后面每多一个区域,代码就多拧巴一点。这个感觉很像收纳箱,刚开始图省事全塞一个箱子里,短期看挺干净,真要找东西时只会想骂人。

我现在已经彻底接受一个现实:第一版页面素一点完全没关系,结构先扁了才是真的难救。

6. 真正把我绕进去的,不是表单,而是“到底谁来宣布这条消息成立”

表面上看,发送消息就是个普通表单提交。可我后来越写越觉得,真正要命的地方从来不是表单本身,而是那句一直悬在半空、你不想清楚它系统就会开始闹脾气的话:

成功之后,当前页面到底要不要自己先把消息插进去?

我一开始真干过这种事。

也就是在{:ok, message}以后,自己先插一条,本地先爽一下,等 PubSub 广播回来再插一次。结果当然也很配合气氛,自己的消息一发就是两条,像页面忽然学会了复读。

后来我干脆给自己立了个规矩:

谁都别抢跑,页面更新一律以广播为准。

说白了就是,handle_event/3发起动作,Chat.create_message/1负责让这件事在业务上真正成立,handle_info/2再把那个结果落回页面。

这样写最朴素的好处就是:

发送人和接收人走的是同一条页面更新路径。

只要这一条路径是对的,多开几个浏览器窗口都不会打架。

这在实时场景里非常重要。因为一旦“自己页面一套逻辑,别人页面另一套逻辑”,你后面就很容易开始修那种看着像偶发、其实是结构分叉造成的问题。

7. 我这次踩得最实在的 5 个坑,几乎都不是语法问题

这块我尽量不写成“温馨提示”,直接写我自己真踩过、而且很容易再踩的几个点。

7.1 我第一次在mount/3里直接订阅时,日志像闹鬼

这个上面已经说过一次,但它值得再重复一次。

我当时就是手快,直接在mount/3里订了。后来才反应过来,这种事就该老老实实等connected?(socket)之后再发生。

这类问题最烦的地方就在于,它未必一眼就报错,它更像是“系统表现有点怪”。这种怪最磨人。

7.2 我也干过“自己先插一次,广播回来再插一次”这种蠢事

这就是我前面说的“自己给自己复读”。

当时我就是图省事,想先让自己页面爽一下。结果页面也确实爽了两次。

结果就是发送人页面重复,其他人页面正常。这个 bug 很坏,它不大张旗鼓,但特别会阴人。

7.3 我也短暂地动过“要不每次都全量查吧”的歪念头

这个点现在已经成了我判断“这东西到底像不像真实项目”的一个直觉开关。

那种歪念头长出来时,人会特别想把“增量更新”四个字先扔一边,转头去抱数据库大腿。

这么写短期当然也能跑,但味道会很不对:

“我懒得想增量更新了,先把数据库当止痛片吞一口。”

问题是聊天室的消息不是只变一次,它会一直变。你每来一条都全查一次,后面基本只会越来越重。

所以我后来给自己留的思路就简单很多:

  • 初始进入页面,全量拿最近 N 条
  • 收到新增广播,只插入这一条
  • 真遇到需要重新同步的大动作,再明确做一次全量刷新

这条边界分清以后,整个页面的呼吸感会好很多。

7.4 时间排序这件小事,我一开始也偷过懒

这个坑不大,但它特别擅长恶心人。

我一开始只盯着inserted_at,后来压消息的时候,偶尔会觉得顺序像在碰运气。再往后我就学乖了,顺手把id也带上,让它别在这种小地方和我玩心眼。

它不是多高级的优化,但在“消息天生带顺序感”的场景里,这种确定性挺值钱的。

7.5 提交失败时我还顺手清空过表单,现在想想真想打自己一下

这个坑也很真实。

如果消息因为长度校验失败了,你这时候把bodyusername一起清掉,用户的第一反应不是“哦,校验触发了”,而是“我刚刚写那一坨东西呢?”

后来我对表单重置这件事就只剩一个朴素想法:

  • 成功了,再清空该清空的字段
  • 失败了,就把原表单和错误一起留在那儿

这种细节单看不起眼,但它决定了页面是不是“像个活人做的东西”。

8. 写到这里,我才第一次觉得这个聊天室“像是真的了”

如果硬要把这篇压成一句人话,那就是:页面进来以后先订阅房间,先把历史消息摆出来;有人发消息时,先让业务把这件事坐实,再把结果广播出去;每个页面收到广播以后,只做增量更新。没有人偷偷抢跑,也没有人背地里多干一摊活。

它最打动我的地方,真不是“没有写自定义前端状态管理”这么简单。

而是:

业务动作、实时同步、页面更新,这三件事终于是在同一条服务端链路里发生的。

这在聊天室这种场景里,真的会让脑子轻很多。

当然,到这里还不算完整项目。至少还有几块东西,我自己写到这儿时已经开始惦记了:

  • 在线用户列表怎么做
  • 多房间切换怎么组织 topic 和路由
  • 历史消息无限滚动怎么处理
  • 部署到生产环境后,WebSocket 相关配置要注意什么

这些我准备放到下篇接着写。因为对我来说,第一篇先把这条链路站稳,比一上来功能拉满重要得多。

总结

如果只让我压缩成一句话,那我现在对聊天室这个实战最大的感受是:

LiveView 最值钱的,不是“前端少写一点”,而是“实时页面终于不用维护两套状态真相”。

聊天室只是个切口,但它特别适合把这件事讲透。

如果你也在写 LiveView,或者你也有过那种“概念都认识,真落地却总觉得哪儿发虚”的时刻,那聊天室这个题目真挺诚实。它不大,也不新鲜,但特别会暴露问题。代码哪里想清楚了,哪里还在糊弄自己,跑起来基本藏不住。

http://www.jsqmd.com/news/1146771/

相关文章:

  • 深度解析TegraRcmGUI:Windows平台Nintendo Switch注入工具的终极实现
  • Java计算机毕设之基于前后端分离的校园图书闲置流转平台的设计与实现 基于 SpringBoot 的二手书竞拍交易管理系统(完整前后端代码+说明文档+LW,调试定制等)
  • 数据仓库与数据挖掘 5 大核心算法:Apriori、k-Means、PageRank、ID3、DBSCAN 实战解析
  • ppt模板_0154_商务雾化
  • 这些都看懂了,你的数据库SQL注入就没问题了!网络安全零基础入门到精通教程!
  • Claude 3.5 Sonnet vs GPT-4o 性能对比:5项基准测试与3个真实场景分析
  • Free Claude Code代理搭建:低成本AI编程助手部署指南
  • MBTI-16型人格测试(200题完整版)免费 API 接口详解与调用教程
  • 数据治理和LLM结合后能解决哪些企业痛点?2026年实测效果分享
  • 我受够了做PPT,于是写了一个 Skill,让 AI 直接生成教程网页
  • 2026国内AI数字人软件TOP10盘点:全场景适配且合规靠谱之选
  • 想找正规高颜值入户门厂家?哪家靠谱这里给你揭秘!
  • Portainer 2.21.2 部署与 cpolar 内网穿透:3步实现远程 Docker 管理
  • 线下活动执行测评|千场落地经验,流程与细节表现稳定
  • 高温旺季空调产线满负荷运转:四类典型测控难题与应对方案
  • 腾讯 APIJSON:让后端 CRUD 写到怀疑人生的 ORM 工具
  • 鸿蒙多设备办公怎么协作?三个场景效率实打实提升
  • IIM-20670与MK20DN128VFM5高精度运动跟踪方案详解
  • 如何免费获取Steam创意工坊模组:WorkshopDL跨平台下载工具完整指南
  • 从成像到鉴权:Windows Hello 多模态生物识别完整技术链路科普
  • 【计算机Java毕业设计案例】基于 SpringBoot+Vue 的宠物寄养评价反馈系统的设计与实现 基于前后端分离的宠物寄养收费台账管理系统(程序+文档+讲解+定制)
  • kunpeng-extension-for-pytorch社区贡献指南:如何参与开源AI项目
  • 抖音下载器:3分钟实现无水印批量下载的完整指南
  • JDspyder京东抢购脚本:3分钟快速部署的自动化抢购终极指南
  • 【AI原理】LLM 解析:底层原理 + 主流模型选型 + 通用 API 接入 + DeepSeek API 全流程落地
  • 为什么越来越多便携设备开始采用PD供电?30W无电池补光棒方案分析
  • Deceive终极指南:如何在Riot游戏中实现完美隐身,保持社交功能完整
  • 2026.7.7:C++命令行解析开源库cxxopts上手教程
  • Sulfo-CY5-COOH┃磺化花菁染料CY5羧基┃Sulfo-Cyanine5 Carboxylic Acid┃CAS 1121756-16-8┃水溶性近红外荧光标记试剂
  • 3分钟快速上手:Switch图形化注入工具TegraRcmGUI终极指南