content_for и yield: как Rails собирает страницу
Статья про одну маленькую пару инструментов: yield и content_for. Они выглядят тривиально ровно до того момента, когда нужно ответить на вопрос «а в каком порядке это вообще выполняется» - и особенно «а что будет, если я объявлю секцию, которую никто не выводит».
Все утверждения ниже проверены запуском на ActionView 6.1; код экспериментов приведён.
Задача
Layout - это рамка вокруг страницы. Проблема в том, что рамке нужны данные, которые знает только конкретная страница: заголовок вкладки, <meta> для соцсетей, свой CSS, хлебные крошки, кнопки в шапке.
Наивное решение - инстанс-переменные:
<%# app/controllers/products_controller.rb %>
@page_title = "Кроссовки Nike"
<%# app/views/layouts/application.html.erb %>
<title><%= @page_title %></title>
Работает для строк и разваливается на разметке: если в заголовке нужен <span> или ссылка, вы начинаете собирать HTML в контроллере. content_for решает ровно это - позволяет шаблону отправить кусок разметки наверх, в layout.
Три слова словаря
<%# layouts/application.html.erb %>
<head>
<title><%= yield :title %></title>
<%= yield :head %>
</head>
<body class="<%= content_for?(:sidebar) ? 'two-column' : 'one-column' %>">
<main><%= yield %></main>
<%= yield :sidebar %>
</body>
<%# products/show.html.erb %>
<% content_for :title, "Кроссовки Nike" %>
<% content_for :head do %>
<meta property="og:image" content="<%= @product.image_url %>">
<% end %>
<h1><%= @product.name %></h1>
yieldбез аргумента - основное тело страницы;yield :name- именованный слот;content_for :name- заполнение слота (блоком или строкой);content_for?(:name)- проверка, что слот непустой, без его вывода.
Разница между yield :name и content_for :name при чтении одна и практическая: yield работает только внутри шаблонов, content_for - ещё и внутри хелперов.
Порядок сборки: снизу вверх
Главная неочевидность: layout рендерится последним. Не первым, как подсказывает интуиция «сначала рамка, потом содержимое».
Вот код Rails, который это делает - actionview/lib/action_view/renderer/template_renderer.rb:
def render_with_layout(view, template, path, locals)
layout = path && find_layout(path, locals.keys, [formats.first])
body = if layout
# yield(layout) здесь - это рендер основного шаблона.
# Его результат кладётся в буфер под ключ :layout ...
view.view_flow.set(:layout, yield(layout))
# ... и только потом рендерится сам layout
layout.render(view, locals) { |*name| view._layout_for(*name) }
else
yield
end
build_rendered_template(body, template)
end
А yield без аргумента внутри layout - это просто чтение того же буфера (action_view/context.rb):
def _layout_for(name = nil)
name ||= :layout
view_flow.get(name).html_safe
end
То есть <%= yield %> в layout не «запускает» шаблон. Шаблон уже отработал; yield достаёт готовую строку из хеша по ключу :layout.
Эксперимент
Соберём мини-стенд на голом ActionView и запишем реальный порядок вызовов:
require "action_view"
require "action_view/testing/resolvers"
TEMPLATES = {
"layouts/app.html.erb" => <<~ERB,
<% $log << "layout: start" %>
HEAD[<%= yield :head %>]
BODY[<%= yield %>]
<% $log << "layout: end" %>
ERB
"page.html.erb" => <<~ERB,
<% $log << "page: start" %>
<% content_for :head do %><% $log << "page: блок content_for(:head)" %>meta<% end %>
<% content_for :nowhere do %><% $log << "page: блок content_for(:nowhere)" %>wasted<% end %>
<%= render "widget" %>
<% $log << "page: end" %>
ERB
"_widget.html.erb" => <<~ERB,
<% $log << "partial: работает" %>
<% content_for :head do %><% $log << "partial: блок content_for(:head)" %>+css<% end %>
<widget>
ERB
}
$log = []
lookup = ActionView::LookupContext.new([ActionView::FixtureResolver.new(TEMPLATES)])
view = ActionView::Base.with_empty_template_cache.new(lookup, {}, nil)
puts view.render(template: "page", layout: "layouts/app")
puts $log
Вывод:
1. page: start
2. page: блок content_for(:head)
3. page: блок content_for(:nowhere)
4. partial: работает
5. partial: блок content_for(:head)
6. page: end
7. layout: start
8. layout: end
HEAD[meta+css]
BODY[
<widget>
]
Обратите внимание: HEAD в готовом HTML стоит выше BODY, хотя выполнялся позже. Порядок в исходнике и порядок выполнения - разные вещи.
Заодно видно, что content_for из партиала спокойно долетел до layout и дописался к тому, что положила страница: meta+css. По умолчанию content_for конкатенирует, а не заменяет.
sequenceDiagram
participant C as Controller
participant TR as TemplateRenderer
participant V as page.html.erb
participant P as _widget.html.erb
participant F as view_flow (Hash)
participant L as layouts/app.html.erb
C->>TR: render :show
TR->>V: render (шаблон первым!)
V->>F: content_for :head -> "meta"
V->>F: content_for :nowhere -> "wasted"
V->>P: render "widget"
P->>F: content_for :head -> += "+css"
P-->>V: HTML виджета
V-->>TR: тело страницы
TR->>F: set(:layout, тело страницы)
TR->>L: render (layout последним)
L->>F: yield :head -> get(:head)
L->>F: yield -> get(:layout)
L-->>C: готовый HTML
Где живёт содержимое
Всё это - один объект ActionView::OutputFlow, живущий ровно один запрос (action_view/flows.rb):
class OutputFlow
def initialize
@content = Hash.new { |h, k| h[k] = ActiveSupport::SafeBuffer.new }
end
def get(key)
@content[key]
end
def set(key, value)
@content[key] = ActiveSupport::SafeBuffer.new(value)
end
def append(key, value)
@content[key] << value
end
end
Хеш строк, и ничего больше. Тело страницы хранится там же, под зарезервированным ключом :layout, рядом с вашими :head и :sidebar.
Сам content_for тоже прост:
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
Ключевая строка - capture(&block). capture подменяет выходной буфер, выполняет блок целиком и возвращает накопленную строку. Никакой ленивости: блок исполняется в момент встречи, независимо от того, понадобится ли он кому-нибудь.
А если content_for есть, а yield нет?
Это и был исходный вопрос. Ответ: блок выполнится, HTML построится, строка ляжет в хеш и провисит там до конца запроса, а потом её соберёт GC.
В эксперименте выше секция :nowhere нигде не выводилась. Проверим состояние буфера после рендера:
p view.view_flow.content.keys
# => [:head, :nowhere, :layout]
p view.view_flow.content[:nowhere].to_s
# => "wasted"
Строчка 3. page: блок content_for(:nowhere) в логе - это доказательство: работа была сделана. Rails не анализирует layout заранее и не знает, какие слоты кто-то ждёт.
Сколько это стоит
Померим. 200 «мусорных» content_for внутри цикла на одну страницу, 300 рендеров:
Benchmark.bm(22) do |x|
x.report("без content_for") { N.times { run(a) } }
x.report("content_for в никуда") { N.times { run(b) } }
end
user system total real
без content_for 0.079012 0.004745 0.083757 ( 0.083810)
content_for в никуда 0.179607 0.007918 0.187525 ( 0.187599)
allocated objects: без=1852 с мусорным content_for=3499
Рендер стал в 2.2 раза медленнее, объектов выделено почти вдвое больше. Но пересчитаем на один вызов: около 1.7 микросекунды и ~8 объектов.
Практический вывод из этих чисел:
- Один-два «мёртвых»
content_forна странице - это шум. На фоне запроса в базу они не видны. Оптимизировать тут нечего. content_forвнутри цикла или внутри партиала коллекции - уже реальные деньги. Тысяча строк таблицы, каждая со своимcontent_for, - это миллисекунды и мусор для GC на ровном месте.- Если внутри блока есть запрос к базе или вызов хелпера с логикой - цена уже не в микросекундах.
captureвыполнит всё честно, включая@product.reviews.count.
Настоящая проблема не в производительности
Она в тишине. Rails не выдаёт никакого предупреждения, если имя не совпало:
<%# layout %>
<%= yield :sidebar %>
<%# view %>
<% content_for :side_bar do %>...<% end %>
Тесты зелёные, страница рендерится, сайдбар пустой. Отладка такого - это глазами сверить два имени в разных файлах.
Пара практик, которые с этим помогают:
<%# 1. Явно показать, что слот необязательный - и заодно поменять вёрстку %>
<body class="<%= content_for?(:sidebar) ? 'has-sidebar' : '' %>">
<%# 2. Значение по умолчанию %>
<title><%= content_for?(:title) ? yield(:title) : "Магазин" %></title>
А если хочется гарантий - имена слотов вынести в константы или хелперы (page_title(...) вместо content_for :title), чтобы опечатка ловилась на уровне Ruby, а не на глаз.
Побочный эффект: накопление
Раз content_for по умолчанию дописывает, партиал в коллекции запишется столько раз, сколько элементов:
T = {
"layouts/app.html.erb" => "SIDEBAR[<%= yield :sidebar %>]\nBODY[<%= yield %>]",
"page.html.erb" => "<%= render partial: 'item', collection: [1,2,3] %>",
"_item.html.erb" => "<% content_for :sidebar do %>(<%= item %>)<% end %>[<%= item %>]"
}
SIDEBAR[(1)(2)(3)]
BODY[[1][2][3]]
Иногда это ровно то, что нужно (собрать все модалки со страницы). Иногда - тройной <script> в <head>. Лечится content_for :sidebar, flush: true do, который вызывает set вместо append.
Отдельная ловушка: content_for не переживает фрагментное кеширование. Об этом честно написано прямо в исходниках Rails:
WARNING:
content_foris ignored in caches. So you shouldn't use it for elements that will be fragment cached.
При попадании в кеш блок cache do ... end не выполняется - отдаётся готовая строка. Значит, content_for внутри него тоже не выполнится, и слот окажется пустым. На холодном кеше всё работает, на прогретом ломается, что делает баг особенно приятным.
Когда порядок переворачивается: provide и streaming
У content_for есть брат - provide. В обычном режиме разница почти незаметна, а вот при render stream: true она принципиальна.
При стриминге Rails хочет отдать <head> браузеру как можно раньше, но <head> находится в layout, а его содержимое приходит из шаблона. Порядок «шаблон, потом layout» тут не подходит. Решение - файберы (renderer/streaming_template_renderer.rb):
fiber = Fiber.new { layout.render(view, locals, output, &yielder) }
view.view_flow = StreamingFlow.new(view, fiber)
fiber.resume # запускаем layout ПЕРВЫМ
if fiber.alive? # layout встал на yield :title
content = template.render(view, locals, &yielder)
view.view_flow.set(:layout, content)
fiber.resume while fiber.alive?
end
StreamingFlow#get при отсутствии ключа делает Fiber.yield - то есть замораживает layout и передаёт управление шаблону. А provide при записи делает @fiber.resume и размораживает layout обратно:
def append!(key, value)
super
@fiber.resume if @waiting_for == key
end
sequenceDiagram
participant L as layout (в Fiber)
participant F as StreamingFlow
participant V as view
participant B as браузер
L->>B: открыли html и head
L->>F: yield :title
F-->>L: ключа нет -> Fiber.yield
F->>V: рендерим шаблон
V->>F: provide :title, "Кроссовки"
F->>L: fiber.resume
L->>B: title и закрытие head
Note over V: шаблон дорендеривается
V->>F: set(:layout, тело)
L->>B: body с телом страницы
Разница в семантике: content_for говорит «может, я допишу ещё», поэтому при стриминге layout будет ждать до конца шаблона. provide говорит «всё, слот готов» - и layout продолжается немедленно. Поэтому в стримящихся страницах для <title> и <head> используют provide, а content_for оставляют для слотов, которые собираются из нескольких мест.
Два семейства шаблонизаторов
Здесь становится видно, что Rails выбрал не единственно возможную модель. Все движки с «блоками» делятся на два лагеря.
flowchart TB
subgraph PUSH["Push / буфер: child толкает"]
direction TB
C1["child выполняется целиком"] --> C2["каждый content_for<br/>пишет в общий буфер"]
C2 --> C3["layout читает буфер"]
C3 --> C4["лишняя секция выполнена,<br/>результат выброшен"]
end
subgraph PULL["Pull / наследование: parent тянет"]
direction TB
P1["parent: block head"] --> P2["ищет переопределение<br/>в дочернем шаблоне"]
P2 --> P3["вызывает только те блоки,<br/>которые встретил"]
P3 --> P4["лишний блок ребёнка<br/>НЕ выполняется"]
end
Pull: наследование шаблонов
Jinja2 / Django (Python), Twig (PHP), Smarty 3+ (PHP), Go html/template, handlebars-layouts (Node).
Ребёнок объявляет {% extends %}, родитель командует парадом. Блоки компилируются в отдельные функции, и родитель вызывает те, до которых дошёл.
Проверил на Jinja2 - блок nowhere, которого нет в базовом шаблоне, не исполняется вовсе:
'base.html': 'HEAD[{% block head %}default{% endblock %}] BODY[{% block content %}{% endblock %}]',
'page.html': '''{% extends "base.html" %}
{% block head %}{{ log("head block runs") }}meta{% endblock %}
{% block content %}{{ log("content block runs") }}page body{% endblock %}
{% block nowhere %}{{ log("NOWHERE block runs") }}wasted{% endblock %}'''
RUN: head block runs
RUN: content block runs
HEAD[meta] BODY[page body]
NOWHERE не появился. И порядок выполнения тут совпадает с порядком в HTML: head раньше content, потому что так стоит в родителе. В Rails было бы наоборот.
То же самое в Go, где роль блоков играют именованные шаблоны в наборе:
base := `HEAD[{{block "head" .}}default{{end}}] BODY[{{template "content" .}}]`
child := `{{define "content"}}...{{end}}
{{define "head"}}...{{end}}
{{define "nowhere"}}{{log "NOWHERE"}}wasted{{end}}`
RUN: layout start
RUN: head block
RUN: content block
RUN: layout end
nowhere распарсен, лежит в наборе шаблонов и просто никогда не вызывается. Стоимость - несколько байт памяти на разобранное дерево, ноль процессорного времени.
Тот же результат на handlebars-layouts: {{#content "nowhere"}} не выполняется, потому что блоки хранятся как отложенные функции и вызываются только из {{#block}} родителя.
Smarty здесь стоит особняком и заслуживает отдельного упоминания, потому что умеет оба подхода. Его {extends} / {block} разрешаются вообще на этапе компиляции - дочерний блок, которому нет пары в родителе, физически вырезается компилятором и в скомпилированный PHP не попадает. Но у Smarty есть и второй механизм, {capture name="foo"}...{/capture} с чтением через {$smarty.capture.foo}, - и это ровно модель Rails: выполнить и положить в массив.
Push: буфер и обратный порядок
Rails ActionView, Laravel Blade, express-ejs-layouts (Node).
Сначала целиком выполняется страница, попутно наполняя общий буфер, потом рендерится обёртка и читает из буфера.
Blade - почти копия Rails: @section пишет в массив $sections, @yield('name') читает. @extends не запускает layout сразу, а откладывает его рендер на конец дочернего шаблона. Секция без @yield точно так же выполняется впустую. Ближайший аналог content_for с конкатенацией - это @push / @stack.
В Node такого механизма нет ни в EJS, ни в Handlebars из коробки: чистый EJS умеет только include(), чистый Handlebars - только партиалы. Layout-модель приносят обёртки. Реализация express-ejs-layouts при этом самая прямолинейная из всех - contentFor просто вставляет в вывод текстовый маркер:
var contentPattern = '&&<>&&';
function contentFor(contentName) {
return contentPattern + contentName + contentPattern;
}
function parseContents(locals) {
var str = locals.body,
regex = new RegExp('\r?\n?' + contentPattern + '.+?' + contentPattern + '\r?\n?', 'g'),
split = str.split(regex),
matches = str.match(regex);
locals.body = split[0];
// ... содержимое между маркерами раскладывается по locals[name]
}
То есть страница рендерится в одну строку целиком, а потом режется регуляркой по разделителям. Секция, которую никто не выведет, здесь не просто выполняется впустую - она ещё и участвует в разборе строки.
Сводка
| Движок | Механика | Порядок | Неиспользованный блок |
|---|---|---|---|
| Rails ActionView | хеш view_flow |
снизу вверх | выполняется, результат выброшен |
| Laravel Blade | массив $sections |
снизу вверх | выполняется, результат выброшен |
| express-ejs-layouts | маркеры в строке + split |
снизу вверх | выполняется и разбирается регуляркой |
Smarty {capture} |
массив $smarty.capture |
снизу вверх | выполняется, результат выброшен |
| Jinja2 / Django | наследование блоков | сверху вниз | не выполняется |
| Twig | блоки компилируются в методы | сверху вниз | не выполняется |
Smarty {extends} |
слияние при компиляции | сверху вниз | вырезается компилятором |
Go html/template |
именованные шаблоны в наборе | сверху вниз | не выполняется |
| handlebars-layouts | блоки как отложенные функции | сверху вниз | не выполняется |
Чем Rails платит и что покупает
Pull-модель эффективнее и предсказуемее по порядку выполнения. Но у неё есть жёсткое ограничение: вкладываться в слот может только сам дочерний шаблон. В Jinja2 партиал, подключённый через {% include %} на третьем уровне вложенности, не может дописать <script> в <head> родителя - блоки принадлежат отношению «ребёнок-родитель», а не всему дереву рендера.
В Rails может кто угодно и с любой глубины. Компонент в партиале в партиале в коллекции добавляет свой CSS в <head>, и это работает, потому что буфер один на весь запрос. Именно это делает возможными вещи вроде javascript_include_tag из вложенного компонента или сбора модалок со всей страницы в один блок перед </body>.
Лишние микросекунды на «мёртвый» content_for - плата ровно за эту свободу. Rails не может знать заранее, кто и откуда захочет что-то положить, поэтому выполняет всё и разбирается потом.
Шпаргалка
- Layout рендерится после шаблона.
yieldне запускает шаблон, а читает готовую строку изview_flow[:layout]. content_forвыполняет блок немедленно черезcapture. Ленивости нет.- Секция без парного
yield- это выполненная и выброшенная работа. Один вызов ~1.7 мкс: некритично. В цикле или с запросом в базу внутри - уже заметно. - Rails молчит при опечатке в имени слота. Это опаснее, чем потерянные микросекунды.
content_forпо умолчанию дописывает. Нужна замена -flush: true.content_forне работает внутри фрагментного кеша при попадании в кеш.- Читать слот в хелпере можно только через
content_for :name,yield :nameтам недоступен. - Для стриминга (
render stream: true) используйтеprovideвместоcontent_for.
Ссылки
- Layouts and Rendering in Rails - Structuring Layouts
- Исходники:
action_view/helpers/capture_helper.rb,action_view/flows.rb,action_view/context.rb,action_view/renderer/template_renderer.rb,action_view/renderer/streaming_template_renderer.rb - Turbo Frames с нуля и Stimulus с нуля - про то, что происходит с этим HTML дальше, уже в браузере