Ruby on Rails 中文 Wiki
ERB和capture (修订 #2)

作者: liusong1111, 原文:http://www.javaeye.com/post/298191

回顾一个熟悉的场景:
在某个layout文件里(如layouts/application.rhtml):

<html>   
  <head>   
  </head>   
  <body>   
    菜单、布局等的通用html   
    <%= yield %>   
  </body>   
</html>   

<= yield>是个占位符,当浏览/users/index时,它就被 index.rhtml(原始页面) 生成的内容替换了。

可以说,rails的layout与java的sitemesh在功能、运行机制上几乎一模一样,原始页面 + 模板页面 => 输出结果。sitemsh是用taglib做占位符。 细心的看官说,等等,人家sitemesh很体贴,把原始页面的head和body部份分别解析出来了,可以在模板的不同位置分别引用:

<html>   
  <head>   
    模板通用的head   
    <decorator:head/>   
  </head>   
  <body>   
    模板通用的html   
    <decorator:body/>   
  </body>   
</html>   

而rails的<= yield>,代表的是整个的原始页面内容,咋达到sitemesh上面的效果啊?

别急,再想一个稍微复杂通用的需求:如果原始页面提供了侧栏菜单(side_menu)的html,则在模板指定位置显示出来,否则不显示。
这时,仅能解析出head和body就不够了,sitemesh是怎么做的呢?
原始页面:
Java代码
<content tag="side_menu"> 侧栏菜单的html
</content>

模板页:

<decorator:usePage id="thePage" />   
<body>   
<% if(thePage.getProperty("page.side_menu")!=null) { %>      
  <decorator:getProperty property="page.side_menu"/>   
<% } %>   
</body>   

OK,干净利落,使用了个怪异的content标签,鼓捣几下子sitemesh API/taglib就搞定了。

论到rails layout出手了。
原始页面:

<% content_for("side_menu") do %>   
  侧栏菜单的html   
<% end %>   

模板页:

<body>   
<%= yield :side_menu %>   
</body>   

嗯嗯,又一个干净利索,
用个接收block的helper方法,加上模板的yield,搞定。
可惜的是它不能自动解析head/body,还好如上炮制也不麻烦。

因此,sitemesh和layout都有能力在原始页里定义若干个片段,供模板引用。

h2.台后的故事

关键点有两处:content_for这个helper方法,模板中使用的yield。
简要的说,content_for将block执行的结果存在指定的变量里,模板yield时取到替换。

在继续往下看之前,强烈建议先瞅下俺以前的两个贴:
http://www.javaeye.com/post/268222 (ERB机理)
http://www.javaeye.com/post/268223 (template render机理)

content_for/capture

http://api.rubyonrails.com/classes/ActionView/Helpers/CaptureHelper.html
130行的源码,去掉一半以上的rdoc、空行,还有多少?(42行,原谅俺这个无聊滴人吧)
直接查看源码吧,魔术尽在其中:( actionpack/lib/action_view/helpers/capture_helper.rb )

def content_for(name, content = nil, &block)   
  eval "@content_for_#{name} = (@content_for_#{name} || '') + capture(&block)"   
end   

content_for调用capture方法,对传入的block求值,并把返回的结果存在”@content_for_”开头的成员变量里(对上面例子就是@content_for_side_menu)。
如果没指定content_for的name,就是@content_for_layout。 (它那个参数content=nil没用到啊,为了向下兼容?)
请注意content_for是Action View?::Helpers::Capture Helper模块中的方法,这个模块被默认引入到Action View?::Base,content_for在template中执行,生成的成员变量自然是template的。

我们深入瞧瞧capture是怎么实现的:

def capture(*args, &block)   
  # execute the block   
  begin   
    buffer = eval("_erbout", block.binding)   
  rescue   
    buffer = nil   
  end   

  if buffer.nil?   
    capture_block(*args, &block)   
  else   
    capture_erb_with_buffer(buffer, *args, &block)   
  end   
end   

如果传入的block上下文里有_erbout这个局部变量,就执行capture_erb_with_buffer,否则执行capture_block。
http://www.javaeye.com/post/268222 (ERB机理) 分析过,打印erb.src得知, erb的执行其实是向局部变量_erbout(String类型)中输出结果。
因此,在erb环境下执行capture会到capture_erb_with_buffer,在独立环境下到capture_block。

先看简单的capture_block:

def capture_block(*args, &block)   
  block.call(*args)   
end   

返回block的执行结果,没的说。

再看capture_erb_with_buffer:

def capture_erb_with_buffer(buffer, *args, &block)   
  pos = buffer.length   
  block.call(*args)   

  # extract the block    
  data = buffer[pos..-1]   

  # replace it in the original with empty string   
  buffer[pos..-1] = ''   

  data   
end   

咳,先记下_erbout的当前位置,调用block,把后来生成的那块东东拿出来作为返回值,并把原始位置以后的内容清除,免得影响后面,hack亚

至此,capture的手段就一清二楚了,看到还有人这么玩
http://wiki.rubyonrails.org/rails/pages/Nested

<% pre_content = _erbout.dup %>   

<%= "stuff" %>   
<%= "more stuff" %>   

<%   
post_content = _erbout.dup   

_erbout = pre_content   

post_content.slice!(pre_content)   
%>   

<p><%= post_content %></p>   

layout的yield

没错,在layout中可以直接<= @content_for_side_menu>,不过已经deprecated了,标准用法就是<= yield :side_menu>。

先看layout是如何render的,查看源码:actionpack/lib/action_controller/layout.rb ,瓦瓦,前几行就是传说中的经典代码(DHH在接受《超越Java》作者Bruce采访时谈到”为何AOP在Ruby中没有被采用”,摆的就是这段代码。):

module ActionController    
  module Layout    
    def self.included(base)   
      base.class_eval do   
        alias_method :render_with_no_layout, :render   
        alias_method :render, :render_with_a_layout   
      end   
    end   
  end   
end   

查看action_controller.rb源码得知 Action Controller?::Base类 include了Action Controller?::Layout。
结合上面那段代码得知,当Action Controller?::Layout这个module被include时,会把所在类的原始的render方法改名为render_with_no_layout,并把它自己的render_with_a_layout改名成render,从而达到偷梁换柱不可告人的目的。这一招确实巧妙,对于template的使用者来说,只需知道template有render方法,不必知道layout的存在,layout通过mixin的方式对输出进行拦截-注入的(把template的render偷偷换成了render_with_a_layout,从而先调用原始的render将输出存储在成员变量里,又在同一个template变量下执行layout的render,从而将两者组装起来)。

render_with_a_layout方法的大致实现如下:

def render_with_a_layout(参数)   
  if apply_layout?(参数) # 如果需要套用模板页   
    layout = pick_layout(参数) # 决定用哪个模板   
    content_for_layout = render_with_no_layout(参数) # 调用原始的render方法,并用变量存储输出结果   
    add_variables_to_assigns # 复制成员变量   
    @template.instance_variable_set("@content_for_layout", content_for_layout) # 将原始输出存在成员变量@content_for_layout里   
    render_text(@template.render_file(layout)) # 执行模板页   
  else   
    render_with_no_layout(参数)    
  end   
end   

我们看到最后一步是对layout调用render_file,我在 http://www.javaeye.com/post/268223 (template render机理)中描述的第十步是不准确的:
引用
十、 compile_and_render_template中调send(method_name),从而调用到上面生成的方法_run_accounts_index_rhtml,返回ERB执行结果。

实际上它的真实代码是:

send(method_name, local_assigns) do |*name|   
  instance_variable_get "@content_for_#{name.first || 'layout'}"   
end   

send方法多了local_assigns(这个我们不管)和block参数,这个block就是layout中yield的接收器,还记得我们在layout中怎么用yield吗?
<=yield> 取到了 @content_for_layout
<=yield :side_menu> 取到了@content_for_side_menu

yeah!~

它的威力

  • nested layouts, 如这个需求 http://www.javaeye.com/topic/83809
  • 对页面片段的DSL,如portlet DSL(结合Active Resource是不是更有前途呢)?
    Java代码
    < portlet :title=>‘xxx’ do >
    portlet HTML
    < end >
  • 深入挖掘erb,实现专用模板、模板的模板…

约束:
http://api.rubyonrails.com/classes/ActionView/Helpers/CaptureHelper.html 说:
引用

NOTE: Beware that content_for is ignored in caches. So you shouldn‘t use it for elements that are going to be fragment cached.

参考资料:
Nested Layout:
http://wiki.rubyonrails.org/rails/pages/Nested

nested-layouts plugin(svn貌似连不上?):
http://rubyforge.org/projects/nested-layouts/

“Content for Whom?”
http://errtheblog.com/post/28