layoutのyieldが何をしているのかという話

共通レイアウトファイルからyieldするときの挙動を検証してみました。
これが分かったところで、特に何か応用できるわけでもないと思いますが
ブラックボックスの気持ち悪さを少し解消できました。

rails version : 5.2.4.5

動作の検証

ベース

まず適当にこんな感じでページを作ります。

app/views/hoge/index.html.slim
h1 Page Title

p hogehoge
p fugafuga

br
  
= link_to 'Blogs', blogs_path
app/views/layout/application.html.slim
doctype html
html
  head
    [ ... ]
  body
    = yield

こんな感じのページになります。
f:id:xaci:20210616223057p:plain

変更

viewでprovideを使ってコンテンツブロックを作って、共通レイアウトで名前付きのyieldを呼び出してみます。

app/views/hoge/index.html.slim
- # bodyという名前のコンテンツブロックを作成
- provide :body
  h3 provide :body1

h1 Page Title

p hogehoge
p fugafuga

- # titleという名前のコンテンツブロックを作成
- provide :title
  h2 provide :title

- # bodyという名前のコンテンツブロックを作成
- provide :body
  h3 provide :body2

br

= link_to 'Blogs', blogs_path
app/views/layout/application.html.slim
doctype html
html
  head
    [ ... ]
  body
    p ------------------------------
    p yield :title
    = yield :title
    p ------------------------------
    p yield :body
    = yield :body
    p ------------------------------
    p yield
    = yield
    p ------------------------------

出力ページはこの様になります。
f:id:xaci:20210616223620p:plain

同じ名前でprovideしたところは定義順に出力され、上書きされない。
ということがわかると思います。

どういう実装になっているのか

ということで処理を詳しくみていきます。

- provide :body
  h3 provide :body1

provideは以下の様に定義されます。

actionview/lib/action_view/helpers/capture_helper.rb
def provide(name, content = nil, &block)
  content = capture(&block) if block_given?
  result = @view_flow.append!(name, content) if content
  result unless content
end

&block : do~end の中身をprocオブジェクトとして格納しています。
capture(&block) : &blockの中身をstringで返します。

@view_flow.append!(name, content)
# @view_flow.append!(:body, <h3>provide :body1</h3>

この引数が二つあるview_flow.appendは以下のようにオーバーライドされています。

actionview/lib/action_view/flows.rb
append(key , value)do
  @content[key] << value
end

これによって@view_flowには以下の様な形で値が格納されます。

pry() > @view_flow
=> <ActionView::OutputFlow:0x00007ff5454f2d50 @content={:body=>"<h3>provide :body1</h3>"}>

同じ名前でprovideした時に定義順に@content[key]に格納されていく事がappendのコードから分かると思います。


actionview/lib/action_view/renderer/template_renderer.rb
def render_with_layout(path, locals)
  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

index.html.slim全体はyield(layout)で取得されて、:layoutというkeyでview_flowにセットされます。

ここのyieldは詳しくみませんが、index.html.slimをrenderしています。

yield(layout)で取得している途中でprovideのブロックを@view_flowに格納してく感じです。

※この辺りのコードリーディングをしている時にyieldは遅延評価だと理解しました。

ちなみにlayoutは上書きなのでviewテンプレート内でprovide :layout {}を定義しても消滅します。

 pry()> view.view_flow
=> #<ActionView::OutputFlow:0x00007ff546a780a8
 @content=
  {:body=>"<h3>provide :body1</h3><h3>provide :body2</h3>",
   :title=>"<h2>provide :title</h2>",
   :layout=>"<h1>Page Title</h1><p>hogehoge</p><p>fugafuga</p><br /><a href=\"/blogs\">Blogs</a>"}>

layout.render(view, locals)でapplication.html.slimをrenderしています。

つまり共通レイアウトの中のyield :nameview._layout_for(*name)を実行します。

actionview/lib/action_view/helpers/rendering_helper.rb
def _layout_for(*args, &block)
  name = args.first

  if block && !name.is_a?(Symbol)
    capture(*args, &block)
  else
    super
  end
end

layout_forにブロックは渡されていないのでsuper classのlayout_forを呼びます。

actionview/lib/action_view/context.rb
def _layout_for(name = nil)
  name ||= :layout
  view_flow.get(name).html_safe
end

こいつがレイアウトファイルにおけるyieldの実体です。

名前をつけないyieldはyield(:layout)と同じという事ですね。

実際に共通レイアウトからview_flow.get(name).html_safeを呼んで、yieldと同じ出力を得ることができました。

参考

GitHub - rails/rails: Ruby on Rails

Disassembling Rails — Template Rendering (2) | by Stan Lo | Ruby Inside | Medium

ActionView - APIdock