2017-04-24

Railsのviewとlayoutの評価順についてコードを読んで納得した

Railsのviewとlayoutの評価順序はview => layoutとなっており、viewで定義したcontext_forやprovideはlayout側でyieldすることで利用できるようになっていたり、layout側で引数無しのyieldをすれば評価後のviewのレンダリングが出来るようになっています。

今回、この挙動をコードレベルで理解したかったので関連する部分のコードリーディングをしてみました。

Railsのバージョンは5.0.1です。

コードリーディング

レンダリング時にはActionView::TemplateRenderer.new(@lookup_context).render(context, options)が呼び出されます。

module ActionView
  class TemplateRenderer < AbstractRenderer #:nodoc:
    def render(context, options)
      @view    = context
      @details = extract_details(options)
      template = determine_template(options)

      prepend_formats(template.formats)

      @lookup_context.rendered_format ||= (template.formats.first || formats.first)
      render_template(template, options[:layout], options[:locals])
    end
    # ...

render_template、render_with_layoutメソッドが肝になります。

# Renders the given template. A string representing the layout can be
# supplied as well.
def render_template(template, layout_name = nil, locals = nil) #:nodoc:
  view, locals = @view, locals || {}

  render_with_layout(layout_name, locals) do |layout|
    instrument(:template, :identifier => template.identifier, :layout => layout.try(:virtual_path)) do
      template.render(view, locals) { |*name| view._layout_for(*name) }
    end
  end
end

def render_with_layout(path, locals) #:nodoc:
  layout  = path && find_layout(path, locals.keys, [formats.first])
  content = yield(layout)

  if layout
    view = @view
    view.view_flow.set(:layout, content)
    layout.render(view, locals){ |*name| view._layout_for(*name) }
  else
    content
  end
end

render_with_layoutのyield(layout)でブロックの内容を評価していますが、このブロックはview部分のレンダリングになります。引数のlayoutはActionView::Templateのインスタンスになります。ActionView::Template#renderは以下のように定義されています。

def render(view, locals, buffer=nil, &block)
  instrument("!render_template".freeze) do
    compile!(view)
    view.send(method_name, locals, buffer, &block)
  end
rescue => e
  handle_render_error(view, e)
end

compile!メソッドが重要で、HTMLを出力するメソッドを動的に定義しています。

# Compile a template. This method ensures a template is compiled
# just once and removes the source after it is compiled.
def compile!(view) #:nodoc:
  return if @compiled

  # Templates can be used concurrently in threaded environments
  # so compilation and any instance variable modification must
  # be synchronized
  @compile_mutex.synchronize do
    # Any thread holding this lock will be compiling the template needed
    # by the threads waiting. So re-check the @compiled flag to avoid
    # re-compilation
    return if @compiled

    if view.is_a?(ActionView::CompiledTemplates)
      mod = ActionView::CompiledTemplates
    else
      mod = view.singleton_class
    end

    instrument("!compile_template") do
      compile(mod)
    end

    # Just discard the source if we have a virtual path. This
    # means we can get the template back.
    @source = nil if @virtual_path
    @compiled = true
  end
end

ここが動的に定義している部分↓#{code}で色々やってます。

def compile(mod) #:nodoc:
  ...
  source = <<-end_src
    def #{method_name}(local_assigns, output_buffer)
      _old_virtual_path, @virtual_path = @virtual_path, #{@virtual_path.inspect};_old_output_buffer = @output_buffer;#{locals_code};#{code}
    ensure
      @virtual_path, @output_buffer = _old_virtual_path, _old_output_buffer
    end
  end_src
  ...
  mod.module_eval(source, identifier, 0)
end

実際にはこのようなメソッドが定義されます。

def _app_views_home_index_html_erb__1617539985954536741_70330776361600(local_assigns, output_buffer)
  _old_virtual_path, @virtual_path = @virtual_path, "home/index";
   _old_output_buffer = @output_buffer;;
  @output_buffer = output_buffer || ActionView::OutputBuffer.new;
  @output_buffer.safe_append='<h1>Home#index</h1>\n<div>'.freeze; 
   @search.errors.full_messages.each do |msg|
    @output_buffer.safe_append='    <p>'.freeze;
    @output_buffer.append=( msg );
    @output_buffer.safe_append='</p>'.freeze;
  end

  content_for :hoge do
    @output_buffer.safe_append='    '.freeze;
    @output_buffer.append=( link_to root_path, 'hoge');
    @output_buffer.safe_append='\n'.freeze;   
  end
  @output_buffer.append=( yield :hoge );

  @output_buffer.safe_append='  '.freeze;
  @output_buffer.append=  form_for @search, url: root_path, method: :get do |f| 
    @output_buffer.safe_append='\n'.freeze;
    @output_buffer.append=( f.text_field :created_at_from, class: 'datepicker' );
    @output_buffer.safe_append='\n'.freeze;
    @output_buffer.append=( f.text_field :created_at_to, class: 'datepicker' );
    @output_buffer.safe_append='\n'.freeze;
    @output_buffer.append=( f.submit '送信' );
    @output_buffer.safe_append='\n'.freeze;   
  end
  @output_buffer.safe_append='</div>'.freeze;
  @output_buffer.to_s
ensure
  @virtual_path, @output_buffer = _old_virtual_path, _old_output_buffer
end

<%= %>で書いたものはappend、HTMLで書いたものはsafe_appendされるように変換されている感じがします(補足の項を参照)

view.sendでは定義した動的メソッドを呼び出します。viewやlayoutにyieldが含まれていればブロック内の{ |*name| view._layout_for(*name) }が評価されます。この時点でview部分のレンダリングが完了しています。

layoutの評価箇所を再掲します↓

if layout
  view = @view
  view.view_flow.set(:layout, content)
  layout.render(view, locals){ |*name| view._layout_for(*name) }
else
  content
end

layoutがあればview.view_flow.set(:layout, content)が呼ばれます。

# Called by each renderer object to set the layout contents.
def set(key, value)
  @content[key] = ActiveSupport::SafeBuffer.new(value)
end

getは@contentからコンテンツを取得するメソッドになっています。layoutファイル内でyieldを実行するとブロックが評価されることになり、view._layout_for(*name)が呼ばれます。

def _layout_for(name=nil)
  name ||= :layout
  view_flow.get(name).html_safe
end

指定がなければ:layout、つまりviewでレンダリングした部分が呼ばれます。指定があればcontent_forやprovideで定義したコンテンツが返されます。これがerbで使っているyieldの正体です。

ちなみにcontent_forはこんな感じで、yieldした結果を@view_flow.appendしています。

def content_for(name, content = nil, options = {}, &block)
  if content || block_given?
    if block_given?
      options = content if content
      content = capture(&block)
    end
    if content
      options[:flush] ? @view_flow.set(name, content) : @view_flow.append(name, content)
    end
    nil
  else
    @view_flow.get(name).presence
  end
end

provideも@view_flow.appendでコンテンツを追加しています。

def provide(name, content = nil, &block)
  content = capture(&block) if block_given?
  result = @view_flow.append!(name, content) if content
  result unless content
end

ということでview => layoutの順に評価され、評価されたviewはcontent_forやprovideで定義したコンテンツと同様のハッシュの中に格納され、layoutファイル内ではyieldでコンテンツを取得している、ということになります。

補足

動的メソッドのコード内にあるappendとsafe_appendですが、ActionView::Template::Handlers::Erubisクラスで定義されていました。

def add_text(src, text)
  return if text.empty?

  if text == "\n"
    @newline_pending += 1
  else
    src << "@output_buffer.safe_append='"
    src << "\n" * @newline_pending if @newline_pending > 0
    src << escape_text(text)
    src << "'.freeze;"

    @newline_pending = 0
  end
end

BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/

def add_expr_literal(src, code)
  flush_newline_if_pending(src)
  if code =~ BLOCK_EXPR
    src << '@output_buffer.append= ' << code
  else
    src << '@output_buffer.append=(' << code << ');'
  end
end
このエントリーをはてなブックマークに追加