はじめに
「Redmine view customize pluginでチケット編集画面のボタンをカスタマイズする」という記事を書いたのですが、名前付きcontent_forの記述箇所とレンダリング後の表示順の関係がいまいち謎だったので、実装と処理を追ってみました。試したRailsのバージョンは7.2.2.1 と 8.0.2 です。
最近のRailsでは、ガイドによると、
新しく生成したアプリケーションには、app/views/layouts/application.html.erbテンプレートの<head>要素内に<%= yield :head %>が含まれます。
とあるので、このcontent_for(:head)を取り上げていきます。
なお、名前付きcontent_forの第2引数にテキストやヘルパーメソッドを渡すこともできますが、処理内容的には基本同じと思われるので、本記事ではまとめて「ブロック」と表記します。
結論
content_for(:head)は、記述される位置により、以下の順序で表示されるようです。
- app/views/[controller]/[action].html.erb 内のcontent_for(:head)ブロック
1.1. 部分テンプレート(partial)内などで、複数のcontent_for(:head)ブロックが記述されている場合、上から順に各content_for(:head)ブロックの結果が追加される
1.2. content_for(:head)ブロック内で入れ子になってcontent_for(:head)ブロックが呼び出された場合、子のcontent_for(:head)ブロックの結果がまず追加され、その後に親のcontent_for(:head)ブロックの結果が追加される(ソースのみかけ上の記述順と逆になる) - レイアウト内で、
<%= yield :head %>より前に記述されているcontent_for(:head)ブロック(ヘルパーメソッド内の記述など)の結果が追加される -
<%= yield :head %>の位置に、これまでに追加されてきた内容が表示される - ⚠️ レイアウト内の
<%= yield :head %>より後ろで呼び出されたcontent_for(:head)ブロックは 表示されない
検証用サンプルアプリ
サンプルアプリとして、以下のような簡単なものを作って動作を確かめてみます。
新規Railsアプリ(test_app)の作成
$ rails new test_app # --skip-bundle 付けてゴニョゴニョは省略
$ cd test_app
welcomeコントローラを作成
$ bin/rails g controller welcome index
ヘルパーメソッド(app/helpers/application_helper.rb)として、content_for(:head)を呼び出しつつ、戻り値も返すメソッドを定義します。
module ApplicationHelper
def helper_method(str)
content_for(:head) do
raw("<!-- helper_method[#{str}] content_for(:head) block -->") + "\n"
end
return raw("<!-- helper_method[#{str}] return text -->")
end
end
レイアウトファイル(app/views/layouts/application.html.erb)では、<%= yield :head %>の前後でもヘルパーメソッドを呼び出すようにします。
@@ -7,8 +7,15 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
+ <% basename = "layouts/#{File.basename(__FILE__)}" %>
+ <%= helper_method("#{basename}#before yield(:head)") %>
+
+ <!-- BEGIN yield :head -->
<%= yield :head %>
+ <!-- END yield :head -->
+ <%= helper_method("#{basename}#after yield(:head)") %>
+
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
welcome#index のビューファイル(app/views/welcome/index.html.erb)で、部分テンプレートとその後ろにcontent_for(:head)ブロックを書いて、その前・中・後でhelper_methodを呼びます。
<%= render partial: 'content_for', locals: { tag: '1st_partial_content_for' } %>
<% tag = "app/views/welcome/#{File.basename(__FILE__)}" %>
<%= helper_method("#{tag}#before content_for(:head)") %>
<% content_for(:head) do %>
<!-- content_for(:head) block[<%= tag %>] -->
<%= helper_method("#{tag}#inner content_for(:head)") %>
<%- end %>
<%= helper_method("#{tag}#after content_for(:head)") %>
partialの内容(app/views/welcome/_content_for.html.erb)は、index.html.erbの後半部分と同じく、content_for(:head)ブロックを書いて、その前・中・後でhelper_methodを呼びます(index.html.erbの中でpartialを並べても等価ですが、あえて)
<%= helper_method("#{tag}#before content_for(:head)") %>
<% content_for(:head) do %>
<!-- content_for(:head) block[<%= tag %>] -->
<%= helper_method("#{tag}#inner content_for(:head)") %>
<%- end %>
<%= helper_method("#{tag}#after content_for(:head)") %>
アプリを実行します
$ bin/rails s
http://localhost:3000/welcome/index にアクセスすると、以下のようなHTMLが返ってきます。
<html>
<head>
:
<!-- helper_method[layouts/application.html.erb#before yield(:head)] return text -->
<!-- BEGIN yield :head -->
<!-- helper_method[1st_partial_content_for#before content_for(:head)] content_for(:head) block -->
<!-- helper_method[1st_partial_content_for#inner content_for(:head)] content_for(:head) block -->
<!-- content_for(:head) block[1st_partial_content_for] -->
<!-- helper_method[1st_partial_content_for#inner content_for(:head)] return text -->
<!-- helper_method[1st_partial_content_for#after content_for(:head)] content_for(:head) block -->
<!-- helper_method[app/views/welcome/index.html.erb#before content_for(:head)] content_for(:head) block -->
<!-- helper_method[app/views/welcome/index.html.erb#inner content_for(:head)] content_for(:head) block -->
<!-- content_for(:head) block[app/views/welcome/index.html.erb] -->
<!-- helper_method[app/views/welcome/index.html.erb#inner content_for(:head)] return text -->
<!-- helper_method[app/views/welcome/index.html.erb#after content_for(:head)] content_for(:head) block -->
<!-- helper_method[layouts/application.html.erb#before yield(:head)] content_for(:head) block -->
<!-- END yield :head -->
<!-- helper_method[layouts/application.html.erb#after yield(:head)] return text -->
</head>
<body>
<!-- BEGIN app/views/welcome/index.html.erb -->
<!-- BEGIN app/views/welcome/_content_for.html.erb --><!-- helper_method[1st_partial_content_for#before content_for(:head)] return text -->
<!-- helper_method[1st_partial_content_for#after content_for(:head)] return text -->
<!-- END app/views/welcome/_content_for.html.erb -->
<!-- helper_method[app/views/welcome/index.html.erb#before content_for(:head)] return text -->
<!-- helper_method[app/views/welcome/index.html.erb#after content_for(:head)] return text -->
<!-- END app/views/welcome/index.html.erb -->
</body>
</html>
アプリの動作解説
以下、content_for(:head)により追加されるコンテンツの保存先を'@content[:head]'として説明します。
ビューのレンダリング
welcome/indexにアクセスがくると、まずは対応するビュー(app/views/welcome.index.html.erb)のレンダリング処理が開始されます。最初に、
<%= render partial: 'content_for', locals: { tag: '1st_partial_content_for' } %>
行の評価が実行され、app/views/welcome/_content_for.html.erbの処理が始まります。このファイルの1行目は、
<%= helper_method("#{tag}#before content_for(:head)") %>
ですので、
<!-- helper_method[1st_partial_content_for#before content_for(:head)] content_for(:head) block -->
が、@content[:head]に入り、メソッド自体の戻り値である
<!-- helper_method[1st_partial_content_for#before content_for(:head)] return text -->
は、<body>タグ内(用の変数?未確認)に入ります。
次にapp/views/welcome/_content_for.html.erb内の
<% content_for(:head) do %>
<!-- content_for(:head) block[<%= tag %>] -->
<%= helper_method("#{tag}#inner content_for(:head)") %>
<%- end %>
が処理されますが、このブロックは内部でhelper_method("#{tag}#inner content_for(:head)")を呼び出しており、この時点で、
<!-- helper_method[1st_partial_content_for#inner content_for(:head)] content_for(:head) block -->
が@content[:head]に追加されます(❗️入れ子の子側のcontent_for(:head)ブロックの結果が先に追記される)。その後、content_for(:head)ブロックの戻り値である、
<!-- content_for(:head) block[1st_partial_content_for] -->
<!-- helper_method[1st_partial_content_for#inner content_for(:head)] return text -->
が@content[:head]に追加されます。
最後に、app/views/welcome/_content_for.html.erbの、
<%= helper_method("#{tag}#after content_for(:head)") %>
行が処理され、
<!-- helper_method[1st_partial_content_for#after content_for(:head)] content_for(:head) block -->
が@content[:head]に、
<!-- helper_method[1st_partial_content_for#after content_for(:head)] return text -->
が<body>タグに追記されて、app/views/welcome/_content_for.html.erbの処理が終わります。
次に、app/views/welcome/index.html.erb内の、
<% tag = "app/views/welcome/#{File.basename(__FILE__)}" %>
<%= helper_method("#{tag}#before content_for(:head)") %>
<% content_for(:head) do %>
<!-- content_for(:head) block[<%= tag %>] -->
<%= helper_method("#{tag}#inner content_for(:head)") %>
<%- end %>
<%= helper_method("#{tag}#after content_for(:head)") %>
の部分が処理されますが、流れは部分テンプレートと同じなので詳細は省略して、
<!-- helper_method[app/views/welcome/index.html.erb#before content_for(:head)] content_for(:head) block -->
<!-- helper_method[app/views/welcome/index.html.erb#inner content_for(:head)] content_for(:head) block -->
<!-- content_for(:head) block[app/views/welcome/index.html.erb] -->
<!-- helper_method[app/views/welcome/index.html.erb#inner content_for(:head)] return text -->
<!-- helper_method[app/views/welcome/index.html.erb#after content_for(:head)] content_for(:head) block -->
という内容が@content[:head]に、
<!-- helper_method[app/views/welcome/index.html.erb#before content_for(:head)] return text -->
<!-- helper_method[app/views/welcome/index.html.erb#after content_for(:head)] return text -->
という内容が<body>タグ内に追記されて、app/views/welcome/index.html.erbの処理が完了します。
レイアウトのレンダリング
ビューのレンダリングが終わると、レイアウトのレンダリングが開始されます。レイアウトファイル内の本記事での主題の部分は以下のようになっています。
<!DOCTYPE html>
<html>
<head>
:
<% basename = "layouts/#{File.basename(__FILE__)}" %>
<%= helper_method("#{basename}#before yield(:head)") %>
<!-- BEGIN yield :head -->
<%= yield :head %>
<!-- END yield :head -->
<%= helper_method("#{basename}#after yield(:head)") %>
:
</head>
<body>
<%= yield %>
</body>
</html>
<%= yieod :head %>の直前に記述されている<%= helper_method("#{basename}#before yield(:head)") %>は、内部でcontent_for(:head)を呼び出しているので、ここで@content[:head]の末尾に、
<!-- helper_method[layouts/application.html.erb#before yield(:head)] content_for(:head) block -->
が追記されます。また、戻り値である、
<!-- helper_method[layouts/application.html.erb#before yield(:head)] return text -->
は、helper_methodの呼び出し位置に表示されます(❗️content_for(:head)を呼び出すことにより、ビューに記述されているcontent_for(:head)ブロックの後ろへの追記との使い分けができる)。
そして、
<%= yield :head %>
の呼び出しにより、これまで蓄積されてきた@content[:head]の中身がこの位置に出力されて、お役御免となります。
<%= yield :head %>の後ろに記述されている
<%= helper_method("#{basename}#after yield(:head)") %>
でも、content_for(:head)が呼び出され、@content[:head]に追記されますが、時既に遅し、で表示はされません。
Railsにおける実装の解説
結果だけ見ると一見トリッキーな動きに見えた名前付きcontent_forですが、実装自体は非常にシンプルでした。
# File actionview/lib/action_view/helpers/capture_helper.rb, line 172
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
@view_flow はOutputFlowクラスのインスタンス変数(actionview/lib/action_view/context.rb:19)
@view_flow = OutputFlow.new
で、OutputFlowクラスの実装(actionview/lib/action_view/flows.rb)も@contentというハッシュで指定されたキーの値を連結するだけ(ActiveSupport::SafeBufferで安全性担保)というシンプルなものでした。
module ActionView
class OutputFlow # :nodoc:
attr_reader :content
def initialize
@content = Hash.new { |h, k| h[k] = ActiveSupport::SafeBuffer.new }
end
# Called by _layout_for to read stored values.
def get(key)
@content[key]
end
# Called by each renderer object to set the layout contents.
def set(key, value)
@content[key] = ActiveSupport::SafeBuffer.new(value.to_s)
end
# Called by content_for
def append(key, value)
@content[key] << value.to_s
end
alias_method :append!, :append
end
これが、個別ページごとに動的なHTMLを組み立てるために、
- まず、ビューファイルを処理する
- その後、レイアウトファイルを処理する
というこれまたシンプルな流れで処理されています。これと、
- content_for()は、ビュー内でもヘルパーメソッドからも呼び出し可能
- content_for()は、処理が完了した順に、その結果がバッファ(
@content[])に連結される
という組み合わせにより、
- 複数の独立した名前付きcontent_for()が記述された場合、各々の結果が上から順番に連結される
- 名前付きcontent_for()が入れ子で呼び出された場合、子→親の順番で結果が連結される
- ビューファイル→レイアウトファイル(内の名前付きのyieldが呼び出される前まで)の順番で名前付きcontent_for()の結果が連結される
という表示順になっていました。
この機序をベースに、プラグイン機能として、<%= yield :head %>の直前でフックを呼び出すようにしておけば、<head>タグ内で、
- 戻り値でフックの呼び出し位置にコードを挿入
- content_for(:head)の呼び出しで、ビュー内で動的に生成されたcontent_for(:head)ブロックの後ろにコードを挿入
を使い分けられるようになる、というのは覚えておくと便利そうです。
「いやでも今の実装がたまたまそうなっているだけであって、将来的にこの挙動が変わる可能性はないのか?」という疑問がふとよぎりましたが、
- レイアウト内で
<%= yield :head %>が呼び出される前にビューが処理されて@content[:head]にコンテンツが保存されている必要があるので、ビュー→レイアウトという処理順は動かしようがない -
<%= yield :head %>の直前に呼び出されているメソッド内でのcontent_for(:head)呼び出しは、@content[:head]の末尾に追加されることが保証される
ので、Railsで根本的なビュー周りの仕様変更がない限りは心配ご無用でしょう。