用 LiveView 做一个实时聊天室:我以为省的是 JS,结果省的是一整套状态同步
前言
我写这篇,不是因为聊天室有多新鲜,恰恰相反,是因为它太老了。
老到什么程度?老到几乎每个框架在证明“我能做实时”的时候,都会把它拎出来遛一圈。结果轮到我自己上手时,我还是翻车了几次。不是那种宏大翻车,不是服务挂了数据库炸了,而是那种更烦人的小翻车:
- 消息明明发成功了,自己这边怎么冒出两条
- 列表是能更新,但更新得像在偷偷全表重刷
- 代码跑得通,可我越看越觉得这条链路心里发虚
这种时刻很像你组装了一把椅子,四条腿都装上了,坐也能坐,但你总感觉它下一秒就会当场散架。LiveView 做实时页面时,最折磨我的就是这种“能跑,但不踏实”。
subscribe、broadcast、handle_info/2我都认识,单拎出来谁也不吓人。可一旦它们真的开始排队进一个页面里,问题就会从“函数怎么写”变成“责任到底怎么分”。
所以我后来不想空聊了,干脆拿聊天室正面硬碰一下。
原因很简单,聊天室这东西虽然老,但它对 LiveView 太友好了:
- 有高频实时更新
- 有多用户同步
- 有表单提交
- 有列表渲染
- 稍微写歪一点,问题会立刻露馅
说白了,它特别适合拿来检验一句话到底是不是实话:
LiveView 的爽点,到底是不是“不写 JS”,还是“状态终于不用在两边来回搬了”。
我这次没有把摊子铺得很大,第一版就只盯着一个最短闭环:
- 先有房间和消息
- 能看到历史消息
- 能发送消息
- 一个用户发出去,另一个用户能立刻看到
在线用户列表、多房间切换、部署和生产环境细节,我放到下篇再接着聊。不然第一篇就全塞进去,很容易写成一种很熟悉的技术文章形态:看着挺全,读着挺累,最后谁也记不住重点。
1. 我为什么宁可写聊天室,也不想再写一个 Todo
这个判断我现在越来越主观了:
要体现 LiveView 的优势,聊天室比 Todo 强太多。
Todo 当然也能写,而且很适合入门。但它的问题也很明显:它更多是在证明“LiveView 能做 CRUD”,而不是证明“LiveView 的实时模型到底值不值钱”。
聊天室就不一样了。
它一上来就把几个最核心的问题同时摆在桌上:
- 消息发出后,别的用户什么时候看到
- 当前用户自己的页面怎么更新
- 页面状态和数据库状态谁先谁后
- 消息列表变长以后,还能不能继续优雅
这些问题在 SPA 里也不是做不了,但一般都会开始长出这些东西:
- WebSocket 客户端状态
- 本地消息列表缓存
- 乐观更新
- 回滚
- 接口和推送消息的双轨同步
而 LiveView 处理它的方式特别朴素,甚至朴素到有点像在侮辱问题:
用户发事件给服务端,服务端完成业务动作,再把变化广播给订阅这个房间的页面进程。
你会发现聊天室一旦跑起来,LiveView 的那股“服务端统一状态”的味道会特别明显。
2. 我把第一版收得很死,不是克制,是怕自己写成“小型 IM 宇宙”
我一开始也有过那种冲动:
- 要不要一上来就做私聊
- 要不要做已读未读
- 要不要支持图片
- 要不要把在线状态顺手也带上
后来我忍住了。因为这些需求每加一个,都会把第一版重心拉偏。
所以我后来给自己划了一条很死的线:
- 先只有房间消息,不做私聊
- 先只处理文本消息
- 不引入认证系统,昵称先跟消息一起提交
- 不做前端乐观更新,一切以服务端落库成功为准
- 列表更新要走增量,不要每次都全量重查
这里面我最不肯松口的是第 4 条和第 5 条。
因为我后来越来越觉得,聊天室第一版最容易歪掉的,不是页面丑,而是:
- 还没落库就先假装成功了
- 每来一条消息就把整个列表再查一遍
这两个坑,前者会让状态开始发虚,后者会让你后面性能和 diff 都难受。
3. 我第一版把数据模型压到只剩两张表,反而一下清醒了
这一版我只保留两个核心实体:rooms和messages。
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这里我其实做了一个看起来不那么“完整”、但我自己很认同的选择:
先别把认证系统绑进来。
不是说认证不重要,而是如果一上来就把users、current_user、路由守卫、房间权限全捆进来,整个讨论会立刻跑偏。原本想聊的是聊天室链路,最后很可能写成一篇“顺便再解释三套东西”的大杂烩。
等到后面专门聊认证时,再把username这层替换成user_id,思路反而更顺。
3.3 我后来最认的一次收手,是把广播从 LiveView 里请了出去
这一点算是我这次最想说的观点之一。
我第一次写聊天室时,脑子里的默认剧本其实特别直白:
- LiveView 收到表单提交
- LiveView 调
Repo.insert - 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 提交失败时我还顺手清空过表单,现在想想真想打自己一下
这个坑也很真实。
如果消息因为长度校验失败了,你这时候把body和username一起清掉,用户的第一反应不是“哦,校验触发了”,而是“我刚刚写那一坨东西呢?”
后来我对表单重置这件事就只剩一个朴素想法:
- 成功了,再清空该清空的字段
- 失败了,就把原表单和错误一起留在那儿
这种细节单看不起眼,但它决定了页面是不是“像个活人做的东西”。
8. 写到这里,我才第一次觉得这个聊天室“像是真的了”
如果硬要把这篇压成一句人话,那就是:页面进来以后先订阅房间,先把历史消息摆出来;有人发消息时,先让业务把这件事坐实,再把结果广播出去;每个页面收到广播以后,只做增量更新。没有人偷偷抢跑,也没有人背地里多干一摊活。
它最打动我的地方,真不是“没有写自定义前端状态管理”这么简单。
而是:
业务动作、实时同步、页面更新,这三件事终于是在同一条服务端链路里发生的。
这在聊天室这种场景里,真的会让脑子轻很多。
当然,到这里还不算完整项目。至少还有几块东西,我自己写到这儿时已经开始惦记了:
- 在线用户列表怎么做
- 多房间切换怎么组织 topic 和路由
- 历史消息无限滚动怎么处理
- 部署到生产环境后,WebSocket 相关配置要注意什么
这些我准备放到下篇接着写。因为对我来说,第一篇先把这条链路站稳,比一上来功能拉满重要得多。
总结
如果只让我压缩成一句话,那我现在对聊天室这个实战最大的感受是:
LiveView 最值钱的,不是“前端少写一点”,而是“实时页面终于不用维护两套状态真相”。
聊天室只是个切口,但它特别适合把这件事讲透。
如果你也在写 LiveView,或者你也有过那种“概念都认识,真落地却总觉得哪儿发虚”的时刻,那聊天室这个题目真挺诚实。它不大,也不新鲜,但特别会暴露问题。代码哪里想清楚了,哪里还在糊弄自己,跑起来基本藏不住。
