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