Turbo Frames с нуля: интерактивный Rails без SPA

Turbo Frames с нуля

В Rails можно сделать интерактивный интерфейс без отдельного JSON API, глобального клиентского состояния и React-приложения. Сервер продолжает рендерить HTML, а браузер заменяет только нужный фрагмент страницы.

Недавно я переделывал выбор списков желаний в интернет-магазине автозапчастей. Пользователь нажимает «Сохранить», видит свои списки, отмечает несколько чекбоксов, может тут же создать новый список и подтверждает выбор. Страница при этом не перезагружается.

На этом небольшом сценарии проявились почти все фундаментальные свойства Turbo Frames: границы фрейма, lazy loading, стабильные DOM ID, совместная работа с Turbo Streams и состояние, которое существует только в браузере.

Какую проблему вообще решает Turbo

Классический серверный сайт работает просто:

  1. Браузер запрашивает URL.
  2. Сервер читает базу и строит HTML.
  3. Браузер заменяет всю страницу.

У такой архитектуры один источник правды и понятный поток данных. Но даже маленькое действие, например переименование списка, вызывает полную навигацию и теряет локальное состояние страницы: позицию прокрутки, открытое окно, фокус ввода.

SPA обычно решает это иначе:

  1. JavaScript запрашивает JSON.
  2. Хранит состояние в браузере.
  3. Компоненты React, Vue или другого фреймворка превращают состояние в DOM.

Интерфейс становится отзывчивым, но появляется вторая система рендеринга. Сервер знает бизнес-правила и данные, клиент знает, как из них собрать экран, а контракт JSON должен согласовать эти два мира.

Hotwire предлагает третью формулировку: передавать по сети не данные для интерфейса, а уже готовый HTML. Сервер остаётся владельцем представления, а небольшой универсальный JavaScript знает, какой кусок документа заменить.

Всю схему можно свести к одному потоку:

flowchart LR
    B[Браузер] -->|HTTP-запрос| S[Rails-сервер]
    S -->|готовый HTML| B
    B -->|Turbo Drive: весь body| D[Обычная навигация]
    B -->|Turbo Frame: область с ID| F[Частичная навигация]
    B -->|Turbo Stream: DOM-команда| T[Точечное изменение]
    F --> J[Stimulus: локальное поведение]
    T --> J

Turbo не превращает серверный HTML в JSON и не вводит отдельное клиентское состояние сам по себе. Он только выбирает, какую часть ответа применить к DOM.

Для обычного web-интерфейса важны четыре механизма:

  • Turbo Drive ускоряет обычные переходы и формы, заменяя <body> без полной перезагрузки страницы;
  • Turbo Frames создают отдельные области навигации внутри страницы;
  • Turbo Streams выполняют точечные DOM-операции вроде replace, prepend и remove;
  • Stimulus добавляет тот JavaScript, который всё же относится к поведению браузера: фокус, drag-and-drop, открытие виджета, работу с внешней библиотекой.

Строго говоря, Drive, Frames и Streams являются механизмами Turbo. Stimulus - отдельная часть Hotwire, а ещё в семейство входит Hotwire Native для мобильных приложений.

Ментальная модель Turbo Frame

Turbo Frame - это HTML-элемент с уникальным id:

<turbo-frame id="wishlist_picker">
  <a href="/wishlists">Выбрать список</a>
</turbo-frame>

Ссылка находится внутри фрейма, поэтому Turbo перехватывает клик и делает обычный HTTP-запрос в фоне. В ответе он ищет фрейм с тем же ID:

<turbo-frame id="wishlist_picker">
  <form action="/wishlist_items" method="post">
    <label><input type="checkbox" name="wishlist_ids[]" value="1"> Volvo</label>
    <button>Готово</button>
  </form>
</turbo-frame>

Затем содержимое старого wishlist_picker заменяется содержимым нового. Остальная страница не меняется.

Ответ может быть полным HTML-документом. Turbo всё равно вырежет из него только совпадающий фрейм. Поэтому один Rails action способен обслуживать оба режима:

  • обычный переход без Turbo показывает самостоятельную страницу;
  • запрос из фрейма обновляет только область внутри страницы.

В Rails разметка обычно создаётся helper-ом:

<%= turbo_frame_tag "wishlist_picker" do %>
  <%= link_to "Выбрать список", wishlists_path %>
<% end %>

Важная мысль: фрейм задаёт не визуальный компонент, а контекст навигации. Всё внутри него по умолчанию загружает ответы обратно в него. Граница должна соответствовать области, которую мы готовы целиком заменить.

Lazy loading: HTML по требованию

Фрейм может сам загрузить содержимое через src:

<turbo-frame
  id="wishlist_picker"
  src="/wishlists/selector?variant_id=42"
  loading="lazy"
>
  <span>Загрузка...</span>
</turbo-frame>

Согласно документации Turbo Frames, loading="lazy" откладывает запрос до появления элемента в видимой области. Это особенно удобно для содержимого модального окна, вкладки или блока ниже первого экрана.

В моём случае карточка товара сразу рендерит только кнопку и оболочку нижней панели. Список пользователя загружается при открытии панели. Это даёт два преимущества:

  • главная выдача товаров не выполняет тяжёлый запрос списков для каждой карточки;
  • пользователь получает свежие данные именно в момент выбора.

Поток lazy loading выглядит так:

sequenceDiagram
    participant V as Виджет
    participant F as Turbo Frame
    participant S as Rails
    participant D as DOM

    V->>F: элемент появился в viewport
    F->>S: GET /wishlists/selector
    S-->>F: HTML с тем же id
    F->>D: заменить только содержимое фрейма

Но отсюда следует правило, которое легко пропустить: GET такого endpoint должен быть безопасным, то есть не менять данные. Безопасный GET заодно является идемпотентным: повтор запроса не создаёт новых эффектов. Turbo умеет предварительно загружать ссылки, браузеры повторяют запросы, а поисковые роботы ходят по URL. Создавать «список по умолчанию» при открытии селектора было бы ошибкой. Ветка создаёт его только после явного PUT по кнопке «Готово».

Один реальный интерфейс, три вида состояния

Селектор списка желаний выглядит как один виджет, но его состояние живёт в трёх местах.

Состояние Где живёт Пример
Сохранённое база данных на сервере товар уже входит в списки 2 и 5
Черновое текущий DOM браузера пользователь снял 2 и отметил 7, но ещё не нажал «Готово»
Визуальное JavaScript и Bootstrap нижняя панель открыта, поле ввода получило фокус

Это различие определило архитектуру сильнее, чем внешний вид.

Границы ответственности удобнее представить отдельно:

flowchart TB
    DB[(База данных)] -->|сохранённое членство| R[Rails HTML]
    R -->|рендерит| DOM[DOM браузера]
    DOM -->|черновой выбор| DOM
    ST[Stimulus + Bootstrap] -->|фокус и offcanvas| DOM
    TF[Turbo Frame] -->|заменяет область с ID| DOM
    TS[Turbo Stream] -->|prepend / replace / remove| DOM

Если операция затрагивает только DOM, серверу не нужно узнавать о ней. Если данные должны пережить следующий запрос, их нужно явно отправить и сохранить.

Упрощённая структура страницы получилась такой:

<div data-controller="wishlist-selector">
  <turbo-frame id="wishlist_button_variant_42">
    <button>♡ Сохранить</button>
  </turbo-frame>

  <div class="offcanvas">
    <form id="wishlist_button_variant_42_form" action="/wishlist_items/sync_memberships" method="post">
      <input type="hidden" name="_method" value="put">
    </form>

    <turbo-frame
      id="wishlist_button_variant_42_wishlists"
      src="/wishlists/selector?variant_id=42"
      loading="lazy"
    >
      Загрузка...
    </turbo-frame>

    <button type="submit" form="wishlist_button_variant_42_form">
      Готово
    </button>
  </div>
</div>

Здесь два разных фрейма:

  1. Маленький фрейм кнопки нужен, чтобы после сохранения заменить пустое сердечко на заполненное.
  2. Фрейм списка независимо загружает актуальные чекбоксы.

Визуально всё это одна нижняя панель. С точки зрения обновлений это две области с разным временем жизни.

Почему нельзя просто перерисовать весь список

Пользователь открыл селектор и изменил несколько чекбоксов. Эти изменения ещё не дошли до сервера. Затем он нажал «Новый список», ввёл название и сохранил его.

Наивная реализация после создания списка заново рендерит весь фрейм:

render partial: "wishlist_selector_list"

Сервер знает сохранённое состояние, но ничего не знает про ещё не отправленные изменения чекбоксов. Полная замена фрейма молча вернёт их к старым значениям.

Проблема не в Turbo. Мы просто перерисовали область, внутри которой находился пользовательский черновик.

Решение в ветке - вернуть две точечные операции Turbo Stream:

render turbo_stream: [
  turbo_stream.prepend(
    "wishlist_button_variant_42_wishlists_items",
    partial: "wishlist_selector_row",
    locals: { wishlist: @wishlist, checked: true }
  ),
  turbo_stream.replace(
    "wishlist_button_variant_42_wishlists_quick_create",
    partial: "wishlist_selector_quick_create"
  )
]

Новая строка добавляется в начало списка, форма создания очищается, а существующие DOM-узлы с изменёнными чекбоксами остаются на месте.

Это один из главных уроков server-driven UI: размер ответа должен учитывать не только данные сервера, но и несохранённое состояние браузера.

Turbo Frame и Turbo Stream - не одно и то же

Их часто смешивают, потому что оба обновляют части страницы.

Turbo Frame Turbo Stream
Главная идея отдельный контекст навигации команда изменения DOM
Как выбирается цель совпадающий <turbo-frame id> target с DOM ID или targets с CSS-селектором
Типичный источник ссылка, форма или src фрейма ответ формы, WebSocket или SSE
Типичное действие заменить содержимое фрейма append, prepend, before, after, replace, update, remove, morph или refresh
Нужен ли <turbo-frame> у цели да нет, подходит обычный HTML-элемент
Когда применять область сама загружает и заменяет себя один ответ должен изменить несколько точек страницы

Операции и количество целей

Названия replace и update описывают разный размер замены:

  • append добавляет содержимое в конец целевого элемента;
  • prepend добавляет содержимое в начало целевого элемента;
  • replace заменяет сам целевой элемент целиком. Если новый элемент должен оставаться доступным для следующих обновлений, ему возвращают тот же ID;
  • update заменяет только содержимое целевого элемента, сохраняя его оболочку и атрибуты;
  • before и after вставляют содержимое рядом с целью, до или после неё;
  • remove удаляет целевой элемент;
  • morph выполняет замену с сохранением подходящих DOM-узлов, а refresh запрашивает обновление страницы.

По умолчанию target указывает на один элемент по его DOM ID. Если одну и ту же операцию нужно применить сразу к нескольким элементам, используется targets с CSS-селектором:

<turbo-stream action="remove" targets=".flash-message">
</turbo-stream>

У Turbo Streams есть и второй, более важный для Rails, уровень пакетного обновления: один HTTP-ответ может содержать любое количество <turbo-stream> элементов. Поэтому в одном ответе можно выполнить разные действия над разными целями, например prepend для списка и replace для формы. Rails превращает массив turbo_stream в такую последовательность команд, а Turbo применяет их по порядку.

После нажатия «Готово» сервер в моём примере меняет сразу две независимые области:

  • сердечко конкретного товара;
  • счётчик списков в навигации.

Это естественный Turbo Stream response с двумя replace. Оборачивать счётчик навигации во фрейм только ради stream-обновления не нужно. На это отдельно указывает документация Turbo Streams.

Короткое правило:

  • если ссылка или форма «путешествует» внутри своей области, начинайте с Frame;
  • если сервер должен разослать несколько точечных изменений, используйте Stream;
  • если нужен только локальный эффект браузера, например поставить фокус, используйте Stimulus.

Зачем здесь всё-таки нужен Stimulus

Turbo не пытается заменить весь JavaScript. Он забирает сетевое взаимодействие и рендеринг, но не обязан управлять Bootstrap-компонентами.

В текущей ветке Stimulus делает три небольшие вещи:

  1. Переносит offcanvas в <body>, чтобы z-index карточки товара не запирал панель внутри нового stacking context.
  2. При каждом открытии вызывает frame.reload(), чтобы показать актуальные списки.
  3. Другой контроллер ставит фокус в поле после завершения Bootstrap-анимации collapse.

То есть бизнес-состояние не дублируется в JavaScript. Там остаётся только поведение DOM и стороннего UI-компонента.

Есть и тонкость: Stimulus targets ищутся внутри элемента контроллера. Если переместить target из этого поддерева в <body>, после переноса к нему нельзя относиться как к обычному динамическому target. Поэтому ссылка на DOM-элемент сохраняется до переноса, а перемещается только дочерняя панель, не сам корень контроллера. Иначе можно получить цикл disconnect / connect.

Это уже не основа Turbo Frames, но хороший пример архитектурной границы: Turbo отвечает за HTML по сети, Stimulus - за жизненный цикл элементов в браузере.

Семь правил, которые экономят время

1. ID является контрактом

Запрос из turbo-frame#abc ожидает в ответе turbo-frame#abc. Если совпадающего элемента нет, Turbo считает ответ непригодным для фрейма и показывает ошибку Content missing.

В Rails удобно строить ID через dom_id:

dom_id(variant, :wishlist_button)
# wishlist_button_variant_42

Так ID остаётся стабильным и уникальным даже при десятках карточек на странице.

2. Выбирайте минимальную заменяемую область

Если после действия меняется только сердечко, не заменяйте всю карточку товара. Большая область повышает шанс потерять фокус, состояние формы, раскрытые элементы и подключённые JS-виджеты.

3. GET ничего не создаёт

Lazy loading и prefetch делают фоновые GET-запросы менее предсказуемыми. Любое создание, удаление или изменение должно происходить через явную небезопасную HTTP-операцию: POST, PUT, PATCH или DELETE.

4. Не путайте состояние базы и состояние DOM

Сервер не знает о несохранённом вводе. Перед replace спросите: есть ли внутри цели текстовое поле, выбранный чекбокс, позиция прокрутки или открытый элемент, который существует только в браузере?

5. Один partial должен возвращать правильную оболочку

Если endpoint обслуживает frame navigation, его ответ должен включать ожидаемый <turbo-frame>. Удобно держать тег в partial, который используется и при первом рендере, и при обновлении.

6. Фрейм не обязателен для Stream target

Turbo Stream работает с любым элементом с подходящим id. Лишний фрейм меняет поведение вложенных ссылок и форм, поэтому добавлять его «на всякий случай» вредно.

7. Проверяйте поведение, а не только HTML ответа

Controller spec проверит правильные stream actions, но не обнаружит потерю несохранённых чекбоксов после замены родителя. Для такого сценария нужен browser test: изменить выбор, создать новый список и убедиться, что прежние отметки сохранились.

Разве это не htmx?

Очень похоже. htmx расширяет HTML атрибутами, которые описывают запрос, событие, цель и способ замены:

<button
  hx-post="/wishlist_items"
  hx-target="#wishlist_button_variant_42"
  hx-swap="outerHTML"
>
  Сохранить
</button>

Turbo Frame выражает более узкую конвенцию: ссылки и формы внутри именованной области обновляют такую же область. htmx даёт больше локального контроля: запрос может исходить почти от любого элемента, запускаться разными событиями и менять явно указанную цель.

Практическая разница:

  • Turbo особенно естественен в Rails: helpers, MIME type для streams, respond_to, broadcasting и соглашения уже встроены в экосистему;
  • htmx не привязан к серверному фреймворку и обычно явнее описывает поведение прямо на элементе;
  • в Turbo меньше атрибутов для стандартного CRUD-потока;
  • в htmx проще сделать нестандартный swap без перехода к отдельному формату Turbo Stream.

Идея htmx тоже не возникла вчера. Авторы прямо называют его продолжением intercooler.js, библиотеки на jQuery, которая добавляла серверные взаимодействия через HTML-атрибуты.

«Но такое же давно было в PHP»

Да, семейство идей старше Hotwire.

В середине 2000-х PHP-библиотека xajax позволяла вызвать PHP-функцию из JavaScript и асинхронно изменить часть страницы. Репозиторий содержит copyright с 2005 года. Ответ xajax был ближе к набору XML-команд вроде «присвоить этому элементу такой HTML», чем к сегодняшнему REST-переходу между HTML-представлениями, но ощущение для разработчика было знакомым: сервер решает, что показать, без ручного написания большого клиентского приложения.

В ASP.NET Web Forms был UpdatePanel: сервер заново выполнял жизненный цикл страницы, а браузер обновлял выбранную область без полного postback. Это очень похоже внешне, хотя протокол, тяжёлое скрытое состояние страницы и модель серверных controls были другими.

Современный PHP-наследник этой линии - Laravel Livewire. Он рендерит Blade на сервере, слушает события браузера и делает AJAX-запросы. Но Livewire является компонентной системой: вместе с HTML он хранит JSON snapshot публичного состояния PHP-компонента, затем гидратирует его на следующем запросе. Turbo Frame проще и более stateless: URL плюс HTML-ответ с совпадающим ID.

Ещё два близких родственника:

  • Unpoly добавляет серверным приложениям fragment updates, слои, preload и progressive enhancement независимо от языка;
  • Phoenix LiveView начинает с обычного HTTP + HTML, затем держит stateful-процесс на сервере и отправляет DOM-diff через постоянное соединение.

Все они оставляют рендеринг ближе к серверу, но по-разному отвечают на вопрос «где живёт состояние между действиями».

А что происходит в React и Next.js

Здесь важно различать три технологии, которые часто называют одним словом SSR.

Классический SSR рендерит начальный HTML на сервере. После загрузки React гидратирует разметку, и дальнейшие действия обычно снова обслуживает клиентское приложение. Сам по себе SSR не является аналогом Turbo Frames.

React Server Components выполняются до bundling в серверном окружении и не попадают в клиентский bundle. Они могут читать данные рядом с источником и передавать результат клиентским компонентам. Но это не «вернуть обычный HTML и вставить по ID». Фреймворк передаёт специальное представление дерева компонентов.

Next.js App Router по умолчанию строит страницы и layouts из Server Components. При следующей навигации сервер генерирует RSC payload, а клиентский router объединяет его с уже существующим деревом, сохраняя состояние общих layouts. Next.js добавляет prefetch, streaming, Suspense и client-side transitions.

По цели это сосед Turbo: меньше клиентского кода, серверный рендеринг и частичные обновления. По устройству это другой уровень абстракции:

Turbo Frames Next.js App Router + RSC
Единица композиции область HTML с ID дерево React-компонентов и route segments
Формат обновления обычный HTML RSC payload плюс HTML для первого ответа
Клиентская модель DOM и универсальный Turbo runtime React runtime, reconciliation и client components
Сервер любой, способный вернуть HTML React-фреймворк с поддержкой RSC
Локальная интерактивность Stimulus или обычный JS Client Components, hooks и state

Поэтому фраза «Vercel теперь тоже вернулся к серверу» в целом верна, но технологии не стали одинаковыми. Turbo развивает гипермедийную модель браузера. React переносит границу выполнения компонентов между сервером и клиентом, сохраняя React как модель интерфейса.

Когда Turbo Frames подходят хорошо

Turbo особенно убедителен, если:

  • приложение уже рендерит HTML на сервере;
  • интерфейс в основном состоит из форм, списков, карточек, фильтров и CRUD;
  • бизнес-правила и авторизация живут на сервере;
  • важны progressive enhancement и простые HTTP-сценарии;
  • команда хочет избежать дублирования типов, API и шаблонов на двух сторонах.

Не стоит насильно применять их к интерфейсу, где:

  • большинство действий должно мгновенно работать без сети;
  • есть сложный граф локального состояния с undo/redo;
  • выполняется тяжёлая клиентская визуализация, редактор, canvas или игра;
  • приложение должно долго работать offline;
  • один экран непрерывно комбинирует данные из множества независимых backend API прямо в браузере.

Граница не обязана проходить по всему приложению. Каталог, checkout и настройки могут работать на Turbo, а сложный редактор внутри одной страницы - быть React-компонентом.

Что я вынес из этой ветки

До этой задачи Turbo Frames казались мне просто «iframe без iframe»: сервер вернул кусок HTML, браузер его вставил. Механика действительно настолько проста, но проектирование строится вокруг более глубокого вопроса: кто владеет состоянием в каждый момент действия?

В селекторе wishlist ответ оказался таким:

  • база владеет сохранённым членством товара в списках;
  • DOM временно владеет ещё не подтверждёнными чекбоксами;
  • Turbo владеет доставкой и заменой серверного HTML;
  • Stimulus владеет фокусом и жизненным циклом Bootstrap offcanvas.

После такого разделения решения становятся почти механическими:

  • загрузить свежий список - lazy Turbo Frame;
  • добавить одну строку, не потеряв черновик - turbo_stream.prepend;
  • обновить сердечко и навигацию - два stream replace;
  • поставить курсор в поле - шесть строк Stimulus;
  • создать список по умолчанию - только после явного PUT, не на GET.

Turbo не отменяет состояние на клиенте. Он позволяет не создавать для него отдельную архитектуру там, где браузерный DOM уже является достаточной моделью.

Ссылки