
前言我写这篇不是因为聊天室有多新鲜恰恰相反是因为它太老了。老到什么程度老到几乎每个框架在证明“我能做实时”的时候都会把它拎出来遛一圈。结果轮到我自己上手时我还是翻车了几次。不是那种宏大翻车不是服务挂了数据库炸了而是那种更烦人的小翻车消息明明发成功了自己这边怎么冒出两条列表是能更新但更新得像在偷偷全表重刷代码跑得通可我越看越觉得这条链路心里发虚这种时刻很像你组装了一把椅子四条腿都装上了坐也能坐但你总感觉它下一秒就会当场散架。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.insertLiveView 自己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或者你也有过那种“概念都认识真落地却总觉得哪儿发虚”的时刻那聊天室这个题目真挺诚实。它不大也不新鲜但特别会暴露问题。代码哪里想清楚了哪里还在糊弄自己跑起来基本藏不住。