前言
如果说前几篇里的生命周期、表单、PubSub,是在帮我理解 LiveView 这套范式怎么跑起来,那LiveComponent这一块,就是我第一次明显感受到:
它像组件,但又不是我以前在 React 里习惯的那种组件。
我一开始最大的误判,是把它理解成:
- “有状态组件”
- “子组件”
- “拆页面的标准答案”
结果真写起来才发现,这三个理解都只对了一半。
LiveComponent确实有自己的assigns,也能处理自己的事件;但它又不是独立进程,也不是一个迷你 LiveView,更不是“只要想复用就该上”的默认选择。
所以这篇我最想先讲清楚一句话:
在 LiveView 里,复用 UI 用 function component 就够了;只有当这块 UI 需要自己的身份、自己的事件入口、自己的局部状态时,LiveComponent才真正值得上。
这句话如果你先吃透,后面很多坑都能少踩一半。
1. 先说结论:不是为了复用就上LiveComponent
我现在对这件事的判断非常粗暴,但很好用:
- 只负责展示:优先用 function component
- 需要自己处理事件:考虑
LiveComponent - 列表里每一项都有局部状态:优先考虑
LiveComponent - 需要被定点更新:考虑
LiveComponent+send_update/2
如果你也有 React 背景,很容易脑补成:
“页面拆小一点,总归是好事。”
但在 LiveView 里,这个习惯最好收一收。因为 function component 已经足够解决大部分“模板复用”的问题了,而且更简单、更直白,也更不容易把状态边界搞乱。
举个例子,一个只负责展示用户信息的小卡片,根本没必要上LiveComponent:
defmodule MyAppWeb.UserCard do use MyAppWeb, :html attr :user, :map, required: true def user_card(assigns) do ~H""" <div class="rounded-lg border p-4"> <p class="font-medium"><%= @user.name %></p> <p class="text-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-click="edit" phx-target={@myself}> 编辑 </button> <.simple_form for={@form} phx-change="validate" phx-submit="save" phx-target={@myself} > ... </.simple_form>phx-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 id="comments" phx-update="stream"> <.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的边界问题绕过,欢迎把你的踩坑现场放评论区。这个东西一旦用顺了,真的很香;但在用顺之前,它也确实挺会教育人的。