使用 Phoenix LiveView 构建交互式应用
使用 Phoenix LiveView 构建搜索应用,了解服务器端状态、HEEx 模板、事件和内置 JavaScript Hook,并判断 LiveView 的适用场景与局限。
Phoenix LiveView 在服务器端渲染 HTML,把每个页面的状态保存在一个长期运行的 Elixir 进程中,并通过持久连接只把页面中发生变化的部分发送给浏览器。
如果你用 React 或 Vue 写过 CRUD 页面,一定熟悉这套流程:一个 JSON 接口、一个客户端 store、各种加载状态,还有前后端各写一遍的校验逻辑。这些代码大多只是在同一份状态的两个副本之间搬运数据。
本文将展示 LiveView 如何为这类应用去掉 API 层。你将使用 LiveView 1.2 语法构建一个实时搜索框,加入一段客户端 JavaScript,并了解这种模式在哪些场景下不再适用。
核心要点
- LiveView 将 UI 状态保存在服务器进程中。浏览器事件通过 WebSocket 发送,LiveView 只回传发生变化的 HTML。
- LiveView 的首次渲染是一个普通的 HTTP 响应,WebSocket 连接建立后
mount/3会再次运行。 - 一个基础的 LiveView 需要三个回调:
mount/3设置初始状态,render/1返回 HEEx 模板,handle_event/3响应phx-click、phx-change等绑定并更新状态。 - 同位钩子(colocated hook)把组件的 JavaScript 放在紧挨其标记的
<script>标签中,需要 Phoenix 1.8 或更高版本。 - 每个由服务器处理的事件都意味着一次网络往返。对于离线优先应用以及客户端交互密集的 UI,React 或 Vue 仍然是更好的选择。
Phoenix LiveView 是做什么的?
Phoenix LiveView 将状态管理、事件处理和渲染从浏览器移到服务器端,浏览器则保持一条实时连接来接收更新。LiveView 文档将每个 LiveView 描述为一个进程:它接收事件、修改自身状态,并只把有差异的部分发送给页面。
对照你熟悉的 SPA 模型:
| SPA 概念 | LiveView 中的对应物 |
|---|---|
组件状态(useState、ref) | 服务器端 socket 上的 assigns |
事件处理函数(onClick) | handle_event/3 回调 |
| 虚拟 DOM 补丁 | 通过 WebSocket 推送的 HTML diff |
| 获取数据的 API 调用 | 同一进程内的一次函数调用 |
其生命周期分为两个阶段。首次渲染是一个普通的 HTTP 响应,因此在任何 JavaScript 加载之前,页面就已可见且可被搜索引擎索引。随后浏览器建立 WebSocket 连接,mount/3 会在一个随页面打开期间一直存活的进程中再次运行。因此,mount/3 中的代码在每次页面加载时会执行两次。
如果你用过 htmx,那么用服务器渲染的 HTML 驱动 UI 这一思路应该不会陌生。区别在于,LiveView 还会在服务器上为每个已连接的页面维护一个有状态的进程。
如何创建 Phoenix 项目并添加 live 路由?
新建的 Phoenix 1.8 应用已经预先配置好 LiveView,因此只需一条生成器命令,就能获得本示例所需的一切。在安装 Elixir 和 Phoenix 安装器之后,运行:
mix phx.new my_app --no-ecto
cd my_app
mix phx.server
--no-ecto 参数会跳过数据库层,演示项目用不到它。开发服务器监听 http://localhost:4000。你不需要 --live 参数:一些旧教程会传入这个参数,但如今 LiveView 已是每个生成应用的标配。
打开 lib/my_app_web/router.ex,在已有的 scope "/", MyAppWeb do 代码块中、pipe_through :browser 下方添加:
live "/search", SearchLive
live 宏将一个路径映射到一个 LiveView 模块,就像 get 将路径映射到控制器一样。凡是出现 MyApp 的地方,请替换为你自己的应用名。Phoenix 开箱即带生成器、认证脚手架和 LiveView,是一个”开箱即用”(batteries-included)的框架。
如何用 Phoenix LiveView 构建实时搜索框?
实时搜索框需要一个随输入实时响应的表单绑定,以及一个用于重置状态的点击绑定。创建 lib/my_app_web/live/search_live.ex:
defmodule MyAppWeb.SearchLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, assign(socket, query: "", results: frameworks())}
end
def render(assigns) do
~H"""
<form phx-change="search" phx-submit="search">
<input
id="search-input"
type="text"
name="q"
value={@query}
placeholder="Filter frameworks"
autocomplete="off"
phx-debounce="300"
/>
</form>
<button phx-click="clear">Clear</button>
<p>{length(@results)} matches</p>
<ul>
<li :for={name <- @results}>{name}</li>
</ul>
"""
end
def handle_event("search", %{"q" => q}, socket) do
{:noreply,
socket
|> assign(:query, q)
|> assign(:results, filter(q))}
end
def handle_event("clear", _params, socket) do
{:noreply, assign(socket, query: "", results: frameworks())}
end
defp frameworks do
["Phoenix", "Rails", "Laravel", "Django", "Next.js", "Nuxt", "SvelteKit", "Remix"]
end
defp filter(q) do
needle = String.downcase(q)
Enum.filter(frameworks(), &String.contains?(String.downcase(&1), needle))
end
end
mount/3 依次接收 params、session 和 socket,并返回带有初始 assigns 的 socket。render/1 返回一个用 ~H sigil 构建的 HEEx 模板。phx-change、phx-submit 和 phx-click 都会向 handle_event/3 发送一个具名事件;handle_event/3 根据事件名进行匹配,返回带有更新后 assigns 的 {:noreply, socket},再由 LiveView 推送受影响 HTML 的 diff。
phx-change 会在用户输入时触发,这意味着每次按键都是一次往返。phx-debounce 可以对其限流,取值为毫秒数或 "blur"。让 phx-submit 指向同一个事件,意味着按下回车键时由 LiveView 处理,而不是由浏览器提交表单。表单绑定指南中说明,表单参数会以字符串为键、以输入框名称命名的 map 形式传入,例如 %{"q" => "pho"}。
模板使用 {@query} 进行插值。使用 <%= @query %> 的教程写于当前语法出现之前。:for 属性是 HEEx 中推导式(comprehension)的简写。
理解这个文件所需的 Elixir 知识:
- 原子(Atom):如
:ok、:noreply和:query,是具名常量,类似于字符串枚举。 - 元组(Tuple):如
{:noreply, socket},是固定长度的值的组合。回调通过返回元组来表明发生了什么。 - 模式匹配的函数头:两个
handle_event/3子句是独立的定义,Elixir 会执行第一个参数与事件名匹配的那个。%{"q" => q}对参数进行解构,作用类似 JavaScript 中的const { q } = params。 - 管道(Pipe):
|>将左侧的值作为下一个调用的第一个参数传入。 - 不可变性:
assign返回一个新的 socket,不会原地修改任何数据。
如何通过同位钩子添加客户端 JavaScript?
同位钩子让你可以把组件的 JavaScript 写在紧挨其标记的 <script> 标签中。钩子名称以点号开头,且其挂载的元素必须拥有唯一的 DOM id。完整的钩子 API 请参阅 JS 互操作指南。
下面的示例添加了键盘快捷键:/ 聚焦搜索框,Escape 清空搜索框。将 render/1 中的 <input> 替换为:
<input id="search-input" type="text" name="q" value={@query}
placeholder="Filter frameworks" autocomplete="off"
phx-debounce="300"
phx-hook=".SearchShortcuts" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".SearchShortcuts">
export default {
mounted() {
this.onKey = (e) => {
if (e.key === "/" && document.activeElement !== this.el) {
e.preventDefault()
this.el.focus()
}
if (e.key === "Escape" && document.activeElement === this.el) {
this.el.value = ""
this.el.blur()
this.pushEvent("clear", {})
}
}
window.addEventListener("keydown", this.onKey)
},
destroyed() {
window.removeEventListener("keydown", this.onKey)
}
}
</script>
mounted 在元素加入页面时运行,destroyed 在元素被移除时运行,因此监听器只会被绑定一次,随后得到清理。pushEvent("clear", {}) 会触发与按钮相同的 handle_event("clear", ...) 子句,使客户端和服务器共用同一条代码路径。在推送事件之前先清空输入框并使其失焦,可以让界面上显示的值与服务器状态保持一致。
ColocatedHook 模块会在编译时提取这段脚本。以点号开头的名称会以模块名作为命名空间,该功能需要 Phoenix 1.8 或更高版本。由 mix phx.new 生成的 app.js 已经注册了这些钩子:
import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
// ...
hooks: {...colocatedHooks}
LiveView 何时适用,何时不适用?
handle_event/3 处理的每一个事件都是一次到服务器的往返。在高速网络下,LiveView 的响应几乎是即时的;而在慢速网络下则会明显卡顿。纯视觉层面的变化(例如打开下拉菜单)可以借助 Phoenix.LiveView.JS 命令在浏览器中完成,完全无需请求服务器。
在 localhost 上,这种延迟是感受不到的。由 mix phx.new 生成的 assets/js/app.js 已经将 socket 暴露为 window.liveSocket,因此要模拟慢速连接,可以在浏览器控制台中运行:
liveSocket.enableLatencySim(500)
// later
liveSocket.disableLatencySim()
延迟模拟器会将你传入的毫秒数作为与服务器之间的单向延迟。在生产环境中,慢速连接下 LiveView 应用的会话回放会从用户视角呈现出同样的问题:输入内容的显示滞后于打字速度,页面断开并重连时会出现闪烁。
LiveView 还有另外两项成本。由于事件依赖与服务器的实时连接,离线支持很难实现。此外,你必须在生产环境中运行 Elixir,并且每个已连接的页面都对应一个进程。
| 场景 | 更合适的选择 | 原因 |
|---|---|---|
| CRUD 页面、仪表盘、表单、后台管理工具 | LiveView | 无需单独的 API 层;状态和渲染都留在服务器端 |
| 离线优先或网络条件较差的应用 | React / Vue | LiveView 需要实时连接才能处理事件 |
| 客户端交互密集型应用(编辑器、Canvas、大量拖拽的 UI) | React / Vue | 每次交互都需要一次往返;钩子能缓解问题,但无法替代客户端框架 |
| 团队无意使用 Elixir | React / Vue | 你需要在服务器上运行并维护 Elixir |
如需更全面地了解服务器驱动 UI 与客户端驱动 UI 之间的取舍,请参阅这篇 htmx 与 Vue、React 的对比。
总结
对于原本需要 JSON API 和客户端 store 的 CRUD 类应用,LiveView 是一个很好的选择。你在一个模块中编写回调,由服务器持有状态,而必须在浏览器中运行的部分则交给钩子或 JS 命令。建议的下一步是:生成一个 Phoenix 1.8 应用,把当前 SPA 中的某个表单用 LiveView 重写一遍,并开启延迟模拟器,观察它在慢速连接下的表现,然后再决定是否全面采用。
常见问题
LiveView 的 WebSocket 连接断开后会发生什么?
客户端会以指数退避的方式自动重连,并再次运行 mount/3,因此任何未在 mount/3 中重建的 assigns 都会丢失。同时具有 id 属性和 phx-change 绑定的表单会自动恢复:重连后,LiveView 会通过 phx-change 事件重新发送当前的表单值。可以添加 phx-auto-recover 来触发另一个恢复事件,或将其设置为 'ignore' 以关闭恢复功能。
如何避免 mount/3 中的代码执行两次?
用 connected?(socket) 检查将其包裹起来。Phoenix.LiveView.connected?/1 在静态 HTTP 渲染期间返回 false,在 WebSocket 建立连接后返回 true。因此,放在 if connected?(socket) 代码块中的订阅、定时器和开销较大的查询只会在 live 进程中运行。将开销较小的默认 assigns 放在检查之外,这样静态渲染仍能生成完整且可被索引的 HTML。
如何在不启动浏览器的情况下测试 LiveView?
使用随 LiveView 一同发布、基于 ExUnit 运行的 Phoenix.LiveViewTest。调用 live(conn, path) 挂载视图并获取视图句柄,然后使用 render_click、render_change 等辅助函数驱动它,并对其返回的 HTML 进行断言。这些测试直接与 LiveView 进程通信,因此客户端钩子中的 JavaScript 不会在测试中运行。
函数组件和 LiveComponent 有什么区别?
函数组件是一个无状态函数,接收 assigns 并返回 HEEx,因此其标记中触发的事件会交给父级 LiveView 处理。LiveComponent 通过 use Phoenix.LiveComponent 定义,拥有自己的状态,并且当其标记设置了 phx-target={@myself} 时会自行处理事件。两者都运行在父级 LiveView 进程中。默认应使用函数组件,只有在需要将状态和事件处理封装在组件内部时才选择 LiveComponent。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k