当前位置: 首页 > news >正文

LiveView 的生命周期:mount、handle_event 和 Socket 到底怎么运转

前言

先说一个我自己刚上手 LiveView 时的真实感受:

它看起来像在写页面,实际是在写一个服务端进程。

这句话如果没转过来,后面会非常容易写出一堆“能跑,但是味儿不对”的代码。

我第一次写 LiveView 的时候,脑子里还是 React 那套模型:

  • 页面初始化就是组件挂载
  • 点击按钮就是前端事件
  • URL 变化就是路由状态
  • props 传进来,state 自己维护

结果一上手就踩坑。

我在mount/3里打日志,发现它竟然跑了两次;我在mount/3里订阅 PubSub,结果调试时一脸懵;我把各种参数解析、数据库查询、事件处理全塞进同一个地方,代码很快就开始发臭。

后来我才意识到,LiveView 的生命周期不是“前端组件生命周期”的 Elixir 版。

它真正的心智模型是:

一次 LiveView 页面,先经历普通 HTTP 渲染,再升级成一个有状态的服务端进程。浏览器负责发事件,服务端进程负责改状态,LiveView 负责把 DOM diff 推回浏览器。

这篇就把这条线掰开讲清楚。

1. 先把整条生命周期跑一遍

如果只记一句话,我建议记这个:

LiveView 不是一上来就是 WebSocket,它先是一次普通 HTTP 请求。

一个通过 router 挂载的 LiveView,典型流程大概是这样:

浏览器发起 HTTP GET | v mount/3 # 第一次,静态渲染阶段 | v handle_params/3 # 如果这个 LiveView 挂在 router 上 | v render/1 # 返回一份普通 HTML | v 浏览器加载页面,LiveSocket 建立连接 | v mount/3 # 第二次,connected 阶段 | v handle_params/3 | v render/1 # 之后通过 WebSocket 推 diff | +--> handle_event/3 # 客户端 phx-click/phx-submit/phx-change | +--> handle_info/2 # 服务端消息、PubSub、定时器 | +--> handle_params/3 # live patch 导致 URL 参数变化

注意这里有几个重点:

  1. mount/3通常会跑两次。
  2. handle_params/3mount/3后面跑,也会因为 live patch 再跑。
  3. handle_event/3处理的是浏览器发来的事件。
  4. handle_info/2处理的是服务端进程收到的消息。
  5. 每次你改了socket.assigns,LiveView 都会重新 render,然后把变化推给浏览器。

所以 LiveView 的生命周期,本质上不是“页面生命周期”,而是:

HTTP 初始渲染 + WebSocket 连接后的服务端进程事件循环。

这个理解一旦建立,很多 API 就突然合理了。

2.socket.assigns:别再把它当 props 了

很多前端同学看 LiveView 第一眼,会自然把assigns理解成 props。

这个类比能帮你入门,但不能一直这么想。

在 React 里,props 是父组件传给子组件的数据;state 是浏览器内存里的组件状态。

在 LiveView 里,socket.assigns是服务端 LiveView 进程维护的一份状态 map。

举个最小例子:

defmodule DemoWeb.CounterLive do use DemoWeb, :live_view def mount(_params, _session, socket) do {:ok, assign(socket, count: 0)} end def handle_event("inc", _params, socket) do {:noreply, update(socket, :count, &(&1 + 1))} end def render(assigns) do ~H""" <div> <p>count: <%= @count %></p> <button phx-click="inc">+1</button> </div> """ end end

这里的@count来自socket.assigns.count

按钮点击以后,浏览器不是自己把count + 1,而是发一个"inc"事件到服务端。服务端在handle_event/3里更新socket.assigns,然后 LiveView 重新渲染,把 diff 推回浏览器。

所以我现在更愿意这么理解:

socket.assigns是这张页面在服务端的状态快照。

它不是数据库,不是 session,也不是前端缓存。它的生命周期跟当前 LiveView 连接绑定。

这点很关键,因为它会直接影响你怎么写代码。

一个我踩过的坑:把 assigns 当成“万能仓库”

我一开始很容易把所有东西都塞进 assigns:

assign(socket, current_user: user, posts: posts, all_tags: all_tags, permissions: permissions, raw_payload: payload, debug_meta: meta )

看起来省事,实际上后面会很难受。

LiveView 的 diff 很聪明,但不是魔法。你塞进 assigns 的东西越杂,模板依赖越乱,后面追踪“到底哪个状态导致页面变了”就越痛苦。

我的经验是:

  • 模板真的要用的,放 assigns
  • 事件处理临时要用的,尽量局部变量解决
  • 大对象、原始 payload、调试信息,不要顺手塞进去
  • 列表很大时,优先考虑后面会讲到的streams

一句话:

assigns 要像页面状态,不要像垃圾桶。

3.mount/3:初始化状态,但别乱做副作用

mount/3是 LiveView 的入口。

它有三个参数:

def mount(params, session, socket) do {:ok, socket} end

这三个参数分工很清楚:

  • params:URL 里的公开参数,用户可以改,不可信
  • session:服务端放进 session 的私有数据,常用来拿当前用户信息
  • socket:当前 LiveView 的 socket,里面会放 assigns

举个例子:一个用户详情页。

defmodule DemoWeb.ProfileLive do use DemoWeb, :live_view alias Demo.Accounts def mount(%{"id" => id}, %{"user_token" => token}, socket) do current_user = Accounts.get_user_by_session_token(token) profile = Accounts.get_profile!(id) {:ok, socket |> assign(:current_user, current_user) |> assign(:profile, profile)} end end

这段能说明mount/3适合做什么:

  • 初始化页面必须的数据
  • 根据 session 恢复当前用户
  • 给模板准备第一屏需要的 assigns

但这里有一个 LiveView 新手必踩坑:

mount/3通常会跑两次。

第一次是静态 HTTP 渲染,第二次是 WebSocket 连接建立后的有状态渲染。

你可以用connected?/1判断当前 socket 是否已经连上:

def mount(_params, _session, socket) do IO.inspect(connected?(socket), label: "connected?") {:ok, assign(socket, count: 0)} end

第一次通常是:

connected?: false

第二次是:

connected?: true

错误写法:在mount/3里无脑做副作用

比如你写一个实时订单页,想订阅订单状态变化:

def mount(%{"id" => id}, _session, socket) do Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}") {:ok, assign(socket, order_id: id)} end

这段代码的问题不是“完全不能跑”,而是心智不对。

静态渲染阶段的进程很快就结束了,你在这个阶段做订阅没有意义。更麻烦的是,如果这里换成外部 API 调用、发消息、写日志、打点、创建任务,就可能出现重复执行或无意义执行。

正确写法一般是:

def mount(%{"id" => id}, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}") end order = Orders.get_order!(id) {:ok, socket |> assign(:order_id, id) |> assign(:order, order)} end

我的判断标准很简单:

  • 第一屏必须要有的数据,可以在mount/3里取
  • PubSub 订阅、定时器、只对 WebSocket 连接有意义的事情,放进connected?(socket)判断里
  • 会产生外部副作用的操作,不要随手放mount/3

比如定时器就是典型例子:

def mount(_params, _session, socket) do if connected?(socket) do :timer.send_interval(1_000, self(), :tick) end {:ok, assign(socket, now: DateTime.utc_now())} end def handle_info(:tick, socket) do {:noreply, assign(socket, now: DateTime.utc_now())} end

这就是connected?/1最常见的价值:

区分“静态 HTML 首屏”与“真正活起来的 LiveView 进程”。

4.handle_params/3:URL 状态归它管,别塞给mount/3

handle_params/3是我觉得最容易被低估的回调。

它会在mount/3之后调用,并且当你用 live patch 改变当前 LiveView 的 URL 参数时,它还会再次调用。

它的签名是:

def handle_params(params, uri, socket) do {:noreply, socket} end

适合它处理的东西有:

  • 搜索关键词
  • 分页页码
  • tab
  • 排序字段
  • 筛选条件
  • 当前详情 ID

也就是一句话:

凡是应该体现在 URL 里的页面状态,优先考虑放到handle_params/3

举个例子:文章列表页支持搜索和分页。

defmodule DemoWeb.PostIndexLive do use DemoWeb, :live_view alias Demo.Blog def mount(_params, _session, socket) do {:ok, socket |> assign(:page_title, "Posts") |> assign(:posts, []) |> assign(:filters, %{})} end def handle_params(params, _uri, socket) do filters = %{ "q" => Map.get(params, "q", ""), "page" => Map.get(params, "page", "1") } posts = Blog.list_posts(filters) {:noreply, socket |> assign(:filters, filters) |> assign(:posts, posts)} end end

模板里可以这样触发 patch:

def render(assigns) do ~H""" <div> <.link patch={~p"/posts?q=elixir&page=1"}>Elixir</.link> <.link patch={~p"/posts?q=liveview&page=1"}>LiveView</.link> <ul> <li :for={post <- @posts}> <%= post.title %> </li> </ul> </div> """ end

点击链接以后,不是整页刷新,而是在同一个 LiveView 里触发 URL 参数变化,然后进入handle_params/3

我以前的错误:把 URL 参数只在mount/3里处理

错误写法大概是这样:

def mount(params, _session, socket) do posts = Blog.list_posts(params) {:ok, assign(socket, posts: posts)} end

这在首次进入页面时没问题。

但如果后面你做了<.link patch={...}>push_patch/2,你会发现 URL 变了,页面状态却没有按预期更新。

因为mount/3不是给每次 URL 参数变化准备的。这个职责应该交给handle_params/3

我的经验是:

  • mount/3管“这个 LiveView 活起来前,需要准备什么基础状态”
  • handle_params/3管“当前 URL 对页面状态有什么影响”
  • handle_event/3管“用户操作导致了什么变化”
  • handle_info/2管“服务端消息导致了什么变化”

这四个边界分清楚,LiveView 代码会清爽很多。

5.handle_event/3:处理客户端事件,但参数永远别信

handle_event/3处理的是客户端通过phx-绑定发来的事件。

比如按钮:

def render(assigns) do ~H""" <button phx-click="inc">+1</button> """ end def handle_event("inc", _params, socket) do {:noreply, update(socket, :count, &(&1 + 1))} end

再比如带参数:

def render(assigns) do ~H""" <button phx-click="delete" phx-value-id={@post.id}> 删除 </button> """ end def handle_event("delete", %{"id" => id}, socket) do Blog.delete_post!(id) {:noreply, put_flash(socket, :info, "删除成功")} end

这里有个非常重要的安全点:

handle_event/3收到的 params 来自客户端,不可信。

别因为它是 LiveView,就以为不用做权限校验了。

错误写法:

def handle_event("delete", %{"id" => id}, socket) do Blog.delete_post!(id) {:noreply, socket} end

这个问题很明显:用户可以在浏览器里伪造事件参数。你不能只看按钮上渲染了哪个 ID,就默认这个 ID 一定合法。

更稳一点的写法:

def handle_event("delete", %{"id" => id}, socket) do user = socket.assigns.current_user post = Blog.get_post!(id) if Blog.can_delete?(user, post) do {:ok, _post} = Blog.delete_post(post) {:noreply, socket |> put_flash(:info, "删除成功") |> push_patch(to: ~p"/posts")} else {:noreply, put_flash(socket, :error, "没有权限删除这篇文章")} end end

LiveView 省掉的是前后端状态同步成本,不是安全校验。

这个坑我觉得必须反复说:

LiveView 让你少写 API,不代表用户输入突然可信了。

handle_event/3的返回值怎么理解

最常见返回值是:

{:noreply, socket}

意思不是“不回复浏览器页面更新”,而是“不额外回复一个事件结果”。只要你更新了 socket assigns,LiveView 仍然会重新 render 并把 diff 推给客户端。

比如:

def handle_event("toggle", _params, socket) do {:noreply, update(socket, :open?, &(!&1))} end

这段会更新页面。

我刚开始看到:noreply时还愣过一下:不 reply 那页面怎么变?

后来才明白,LiveView 的页面更新不靠你手动 response,它靠 socket 状态变化后的 render/diff 流程。

6.handle_info/2:真正体现 BEAM 味道的地方

如果说handle_event/3是浏览器驱动,那handle_info/2就是服务端驱动。

它处理的是发给 LiveView 进程的普通 Elixir 消息。

这就是 LiveView 很不一样的地方:

你的页面是一个进程,所以它可以接收消息。

举个定时刷新例子:

def mount(_params, _session, socket) do if connected?(socket) do Process.send_after(self(), :refresh, 5_000) end {:ok, assign(socket, stats: load_stats())} end def handle_info(:refresh, socket) do Process.send_after(self(), :refresh, 5_000) {:noreply, assign(socket, stats: load_stats())} end

再举个 PubSub 的例子。比如聊天室里,有人发了一条新消息。

def mount(%{"room_id" => room_id}, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Demo.PubSub, "room:#{room_id}") end {:ok, socket |> assign(:room_id, room_id) |> assign(:messages, Chat.list_messages(room_id))} end def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do room_id = socket.assigns.room_id user = socket.assigns.current_user {:ok, message} = Chat.create_message(room_id, user, body) Phoenix.PubSub.broadcast( Demo.PubSub, "room:#{room_id}", {:new_message, message} ) {:noreply, socket} end def handle_info({:new_message, message}, socket) do {:noreply, update(socket, :messages, fn messages -> messages ++ [message] end)} end

这段代码很能体现 LiveView 的味道:

  • 用户提交表单,进入handle_event/3
  • 服务端写数据库
  • 服务端广播消息
  • 所有订阅了这个房间的 LiveView 进程收到消息
  • 每个进程进入自己的handle_info/2
  • 每个浏览器收到对应的 DOM diff

如果换成传统 SPA,你大概率要写:

  • 发送消息 API
  • WebSocket 订阅客户端逻辑
  • 前端消息状态合并
  • 后端广播逻辑
  • 断线重连和重复消息处理

LiveView 不是说这些复杂性完全消失了,但它把很多东西收回到服务端同一种语言、同一个进程模型里。

这个体验非常不一样。

这里也有坑:别把handle_info/2写成万能入口

我见过一种写法,所有事情都先send(self(), xxx),然后丢给handle_info/2做。

少量异步解耦没问题,但如果滥用,很快就会变成“消息面条”:

def handle_event("save", params, socket) do send(self(), {:save_later, params}) {:noreply, assign(socket, saving?: true)} end def handle_info({:save_later, params}, socket) do # 这里又发消息,又改状态,又查数据库 {:noreply, socket} end

我的建议是:

  • 用户事件能同步处理清楚,就放handle_event/3
  • 外部进程、PubSub、定时器、后台任务结果,放handle_info/2
  • 真正耗时的任务,不要阻塞 LiveView 进程,考虑assign_async/3start_async/3或业务层任务进程

LiveView 是进程,但它不是让你把所有业务都塞进页面进程。

7.render/1:你以为它只是模板,其实它吃的是 assigns

render/1很容易被忽略,因为大家觉得“模板嘛,没什么好讲”。

但 LiveView 里,render/1的关键点是:

它应该尽量是 assigns 的纯展示结果。

也就是说,复杂业务逻辑不要写在模板里。

错误味道:

def render(assigns) do ~H""" <div :for={post <- Enum.filter(@posts, &(&1.published))}> <%= post.title %> </div> """ end

更好的做法是提前在回调里准备好:

def handle_params(params, _uri, socket) do posts = Blog.list_posts(params) published_posts = Enum.filter(posts, & &1.published) {:noreply, socket |> assign(:posts, posts) |> assign(:published_posts, published_posts)} end

模板只负责展示:

def render(assigns) do ~H""" <div :for={post <- @published_posts}> <%= post.title %> </div> """ end

当然,不是说模板里完全不能写逻辑。简单判断、循环、展示格式化都很正常。

我的边界是:

如果这段逻辑需要单独测试、会查数据库、会影响业务分支,就不要写进 render。

8. 生命周期分工:我的个人口诀

把上面这些合起来,我现在写 LiveView 会先问自己四个问题:

8.1 这是页面第一次起来就要有的吗?

是,就看mount/3

比如:

  • 当前用户
  • 页面标题
  • 首屏基础数据
  • 默认表单

但如果是订阅、定时器、后台消息,就加connected?(socket)

8.2 这是 URL 决定的吗?

是,就看handle_params/3

比如:

  • /posts?page=2
  • /posts?q=liveview
  • /settings?tab=billing
  • /orders/123

只要你希望用户刷新、分享链接、浏览器前进后退还能保留状态,就别只放在handle_event/3里。

8.3 这是用户在页面上操作触发的吗?

是,就看handle_event/3

比如:

  • 点击按钮
  • 提交表单
  • 输入框变化
  • 拖拽、选择、删除

但记住:参数来自客户端,不可信。

8.4 这是服务端主动来的消息吗?

是,就看handle_info/2

比如:

  • PubSub 广播
  • 定时器 tick
  • 后台任务完成
  • 其他进程发来的消息

这四个问题问完,大部分 LiveView 代码该放哪就很清楚了。

9. 一个完整一点的例子:订单详情页

最后我们把几个回调放到一个场景里。

假设有一个订单详情页:

  • 打开页面时加载订单
  • URL 里可以切 tab:?tab=timeline?tab=payment
  • 点击按钮可以取消订单
  • 其他系统更新订单状态后,当前页面要实时刷新

代码可以这样组织:

下面代码默认你的认证层已经通过on_mount或类似方式把current_user放进了 assigns,这也是 Phoenix 项目里比较常见的做法。

defmodule DemoWeb.OrderLive.Show do use DemoWeb, :live_view alias Demo.Orders @tabs ~w(summary timeline payment) def mount(%{"id" => id}, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(Demo.PubSub, "order:#{id}") end {:ok, socket |> assign(:order_id, id) |> assign(:order, Orders.get_order!(id)) |> assign(:tab, "summary")} end def handle_params(params, _uri, socket) do tab = params |> Map.get("tab", "summary") |> normalize_tab() {:noreply, assign(socket, :tab, tab)} end def handle_event("cancel", _params, socket) do order = socket.assigns.order user = socket.assigns.current_user case Orders.cancel_order(order, user) do {:ok, updated_order} -> Phoenix.PubSub.broadcast( Demo.PubSub, "order:#{updated_order.id}", {:order_updated, updated_order} ) {:noreply, put_flash(socket, :info, "订单已取消")} {:error, reason} -> {:noreply, put_flash(socket, :error, error_message(reason))} end end def handle_info({:order_updated, order}, socket) do {:noreply, assign(socket, :order, order)} end defp normalize_tab(tab) when tab in @tabs, do: tab defp normalize_tab(_tab), do: "summary" defp error_message(:not_allowed), do: "没有权限取消这个订单" defp error_message(:already_finished), do: "订单已完成,不能取消" defp error_message(_reason), do: "操作失败,请稍后再试" end

对应模板大概是:

def render(assigns) do ~H""" <div> <h1>订单 #<%= @order.id %></h1> <nav> <.link patch={~p"/orders/#{@order_id}?tab=summary"}>概览</.link> <.link patch={~p"/orders/#{@order_id}?tab=timeline"}>动态</.link> <.link patch={~p"/orders/#{@order_id}?tab=payment"}>支付</.link> </nav> <section :if={@tab == "summary"}> <p>状态:<%= @order.status %></p> <button phx-click="cancel">取消订单</button> </section> <section :if={@tab == "timeline"}> <!-- 这里展示订单动态 --> </section> <section :if={@tab == "payment"}> <!-- 这里展示支付信息 --> </section> </div> """ end

这段例子的分工就比较舒服:

  • mount/3:准备订单基础状态,连接后订阅订单主题
  • handle_params/3:处理 URL 里的 tab
  • handle_event/3:处理用户点击取消订单
  • handle_info/2:处理其他地方广播来的订单更新
  • render/1:根据 assigns 展示 UI

我觉得这就是 LiveView 写顺手之后的感觉:

不是到处找“该发哪个 API”,而是在问“这个状态变化来自哪里”。

10. 再强调几个实战坑

10.1mount/3跑两次,不要大惊小怪

这是设计,不是 bug。

第一次给用户一份普通 HTML,第二次建立 WebSocket 后让页面活起来。你要做的是区分哪些事情应该两次都做,哪些事情只该 connected 后做。

10.2params不可信,哪怕它来自 LiveView

mount/3handle_params/3handle_event/3里的 params 都可能被用户改。

该校验校验,该鉴权鉴权,该查数据库查数据库。

10.3socket.assigns不是长期存储

连接断了会重连,进程崩了会重新 mount。

真正重要的数据要落数据库,至少也要有业务层状态来源。assigns 只是当前页面进程的状态。

10.4 不要在回调里堆业务大泥球

LiveView 回调应该负责“接事件、改状态、调业务层”,不要把所有业务规则都写进 LiveView。

我的习惯是:

def handle_event("publish", %{"id" => id}, socket) do user = socket.assigns.current_user case Blog.publish_post(user, id) do {:ok, post} -> {:noreply, assign(socket, :post, post)} {:error, reason} -> {:noreply, put_flash(socket, :error, humanize(reason))} end end

业务规则放Blog.publish_post/2,LiveView 只处理页面状态。

这比在handle_event/3里塞几十行权限、状态机、数据库操作要稳得多。

总结

这期我们把 LiveView 的生命周期主线走了一遍。

我自己的核心结论是:

写 LiveView,不要先想“组件怎么更新”,要先想“这次状态变化从哪里来”。

来自页面初始化,看mount/3;来自 URL,看handle_params/3;来自用户操作,看handle_event/3;来自服务端消息,看handle_info/2;最后统一落到socket.assigns,由 render/diff 推给浏览器。

这个心智模型转过来以后,LiveView 就不再是“不会写 JS 的替代品”,而是一套非常清楚的服务端交互模型。

http://www.gsyq.cn/news/1627148.html

相关文章:

  • S3已成为文件存储标准,阿里/腾讯/华为云都支持,Bun率先原生支持
  • 告别网盘限速:5分钟掌握九大网盘直链下载的终极方案
  • Linux Shell进程管理
  • 告别多软件制图内耗,paperxie 网页端 AI 科研绘图,一页搞定全学科论文可视化
  • 老旧iOS设备性能优化:系统降级与越狱定制技术指南
  • 影刀RPA新手教程:输入框自动填写完全指南——模拟输入vs键盘驱动vs JS注入三种方式对比
  • 10分钟告别C盘焦虑:Windows Cleaner开源清理工具完全攻略
  • 从零搭建可可视化思考链路的智能客服 Agent:拆解工具调用、决策日志与邮件归档完整实现
  • 汽车电子智能散热系统设计与PWM风扇控制优化
  • 智能井盖系统让城市管网运维管理更高效
  • Kimi K2.5四大模式原理与选型指南:快速/思考/智能体/集群如何正确匹配任务
  • 3步安装终极指南:让老旧安卓电视焕然一新的直播软件优化方案
  • 激光雷达vs纯视觉:2026智能驾驶传感器路线终极解析
  • 芯片烧录:从准备到完成的全流程解析
  • 2026自动驾驶量产核心岗位能力解构
  • ChatGPT生成分析报告真的可靠吗?27个真实业务场景验证的5大风险红线与校验清单
  • DRV8213电机驱动器与智能散热系统设计实战
  • 【金戈铁马】驰骋天下抓黑马主图选股公式用法详解
  • TM4C129XNCZAD与M24M01E-F的I²C存储扩展实战
  • DeepSeek-V4如何用开源与成本穿透力重构AI服务范式
  • Apache Shiro反序列化漏洞实战:从Vulhub复现到纵深防御
  • 冠宇仪器中标快检项目:盐都区农贸市场试剂采购彰显技术实力
  • 硬核实践:使用 Docker 部署生产级 Java环境
  • STC3115与PIC18F87J10在电池管理系统中的核心价值与应用
  • 【IDEA JDK编译版本校准黄金法则】:3分钟强制同步project、module、SDK、Maven、Gradle五维JDK版本(附自动检测脚本)
  • 致远OA A6信息泄露漏洞攻防实战:从原理到批量检测与修复
  • Python本体推理与知识表示实战指南
  • 如何用Mermaid Live Editor快速创建专业图表:完全指南
  • Autosar量产笔记索引:配置调试与避坑指南
  • 2026年AI大模型API中转网站亲测榜单发布 词元之河(TokenRiver.ai)硬核实力领跑全赛道