Отдельный фронтенд стоит дороже, чем принято считать. Второй роутер, вторая модель данных, второе описание тех же сущностей, второй деплой и второй набор зависимостей, который надо обновлять. Для команды из десяти человек это нормальная цена. Для одного разработчика — это удвоение работы ради интерфейса, который в итоге показывает таблицы и формы.
Четыре моих продукта рендерятся на серве. Расскажу, где такой подход выигрывает, а где проигрывает.
Что Turbo Drive даёт бесплатно
Подключение — одна строка в точке входа. После этого каждый переход по ссылке перестаёт быть полной перезагрузкой: Turbo забирает страницу запросом, подменяет body и обновляет адрес.
В моём локальном замере полная перезагрузка страницы задач занимала 780 мс до отрисовки, переход через Turbo Drive — 210 мс. Это не универсальный бенчмарк: числа зависят от страницы и машины. В моём случае сервер и сеть были теми же, а экономилось повторное построение документа и повторный запуск клиентского кода; статические ресурсы при этом могли приезжать из кеша браузера.
Что не решается само:
- Состояние прокрутки. Turbo восстанавливает его для истории назад, но при переходе по ссылке страница начинается сверху, и в длинных списках это раздражает.
- Скрипты, которые ждали
DOMContentLoaded. Событие больше не наступает. Всё, что инициализировалось один раз, надо переписывать на события Turbo или на Stimulus. - Утечки. Обработчик, повешенный на
document, переживает навигацию. После десяти переходов их десять.
Третий пункт — источник самых противных багов. Выпадающее меню, которое после пяти переходов открывается и сразу закрывается, потому что обработчиков стало пять.
Фреймы там, где меняется часть страницы
Turbo Frame — это область, которая обновляется независимо от остальной страницы. Ссылка внутри фрейма меняет только фрейм.
Самый явный выигрыш — список с фильтрами:
Ниже сокращённый фрагмент шаблона; параметры полей и действия контроллера, не относящиеся к фильтрации, опущены:
<turbo-frame id="issue-list" data-turbo-action="advance">
<form action="{{ org_path('issues_index') }}"
data-controller="auto-submit"
data-action="input->auto-submit#submit change->auto-submit#submit">
<input type="search" name="q" value="{{ query }}">
<select name="status">…</select>
</form>
{% for issue in issues %}
{% include 'tasks/_issue_row.html.twig' %}
{% endfor %}
{{ knp_pagination_render(issues) }}
</turbo-frame>Фильтр меняет список, не трогая шапку, боковое меню и фокус в поле поиска. data-turbo-action="advance" добавляет запись в историю, так что кнопка «назад» возвращает предыдущий фильтр, а адрес можно скопировать и отправить коллеге.
Тут же ловушка, на которую я потратил час: сервер должен вернуть страницу с фреймом того же идентификатора. Если контроллер отдаёт другой шаблон, Turbo не находит ожидаемый фрейм и сообщает об этом в консоли; то, что увидит пользователь, зависит от версии Turbo и настроенного обработчика ошибки.
Вторая частая ошибка — забыть, что ответ на запрос фрейма всё равно рендерит весь макет. Работать будет, но каждый ввод символа в поиск станет генерировать полную страницу. Проверка на запрос фрейма решает:
$template = $request->headers->has('Turbo-Frame')
? 'tasks/_list.html.twig'
: 'tasks/index.html.twig';Stimulus вместо обвязки
Всё, что раньше писалось как «найти элемент по селектору и повесить обработчик», превращается в контроллер, привязанный к разметке:
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } };
submit() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.element.requestSubmit(), this.delayValue);
}
disconnect() {
clearTimeout(this.timeout);
}
}Главное здесь — disconnect(). Контроллер знает, когда его элемент исчез со страницы, и прибирает за собой. Ровно этого не хватает обычному скрипту, и ровно из-за этого копятся обработчики.
Второе преимущество — привязка идёт от разметки. Шаблон говорит data-controller="auto-submit", и понятно, что здесь происходит, не открывая JS. Обратный поиск — «где инициализируется этот виджет» — тоже становится однозначным.
Alpine рядом со Stimulus
Два инструмента для похожих задач в одном проекте выглядят как непоследовательность. Зоны у них разные, и разделение я держу строго.
Alpine — для состояния, которое живёт внутри одного куска разметки и никого не касается: открыто или закрыто, какая вкладка активна, показан ли пароль.
Сокращённый фрагмент раскрывающегося блока выглядит так:
<div x-data="{ open: false }">
<button @click="open = !open" :aria-expanded="open">Фильтры</button>
<div x-show="open" x-collapse>…</div>
</div>Пять секунд работы, ноль файлов. Заводить ради этого контроллер — избыточно.
Stimulus — для всего, что обращается к серверу, работает с несколькими элементами или требует уборки. Автоотправка формы, перетаскивание, редактор, подписка на поток событий.
Граница: если понадобился fetch или setTimeout — это Stimulus. Если только классы и флаги — Alpine.
В моей сборке оба инструмента добавляли примерно 25 килобайт после сжатия. Это число стоит перемерять после обновления зависимостей, но для текущего проекта цена меня устраивает.
Где серверного рендера не хватило
Серверного рендера не хватило в двух местах.
Доска задач с перетаскиванием. Карточка должна двигаться за курсором, колонки — подсвечиваться, счётчики — меняться до ответа сервера. Всё это состояние на клиенте, и оно живое.
Turbo Frame здесь не помогает: подмена HTML во время перетаскивания рвёт взаимодействие. Я перевёл доску на Live Components, где сервер остаётся источником рендера и бизнес-логики, а сериализуемые свойства компонента ездят между браузером и сервером. Обновление приезжает точечно. Это отдельная история, и она не бесплатная.
Редактор документа. Богатый текст — это по определению клиентское приложение внутри страницы. Никакой серверный рендер тут не поможет, вопрос только в том, какой редактор подключить и как санировать то, что он выдаёт.
Общее у обоих случаев: между запросами к серверу интерфейс успевает сменить несколько состояний. Вот это и есть граница. Если интерфейс должен жить своей жизнью между двумя запросами, серверного рендера уже недостаточно.
Цена решения
Первый экран рендерится сервером и приезжает готовым. На странице со списком проектов это было примерно на 400 мс быстрее варианта SPA, который сначала загружал бандл, а затем запрашивал данные. Это результат конкретной реализации, а не свойство любой SPA: серверный рендер, предварительная загрузка данных и размер клиентского бандла могут изменить сравнение.
Формы работают штатно: браузерная валидация, автозаполнение, менеджер паролей. В SPA всё это надо чинить руками, и обычно чинят наполовину.
Что хуже. Каждое действие — обращение к серверу, и на плохой связи интерфейс ощущается медленным: между нажатием и реакцией нужно дождаться полного цикла «запрос — ответ». Оптимистичные обновления в этой модели делать неудобно.
И ещё: работа офлайн исключена полностью. Для внутренних инструментов это не проблема, для приложения, которым пользуются в поле, — стоп-фактор.
Когда я бы всё-таки выбрал SPA
Три признака, при которых серверный рендер проиграет.
Интерфейс — это одно рабочее пространство, из которого пользователь не уходит часами: редактор, конструктор, панель мониторинга с живыми графиками.
Приложение должно работать без сети или на очень плохой сети.
Есть отдельная фронтенд-команда, и API нужен всё равно, потому что клиентов несколько.
В моих продуктах нет ни одного из этих трёх признаков. Ответ на вопрос «а не пора ли» я проверяю раз в полгода, и пока каждый раз получается «нет».