---
title: "content_for и yield: как Rails собирает страницу"
date: 2026-08-20T16:00:00
tags: [rails, ruby, erb, templates, frontend, backend, tech]
description: "Разбор порядка сборки шаблонов в Rails: почему layout рендерится последним, где хранится содержимое content_for, что происходит с секцией, которую никто не выводит, и чем модель Rails отличается от Jinja2, Twig, Go html/template и Handlebars."
embedding:
  version: 1
  model: "text-embedding-3-small"
  dimensions: 512
  hash: "sha256:9f007908609bdad5c9f9118152af44099db0aae11a4b97f3b56e20a24fd8d8fc"
  vector: "9xzPCCbp+tsK7xPx/BIO7AHk+R8T0BsO9+0PDPT0/+QV3gQL//kiEgX37/U2GfP4Byv8Cej/5PvU//8CJvf9EOYNxfcKECj3FecJ8CMVF/QZ3ObTBPXw+QT30hbsGhoX9hbyFE7m5t8x484J9fzM8+0lC+IdEwTz5AHv8v3jHuXv7x0BCOH2EPsBBBD0/hr88UPw/uXu9/Hc/vXU6+oD/PIL+f/j7e/8yAz78Nv+/uYJ2gYEAhoKCurzAwfzFe0S9AgO7ybhCxkAMA7lDh7k/+P4DAcK+/cDABMEF/saAhETIB/t8gAkHQDoJfbW8f3ZAwn75x4O4OISHzH+5PLvzhApAOTkD/D3BCj8CRn5GicVA+nfIezjGvDn+h4E/fMcLxr4/vsFExYh/AoHC+EMFjbm+Qn6/9YT1PDx9Sv5E8L56fzwB/sQ/On78eEE+PzG9Qj6DuPp7PP28wf6DAoO8QgEDvgN3ecG7vX2CBAz6woQDBQ3B/z53woOAhTrCg39+g72GdcGAwz2/Cf+KRkQCBch4e/4/CP2B+od9goACtr97g4gBuPnAvccJOsK3fMYEA/9CeMWBekE8PQJvPX6BffU+gn1HO3xHggE9Bo25g0PFgULIfX0AeXu5hzu/ePuA+H///d///UW8PUMIPbr5dzqItTtGv4C++oC9wcKEP0="
  scale: 0.0019223046
---

![content_for и yield в Rails](img/content-for-yield-rails-preview.jpg)

Статья про одну маленькую пару инструментов: `yield` и `content_for`. Они выглядят тривиально ровно до того момента, когда нужно ответить на вопрос «а в каком порядке это вообще выполняется» - и особенно «а что будет, если я объявлю секцию, которую никто не выводит».

Все утверждения ниже проверены запуском на ActionView 6.1; код экспериментов приведён.

<!--truncate-->

## Задача

Layout - это рамка вокруг страницы. Проблема в том, что рамке нужны данные, которые знает только конкретная страница: заголовок вкладки, `<meta>` для соцсетей, свой CSS, хлебные крошки, кнопки в шапке.

Наивное решение - инстанс-переменные:

```erb
<%# app/controllers/products_controller.rb %>
@page_title = "Кроссовки Nike"

<%# app/views/layouts/application.html.erb %>
<title><%= @page_title %></title>
```

Работает для строк и разваливается на разметке: если в заголовке нужен `<span>` или ссылка, вы начинаете собирать HTML в контроллере. `content_for` решает ровно это - позволяет шаблону отправить кусок **разметки** наверх, в layout.

## Три слова словаря

```erb
<%# 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>
```

```erb
<%# 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`:

```ruby
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`):

```ruby
def _layout_for(name = nil)
  name ||= :layout
  view_flow.get(name).html_safe
end
```

То есть `<%= yield %>` в layout не «запускает» шаблон. Шаблон уже отработал; `yield` достаёт готовую строку из хеша по ключу `:layout`.

### Эксперимент

Соберём мини-стенд на голом ActionView и запишем реальный порядок вызовов:

```ruby
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` конкатенирует, а не заменяет.

```mermaid
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`):

```ruby
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` тоже прост:

```ruby
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` нигде не выводилась. Проверим состояние буфера после рендера:

```ruby
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 рендеров:

```ruby
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 **не выдаёт никакого предупреждения**, если имя не совпало:

```erb
<%# layout %>
<%= yield :sidebar %>

<%# view %>
<% content_for :side_bar do %>...<% end %>
```

Тесты зелёные, страница рендерится, сайдбар пустой. Отладка такого - это глазами сверить два имени в разных файлах.

Пара практик, которые с этим помогают:

```erb
<%# 1. Явно показать, что слот необязательный - и заодно поменять вёрстку %>
<body class="<%= content_for?(:sidebar) ? 'has-sidebar' : '' %>">

<%# 2. Значение по умолчанию %>
<title><%= content_for?(:title) ? yield(:title) : "Магазин" %></title>
```

А если хочется гарантий - имена слотов вынести в константы или хелперы (`page_title(...)` вместо `content_for :title`), чтобы опечатка ловилась на уровне Ruby, а не на глаз.

### Побочный эффект: накопление

Раз `content_for` по умолчанию дописывает, партиал в коллекции запишется столько раз, сколько элементов:

```ruby
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`):

```ruby
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 обратно:

```ruby
def append!(key, value)
  super
  @fiber.resume if @waiting_for == key
end
```

```mermaid
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 выбрал не единственно возможную модель. Все движки с «блоками» делятся на два лагеря.

```mermaid
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`, которого нет в базовом шаблоне, не исполняется вовсе:

```python
'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, где роль блоков играют именованные шаблоны в наборе:

```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` просто вставляет в вывод текстовый маркер:

```js
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](https://guides.rubyonrails.org/layouts_and_rendering.html#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 с нуля](/ru/blog/tech/frontend/turbo-frames-s-nulya/) и [Stimulus с нуля](/ru/blog/tech/frontend/stimulus-s-nulya/) - про то, что происходит с этим HTML дальше, уже в браузере
