news 2026/4/18 7:12:40

Phoenix视图与组件

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Phoenix视图与组件

今天我们来学习 Phoenix 的视图和组件,我们通过它们来渲染前端页面。其实严格来说,Phoenix 中并没有视图这个东西,视图只不过是一个组件(Component)。没错,组件既是视图,也是用来构建视图的积木,这也是”组件”这个名称的由来。

这里我们将要介绍的是 Phoenix 中的静态视图,因为 Phoenix 中还有动态视图,我们在《Phoenix 动态视图与动态组件》中再介绍它。虽然 Phoenix 中并没有视图,但是确实有动态视图。

接下来我们将不再区分视图和组件这两个概念,因为它们是同一个东西。但为了描述方便会混用这两个名词,希望不会引起误解。

组件的本质

组件本质上只是一个返回 HEEx 模板的 Elixir 函数,函数接受一个名叫assigns的参数,参数中是要填入模板中的数据。虽然函数参数名称是可以任意的,但是在这里参数名叫做assigns是一种人文约定,Phoenix 文档中也是直接用 assigns 来指代这个参数。

def index(assigns) do ~H""" <H1>Hello Phoenix!</H1> """ end

HEEx = HTML + EEx(EmbeddedElixir)。也就是嵌入了 Elixir 表达式的 HTML,EEx 是 Elixir 的表达式求值引擎。

渲染视图

视图的渲染通常在 Controller 中完成。以 Phoenix 模板工程为例,在PageController中,我们通过Controller模块提供的render函数来渲染首页视图。

defmodule HelloWeb.PageController do use HelloWeb, :controller def home(conn, _params) do # The home page is often custom made, # so skip the default app layout. render(conn, :home, layout: false) end end

这里:home就是我们要渲染的视图,一个名为home的函数。

第一个问题:视图究竟在哪儿呢?

Phoenix 会根据 Controller 的名称和视图类型来决定去哪里寻找视图。控制器模块通常会命名为SomeControllerSome是控制器名称,那么 Phoenix 就会去SomeTYPE模块中寻找视图函数,TYPE是视图类型,比如HTML表示 html 相应,JSON就表示 json 相应。这是 Phoenix 的命名约定,我们应该遵守它。因此上例中 Phoenix 会去PageHTML模块中去寻找home函数。

第二个问题:视图类型怎么确定?

眼下还有一个问题,视图类型究竟是什么。不过在此之前,我们可以先通过get_format/1函数看看响应类型是什么,他也是Phoenix.Controller模块的函数,因此可以在控制器中直接调用。

def home(conn, _params) do # The home page is often custom made, # so skip the default app layout. IO.puts("format: #{get_format(conn)}") render(conn, :home, layout: false) end

刷新网页,我们会看到下面的日志。

format: html

不错,确实是 html。接下来我们来看看为什么以及怎么去设置类型。

router.ex中找到PageController路由。

scope "/", HelloWeb do pipe_through :browser get "/", PageController, :home end

在这个路由中,我们使用了:browser管线,而它的定义如下。

pipeline :browser do plug :accepts, ["html"] ... ... end

这里我们使用了acceptsplug,它的作用就是与前端协商响应格式,这里我们接受 html 格式。打开浏览器调试,查看请求的 Accept Header。

在请求的 Accept 头里面,也支持 html 格式,于是就这么愉快的决定了。

如果我们注释掉plug :accepts, ["html"],再次刷新网页,就会看到如下的错误。

这说明格式其实是存储在请求参数的_format字段中的。另一种直接由前端决定请求格式的方式是在请求参数中加上_format参数:http://localhost:4000/?_format=html。当请求中没有_format参数时,才会去解析 Accept 头。

浏览器由于历史原因 Accept 头比较混乱,Phoenix 的策略是只要有以下情况之一就设置成 html:

  • Accept 列表中包含 html 格式;
  • 指定了多种类型且包含*/*通配格式。

acceptsplug 除了用来协商格式,也指定了一个支持的格式列表,对于格式不在支持列表内的请求,Phoenix 会拒绝响应。

除了由客户端指定响应格式,服务端也可以使用put_format直接设置格式,并且具有最高优先级。

pipeline :browser do # plug :accepts, ["html"] plug :put_format, "html" ... ... end

在服务端设置响应格式的另一种方式是在控制器的render函数中使用字符串参数"view.type"指定视图参数,view是视图函数名称,type是响应类型。

def home(conn, _params) do # The home page is often custom made, # so skip the default app layout. IO.puts("format: #{get_format(conn)}") render(conn, "home.html", layout: false) end

这里我们用home.html来指定视图,表示渲染视图来自 html 模板中的home函数,它并不表示home.html文件,事实上也并不存在这个文件。

这种根据 format 返回不同格式响应的能力让我们可以在同一个 uri 上提供多种响应格式,如 html 和 json,而不必为此开两个路由。

如果想要在accetpsplug 中使用自定义类型,需要在config/config.exs中进行配置。

config :mime, :types, %{ "application/vnd.api+json" => ["json-api"] }

这个配置是一个 map,键是 media type,值是在 phoenix 中使用的类型。配置完以后,还需要重新编译 plug。

mix deps.clean mime --build mix deps.get

然后就可以使用json-api这个格式了。

plug :accepts, ["html", "json-api"]

现在我们知道去哪里寻找视图函数了,打开page.html.ex文件,并没有一个名为home的函数存在。但是有embed_templates "page_html/*"这么一行代码。

这是 phoenix 框架为我提供的一个便利,embed_templates可以将.heex模板文件嵌入模块中,编译成一个视图函数,支持通配或者指定具体文件。文件名命名为some.type.heexsome既是文件名,也是最后编译出的视图函数名。当然只有type匹配当前模块格式的文件才会被嵌入。例如在示例的page_html目录下,只有一个home.html.heex文件,它最终会嵌入PageHTML模块,变成它里面的一个名为home的函数。

虽然.heex模板放在哪个目录下并无要求,但是按照 phoenix 的命名规约,一般都会将.heex模板文件放到和视图模块文件同名的目录下。

.heex文件中的内容就是heex模板,也就是视图函数中~H""""""之间的内容。当模板内容比较长的时候,用单独的文件来管理这些模板是更加明智的选择,它可以防止视图模块变得冗长和难以阅读。

如果我们遵守 phoenix 的规则,它会自动帮我们确定视图模块,当然我们也可以使用put_view/2函数手动设置视图模块。

让我们在controllers目录下创建一个page_view.ex文件,并输入以下内容:

defmodule HelloWeb.PageView do use HelloWeb, :html def home(assigns) do ~H""" <h1>Hello Phoenix!</h1> """ end end

然后回到page_controller.ex中修改home函数如下:

def home(conn, _params) do # The home page is often custom made, # so skip the default app layout. IO.puts("format: #{get_format(conn)}") conn |> put_view(HelloWeb.PageView) |> render(:home, layout: false) end

刷新页面,现在我们将看到Hello Phoenix!显示在页面中。

put_view/2还支持为多个格式设置不同的视图模块,比如:

put_view(conn, html: AppHTML, json: AppJSON)

HEEx语法

最简单的 HEEx 模板就是单纯的 html 片段。

<h1>Hello Phoenix!</h1>

当然我们并不满足于如此简单的用法,我还希望可以根据参数来生成 html 片段。

def home(assigns) do ~H""" <h1>Hello <%= assigns.name %></h1> """ end

因为所有变量都是通过assigns来访问的,因此 phoenix 允许我们通过@来访问变量,减少键盘敲击。

<h1>Hello <%= @name %></h1>

<%= %>会求值其中的 Elixir 表达式并将结果插入此处,而<% %>只会求值其中的表达式但不会插入结果。

如果是在标签内部插值,则要使用{@xxx},比如设置标签属性。

<h1 class={@class}>...</h1>

对于<h1 class={@class}>,我们可以传递一个列表指定多个属性。

conn |> assign(:class, ["text-2xl", "font-medium"]) |> render(:home)

它会渲染为<h1 class="text-2xl font-medium">

而对于<h1 {@myattr}>,则可以传递一个关键字列表或 map 来设置属性。

conn |> assign(:myattr, [class: "btn", id: "mybtn"]) |> render(:home)

对于编写 html 模板,使用iffor语句来控制内容显示以及循环生成内容是很常见的需求,HEEx 当然也支持。

if语法有两种格式:

<%= if @expr do %> ... ... <% end %> <p :if={@expr}>...</p>

举个例子:

<%= if @show do %> <h1 class="text-2xl font-medium">Hello <%= @name %></h1> <% end %> <p :if={@show}>phoenix is so good!</p>

我们可以通过show变量来控制内容的显示:

conn |> assign(:show, true) |> assign(:name, "phoenix") |> render(:home)

for自然也有两种语法格式:

<%= for item <- @items do %> ... ... <% end %> <li :for={item <- @items}>...</li>

举个例子:

<ul> <%= for name <- @names do %> <li><%= name %></li> <% end %> </ul> <hr /> <ol> <li :for={name <- @names}><%= name %></li> </ol>

我们这样去渲染它:

conn |> assign(:names, ["elixir", "go", "haskell"]) |> render(:home)

组件是构建视图的积木,就像函数可以调用其他函数一样,HEEx 模板内也可以调用其他组件,以此来复用组件,构建视图。

调用函数组件只需要把 html 标签名换成函数名就可以了。

defmodule HelloWeb.PageView do use HelloWeb, :html def home(assigns) do ~H""" <HelloWeb.PageView.hello /> """ end def hello(assigns) do ~H""" <h1>Hello Phoenix!!</h1> """ end end

如果两个函数在同一个模块内,可以省略模块名,直接写成<.hello />,其他模块的函数组件需要通过模块名访问,当然如果我们提前通过import导入模块的话,也可以使用.函数名的简写形式。

defmodule HelloWeb.PageView do use HelloWeb, :html import HelloWeb.PageHTML def home(assigns) do ~H""" <.hello /> <.hello_phoenix /> <HelloWeb.PageHTML.hello_phoenix /> """ end def hello(assigns) do ~H""" <h1>Hello Phoenix!!</h1> """ end end

既然可以调用函数,那么要是能传递参数就更好了。参数可以通过 html 标签属性的方式传递,html 标签属性是一个键值对列表,刚好符合assigns参数的格式。举个例子:

def home(assigns) do ~H""" <.hello name="Tom" age="20" /> """ end def hello(assigns) do ~H""" <h1>I'm <%= @name %>, <%= @age %> years old.</h1> """ end

Html 语法要求属性的值必须是字符串,如果传递给hello组件的参数来自assigns或者是 Elixir 表达式,可以使用{},例如:

<.hello name={@name} age={@age} /> <.hello name={"Tom"} age={20} />

组件统一都接受assigns参数,看不出来组件究竟需要哪些参数,这对于函数调用者来说一点儿也不友好。我们可以使用attr/3宏来声明组件需要接受哪些参数,它的作用范围是其后的所有组件,直到下一个attr/3宏出现。除了提高可读性,编译器也可以据此提供一些有用的信息。例如:

attr :name, :string, required: true attr :age, :integer, required: true def hello(assigns) do ~H""" <h1>I'm <%= @name %>, <%= @age %> years old.</h1> """ end

attr/3有三个参数:attr(name, type, opts \\ [])

  • name原子类型的属性名,对于同一个组件,属性名不能同名,也不能和槽名相同(什么是槽我们稍后介绍)。
  • type原子类型的属性类型。
  • opts一个关键字列表选项,默认为[]

属性支持以下类型:

:any任意类型
:string字符串类型
:atom原子(包括true,falsenil)
:boolean布尔类型
:integer整数类型
:float浮点数类型
:list列表类型
:mapmap 类型
:global任何通用 HTML 属性,加上:global_prefixes定义的属性
struct module使用defstruct/1宏定义了结构体的模块

:global是一个比较特殊的类型,它会把标签中剩余的通用 html 属性全部提取出来变成一个 map 传递给这个字段。举个例子:

def home(assigns) do ~H""" <.notification message="this is message." class="bg-green-200" id="message" /> """ end attr :message, :string, required: true attr :rest, :global def notification(assigns) do IO.inspect(assigns.rest) ~H""" <span {@rest}><%= @message %></span> """ end

渲染结果如下:

<spanclass="bg-green-200"id="message">this is message.</span>

控制台还会打印出以下内容:

%{class: "bg-green-200", id: "message"}

因为:global类型的属性是 map 类型的,因此当给它设置默认值的时候,也要使用 map。默认值会合并到最终的结果中。

attr :rest, :global, default: %{class: "bg-blue-200"}

除了 html 通用属性,许多 js 库也会扩展 html 属性来实现特殊的功能。如果要将这些通用属性之外的属性也合并到:global字段中,我们就需要告诉attr/3宏把这些属性也涵盖进来,有两种方式。

第一种方式是直接在attr/3中使用include选项:

attr :rest, :global, include: ~w(my-data)

这里我们增加了my-data属性。它是一个列表,适合临时添加,单独修改。

第二种是在use Phoenix.Component时,通过global_prefixes选项来添加自定义属性前缀。phoenix 将库的导入都收归到了lib/项目_web.ex文件中,这里我们修改hello_web.ex中的html函数即可。

def html do quote do use Phoenix.Component, global_prefixes: ~w(my-) ... .. end end

这种方式只要匹配前缀就行,适合大规模统一设置,默认的前缀有phx-,这是 phoenix 实现动态视图的属性,在动态视图中我们再介绍。

最后要特别注意的就是:global属性的值是 phoenix 自动传递的,我们不可以显示手动传递。下面这种写法是不对的。

<.notification message="this is message." rest={%{"class" => "bg-green-200"}} />

我们继续来看attr/3宏的第三个参数,看看它支持的选项。

  • :required- 标记属性为必传,如果调用者没有传递该属性,编译器会给出警告。
  • :default- 属性默认值,它会合并到assigns中。
  • :exapmles- 一个属性值的示例列表,用于生成文档。
  • :values- 一个属性可接受值的列表,如果调用者传递的值不在该列表内,编译器会给出警告,它适用于枚举类型的属性。
  • :doc- 属性文档描述。

仅仅是传递参数还不够,我们还希望可以动态生成标签内的内容。比如我们有一个div元素块,里面既可以显示文本,也可以显示图片。换句话说,我们希望组件可以接受一个 html 片段,就像函数可以接受另一个函数做为参数。

html 片段也是通过assigns参数传递的,调用函数组件本身也是一个 html 标签,标签内的 html 片段通过assigns.inner_block访问,我们可以使用render_slot/1函数在组件内渲染 html 片段。phoenix 将它称为插槽(slot),举个例子:

def home(assigns) do ~H""" <.blue_div> <p>Hello, Phoenix!</p> </.blue_div> """ end def blue_div(assigns) do ~H""" <div class="bg-blue-200"> <%= render_slot(@inner_block) %> </div> """ end

示例中的inner_block对应着<p>Hello, Phoenix!</p>,它就像是一个匿名函数组件,叫做匿名插槽。如果我们用IO.inspect(assigns.inner_block)打印出inner_block,可以看到如下的输出:

[ %{ __slot__: :inner_block, inner_block: #Function<5.83536033/2 in HelloWeb.PageView.home/1> } ]

既然有匿名,那就可以命名,命名插槽语法为<:slot_name>...</:slot_name>,命名插槽也通过render_slot函数渲染。举个例子:

def home(assigns) do ~H""" <.blue_div> <p>Hello, Phoenix!</p> <:header><h1>title</h1></:header> </.blue_div> """ end def blue_div(assigns) do ~H""" <div class="bg-blue-200"> <%= render_slot(@header) %> <%= render_slot(@inner_block) %> </div> """ end

注意到前面我们用IO.inspect(assigns.inner_block)打印inner_block时,结果是一个列表,这就意味着插槽是可以同名的,同名插槽构成一个列表。

插槽本身也是一段 html 模板,自然也可以接受参数。就像函数参数分为形参和实参一样,定义函数的参数叫形参,调用函数的参数叫实参。插槽也一样,渲染插槽的参数叫实参,定义插槽的参数叫形参,形参通过:let属性来声明。举个例子:

def home(assigns) do ~H""" <.unordered_list :let={fruit} entries={~w(apples bananas cherries)}> I like <b><%= fruit %></b> ! </.unordered_list> """ end def unordered_list(assigns) do ~H""" <ul> <%= for entry <- @entries do %> <li><%= render_slot(@inner_block, entry) %></li> <% end %> </ul> """ end

它渲染的结果如下:

<ul> <li>I like <b>apples</b> !</li> <li>I like <b>bananas</b> !</li> <li>I like <b>cherries</b> !</li> </ul>

注意到我们在I like <b><%= fruit %></b> !中访问fruit时并没有使用@,这也就是说fruit并不是来自于assigns参数,而是来自:let={fruit}中的fruit。下面这张图可以清晰的看到参数的传递过程。

我们有attr/3宏用来声明组件需要哪些参数,相应的,我们有slot宏来声明组件需要哪些插槽。slot宏有两个分支:slot/2slot/3

slot(name, opts \\ []) slot(name, opts, block)
  • name- 原子类型的插槽名,对于同一个组件,插槽不能同名,也不能和attr属性同名。
  • opts- 关键字列表选项,默认为[]
  • block- 包含attr/3的代码块,默认为nil

插槽本身也是一个 HEEx 模板,类似一个匿名组件,也可以接受参数,自然也可以通过attr/3声明插槽可以接受哪些参数。举个例子:

slot :inner_block, required: true slot :header, required: true do attr :title, :string, required: true end

然而在slot中定义attr还是有一些限制的。首先是不能有默认值,其次只有命名插槽可以有attr代码块,也就是说:inner_block是不可以定义attr代码块的。

插槽选项只有两个:

  • required- 标记一个插槽是必须的,如果未提供,编译器会给出警告。
  • :doc- 插槽文档。

最后让我们来看一个稍微复杂点的例子:

def home(assigns) do ~H""" <.simple_table rows={[%{name: "Jane", age: "34"}, %{name: "Bob", age: "51"}]}> <:column :let={user} label="Name"> <%= user.name %> </:column> <:column :let={user} label="Age"> <%= user.age %> </:column> </.simple_table> """ end slot :column, doc: "Columns with column labels" do attr :label, :string, required: true, doc: "Column label" end attr :rows, :list, default: [] def simple_table(assigns) do ~H""" <table> <tr> <%= for col <- @column do %> <th><%= col.label %></th> <% end %> <%= for row <- @rows do %> <tr> <%= for col <- @column do %> <td><%= render_slot(col, row) %></td> <% end %> </tr> <% end %> </tr> </table> """ end

它会渲染这样一个表格:

<table><tr><th>Name</th><th>Age</th></tr><tr><td>Jane</td><td>34</td></tr><tr><td>Bob</td><td>51</td></tr></table>

<.simple_table>标签内我们定义了两个同名插槽:column,前面我们就说过,插槽是一个列表。而在simple_table函数中,我们通过for循环遍历插槽来渲染表格的每一列。

lib/项目_web/components/core_components.ex文件中,有很多 phoenix 预先为我们提供的函数组件,同时也是非常好的学习示例。

最后我们再来总结一些 HEEx 中的那些符号。

符号说明示例
@访问assigns中的数据,相当于assigns.@name
<%= %>求值 Elixir 表达式并插入模板中<%= @name %>
<% %>求值 Elixir 表达式但不插入模板中<% end %>
{}在标签内插值<p class={@class}>...</p>
.调用函数组件,<MyView.hello />的简写形式<.hello />
:定义插槽<:header>...</:header>
:let定义插槽形参<p :let={name}><%= name %></p>
if条件表达式<%= if @admin do %> ... <% end %>
<p :if={@admin}>...</p>
for循环表达式<%= for item <- @items do %>...<% end %>
<li :for={item <- @items}>...</li>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 8:08:51

自动化工具选型紧急预警:Open-AutoGLM与Power Automate适用边界大揭秘

第一章&#xff1a;自动化工具选型紧急预警&#xff1a;核心矛盾与行业背景在当前DevOps与持续交付高速演进的背景下&#xff0c;自动化工具链的选型已成为企业技术决策中的关键环节。然而&#xff0c;工具泛滥与架构错配正引发一系列“选型危机”——团队在追求效率提升的同时…

作者头像 李华
网站建设 2026/4/18 5:43:24

定制 CentOS7 ISO 的最佳实践

背景&#xff1a; 随着业务形态&#xff0c;业务种类的不断拓展&#xff0c;使用公版 ISO 安装树 与 ISO 镜像存在较大的不便与弊端&#xff1b; 存在安装配置不统一&#xff0c;溯源难等特点&#xff1b;给后期的系统运维带来很高的复杂度 目的&#xff1a; 解决业务需要多种不…

作者头像 李华