You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Phoenix LiveView中构建多步骤表单?

当然可以在LiveView里实现多步骤表单,而且逻辑比前端框架更简洁——毕竟状态全在服务端管控,不用操心前后端同步的问题。结合嵌入式Schema的场景,按下面的方案来就行:

多步骤表单实现方案

1. 定义嵌入式Schema与分步验证的变化集

先把user和user_profile定义为嵌入式Schema,再用一个主Schema(比如Registration)包裹两者,统一管理表单数据。同时针对不同步骤编写对应的验证规则:

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :email, :string
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> validate_required([:email])
    |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, message: "格式不正确")
  end
end

defmodule MyApp.Accounts.UserProfile do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :phone_number, :string
    field :password, :string, virtual: true
    field :confirm_password, :string, virtual: true
  end

  def changeset(profile, attrs) do
    profile
    |> cast(attrs, [:phone_number, :password, :confirm_password])
    |> validate_required([:phone_number, :password, :confirm_password])
    |> validate_length(:password, min: 6)
    |> validate_confirmation(:password, message: "密码不匹配")
    |> validate_format(:phone_number, ~r/^1[3-9]\d{9}$/, message: "手机号格式不正确")
  end
end

defmodule MyApp.Accounts.Registration do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    embeds_one :user, MyApp.Accounts.User
    embeds_one :user_profile, MyApp.Accounts.UserProfile
  end

  # 步骤1变化集:仅验证邮箱
  def step1_changeset(registration, attrs) do
    registration
    |> cast(attrs, [])
    |> cast_embed(:user, with: &MyApp.Accounts.User.changeset/2)
    |> validate_required([:user])
  end

  # 步骤2变化集:验证手机号、密码,保留已通过的邮箱数据
  def step2_changeset(registration, attrs) do
    registration
    |> cast(attrs, [])
    |> cast_embed(:user_profile, with: &MyApp.Accounts.UserProfile.changeset/2)
    |> validate_required([:user_profile])
  end

  # 最终提交的完整验证
  def full_changeset(registration, attrs) do
    registration
    |> step1_changeset(attrs)
    |> step2_changeset(attrs)
  end
end

2. LiveView状态管理与事件处理

在LiveView里维护当前步骤和表单状态,通过事件切换步骤、验证数据,最后完成提交:

defmodule MyAppWeb.RegistrationLive do
  use MyAppWeb, :live_view
  alias MyApp.Accounts.Registration

  @impl true
  def mount(_params, _session, socket) do
    registration = %Registration{}
    form = to_form(Registration.step1_changeset(registration, %{}))

    socket = assign(socket, 
      current_step: 1,
      registration: registration,
      form: form
    )

    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="registration-form">
      <h1>账号注册</h1>

      <%= if @current_step == 1 do %>
        <.form for={@form} phx-submit="next-step" class="step-form">
          <div class="form-group">
            <%= label @form, :email, "邮箱" %>
            <%= email_input @form, :email, class: "form-control" %>
            <%= error_tag @form, :email %>
          </div>
          <%= submit "下一步", class: "btn btn-primary" %>
        </.form>
      <% elsif @current_step == 2 do %>
        <.form for={@form} phx-submit="submit-registration" class="step-form">
          <div class="form-group">
            <%= label @form, :"user_profile.phone_number", "手机号" %>
            <%= text_input @form, :"user_profile.phone_number", class: "form-control" %>
            <%= error_tag @form, :"user_profile.phone_number" %>
          </div>

          <div class="form-group">
            <%= label @form, :"user_profile.password", "密码" %>
            <%= password_input @form, :"user_profile.password", class: "form-control" %>
            <%= error_tag @form, :"user_profile.password" %>
          </div>

          <div class="form-group">
            <%= label @form, :"user_profile.confirm_password", "确认密码" %>
            <%= password_input @form, :"user_profile.confirm_password", class: "form-control" %>
            <%= error_tag @form, :"user_profile.confirm_password" %>
          </div>

          <button type="button" phx-click="prev-step" class="btn btn-secondary">上一步</button>
          <%= submit "完成注册", class: "btn btn-primary" %>
        </.form>
      <% end %>
    </div>
    """
  end

  @impl true
  def handle_event("next-step", params, socket) do
    %{"registration" => attrs} = params
    changeset = Registration.step1_changeset(socket.assigns.registration, attrs)

    if changeset.valid? do
      form = to_form(Registration.step2_changeset(changeset.data, %{}))
      socket = assign(socket, 
        current_step: 2,
        registration: changeset.data,
        form: form
      )
      {:noreply, socket}
    else
      {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  @impl true
  def handle_event("prev-step", _params, socket) do
    form = to_form(Registration.step1_changeset(socket.assigns.registration, %{}))
    {:noreply, assign(socket, current_step: 1, form: form)}
  end

  @impl true
  def handle_event("submit-registration", params, socket) do
    %{"registration" => attrs} = params
    changeset = Registration.full_changeset(socket.assigns.registration, attrs)

    if changeset.valid? do
      # 这里写实际的保存逻辑,比如拆分数据存入user和user_profile表
      # MyApp.Accounts.create_user(changeset.data.user)
      # MyApp.Accounts.create_user_profile(changeset.data.user_profile)

      socket = put_flash(socket, :info, "注册成功!")
      {:noreply, push_navigate(socket, to: ~p"/")}
    else
      {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
end

3. 组件抽离优化(可选)

如果觉得render函数里的步骤代码太臃肿,可以把每一步抽成独立组件:

defmodule MyAppWeb.RegistrationLive.Step1 do
  use MyAppWeb, :component

  attr :form, :any, required: true

  def render(assigns) do
    ~H"""
    <.form for={@form} phx-submit="next-step" class="step-form">
      <div class="form-group">
        <%= label @form, :email, "邮箱" %>
        <%= email_input @form, :email, class: "form-control" %>
        <%= error_tag @form, :email %>
      </div>
      <%= submit "下一步", class: "btn btn-primary" %>
    </.form>
    """
  end
end

defmodule MyAppWeb.RegistrationLive.Step2 do
  use MyAppWeb, :component

  attr :form, :any, required: true

  def render(assigns) do
    ~H"""
    <.form for={@form} phx-submit="submit-registration" class="step-form">
      <div class="form-group">
        <%= label @form, :"user_profile.phone_number", "手机号" %>
        <%= text_input @form, :"user_profile.phone_number", class: "form-control" %>
        <%= error_tag @form, :"user_profile.phone_number" %>
      </div>

      <div class="form-group">
        <%= label @form, :"user_profile.password", "密码" %>
        <%= password_input @form, :"user_profile.password", class: "form-control" %>
        <%= error_tag @form, :"user_profile.password" %>
      </div>

      <div class="form-group">
        <%= label @form, :"user_profile.confirm_password", "确认密码" %>
        <%= password_input @form, :"user_profile.confirm_password", class: "form-control" %>
        <%= error_tag @form, :"user_profile.confirm_password" %>
      </div>

      <button type="button" phx-click="prev-step" class="btn btn-secondary">上一步</button>
      <%= submit "完成注册", class: "btn btn-primary" %>
    </.form>
    """
  end
end

然后在LiveView的render里替换成组件调用:

<%= if @current_step == 1 do %>
  <.live_component module={MyAppWeb.RegistrationLive.Step1} id="step1" form={@form} />
<% elsif @current_step == 2 do %>
  <.live_component module={MyAppWeb.RegistrationLive.Step2} id="step2" form={@form} />
<% end %>

关键注意点

  • 嵌入式Schema的表单字段要通过:user.email、:user_profile.phone_number这种嵌套方式访问
  • 分步验证时,每一步的变化集只验证当前步骤的字段,避免提前出现不必要的错误提示
  • 步骤切换时要复用已保存的registration数据,确保已输入的内容不会丢失

内容的提问来源于stack exchange,提问作者Zain

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.11 19:55:19