前言
如果说上一篇聊生命周期,属于"先把 LiveView 跑起来",那真正让我觉得这套东西有杀伤力的,是写表单的时候。
原因很简单。
表单这个东西,几乎是 Web 开发里最容易把人写烦的部分:
- 前端要维护输入状态
- 前端要写一套校验
- 后端要再写一套校验
- 提交失败后还得把错误映射回页面
- 一旦规则改了,两边总有一边忘了跟
我以前写 SPA,最烦的不是列表,也不是弹窗,而是那种"字段看起来不多,但业务规则很碎"的表单。你以为自己在写 UI,实际上你在维护一套半吊子的业务规则同步系统。
而 LiveView 在表单这件事上的核心思路,非常直接:
别同步两套规则了,规则就放服务端,前端只负责把输入事件送回来。
这也是我觉得 LiveView 最值得认真看的地方之一。
1. 先说结论:LiveView 表单强,不是因为"少写代码",而是因为"少维护一套世界"
很多人介绍 LiveView 表单时,会先讲"实时校验很方便""不用单独写接口"。
这些都对,但我觉得还没打到点上。
真正值钱的是:
Ecto changeset 既是数据变更入口,也是校验规则载体,LiveView 只是把这套结果实时投射到 UI 上。
翻译成人话就是:
- 规则定义一次
- 校验走一套
- 错误消息走一套
- 提交前后都是同一个模型
这件事看起来朴素,实际非常贵。
因为很多业务系统的复杂度,不是业务本身复杂,而是一个简单规则被拆成了:
- 前端 schema 校验一份
- 后端 changeset / DTO / validator 一份
- 接口错误映射一份
最后出 bug 时,你都不知道是"规则错了",还是"两边规则没对齐"。
LiveView 在表单场景下最舒服的一点,就是它把这些本来分裂的东西重新收回来了。
2. 先把三个角色分清:changeset、to_form、模板
我一开始学 LiveView 表单时,最大的困惑就是:
为什么明明已经有 changeset 了,还要再来一个 to_form/2?
后来我才想明白,这三个东西分工非常清楚。
2.1 changeset 负责业务规则,不负责渲染
Ecto.Changeset 本质上是一个"数据变更 + 校验 + 错误信息"的容器。
举个例子,一个最常见的注册表单:
elixir
defmodule Demo.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
field :age, :integer
timestamps()
end
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:email, :name, :age])
|> validate_required([:email, :name])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/)
|> validate_number(:age, greater_than_or_equal_to: 18)
end
end
这段代码表达的是:
- 我允许哪些字段进入系统
- 每个字段有什么规则
- 不合法时返回什么错误
注意,它不是"为了 LiveView 才有的"。没有 LiveView,你照样该写这层。
所以我现在更愿意把 changeset 理解成:
它是业务规则入口,不是表单控件配置文件。
2.2 to_form/2 负责把 changeset 变成模板能消费的表单数据
Phoenix.Component.to_form/2 的作用,是把 changeset 转成一个适合模板绑定的 form 结构。
你可以粗暴理解成:
changeset偏业务语义form偏渲染语义
比如在 LiveView 里,通常不会直接把 changeset 扔给模板,而是这样做:
elixir
changeset = Accounts.change_user(%User{})
socket =
assign(socket, :form, to_form(changeset))
模板里就写:
heex
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} type="email" label="邮箱" />
<.input field={@form[:name]} type="text" label="昵称" />
<.input field={@form[:age]} type="number" label="年龄" />
<:actions>
<.button>提交</.button>
</:actions>
</.simple_form>
这一步非常关键。
因为从 Phoenix 这套 API 的设计上你就能看出来:
表单渲染和业务校验是协作关系,不是同一个抽象层。
2.3 模板负责显示,不负责"发明规则"
这个观点我很坚持。
模板要做的是:
- 渲染字段
- 展示错误
- 绑定事件
模板不该做的是:
- 自己偷偷补一套业务判断
- 为了页面显示方便,篡改字段含义
- 用一堆前端 if/else 替代 changeset 规则
不然写着写着,你又会回到"前后端各管一套"的老路上去。
3. 跑一个最小闭环:初始化、实时校验、提交保存
下面这个例子,我觉得是理解 LiveView 表单最省脑子的方式。
3.1 context 层先把 changeset 和保存逻辑准备好
举个例子:用户注册。
elixir
defmodule Demo.Accounts do
alias Demo.Accounts.User
alias Demo.Repo
def change_user(user, attrs \\ %{}) do
User.registration_changeset(user, attrs)
end
def create_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
end
这里的思路很简单:
change_user/2给页面做校验和回显create_user/1真正落库
你会发现,它们底层都走同一个 changeset。
这就是统一规则的价值。
3.2 LiveView 初始化表单
页面第一次进来时,先给一个空 changeset:
elixir
defmodule DemoWeb.UserRegisterLive do
use DemoWeb, :live_view
alias Demo.Accounts
alias Demo.Accounts.User
def mount(_params, _session, socket) do
form =
%User{}
|> Accounts.change_user()
|> to_form()
{:ok, assign(socket, :form, form)}
end
这里我建议直接把 form assign 进去,而不是把 changeset assign 进去再让模板自己处理。
原因很现实:
模板只关心怎么展示,LiveView 回调里再决定表单数据怎么生成,职责更干净。
3.3 phx-change 做实时校验
这一步是 LiveView 表单最"有感觉"的地方。
elixir
def handle_event("validate", %{"user" => user_params}, socket) do
changeset =
%User{}
|> Accounts.change_user(user_params)
form = to_form(changeset, action: :validate)
{:noreply, assign(socket, :form, form)}
end
模板:
heex
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} type="email" label="邮箱" />
<.input field={@form[:name]} type="text" label="昵称" />
<.input field={@form[:age]} type="number" label="年龄" />
<:actions>
<.button>提交</.button>
</:actions>
</.simple_form>
这段的关键不是"能跑",而是要理解里面发生了什么:
- 用户输入内容
- 浏览器把表单值通过
phx-change发回服务端 - 服务端重新构建 changeset
- changeset 生成错误信息
to_form/2把它转成可渲染结构- LiveView 把 diff 推回浏览器
也就是说,所谓"实时校验",本质上不是浏览器自己做校验,而是:
每次输入变化,都触发一次服务端校验,然后把结果实时返还到界面。
3.4 phx-submit 负责真正提交
phx-change 只是校验,phx-submit 才是持久化动作。
elixir
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.create_user(user_params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "注册成功:#{user.email}")
|> push_navigate(to: ~p"/users/#{user.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
end
这个闭环一旦跑通,LiveView 表单的味道就出来了:
- 校验和提交都在服务端
- 错误和回显天然一致
- 不需要前端再单独解析一遍接口错误
4. phx-change 和 phx-submit 到底怎么分工
这个问题看起来基础,实际上特别容易写歪。
我的结论很明确:
phx-change:负责输入过程中的反馈phx-submit:负责用户确认后的落库
4.1 phx-change 不是"顺手提交一下"
很多人第一次上手,会在 phx-change 里顺便做数据库查询、唯一性检查、远程调用,甚至直接保存。
这就很危险。
因为 phx-change 触发频率很高。默认情况下,用户每敲一个字,都可能来一次服务端 round trip。
举个我自己踩过的坑。
我一开始写邮箱注册时,脑子一热,把"邮箱是否已存在"的查询也放进 validate 流程里。结果就是:
- 用户输入
a - 查一次数据库
- 输入
ab - 再查一次数据库
- 输入
abc - 再查一次数据库
页面倒是挺"实时",数据库也被我实时打扰到了。
所以经验很简单:
phx-change 适合做纯校验、轻量格式判断、即时错误提示,不适合承载重副作用。
如果你确实要降低频率,可以考虑这些做法:
- 给输入框加
phx-debounce - 在
validate里只做内存级规则校验 - 唯一性、外部依赖、真正持久化,尽量放到
phx-submit
比如邮箱输入这种高频字段,我很常用这类写法:
heex
<.input
field={@form[:email]}
type="email"
label="邮箱"
phx-debounce="300"
/>
这样不是不校验,而是别把每次按键都立刻升级成一次服务端压力。
4.2 phx-submit 也不是"反正前面校验过了,就放心写库"
这个坑同样常见。
别因为你已经有 phx-change,就以为 phx-submit 可以省校验。
不行。
因为:
- 用户请求可以被伪造
- 中途数据可能变化
- 你不能拿"UI 已经校验过"当安全边界
所以我的原则一直是:
phx-change 负责体验,phx-submit 负责最终正确性。
这两个不是二选一,而是两个层次。
5. 为什么你的错误消息不显示?这是我踩得最烦的一个坑
这个坑我必须单独拎出来说。
我第一次写 LiveView 表单时,明明 changeset.errors 里已经有内容了,页面就是不显示。那一刻我甚至怀疑自己模板写错了。
后来才发现,问题不在模板,而在 changeset 的 action。
5.1 只做校验还不够,还得告诉 Phoenix"现在该显示错误了"
实时校验时,通常需要这样写:
elixir
changeset =
%User{}
|> Accounts.change_user(user_params)
form = to_form(changeset, action: :validate)
如果你不设置 action,很多情况下错误不会按你预期展示出来。
这不是多余设计,而是 Phoenix 在区分:
- 这只是一个初始空表单
- 还是一个已经校验过、应该展示错误的表单
我自己的理解是:
action 不是业务动作本身,它更像一个"这个表单现在是否进入错误可见状态"的信号。
5.2 不要一上来就把所有错误糊用户脸上
还有一个细节也很重要。
LiveView 表单很容易写成这样:
- 页面一打开
- 所有必填项全红
- 用户还没动手,先被教育一顿
这种体验并不好。
Phoenix 生成的核心组件里,通常会结合 used_input? 一类能力,只在用户真的交互过后再显示对应错误。这个思路我非常认同。
因为实时校验的目标不是"证明你规则很多",而是:
在用户快要犯错的时候,给一个刚刚好的提醒。
6. 再说几个我觉得最容易写臭的实战坑
6.1 坑一:把 changeset 当成页面万能状态容器
这跟上一期把 assigns 当垃圾桶,本质上是一个毛病。
很多人会把所有 UI 状态都往 changeset 身上挂,甚至希望它顺便管理:
- 当前 tab
- 弹窗开关
- 保存按钮 loading
- 某个远程校验状态
我不建议这么干。
changeset 最适合表达的是:
- 当前字段值
- 字段变化
- 校验结果
- 错误信息
别让它背 UI 状态的锅。
不然最后你会得到一个"既像业务对象、又像视图模型、还带点流程状态"的混合怪物。
6.2 坑二:在 handle_event/3 里直接信任 params 类型
handle_event/3 收到的表单参数,本质上都是字符串。
举个例子,age 就算在浏览器里用了 type="number",服务端收到的也还是字符串形式的输入。真正的类型转换,是 cast/4 在做。
所以别在 LiveView 回调里先自己手搓一遍类型判断,再传给 changeset。
让 changeset 去干这件事,代码会更稳。
6.3 坑三:为了"实时"把所有副作用都绑到输入事件上
这个前面提过,但我还想再强调一次。
实时验证不是无限制地把每次键盘输入都升级成一次业务操作。
尤其是下面这些事,尽量别放在高频 phx-change 里:
- 大量数据库查询
- 外部 API 请求
- 发消息、打点、审计日志
- 会改业务状态的动作
不然 LiveView 省下来的前端复杂度,会被你在服务端又造回去。
6.4 坑四:把错误展示写成"提交失败后整页报错"
很多传统服务端表单的习惯是:
- 用户填完整张表
- 点提交
- 上面冒出一坨错误
- 然后开始逐个找字段
LiveView 明明给了你更细粒度的反馈能力,就别再把它写回 2013 年那种体验了。
我的建议是:
- 字段级错误尽量跟字段走
- 提交级错误留给全局 flash 或顶部提示
- 实时校验解决"输入格式不对"
- 提交时处理"业务上不能保存"
这套分层做下来,用户体验会顺很多。
6.5 坑五:编辑表单别总拿 %User{} 起手
创建表单时,用 %User{} 很正常。
但一到编辑场景,很多人图省事,validate 和 save 里还是从 %User{} 重新生成 changeset,这就容易出问题:
- 原有值回显不对
- 某些只在编辑场景下存在的数据丢了
- 你以为自己在"更新",实际在按"新建表单"那套逻辑校验
举个例子,编辑用户资料时,更稳的做法应该是:
elixir
changeset = Accounts.change_user(socket.assigns.user, user_params)
form = to_form(changeset, action: :validate)
这个问题本质上不是语法坑,而是心智坑:
changeset 永远应该基于"你当前正在修改的那份数据"生成。
7. 跟 SPA 表单放一起看,LiveView 到底省掉了什么
如果你本来就是做 React/Vue 这套的,这里体感会特别强。
一个普通 SPA 表单,通常要处理:
- 浏览器本地输入状态
- 前端 schema 校验
- 请求发起和 loading
- 后端错误解析
- 字段错误映射
- 成功后的页面跳转或局部刷新
而 LiveView 的表单闭环通常是:
- 输入变化发回服务端
- changeset 校验
to_form/2回显- 提交时复用同一套规则
- 成功或失败直接回到同一页面状态机
所以我现在越来越觉得:
LiveView 表单最大的优势,不是"少写 JS",而是"把输入、校验、回显、提交收进同一个服务端交互模型"。
这才是它真正降复杂度的地方。
当然,话也得说回来。
如果你做的是:
- 超重前端交互
- 大量本地离线编辑
- 浏览器侧复杂草稿和协同冲突处理
那 SPA 表单依然有它的合理性。
但只要你的业务是典型后台、工作流、管理系统、配置系统、运营系统,那 LiveView 这套表单模型,真的很容易让人写上头。
总结
这篇如果只留一个结论,我希望是这句:
在 LiveView 里,表单不是"前端收集数据,后端顺手验一下",而是"服务端规则主导,前端实时映射结果"。
把这件事想明白,很多 API 就顺了:
changeset为什么是核心to_form/2为什么必须存在phx-change为什么能做到边输边反馈phx-submit为什么还得再走一次完整校验
对我来说,LiveView 表单最香的地方,不是炫技式的实时感,而是:
终于不用再维护两套总会漂移的校验逻辑了。
如果你也写过一堆表单,评论区可以聊聊:你最烦的是双份校验、错误回填,还是那种每改一个字段就要前后端一起动的维护成本。