LiveView表单 #

一、表单基础 #

1.1 基本表单 #

elixir
defmodule HelloWeb.UserLive.Form do
  use HelloWeb, :live_view

  def mount(_params, _session, socket) do
    changeset = Hello.Accounts.change_user(%User{})
    {:ok, assign(socket, :changeset, changeset)}
  end

  def render(assigns) do
    ~H"""
    <.simple_form :let={f} for={@changeset} phx-change="validate" phx-submit="save">
      <.input field={f[:name]} type="text" label="Name" />
      <.input field={f[:email]} type="email" label="Email" />
      <:actions>
        <.button>Save</.button>
      </:actions>
    </.simple_form>
    """
  end

  def handle_event("validate", %{"user" => user_params}, socket) do
    changeset =
      %User{}
      |> Hello.Accounts.change_user(user_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :changeset, changeset)}
  end

  def handle_event("save", %{"user" => user_params}, socket) do
    case Hello.Accounts.create_user(user_params) do
      {:ok, user} ->
        {:noreply,
         socket
         |> put_flash(:info, "User created")
         |> push_navigate(to: ~p"/users/#{user.id}")}

      {:error, changeset} ->
        {:noreply, assign(socket, :changeset, changeset)}
    end
  end
end

1.2 表单组件 #

elixir
attr :for, :any, required: true
attr :as, :atom, default: nil
attr :phx_change, :string, default: nil
attr :phx_submit, :string, default: nil

def simple_form(assigns) do
  ~H"""
  <.form
    :let={f}
    for={@for}
    as={@as}
    phx-change={@phx_change}
    phx-submit={@phx_submit}
  >
    <%= render_slot(@inner_block, f) %>
  </.form>
  """
end

1.3 输入组件 #

elixir
attr :field, Phoenix.HTML.FormField, required: true
attr :type, :atom, default: :text
attr :label, :string, default: nil
attr :placeholder, :string, default: nil
attr :required, :boolean, default: false

def input(assigns) do
  ~H"""
  <div class="form-group">
    <label for={@field.id}>
      <%= @label || Phoenix.HTML.Form.humanize(@field.field) %>
    </label>
    <input
      type={@type}
      name={@field.name}
      id={@field.id}
      value={@field.value}
      placeholder={@placeholder}
      required={@required}
      class={"form-control #{if @field.errors, do: "is-invalid"}"}
    />
    <%= if @field.errors do %>
      <div class="invalid-feedback">
        <%= Enum.join(@field.errors, ", ") %>
      </div>
    <% end %>
  </div>
  """
end

二、表单验证 #

2.1 实时验证 #

elixir
def handle_event("validate", %{"user" => user_params}, socket) do
  changeset =
    socket.assigns.user
    |> Hello.Accounts.change_user(user_params)
    |> Map.put(:action, :validate)

  {:noreply, assign(socket, :changeset, changeset)}
end

2.2 显示错误 #

elixir
attr :field, Phoenix.HTML.FormField, required: true

def error_tag(assigns) do
  ~H"""
  <%= if @field.errors do %>
    <div class="text-red-500 text-sm mt-1">
      <%= for error <- @field.errors do %>
        <p><%= translate_error(error) %></p>
      <% end %>
    </div>
  <% end %>
  """
end

2.3 条件验证 #

elixir
def handle_event("validate", %{"user" => user_params}, socket) do
  changeset =
    socket.assigns.user
    |> Hello.Accounts.change_user(user_params)
    |> maybe_validate_password(user_params)

  {:noreply, assign(socket, :changeset, changeset)}
end

defp maybe_validate_password(changeset, %{"password" => ""}) do
  changeset
end

defp maybe_validate_password(changeset, %{"password" => password}) do
  changeset
  |> Ecto.Changeset.validate_length(:password, min: 8)
end

三、复杂表单 #

3.1 嵌套表单 #

elixir
def render(assigns) do
  ~H"""
  <.simple_form :let={f} for={@changeset} phx-change="validate" phx-submit="save">
    <.input field={f[:name]} type="text" label="Name" />

    <h3>Address</h3>
    <.inputs_for :let={af} field={f[:address]}>
      <.input field={af[:street]} type="text" label="Street" />
      <.input field={af[:city]} type="text" label="City" />
      <.input field={af[:zip]} type="text" label="ZIP" />
    </.inputs_for>

    <:actions>
      <.button>Save</.button>
    </:actions>
  </.simple_form>
  """
end

3.2 动态表单字段 #

elixir
def render(assigns) do
  ~H"""
  <.simple_form :let={f} for={@changeset} phx-change="validate" phx-submit="save">
    <.input field={f[:title]} type="text" label="Title" />

    <h3>Options</h3>
    <.inputs_for :let={of} field={f[:options]}>
      <div class="option-row">
        <.input field={of[:key]} type="text" placeholder="Key" />
        <.input field={of[:value]} type="text" placeholder="Value" />
        <button type="button" phx-click="remove_option" phx-value-index={of.index}>
          Remove
        </button>
      </div>
    </.inputs_for>

    <button type="button" phx-click="add_option">Add Option</button>

    <:actions>
      <.button>Save</.button>
    </:actions>
  </.simple_form>
  """
end

def handle_event("add_option", _params, socket) do
  options = socket.assigns.changeset.changes.options || []
  new_options = options ++ [%{key: "", value: ""}]

  changeset = Ecto.Changeset.put_change(
    socket.assigns.changeset,
    :options,
    new_options
  )

  {:noreply, assign(socket, :changeset, changeset)}
end

def handle_event("remove_option", %{"index" => index}, socket) do
  index = String.to_integer(index)
  options = socket.assigns.changeset.changes.options || []
  new_options = List.delete_at(options, index)

  changeset = Ecto.Changeset.put_change(
    socket.assigns.changeset,
    :options,
    new_options
  )

  {:noreply, assign(socket, :changeset, changeset)}
end

四、文件上传 #

4.1 配置上传 #

elixir
def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:changeset, Hello.Accounts.change_user(%User{}))
    |> allow_upload(:avatar,
      accept: ~w(.jpg .jpeg .png),
      max_entries: 1,
      max_file_size: 5_000_000
    )

  {:ok, socket}
end

4.2 上传表单 #

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

4.3 处理上传 #

elixir
def handle_event("save", %{"user" => user_params}, socket) do
  uploaded_files =
    consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
      dest = Path.join("priv/static/uploads", Path.basename(path))
      File.cp!(path, dest)
      Routes.static_path(socket, "/uploads/#{Path.basename(dest)}")
    end)

  user_params = Map.put(user_params, :avatar, List.first(uploaded_files))

  case Hello.Accounts.create_user(user_params) do
    {:ok, user} ->
      {:noreply,
       socket
       |> put_flash(:info, "User created")
       |> push_navigate(to: ~p"/users/#{user.id}")}

    {:error, changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

4.4 多文件上传 #

elixir
def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:changeset, Hello.Blog.change_post(%Post{}))
    |> allow_upload(:images,
      accept: ~w(.jpg .jpeg .png),
      max_entries: 5,
      max_file_size: 10_000_000
    )

  {:ok, socket}
end

4.5 拖拽上传 #

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

五、表单状态管理 #

5.1 保存表单状态 #

elixir
def handle_event("validate", %{"user" => user_params}, socket) do
  changeset =
    socket.assigns.user
    |> Hello.Accounts.change_user(user_params)
    |> Map.put(:action, :validate)

  socket =
    socket
    |> assign(:changeset, changeset)
    |> assign(:form_data, user_params)

  {:noreply, socket}
end

5.2 重置表单 #

elixir
def handle_event("reset", _params, socket) do
  changeset = Hello.Accounts.change_user(%User{})
  {:noreply, assign(socket, :changeset, changeset)}
end

5.3 自动保存 #

elixir
def handle_event("autosave", %{"user" => user_params}, socket) do
  case Hello.Accounts.update_user(socket.assigns.user, user_params) do
    {:ok, user} ->
      {:noreply,
       socket
       |> assign(:user, user)
       |> assign(:changeset, Hello.Accounts.change_user(user))}

    {:error, changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

六、搜索表单 #

6.1 实时搜索 #

elixir
def render(assigns) do
  ~H"""
  <div>
    <.form phx-change="search">
      <.input
        type="text"
        name="query"
        value={@query}
        placeholder="Search users..."
        phx-debounce="300"
      />
    </.form>

    <ul>
      <%= for user <- @users do %>
        <li><%= user.name %> - <%= user.email %></li>
      <% end %>
    </ul>
  </div>
  """
end

def handle_event("search", %{"query" => query}, socket) do
  users = Hello.Accounts.search_users(query)
  {:noreply, assign(socket, :users, users)}
end

6.2 防抖处理 #

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

七、最佳实践 #

7.1 使用changeset #

elixir
def handle_event("validate", %{"user" => user_params}, socket) do
  changeset =
    socket.assigns.user
    |> Hello.Accounts.change_user(user_params)
    |> Map.put(:action, :validate)

  {:noreply, assign(socket, :changeset, changeset)}
end

7.2 错误处理 #

elixir
def handle_event("save", %{"user" => user_params}, socket) do
  case Hello.Accounts.create_user(user_params) do
    {:ok, user} ->
      {:noreply,
       socket
       |> put_flash(:info, "User created successfully")
       |> push_navigate(to: ~p"/users/#{user.id}")}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply,
       socket
       |> put_flash(:error, "Failed to create user")
       |> assign(:changeset, changeset)}
  end
end

7.3 加载状态 #

elixir
def render(assigns) do
  ~H"""
  <.simple_form :let={f} for={@changeset} phx-change="validate" phx-submit="save">
    <.input field={f[:name]} type="text" label="Name" />
    <:actions>
      <.button disabled={@saving}>
        <%= if @saving, do: "Saving...", else: "Save" %>
      </.button>
    </:actions>
  </.simple_form>
  """
end

def handle_event("save", params, socket) do
  socket = assign(socket, :saving, true)
  case save_user(params) do
    {:ok, user} ->
      {:noreply, push_navigate(socket, to: ~p"/users/#{user.id}")}

    {:error, changeset} ->
      {:noreply, assign(socket, changeset: changeset, saving: false)}
  end
end

八、总结 #

8.1 核心概念 #

概念 说明
.form 表单组件
.input 输入组件
phx-change 变化事件
phx-submit 提交事件
allow_upload 文件上传

8.2 常用绑定 #

绑定 说明
phx-debounce 防抖
phx-target 目标组件
phx-disable-with 提交时禁用

8.3 下一步 #

现在你已经了解了LiveView表单,接下来让我们学习 上下文模式,深入了解业务逻辑组织!

最后更新:2026-03-28