LiveView 进阶实战:状态管理、并发与性能优化

LiveView 进阶实战:LiveView 生命周期与状态管理、嵌套 LiveView 与组件(Function Component)、事件处理与表单、PubSub 与跨进程状态同步、并发与长任务处理(Task.async_stream)、性能优化(防抖/节流、diff 优化)、生产部署与连接恢复、常见陷阱。

引言

LiveView 把「页面状态」搬进 BEAM 进程:服务端持有 socket、diff 推给浏览器,JavaScript 只负责最小 patch。但真到生产,你需要搞清生命周期、跨进程状态同步、并发长任务与性能边界。本文是 LiveView 的进阶篇:状态管理、组件化、PubSub 协作与性能优化。

前置:/elixir-intro-phoenix/(Phoenix 基础)、/elixir-phoenix-channels-realtime/(Channels 与 PubSub)、/erlang-concurrency-actors/(进程并发)。


目录


1. LiveView 生命周期再理解

1.1 进程模型

每个 LiveView 连接是一个进程,浏览器首次渲染 = 服务端生成 HTML,之后只推 diff:

浏览器首次请求 → mount/3 → render → 返回完整 HTML
                 ⇅  建立 WebSocket
后续交互 → handle_event → 改 socket → 只推变更的 DOM(diff)

1.2 关键回调

def mount(_params, _session, socket) do
  # 初始状态(可在这里订阅 PubSub)
  {:ok, assign(socket, :messages, [], :page, 1)}
end

def handle_event("click", _params, socket) do
  # 事件处理:改状态
  {:noreply, assign(socket, :page, socket.assigns.page + 1)}
end

def render(assigns) do
  ~H"""
  <button phx-click="click">页码: <%= @page %></button>
  """
end

记忆:LiveView = 每连接一个 BEAM 进程——mount 初始化、handle_event 改 socket、render 生成 HTML;首载全量、之后只推 diff,浏览器做最小 patch。


2. socket 状态管理与变更追踪

2.1 assign 的变更追踪

assign 是 LiveView 状态的核心:只有被标记 @var 使用的部分才参与 diff。

# 一次性 assign 多个
socket = assign(socket, user: user, posts: posts)

# 变更追踪:Phoenix.Component 的 diffs 只包含变化的 assigns
def handle_event("next", _, socket) do
  {:noreply, update(socket, :page, &(&1 + 1))}
end

2.2 避免大状态

✗ 把整个列表塞进 socket(每个 diff 都大)
✓ 用 @assigns 只传视图需要的字段
✓ 大列表分页/分片,WebSocket 只推变化部分

记忆:assign 是状态核心、update 增量改、diff 只推变化的 assigns——别把大列表塞 socket,按需分片分页。


3. 函数组件与嵌套

3.1 函数组件

无状态 UI 复用:

defmodule MyAppWeb.PlayerCard do
  use Phoenix.Component

  def player_card(assigns) do
    ~H"""
    <div class="card">
      <h3><%= @player.name %></h3>
      <span>评分 <%= @player.score %></span>
    </div>
    """
  end
end

3.2 在页面里使用

<PlayerCard.player_card player={@player} />

3.3 组件 vs 嵌套 LiveView

方式用途
函数组件纯 UI 复用,无状态
嵌套 LiveView独立状态/生命周期(表格、输入框)

记忆:函数组件管纯 UI 复用(use Phoenix.Component)、嵌套 LiveView 管独立状态/生命周期(输入框、局部表格)——能无状态就别嵌套。


4. 事件处理与表单绑定

4.1 phx-click 事件

<button phx-click="save" phx-value-id={@id}>保存</button>
def handle_event("save", %{"id" => id}, socket) do
  # 处理保存
  {:noreply, socket}
end

4.2 表单与 phx-submit

<.form for={@form} phx-submit="create" phx-change="validate">
  <input name="post[title]" />
  <button>提交</button>
</.form>
def handle_event("create", %{"post" => attrs}, socket) do
  # 落库
  {:noreply, socket}
end

4.3 防抖(phx-debounce / phx-throttle)

<input phx-debounce="300" phx-change="search" />   <!-- 输入停 300ms 才发 -->
<input phx-throttle="2000" phx-change="heartbeat" /> <!-- 最多 2s 一次 -->

记忆:事件三件套——phx-click 点按、phx-submit/phx-change 表单、phx-debounce/phx-throttle 节流搜索/心跳防刷服务端。


5. PubSub 跨进程状态同步

5.1 一个 LiveView 更新,多个客户端同步

def mount(_params, _session, socket) do
  # 订阅全局 Topic
  Phoenix.PubSub.subscribe(MyApp.PubSub, "counter")
  {:ok, assign(socket, :count, 0)}
end

def handle_info({:counter, n}, socket) do
  {:noreply, assign(socket, :count, n)}
end

# 任何进程(其他 LiveView、GenServer、Job)更新计数并广播
Phoenix.PubSub.broadcast(MyApp.PubSub, "counter", {:counter, 42})

5.2 多 LiveView 协作

LiveView A(改状态)→ PubSub.broadcast("topic", msg)
                        ↓
LiveView B/C/D(handle_info 收到)→ 各自 update

记忆:跨进程同步 = mount 时 subscribe Topic、handle_info 里 update 状态、任何进程 broadcast 触发——一个 LiveView 改、所有订阅者同步。


6. 并发与长任务

6.1 长任务不能阻塞 LiveView 进程

LiveView 进程处理事件时阻塞 = 该连接所有交互卡住。用异步任务:

def handle_event("export", _params, socket) do
  # 派发到独立进程,完成后广播回 LiveView
  Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
    data = MyApp.Report.generate(socket.assigns.user.id)
    Phoenix.PubSub.broadcast(MyApp.PubSub, "report:" <> socket.assigns.user.id,
                             {:report_ready, data})
  end)
  {:noreply, socket}
end

def handle_info({:report_ready, data}, socket) do
  {:noreply, assign(socket, :report, data)}
end

6.2 并发收集

用 Task.async_stream 并发处理列表(不占 LiveView 进程):

results =
  ids
  |> Task.async_stream(fn id -> MyApp.fetch(id) end,
                       max_concurrency: 8, timeout: 5000)
  |> Enum.map(fn {:ok, r} -> r end)

记忆:LiveView 进程是单线程的——长任务必须 Task 异步化(async_nolink + 完成后 broadcast 回 handle_info);批量并发用 Task.async_stream(max_concurrency)。


7. 性能优化:diff 与节流

7.1 diff 优化

✗ 大列表每次全量重渲染(diff 巨大)
✓ 列表项用 :id 标识(Phoenix.Component 自动做最小更新)
✓ 只更新变化的 assign
<!-- 列表项带 id,LiveView 只 patch 变化的项 -->
<div id={"item-" <> item.id}>

7.2 节流与限频

# 事件太频繁(如 mouse move)→ phx-throttle
# 服务端也做保护:ETS 记录最后时间
def handle_event("pos", _, socket) do
  case :ets.lookup(:pos_throttle, socket.id) do
    [{_, t}] when t > now() - 200 -> {:noreply, socket}  # 丢弃
    _ -> :ets.insert(:pos_throttle, {socket.id, now()})
         {:noreply, socket}
  end
end

7.3 传输层

  • WebSocket 默认压缩(compress: true)
  • 大响应考虑流式传输(send_info / chunked)

记忆:diff 优化 = 列表项带 :id(最小 patch)+ 只更新变化 assign;事件节流用 phx-throttle + 服务端 ETS 限频双保险——把推送量与事件频率压到最小。


8. 连接恢复与离线

8.1 断线自动恢复

LiveView 内置连接恢复:WebSocket 断开后 JS 端重连,服务端进程保持:

# mount 里返回临时区数据(让恢复期 UI 有内容)
def mount(_params, _session, socket) do
  {:ok, assign(socket, :messages, fetch_recent()), temporary_assigns: [messages: []]}
end

8.2 恢复策略

✓ 服务端进程保持 socket 状态(默认)
✓ temporary_assigns:大列表不常驻内存,断线恢复时重新拉
✓ 需要时用 phx-disconnected 提示

记忆:连接恢复 = JS 重连 + 服务端进程保持;temporary_assigns 让大列表不常驻内存、恢复时重拉——断线无感恢复的关键配置。


9. 常见陷阱与调试

9.1 陷阱清单

✗ 在 mount 里做慢 IO(阻塞首次渲染)→ 移到 after_connect / 异步
✗ socket 里塞大 List(diff 膨胀)→ 分片 + temporary_assigns
✗ 忘 subscribe → 广播收不到
✗ handle_event 崩溃 → 连接断开(用 try/rescue + log)

9.2 调试工具

# 开启详细日志
config :phoenix, :live_view, debug: true

# 观察 diff 变化(LiveViewDebugger 扩展)
# 用 IO.inspect 看 socket.assigns

记忆:常见陷阱——mount 慢 IO、socket 大 List、漏 subscribe、事件崩溃;调试用 live_view debug 配置 + LiveViewDebugger 扩展观察 diff。


10. 速查表与一句话记忆

环节做法
初始化mount/3
状态assign / update
事件handle_event + phx-click
表单phx-submit + phx-change
节流phx-debounce / phx-throttle
跨进程PubSub subscribe + handle_info
长任务Task.async_stream
恢复temporary_assigns
调试live_view debug

一句话记忆:LiveView 进阶 = 每连接一个 BEAM 进程(mount 初始化、handle_event 改 socket、render 只推 diff);状态用 assign/update、diff 只包含变化的 assigns(列表项带 :id 做最小 patch);UI 复用函数组件、独立状态嵌套 LiveView;跨进程同步用 PubSub subscribe + handle_info,长任务必须 Task 异步化(async_nolink + 广播回来)别阻塞 LiveView 进程;事件节流 phx-throttle + 服务端限频、temporary_assigns 让大列表断线可恢复;「页面状态在服务端进程」是 LiveView 的本质。"


延伸阅读

  • /elixir-intro-phoenix/ — Phoenix 基础与 LiveView 入门
  • /elixir-phoenix-channels-realtime/ — Channels 与 PubSub 实时
  • /erlang-concurrency-actors/ — 进程并发模型
  • /elixir-ecto-data-access/ — Ecto 数据访问
  • /erlang-otp-framework/ — OTP 进程与任务
  • [[web]] — Web 全栈开发
  • [[javascript]] — 前端交互
  • Phoenix LiveView 文档
  • LiveView 性能指南

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. 自定义 OTP Behaviour 实战:Callback 规范与行为封装
  2. Phoenix Channels 实时通信实战:WebSocket 与 PubSub 深入
  3. Mix 工具链与 Elixir 工程化实战