原文放在 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