今天我们来学习 Phoenix 的视图和组件,我们通过它们来渲染前端页面。其实严格来说,Phoenix 中并没有视图这个东西,视图只不过是一个组件(Component)。没错,组件既是视图,也是用来构建视图的积木,这也是”组件”这个名称的由来。
这里我们将要介绍的是 Phoenix 中的静态视图,因为 Phoenix 中还有动态视图,我们在《Phoenix 动态视图与动态组件》中再介绍它。虽然 Phoenix 中并没有视图,但是确实有动态视图。
接下来我们将不再区分视图和组件这两个概念,因为它们是同一个东西。但为了描述方便会混用这两个名词,希望不会引起误解。
组件的本质
组件本质上只是一个返回 HEEx 模板的 Elixir 函数,函数接受一个名叫assigns的参数,参数中是要填入模板中的数据。虽然函数参数名称是可以任意的,但是在这里参数名叫做assigns是一种人文约定,Phoenix 文档中也是直接用 assigns 来指代这个参数。
def index(assigns) do ~H""" <H1>Hello Phoenix!</H1> """ endHEEx = 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 的名称和视图类型来决定去哪里寻找视图。控制器模块通常会命名为SomeController,Some是控制器名称,那么 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.heex,some既是文件名,也是最后编译出的视图函数名。当然只有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 模板,使用if和for语句来控制内容显示以及循环生成内容是很常见的需求,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> """ endHtml 语法要求属性的值必须是字符串,如果传递给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> """ endattr/3有三个参数:attr(name, type, opts \\ [])。
name原子类型的属性名,对于同一个组件,属性名不能同名,也不能和槽名相同(什么是槽我们稍后介绍)。type原子类型的属性类型。opts一个关键字列表选项,默认为[]。
属性支持以下类型:
:any | 任意类型 |
|---|---|
:string | 字符串类型 |
:atom | 原子(包括true,false和nil) |
:boolean | 布尔类型 |
:integer | 整数类型 |
:float | 浮点数类型 |
:list | 列表类型 |
:map | map 类型 |
: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/2和slot/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> |