前言
如果说前几篇里的生命周期、表单、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:
elixir
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 里这样渲染:
heex
<.live_component
:for={comment <- @comments}
module={MyAppWeb.CommentItemComponent}
id={"comment-#{comment.id}"}
comment={comment}
/>
组件里如果这么写,看起来很顺,其实很坑:
elixir
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 里给它清空了。
更稳一点的写法,是把"父层同步的数据"和"组件内部的局部状态"分开处理:
elixir
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 看起来有状态,实际上你自己把它写成了"伪状态"。
还有一个很真实、也很容易漏掉的坑:
组件要自己处理事件,记得把事件显式打到组件自己身上。
比如:
heex
<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 一个大列表,然后每次全量替换"清爽很多。
举个例子:
elixir
def mount(_params, _session, socket) do
comments = Comments.list_comments()
{:ok, stream(socket, :comments, comments)}
end
模板里这样渲染:
heex
<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 消息,只想更新列表中的某一项:
elixir
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 的边界问题绕过,欢迎把你的踩坑现场放评论区。这个东西一旦用顺了,真的很香;但在用顺之前,它也确实挺会教育人的。