布局与组件 #

一、布局系统 #

1.1 布局概述 #

布局是页面的框架模板,定义了公共的页面结构,如头部、导航、页脚等。

text
布局模板
├── HTML头部
├── 导航栏
├── 主内容区 (@inner_block)
└── 页脚

1.2 默认布局 #

elixir
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"

  def app(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title><%= assigns[:page_title] || "Hello" %></title>
        <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
        <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
        </script>
      </head>
      <body class="bg-gray-100">
        <header>
          <.navigation />
        </header>
        <main class="container mx-auto">
          <%= @inner_content %>
        </main>
        <footer>
          <.footer />
        </footer>
      </body>
    </html>
    """
  end
end

1.3 根布局 #

elixir
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <.live_title suffix=" · Phoenix Framework">
          <%= assigns[:page_title] || "Hello" %>
        </.live_title>
        <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
        <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
        </script>
      </head>
      <body>
        <%= @inner_content %>
      </body>
    </html>
    """
  end
end

二、布局使用 #

2.1 默认布局配置 #

elixir
pipeline :browser do
  plug :put_root_layout, {HelloWeb.Layouts, :root}
end

2.2 控制器指定布局 #

elixir
defmodule HelloWeb.AdminController do
  use HelloWeb, :controller

  plug :put_layout, {HelloWeb.Layouts, :admin}

  def index(conn, _params) do
    render(conn, :index)
  end
end

2.3 动作级别布局 #

elixir
def show(conn, %{"id" => id}) do
  user = Hello.Accounts.get_user!(id)
  render(conn, :show, user: user, layout: {HelloWeb.Layouts, :print})
end

2.4 禁用布局 #

elixir
def pdf(conn, %{"id" => id}) do
  user = Hello.Accounts.get_user!(id)
  render(conn, :show, user: user, layout: false)
end

三、多布局系统 #

3.1 应用布局 #

elixir
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"

  def app(assigns) do
    ~H"""
    <.root>
      <header>
        <.navbar />
      </header>
      <main>
        <.flash_messages flash={@flash} />
        <%= @inner_content %>
      </main>
      <footer>
        <.footer />
      </footer>
    </.root>
    """
  end
end

3.2 管理后台布局 #

elixir
def admin(assigns) do
  ~H"""
  <.root>
    <div class="admin-layout">
      <aside class="sidebar">
        <.admin_menu />
      </aside>
      <main class="content">
        <.admin_header />
        <.flash_messages flash={@flash} />
        <%= @inner_content %>
      </main>
    </div>
  </.root>
  """
end

3.3 认证布局 #

elixir
def auth(assigns) do
  ~H"""
  <.root>
    <div class="auth-layout">
      <div class="auth-container">
        <.logo />
        <.flash_messages flash={@flash} />
        <%= @inner_content %>
      </div>
    </div>
  </.root>
  """
end

四、组件基础 #

4.1 函数组件 #

elixir
defmodule HelloWeb.CoreComponents do
  use Phoenix.Component

  attr :title, :string, required: true
  attr :class, :string, default: ""

  def card(assigns) do
    ~H"""
    <div class={"card #{@class}"}>
      <div class="card-header">
        <h3><%= @title %></h3>
      </div>
      <div class="card-body">
        <%= render_slot(@inner_block) %>
      </div>
    </div>
    """
  end
end

4.2 使用组件 #

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

4.3 组件属性 #

elixir
attr :type, :atom, values: [:primary, :secondary, :danger, :success], default: :primary
attr :size, :atom, values: [:sm, :md, :lg], default: :md
attr :disabled, :boolean, default: false
attr :loading, :boolean, default: false

def button(assigns) do
  ~H"""
  <button
    class={button_classes(@type, @size)}
    disabled={@disabled or @loading}
  >
    <%= if @loading do %>
      <.spinner />
    <% end %>
    <%= render_slot(@inner_block) %>
  </button>
  """
end

defp button_classes(type, size) do
  base = "btn"
  type_class = "btn-#{type}"
  size_class = "btn-#{size}"
  [base, type_class, size_class]
end

五、插槽系统 #

5.1 默认插槽 #

elixir
attr :title, :string, required: true

def panel(assigns) do
  ~H"""
  <div class="panel">
    <div class="panel-header">
      <h3><%= @title %></h3>
    </div>
    <div class="panel-body">
      <%= render_slot(@inner_block) %>
    </div>
  </div>
  """
end

使用:

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

5.2 命名插槽 #

elixir
slot :header
slot :footer

def card(assigns) do
  ~H"""
  <div class="card">
    <%= if @header do %>
      <div class="card-header">
        <%= render_slot(@header) %>
      </div>
    <% end %>
    <div class="card-body">
      <%= render_slot(@inner_block) %>
    </div>
    <%= if @footer do %>
      <div class="card-footer">
        <%= render_slot(@footer) %>
      </div>
    <% end %>
  </div>
  """
end

使用:

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

5.3 插槽参数 #

elixir
attr :items, :list, required: true
slot :item, required: true
slot :empty

def list(assigns) do
  ~H"""
  <%= if Enum.empty?(@items) do %>
    <%= if @empty do %>
      <%= render_slot(@empty) %>
    <% else %>
      <p>No items</p>
    <% end %>
  <% else %>
    <ul>
      <%= for item <- @items do %>
        <li><%= render_slot(@item, item) %></li>
      <% end %>
    </ul>
  <% end %>
  """
end

使用:

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

六、常用组件 #

6.1 导航组件 #

elixir
attr :current_user, :map, default: nil

def navbar(assigns) do
  ~H"""
  <nav class="navbar">
    <div class="navbar-brand">
      <.link navigate={~p"/"} class="logo">
        MyApp
      </.link>
    </div>
    <div class="navbar-menu">
      <.link navigate={~p"/posts"} class="navbar-item">
        Posts
      </.link>
      <.link navigate={~p"/about"} class="navbar-item">
        About
      </.link>
    </div>
    <div class="navbar-end">
      <%= if @current_user do %>
        <.dropdown>
          <:trigger>
            <span><%= @current_user.email %></span>
          </:trigger>
          <:content>
            <.link navigate={~p"/settings"}>Settings</.link>
            <.link href={~p"/logout"} method="delete">Logout</.link>
          </:content>
        </.dropdown>
      <% else %>
        <.link navigate={~p"/login"} class="btn btn-primary">
          Login
        </.link>
      <% end %>
    </div>
  </nav>
  """
end

6.2 Flash消息组件 #

elixir
attr :flash, :map, required: true

def flash_messages(assigns) do
  ~H"""
  <div class="flash-messages">
    <%= if msg = Phoenix.Flash.get(@flash, :info) do %>
      <div class="alert alert-info">
        <%= msg %>
      </div>
    <% end %>
    <%= if msg = Phoenix.Flash.get(@flash, :error) do %>
      <div class="alert alert-error">
        <%= msg %>
      </div>
    <% end %>
    <%= if msg = Phoenix.Flash.get(@flash, :warning) do %>
      <div class="alert alert-warning">
        <%= msg %>
      </div>
    <% end %>
  </div>
  """
end

6.3 分页组件 #

elixir
attr :current_page, :integer, required: true
attr :total_pages, :integer, required: true
attr :path, :string, required: true

def pagination(assigns) do
  ~H"""
  <nav class="pagination">
    <%= if @current_page > 1 do %>
      <.link href={"#{@path}?page=#{@current_page - 1}"} class="pagination-prev">
        Previous
      </.link>
    <% end %>

    <%= for page <- pagination_range(@current_page, @total_pages) do %>
      <%= if page == :ellipsis do %>
        <span class="pagination-ellipsis">...</span>
      <% else %>
        <.link
          href={"#{@path}?page=#{page}"}
          class={"pagination-page #{if page == @current_page, do: "active"}"}
        >
          <%= page %>
        </.link>
      <% end %>
    <% end %>

    <%= if @current_page < @total_pages do %>
      <.link href={"#{@path}?page=#{@current_page + 1}"} class="pagination-next">
        Next
      </.link>
    <% end %>
  </nav>
  """
end

defp pagination_range(current, total) do
  cond do
    total <= 7 -> 1..total
    current <= 4 -> [1, 2, 3, 4, 5, :ellipsis, total]
    current >= total - 3 -> [1, :ellipsis, total - 4, total - 3, total - 2, total - 1, total]
    true -> [1, :ellipsis, current - 1, current, current + 1, :ellipsis, total]
  end
end

6.4 表格组件 #

elixir
attr :rows, :list, required: true
slot :col, required: true

def table(assigns) do
  ~H"""
  <table class="table">
    <thead>
      <tr>
        <%= for col <- @col do %>
          <th><%= col[:label] %></th>
        <% end %>
      </tr>
    </thead>
    <tbody>
      <%= for row <- @rows do %>
        <tr>
          <%= for col <- @col do %>
            <td><%= render_slot(col, row) %></td>
          <% end %>
        </tr>
      <% end %>
    </tbody>
  </table>
  """
end

使用:

heex
defmodule HelloWeb.Layouts do
  use HelloWeb, :html

  embed_templates "layouts/*"
end

七、组件组织 #

7.1 目录结构 #

text
lib/hello_web/
├── components/
│   ├── core_components.ex    # 核心组件
│   ├── form_components.ex    # 表单组件
│   ├── layout_components.ex  # 布局组件
│   └── ui_components.ex      # UI组件
└── layouts/
    └── layouts.ex            # 布局模板

7.2 导入组件 #

elixir
defmodule HelloWeb do
  def html do
    quote do
      use Phoenix.Component

      import HelloWeb.CoreComponents
      import HelloWeb.FormComponents
      import HelloWeb.LayoutComponents
    end
  end
end

八、总结 #

8.1 核心概念 #

概念 说明
布局 页面框架模板
组件 可复用的UI单元
插槽 组件内容注入
属性 组件输入参数

8.2 最佳实践 #

  • 将公共结构放在布局中
  • 将可复用UI封装为组件
  • 使用属性验证组件输入
  • 使用插槽提供灵活性

8.3 下一步 #

现在你已经了解了布局与组件,接下来让我们学习 Ecto简介,深入了解数据库操作!

最后更新:2026-03-28