LiveView 的 LiveComponent:比 React 组件更轻,但我一开始真的把它用错了

前言

如果说前几篇里的生命周期、表单、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-changephx-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/2handle_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只写单个弹窗、单个表单,其实还不算特别能体现价值。

真正让我觉得它很对味的,是大列表里的局部交互

比如评论列表、任务列表、消息列表,这些场景很容易出现两个需求同时存在:

  • 列表本身要高效增删改
  • 每一项又要有自己的局部状态

这时候把streamsLiveComponent搭起来用,体验会比“整页 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

那大概率说明两件事:

  1. 状态边界没收好
  2. 该由父 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/2handle_event/3streamssend_update/2这几块怎么配合才不打架

但它还留着一个自然的后续问题:

当页面不再只是一个 LiveView,而是开始涉及多页面切换、嵌套路由、认证守卫时,结构该怎么组织?

如果你也在写 LiveView,而且刚好被LiveComponent的边界问题绕过,欢迎把你的踩坑现场放评论区。这个东西一旦用顺了,真的很香;但在用顺之前,它也确实挺会教育人的。