前言如果说前几篇里的生命周期、表单、PubSub是在帮我理解 LiveView 这套范式怎么跑起来那LiveComponent这一块就是我第一次明显感受到它像组件但又不是我以前在 React 里习惯的那种组件。我一开始最大的误判是把它理解成“有状态组件”“子组件”“拆页面的标准答案”结果真写起来才发现这三个理解都只对了一半。LiveComponent确实有自己的assigns也能处理自己的事件但它又不是独立进程也不是一个迷你 LiveView更不是“只要想复用就该上”的默认选择。所以这篇我最想先讲清楚一句话在 LiveView 里复用 UI 用 function component 就够了只有当这块 UI 需要自己的身份、自己的事件入口、自己的局部状态时LiveComponent才真正值得上。这句话如果你先吃透后面很多坑都能少踩一半。1. 先说结论不是为了复用就上LiveComponent我现在对这件事的判断非常粗暴但很好用只负责展示优先用 function component需要自己处理事件考虑LiveComponent列表里每一项都有局部状态优先考虑LiveComponent需要被定点更新考虑LiveComponentsend_update/2如果你也有 React 背景很容易脑补成“页面拆小一点总归是好事。”但在 LiveView 里这个习惯最好收一收。因为 function component 已经足够解决大部分“模板复用”的问题了而且更简单、更直白也更不容易把状态边界搞乱。举个例子一个只负责展示用户信息的小卡片根本没必要上LiveComponentdefmodule MyAppWeb.UserCard do use MyAppWeb, :html attr :user, :map, required: true def user_card(assigns) do ~H div classrounded-lg border p-4 p classfont-medium% user.name %/p p classtext-sm text-zinc-500% user.email %/p /div end end这种场景里function component 的优点非常直接没有额外心智负担没有id管理没有update/2父层传什么它就老老实实渲染什么说白了如果只是想把模板抽出来别急着祭出LiveComponent这把刀。2. 我踩过的第一个坑把“局部 UI”误以为“局部状态组件”我刚开始写 LiveView 页面时特别喜欢这么拆一个弹窗一个LiveComponent一个表单一个LiveComponent一个列表项一个LiveComponent一个按钮区一个LiveComponent理由听起来还挺正经职责清晰、组件化、可维护。但问题很快就来了。很多组件其实只是“局部 UI”不是“局部状态单元”。你把它们硬拆成LiveComponent之后会立刻多出一堆问题id取什么事件是给父 LiveView 处理还是给组件自己处理父层一重渲染组件里的表单要不要重置这块状态到底该放父层还是放组件里写着写着你会进入一种非常熟悉的状态代码看起来更“组件化”了但脑子反而更重了。后来我给自己定了一个更实在的标准只有当这块 UI 离开“身份”和“状态”就说不清时我才会考虑LiveComponent。比如下面这类场景就真的适合评论列表里每条评论都能单独进入编辑态商品列表里每一行都有自己的数量选择、展开收起某个局部面板需要自己处理phx-change、phx-submit某块区域需要被外部精确刷新而不是整个页面一起重渲染你会发现这些场景的共同点不是“能复用”而是它们都是页面里的一个“带身份的小单元”。3.LiveComponent真正的价值有状态但比独立 LiveView 轻这是我觉得最值得记住的一层。很多文章会说LiveComponent是“有状态组件”这没错但如果只记住这五个字很容易误会成它和 React 的 stateful component或者 Vue 的子组件是一个意思。其实不是。LiveComponent更准确的理解应该是它在父 LiveView 进程内部给某块 UI 划出一个相对独立的状态和事件边界。注意这个描述里有两个关键词在父 LiveView 进程内部相对独立这意味着它有自己的assigns它能写自己的handle_event/3它必须有稳定的id但它不是独立进程它也没有自己那套完整的页面生命周期这也是为什么我会说它“比 React 组件更轻”但又得补半句轻不代表可以乱用。轻的地方在于不需要单独开一个 LiveView不需要重新建立一套页面级状态同步很多局部交互可以原地完成但限制也很明确它不能像父 LiveView 那样自然地承接所有消息流组件之间不要互相乱发消息状态边界一旦没想清楚update/2很容易和handle_event/3打架4.update/2和handle_event/3怎么配合别互相拆台这是我自己踩得最实的一坑。一句话概括就是update/2负责接住父层给的新数据handle_event/3负责处理组件内部发生的事。听起来很朴素但真写的时候最容易犯的错是每次update/2都把组件本地状态整个重置掉。举个例子评论项支持行内编辑。父 LiveView 里这样渲染.live_component :for{comment - comments} module{MyAppWeb.CommentItemComponent} id{comment-#{comment.id}} comment{comment} /组件里如果这么写看起来很顺其实很坑def update(%{comment: comment} assigns, socket) do {:ok, socket | assign(assigns) | assign(:editing, false) | assign(:form, to_form(Comments.change_comment(comment)))} end错误点就在这儿。只要父 LiveView 因为别的事情重新渲染了这个组件又会进一次update/2然后你刚展开的编辑态没了你表单里刚敲到一半的内容没了你会开始怀疑是不是 LiveView 把你的输入吃了其实不是 LiveView 吃了是你自己在update/2里给它清空了。更稳一点的写法是把“父层同步的数据”和“组件内部的局部状态”分开处理defmodule MyAppWeb.CommentItemComponent do use MyAppWeb, :live_component alias MyApp.Comments def mount(socket) do {:ok, assign(socket, editing: false, form: nil)} end def update(%{comment: comment} assigns, socket) do form case socket.assigns do %{comment: %{id: existing_id}} when existing_id comment.id - socket.assigns.form _ - comment | Comments.change_comment() | to_form() end {:ok, socket | assign(assigns) | assign(:form, form)} end def handle_event(edit, _params, socket) do {:noreply, assign(socket, :editing, true)} end def handle_event(validate, %{comment params}, socket) do form socket.assigns.comment | Comments.change_comment(params) | Map.put(:action, :validate) | to_form() {:noreply, assign(socket, :form, form)} end end这里最关键的思路就一个父层传来的comment要更新但组件内部正在编辑的form不要每次都重建。不然你会发现LiveComponent 看起来有状态实际上你自己把它写成了“伪状态”。还有一个很真实、也很容易漏掉的坑组件要自己处理事件记得把事件显式打到组件自己身上。比如button phx-clickedit phx-target{myself} 编辑 /button .simple_form for{form} phx-changevalidate phx-submitsave phx-target{myself} ... /.simple_formphx-target{myself}少了会怎么样答案很朴素事件大概率会跑去父 LiveView你组件里的handle_event/3根本接不到。我第一次碰到这个问题的时候还以为是自己回调名写错了来回查了半天。后来才发现不是回调没写对是事件根本没打到组件上。5. 一个特别容易忽略的事实它不是独立进程这个点很重要因为它直接影响你怎么理解通信。很多人第一次写LiveComponent会下意识觉得“既然它能handle_event/3那它是不是也能像 LiveView 一样自己接消息、自己订阅、自己广播”现实会比较冷静一点。LiveComponent的事件处理仍然跑在父 LiveView 的那个进程里。也就是说它虽然有自己的状态边界但没有自己的进程边界。这个认知一旦建立你会更容易理解下面这些现象组件里self()拿到的其实是父 LiveView 的进程PubSub 订阅一般还是放在父 LiveView真正收到handle_info/2的通常也是父 LiveView父层再决定是整体刷新还是定点send_update/2这也是为什么我现在不太喜欢把LiveComponent叫“子页面”。它不是。它更像是同一个 LiveView 进程里被框出来的一块可局部管理的 UI 区域。6. 列表场景里LiveComponent要和streams一起看如果LiveComponent只写单个弹窗、单个表单其实还不算特别能体现价值。真正让我觉得它很对味的是大列表里的局部交互。比如评论列表、任务列表、消息列表这些场景很容易出现两个需求同时存在列表本身要高效增删改每一项又要有自己的局部状态这时候把streams和LiveComponent搭起来用体验会比“整页 assign 一个大列表然后每次全量替换”清爽很多。举个例子def mount(_params, _session, socket) do comments Comments.list_comments() {:ok, stream(socket, :comments, comments)} end模板里这样渲染div idcomments phx-updatestream .live_component :for{{dom_id, comment} - streams.comments} module{MyAppWeb.CommentItemComponent} id{dom_id} comment{comment} / /div这套组合我自己很喜欢原因很现实streams负责高效维护列表LiveComponent负责每一项的局部交互二者职责分得很清楚但这里也有一个经典坑我建议直接记住组件id一定要稳定千万别拿列表下标更别拿随机值。不然会发生几件特别烦的事原本这一项的编辑状态跳到另一项身上send_update/2更新不到目标组件你以为是 diff 算法有问题其实是id自己写飘了如果你已经在用streams那最省事的方式通常就是直接用它给你的dom_id。7.send_update/2很好用但别把它当万能对讲机我第一次看到send_update/2时第一反应是“这不就是组件之间通信的正式方案吗”后来发现这个理解也有点过头。send_update/2真正擅长的场景是父层或者外部某个地方已经知道要更新哪个组件于是直接把新 assigns 精准推过去。比如父 LiveView 收到一条 PubSub 消息只想更新列表中的某一项def handle_info({:comment_moderated, comment}, socket) do send_update( MyAppWeb.CommentItemComponent, id: comments-#{comment.id}, comment: comment ) {:noreply, socket} end这里只补一句细节id必须和你渲染这个组件时用的id完全一致。上面这个例子默认你前面配合streams用的是comments-#{comment.id}这种 id如果你模板里传的是别的值send_update/2就得老老实实跟着改。这个思路很利落消息还是父 LiveView 收父 LiveView 决定更新策略具体哪一项需要刷新就send_update/2给哪一项但我不建议把它玩成“组件状态总线”。如果你的页面已经发展到组件 A 改一下send_update/2通知组件 B组件 B 再想办法影响组件 C那大概率说明两件事状态边界没收好该由父 LiveView 统一协调的事被你分散到各个组件里了send_update/2适合“定点刷新”不适合“页面级调度中心”。8. 我现在判断“该不该上LiveComponent”的四个问题写到现在我自己基本会先问这四个问题。8.1 这块 UI 只是展示还是有自己的交互闭环只是展示就 function component。别为了“结构整齐”凭空引入状态。8.2 这块状态是页面级的还是列表项/局部区域级的页面级状态比如筛选条件、当前用户、全局排序通常更适合放父 LiveView。局部状态比如“这一行是否展开”“这一项是否处于编辑态”就很适合LiveComponent。8.3 它有没有稳定身份没有稳定身份的组件往往不值得做成LiveComponent。因为LiveComponent的很多价值本来就建立在“同一个 id 对应同一个局部单元”这件事上。8.4 父层重渲染时我能不能接受它的局部状态被刷新如果不能接受那你就必须认真设计update/2。这一条我吃过亏所以现在特别在意。很多 LiveComponent 的 bug不是事件没处理对而是父层一更新你辛辛苦苦维护的局部状态被自己重置了。总结我现在对LiveComponent的理解可以压成一句很短的话它不是为了“把模板拆小”而是为了“把页面里某个有身份、有局部状态的小单元单独管起来”。这也是为什么我会觉得它很妙。它不像 SPA 里那种层层传 props、层层抬 state 的组件树也不是直接把所有东西塞回一个巨大的 LiveView它卡在中间那个很实用的位置上足够轻又足够有边界。当然前提是你别把它用成“逢组件必上”的银弹。这期主要解决了两个问题LiveComponent到底什么时候该用update/2、handle_event/3、streams、send_update/2这几块怎么配合才不打架但它还留着一个自然的后续问题当页面不再只是一个 LiveView而是开始涉及多页面切换、嵌套路由、认证守卫时结构该怎么组织如果你也在写 LiveView而且刚好被LiveComponent的边界问题绕过欢迎把你的踩坑现场放评论区。这个东西一旦用顺了真的很香但在用顺之前它也确实挺会教育人的。