LiveView 表单真香,但 changeset 也真会坑人:实时校验、错误展示、前后端校验合一

前言

如果说上一篇聊生命周期,属于"先把 LiveView 跑起来",那真正让我觉得这套东西有杀伤力的,是写表单的时候。

原因很简单。

表单这个东西,几乎是 Web 开发里最容易把人写烦的部分:

  • 前端要维护输入状态
  • 前端要写一套校验
  • 后端要再写一套校验
  • 提交失败后还得把错误映射回页面
  • 一旦规则改了,两边总有一边忘了跟

我以前写 SPA,最烦的不是列表,也不是弹窗,而是那种"字段看起来不多,但业务规则很碎"的表单。你以为自己在写 UI,实际上你在维护一套半吊子的业务规则同步系统。

而 LiveView 在表单这件事上的核心思路,非常直接:

别同步两套规则了,规则就放服务端,前端只负责把输入事件送回来。

这也是我觉得 LiveView 最值得认真看的地方之一。

1. 先说结论:LiveView 表单强,不是因为"少写代码",而是因为"少维护一套世界"

很多人介绍 LiveView 表单时,会先讲"实时校验很方便""不用单独写接口"。

这些都对,但我觉得还没打到点上。

真正值钱的是:

Ecto changeset 既是数据变更入口,也是校验规则载体,LiveView 只是把这套结果实时投射到 UI 上。

翻译成人话就是:

  • 规则定义一次
  • 校验走一套
  • 错误消息走一套
  • 提交前后都是同一个模型

这件事看起来朴素,实际非常贵。

因为很多业务系统的复杂度,不是业务本身复杂,而是一个简单规则被拆成了:

  • 前端 schema 校验一份
  • 后端 changeset / DTO / validator 一份
  • 接口错误映射一份

最后出 bug 时,你都不知道是"规则错了",还是"两边规则没对齐"。

LiveView 在表单场景下最舒服的一点,就是它把这些本来分裂的东西重新收回来了。

2. 先把三个角色分清:changesetto_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>

这段的关键不是"能跑",而是要理解里面发生了什么:

  1. 用户输入内容
  2. 浏览器把表单值通过 phx-change 发回服务端
  3. 服务端重新构建 changeset
  4. changeset 生成错误信息
  5. to_form/2 把它转成可渲染结构
  6. 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-changephx-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{} 很正常。

但一到编辑场景,很多人图省事,validatesave 里还是从 %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 表单最香的地方,不是炫技式的实时感,而是:

终于不用再维护两套总会漂移的校验逻辑了。

如果你也写过一堆表单,评论区可以聊聊:你最烦的是双份校验、错误回填,还是那种每改一个字段就要前后端一起动的维护成本。

相关推荐
Slice_cy1 小时前
JavaScript(ES6)
前端
用户298698530141 小时前
在 React 中使用 JavaScript 合并 Excel 文件
前端·javascript·react.js
橘子星1 小时前
JavaScript this 指向全解实战指南
前端·javascript
何出无名之师1 小时前
AIDL的一次调用链路追踪之二,如何和驱动打交道
前端
weedsfly1 小时前
JS垃圾回收:从原理到项目实战,彻底根治内存泄漏
前端·javascript·面试
Jcc1 小时前
虚拟 DOM 是什么?从 Snabbdom 理解 Vue 的 DOM 更新机制
前端
user62229864925811 小时前
Vue 常用技术知识全景:从响应式到组件通信的系统理解
前端
feiyu_gao1 小时前
一个人 + AI:246 commits 做出设计系统 CLI 的故事
前端·ai编程·交互设计