前言先说一个我自己刚上手 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 参数变化注意这里有几个重点mount/3通常会跑两次。handle_params/3在mount/3后面跑也会因为 live patch 再跑。handle_event/3处理的是浏览器发来的事件。handle_info/2处理的是服务端进程收到的消息。每次你改了socket.assignsLiveView 都会重新 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 pcount: % count %/p button phx-clickinc1/button /div end end这里的count来自socket.assigns.count。按钮点击以后浏览器不是自己把count 1而是发一个inc事件到服务端。服务端在handle_event/3里更新socket.assigns然后 LiveView 重新渲染把 diff 推回浏览器。所以我现在更愿意这么理解socket.assigns是这张页面在服务端的状态快照。它不是数据库不是 session也不是前端缓存。它的生命周期跟当前 LiveView 连接绑定。这点很关键因为它会直接影响你怎么写代码。一个我踩过的坑把 assigns 当成“万能仓库”我一开始很容易把所有东西都塞进 assignsassign(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这三个参数分工很清楚paramsURL 里的公开参数用户可以改不可信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/3URL 状态归它管别塞给mount/3handle_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模板里可以这样触发 patchdef render(assigns) do ~H div .link patch{~p/posts?qelixirpage1}Elixir/.link .link patch{~p/posts?qliveviewpage1}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-clickinc1/button end def handle_event(inc, _params, socket) do {:noreply, update(socket, :count, (1 1))} end再比如带参数def render(assigns) do ~H button phx-clickdelete 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 endLiveView 省掉的是前后端状态同步成本不是安全校验。这个坑我觉得必须反复说LiveView 让你少写 API不代表用户输入突然可信了。handle_event/3的返回值怎么理解最常见返回值是{:noreply, socket}意思不是“不回复浏览器页面更新”而是“不额外回复一个事件结果”。只要你更新了 socket assignsLiveView 仍然会重新 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你大概率要写发送消息 APIWebSocket 订阅客户端逻辑前端消息状态合并后端广播逻辑断线重连和重复消息处理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/3、start_async/3或业务层任务进程LiveView 是进程但它不是让你把所有业务都塞进页面进程。7.render/1你以为它只是模板其实它吃的是 assignsrender/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?page2/posts?qliveview/settings?tabbilling/orders/123只要你希望用户刷新、分享链接、浏览器前进后退还能保留状态就别只放在handle_event/3里。8.3 这是用户在页面上操作触发的吗是就看handle_event/3。比如点击按钮提交表单输入框变化拖拽、选择、删除但记住参数来自客户端不可信。8.4 这是服务端主动来的消息吗是就看handle_info/2。比如PubSub 广播定时器 tick后台任务完成其他进程发来的消息这四个问题问完大部分 LiveView 代码该放哪就很清楚了。9. 一个完整一点的例子订单详情页最后我们把几个回调放到一个场景里。假设有一个订单详情页打开页面时加载订单URL 里可以切 tab?tabtimeline、?tabpayment点击按钮可以取消订单其他系统更新订单状态后当前页面要实时刷新代码可以这样组织下面代码默认你的认证层已经通过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}?tabsummary}概览/.link .link patch{~p/orders/#{order_id}?tabtimeline}动态/.link .link patch{~p/orders/#{order_id}?tabpayment}支付/.link /nav section :if{tab summary} p状态% order.status %/p button phx-clickcancel取消订单/button /section section :if{tab timeline} !-- 这里展示订单动态 -- /section section :if{tab payment} !-- 这里展示支付信息 -- /section /div end这段例子的分工就比较舒服mount/3准备订单基础状态连接后订阅订单主题handle_params/3处理 URL 里的 tabhandle_event/3处理用户点击取消订单handle_info/2处理其他地方广播来的订单更新render/1根据 assigns 展示 UI我觉得这就是 LiveView 写顺手之后的感觉不是到处找“该发哪个 API”而是在问“这个状态变化来自哪里”。10. 再强调几个实战坑10.1mount/3跑两次不要大惊小怪这是设计不是 bug。第一次给用户一份普通 HTML第二次建立 WebSocket 后让页面活起来。你要做的是区分哪些事情应该两次都做哪些事情只该 connected 后做。10.2params不可信哪怕它来自 LiveViewmount/3、handle_params/3、handle_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/2LiveView 只处理页面状态。这比在handle_event/3里塞几十行权限、状态机、数据库操作要稳得多。总结这期我们把 LiveView 的生命周期主线走了一遍。我自己的核心结论是写 LiveView不要先想“组件怎么更新”要先想“这次状态变化从哪里来”。来自页面初始化看mount/3来自 URL看handle_params/3来自用户操作看handle_event/3来自服务端消息看handle_info/2最后统一落到socket.assigns由 render/diff 推给浏览器。这个心智模型转过来以后LiveView 就不再是“不会写 JS 的替代品”而是一套非常清楚的服务端交互模型。
LiveView 的生命周期:mount、handle_event 和 Socket 到底怎么运转
发布时间:2026/7/3 11:37:07
前言先说一个我自己刚上手 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 参数变化注意这里有几个重点mount/3通常会跑两次。handle_params/3在mount/3后面跑也会因为 live patch 再跑。handle_event/3处理的是浏览器发来的事件。handle_info/2处理的是服务端进程收到的消息。每次你改了socket.assignsLiveView 都会重新 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 pcount: % count %/p button phx-clickinc1/button /div end end这里的count来自socket.assigns.count。按钮点击以后浏览器不是自己把count 1而是发一个inc事件到服务端。服务端在handle_event/3里更新socket.assigns然后 LiveView 重新渲染把 diff 推回浏览器。所以我现在更愿意这么理解socket.assigns是这张页面在服务端的状态快照。它不是数据库不是 session也不是前端缓存。它的生命周期跟当前 LiveView 连接绑定。这点很关键因为它会直接影响你怎么写代码。一个我踩过的坑把 assigns 当成“万能仓库”我一开始很容易把所有东西都塞进 assignsassign(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这三个参数分工很清楚paramsURL 里的公开参数用户可以改不可信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/3URL 状态归它管别塞给mount/3handle_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模板里可以这样触发 patchdef render(assigns) do ~H div .link patch{~p/posts?qelixirpage1}Elixir/.link .link patch{~p/posts?qliveviewpage1}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-clickinc1/button end def handle_event(inc, _params, socket) do {:noreply, update(socket, :count, (1 1))} end再比如带参数def render(assigns) do ~H button phx-clickdelete 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 endLiveView 省掉的是前后端状态同步成本不是安全校验。这个坑我觉得必须反复说LiveView 让你少写 API不代表用户输入突然可信了。handle_event/3的返回值怎么理解最常见返回值是{:noreply, socket}意思不是“不回复浏览器页面更新”而是“不额外回复一个事件结果”。只要你更新了 socket assignsLiveView 仍然会重新 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你大概率要写发送消息 APIWebSocket 订阅客户端逻辑前端消息状态合并后端广播逻辑断线重连和重复消息处理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/3、start_async/3或业务层任务进程LiveView 是进程但它不是让你把所有业务都塞进页面进程。7.render/1你以为它只是模板其实它吃的是 assignsrender/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?page2/posts?qliveview/settings?tabbilling/orders/123只要你希望用户刷新、分享链接、浏览器前进后退还能保留状态就别只放在handle_event/3里。8.3 这是用户在页面上操作触发的吗是就看handle_event/3。比如点击按钮提交表单输入框变化拖拽、选择、删除但记住参数来自客户端不可信。8.4 这是服务端主动来的消息吗是就看handle_info/2。比如PubSub 广播定时器 tick后台任务完成其他进程发来的消息这四个问题问完大部分 LiveView 代码该放哪就很清楚了。9. 一个完整一点的例子订单详情页最后我们把几个回调放到一个场景里。假设有一个订单详情页打开页面时加载订单URL 里可以切 tab?tabtimeline、?tabpayment点击按钮可以取消订单其他系统更新订单状态后当前页面要实时刷新代码可以这样组织下面代码默认你的认证层已经通过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}?tabsummary}概览/.link .link patch{~p/orders/#{order_id}?tabtimeline}动态/.link .link patch{~p/orders/#{order_id}?tabpayment}支付/.link /nav section :if{tab summary} p状态% order.status %/p button phx-clickcancel取消订单/button /section section :if{tab timeline} !-- 这里展示订单动态 -- /section section :if{tab payment} !-- 这里展示支付信息 -- /section /div end这段例子的分工就比较舒服mount/3准备订单基础状态连接后订阅订单主题handle_params/3处理 URL 里的 tabhandle_event/3处理用户点击取消订单handle_info/2处理其他地方广播来的订单更新render/1根据 assigns 展示 UI我觉得这就是 LiveView 写顺手之后的感觉不是到处找“该发哪个 API”而是在问“这个状态变化来自哪里”。10. 再强调几个实战坑10.1mount/3跑两次不要大惊小怪这是设计不是 bug。第一次给用户一份普通 HTML第二次建立 WebSocket 后让页面活起来。你要做的是区分哪些事情应该两次都做哪些事情只该 connected 后做。10.2params不可信哪怕它来自 LiveViewmount/3、handle_params/3、handle_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/2LiveView 只处理页面状态。这比在handle_event/3里塞几十行权限、状态机、数据库操作要稳得多。总结这期我们把 LiveView 的生命周期主线走了一遍。我自己的核心结论是写 LiveView不要先想“组件怎么更新”要先想“这次状态变化从哪里来”。来自页面初始化看mount/3来自 URL看handle_params/3来自用户操作看handle_event/3来自服务端消息看handle_info/2最后统一落到socket.assigns由 render/diff 推给浏览器。这个心智模型转过来以后LiveView 就不再是“不会写 JS 的替代品”而是一套非常清楚的服务端交互模型。