原文放在 SegmentFault 。
最近在学习用 Elixir 的 MVC 框架 Phoenix 写一个 Chatroom。有一个问题是在 channel 中渲染模板,虽然我用 Phoenix.View.render
方法顺利解决了。但这让我开始思考另外几个问题:
render
方法?在研究有哪些 render
方法之前,我们先看看 Phoenix 的几个使用 render
的场景。
在 controller 中使用 render
:
def foo(conn, _params) do
render conn, "foo.html"
end
在 template 中使用 render
:
<!-- 渲染同一视图中的另一个模板 -->
<%= render "foo.html" %>
<%= render "foo.html", some_model: some_model %>
<!-- 渲染另外一个视图中的模板 -->
<%= render YourApp.OtherView, "bar.html" %>
<%= render YourApp.OtherView, "bar.html", some_model: some_model %>
在其他地方使用 render
,多用于 channel 或者 iex 调试:
Phoenix.View.render(YourApp.CustomView, "foo.html")
除了最后一个例子可以清楚地看到 render
方法来自 Phoenix.View
模块之外,其他几个地方的 render
都不知道出处。这些 render
来自哪里?
如果查一下 Phoenix 的文档,可以发现两个模块定义了 render
方法,它们是 Phoenix.Controller
和 Phoenix.View
,我们可以猜测前者为所有 controller 提供 render
,后者为所有 view 和 template 提供 render
。不过这还需要验证一下。
先看看 controller,Phoenix 的 controller 定义非常简单:
defmodule YourApp.SomeController do
use YourApp.Web, :controller
end
显然一个空的模块是没有实现 render
方法的,那关键就在 YourApp.Web
里。其实这个模块就在项目的 web/web.ex
文件里。大概像下面这样:
defmodule YourApp.Web do
# def model ...
def controller do
quote do
use Phoenix.Controller
alias YourApp.Repo
import Ecto
import Ecto.Query, only: [from: 1, from: 2]
import YourApp.Router.Helpers
import YourApp.Gettext
end
end
# def view ...
end
YourApp.Web
模块的职责是为其他模块加入一些通用的功能,基本上就是执行一些 alias, import, use。
controller 中的 use
那一行代码会调用 YourApp.Web
的 controller
方法,这里我们看到执行了 use Phoenix.Controller
。
先大致解释一下 use
。它是一个 Elixir 的 macro,一般用来为模块附加额外的特性。当模块 A use 模块 B 时,B 的 __using__
回调会被调用,我们可以在里面写代码为模块 A 附加一些东西。
Phoenix.Controller
的 __using__
大概如下所示,看不懂语法和 API 不要紧,明白意思就行:
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
import Phoenix.Controller
# ...
end
end
这里我们可以看到 import Phoenix.Controller
,结合开头 controller 中的 use YourApp.Web, :controller
,其实 Phoenix.Controller
用这种形式被 import
到了所有 controller 中,这意味着 Phoenix.Controller
的所有公有方法都可以在 controller 内部使用,其中也包括 render
方法。注意这是 内部使用 ,像 YourApp.SomeController.render
这种调用是不可行的。
在 template 中调用 <%= render %>
应该属于哪个模块的呢?要回答这个问题,我们得先了解下 view 和 template 的关系。
Phoenix 的视图层分为两个部分:view 和 template,view 是一个 Elixir 模块,template 是一个 EEx 模板文件。一个 view 管理多个 template。举个例子,一个 YourApp.RoomView
下面可以定义 index.html
, show.html
等几个不同 template。要渲染 show.html
,我们可以用 Phoenix.View.render(YourApp.RoomView, "show.html")
。
template 本质上是一个函数,接收动态数据作为参数,组合静态内容并返回结果。对服务器端渲染而言,结果大多是一个字符串。我们经常使用的模板文件,实际上只是把静态内容存放在文件系统里而已。Phoenix 在编译期间会把 template 编译成函数放在 view 中。模板渲染最终会调用 view 中相应的函数。因此在 template 里调用的方法全都来自于 view。template 里的 <%= render %>
等于调用相对应的 view 的 render
方法。
一个典型的 view 定义如下:
defmodule YourApp.RoomView do
use YourApp.Web, :view
end
跟 controller 非常类似的代码。具体源码追溯过程我就不写了,通过同样追溯方法我们最终可以在 Phoenix.View
的 __using__
中看到同样的 import
,如下所示:
defmacro __using__(options) do
# ...
quote do
import Phoenix.View
use Phoenix.Template, root: ...
end
end
看来 view 中的 render
方法应该来自于 Phoenix.View
。不过先别下结论,我们来对比一下方法签名。Phoenix.View.render
的方法签名是 render(module, template, assigns)
,注意其中有 三个参数,并且都是不能省略的 。
再回顾一下 template 中的 render
:
<!-- 渲染同一视图中的另一个模板 -->
<%= render "foo.html" %>
<%= render "foo.html", some_model: some_model %>
<!-- 渲染另外一个视图中的模板 -->
<%= render YourApp.OtherView, "bar.html" %>
<%= render YourApp.OtherView, "bar.html", some_model: some_model %>
可见这个 render
可以接受 一个到三个参数 。这跟 Phoenix.View
的 render
明显不一样。这是怎么回事?
答案在 Phoenix.Template
中。回顾一下上面的代码,Phoenix.View
的 __using__
中还有一行 use Phoenix.Template
,让我们看看 Phoenix.Template
的 __using__
:
defmacro __using__(options) do
quote do
@doc """
Renders the given template locally.
"""
def render(template, assigns \\ ${})
def render(template, assigns) when is_list(assigns) do
render(template, Enum.into(assigns, %{}))
end
def render(module, template) when is_atom(module) do
Phoenix.View.render(module, template, %{})
end
# ...
end
end
它居然为 view 定义了 render
方法!这下事情明白了,当我们在 template 中使用 render
时,如果传入一个或两个参数,其实我们调用的是 Phoenix.Template
为 view 生成的 render
方法;如果传入三个参数,则是调用 Phoenix.View
中的 render
方法。因为这个 render
方法是在 __using__
中定义的,所以 Phoenix 文档是查不到的。
注:Elixir 允许为一个方法定义不同的变种,这些方法并不会互相覆盖。当方法被调用时 Elixir 会通过 pattern match 和 guard 自动去寻找最匹配的方法执行,合理利用可以省不少 if/else
。
有一点值得提醒,跟 Phoenix.View
的 import
不同,Phoenix.Template
是为 view 动态地定义方法(其实是编译期做的),而且这个方法是公有的。这意味着我们可以在其他模块里调用 YourApp.SomeView.render
去渲染 template。
这里想说的有两种,一是在 view 中调用 render
,二是在其他模块中调用 Phoenix.View.render
。
在 view 里面,Phoenix.View
的所有公有方法都可以使用。Phoenix.Template
给了 view 三个 render
方法,但没有把它自己 import
进去,所以它的方法是不能在 view 里直接用的。不过大部分情况下 Phoenix.View
提供的方法已经足够了。
在其他模块中,如果要渲染某个 template,我们其实有两种办法,它们是等价的:
Phoenix.View.render(YourApp.SomeView, "some_template.html")
YourApp.SomeView.render("some_template.html")
虽然第二种方式更简洁,但第一种方式,也就是 Phoenix.View.render
比较推荐,因为它可以设置 layout,而且也算是 view 渲染的标准入口函数。比如 Phoenix.Controller
的 render
内部调用的就是它,而且 Phoenix.View
的其他几个方法比如 render_to_iodata
,render_to_string
调用的也是它。源码追溯过程我就不放上来了,有兴趣的可以自己挖掘。
另外 Phoenix.View.render
渲染某个 view 的 template 的时候,它会在内部调用 view 的 render
方法(Phoenix.Template
提供的方法)。
现在我们可以回答开篇的几个问题作为总结:
render
方法?Phoenix 文档中可以查到两个 render
方法,但实际上有三个 render
方法,前两个在 Phoenix.Controller
和 Phoenix.View
中定义并被 import
到相应的模块中使用,第三个在 Phoenix.Template
的 __using__
中被定义,并在编译时附加给 view。
Phoenix.Controller.render
在 controller 中使用,它关注的是内容协商,即根据客户端的要求来决定渲染类型(HTML/JSON/XML 等)。具体渲染细节会代理给 Phoenix.View.render
去处理。
Phoenix.View.render
可以算是通用的 view 渲染入口,既用在 view 和 template 中,也被其他需要渲染 view 的模块调用。比如 controller。它处理视图层的渲染细节。
Phoenix.Template
提供的 render
是作为 view 的 render
方法的补充,让 view 的 render
方法变得更灵活多变。
Phoenix.Controller.render
内部会调用 Phoenix.View.render
,Phoenix.View.render
内部会调用传入的 view 模块的 render
方法,而这个方法是 Phoenix.Template
为每个 view 生成的。
Phoenix.Controller.render Phoenix.View.render Phoenix.Template Phoenix Guide: Views Content negotiation