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

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-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 里这样渲染:

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 只写单个弹窗、单个表单,其实还不算特别能体现价值。

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

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

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

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

那大概率说明两件事:

  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 的边界问题绕过,欢迎把你的踩坑现场放评论区。这个东西一旦用顺了,真的很香;但在用顺之前,它也确实挺会教育人的。

相关推荐
林希_Rachel_傻希希1 小时前
web性能优化之延迟加载图片和<inframe>
前端·javascript·面试
maxmaxma2 小时前
Konva 从入门到实践 - day1
前端
火星校尉2 小时前
一场数据基建与消费场景的跨界实验
java·前端·数据库·python·php
W是笔名2 小时前
python_let`s try it 6___BMI计算器
java·前端·python
risc1234562 小时前
Lucene80DocValuesConsumer 五种类型源码阅读顺序
java·服务器·前端
小米渣的逆袭2 小时前
Chrome Extension Script World(ISOLATED / MAIN)原理与适用场景
前端·javascript·chrome
微信开发api-视频号协议2 小时前
Codex++安全边界探秘:从模型能力到风险防御
前端·安全·微信·企业微信
想你依然心痛3 小时前
AtomCode 在前端开发中的实战体验:React + TypeScript 项目开发实录
前端·react.js·typescript
疯狂的魔鬼3 小时前
精确计算容器剩余视口高度:useAutoContainerFullHeight 的工程实践
前端·css·typescript