content_for и yield: как Rails собирает страницу

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_for is 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.

Ссылки