Открываю каталог фронтенда в рабочем проекте и вижу два мира. Слева папка контроллеров Stimulus — их четыре. Справа десяток обычных модулей: выпадающие меню, боковые панели, дерево навигации, подсказки поиска, диалоги, частичное обновление.
Это не эволюция от плохого к хорошему. Это две разные ставки, и обе оказались частично верными.
Что реально даёт Stimulus
Три вещи, и только одна из них про удобство.
Привязка от разметки. Элемент говорит data-controller="typeahead", и всё. Не нужно искать элементы селектором, не нужно проверять, есть ли они на странице.
Жизненный цикл. Контроллер знает, когда его элемент появился и когда исчез. Stimulus вызывает disconnect(), когда контроллер отсоединяется от DOM: при удалении элемента, навигации или подмене куска страницы.
Область видимости. this.element — это конкретный экземпляр. Десять выпадающих меню на странице — десять независимых контроллеров без единой строки кода про это.
Первое и третье — удобство. Второе — то, ради чего стоит переписывать.
Один виджет двумя способами
Выпадающее меню. Обычный модуль выглядит так:
export function initDropdowns() {
document.querySelectorAll('[data-dropdown]').forEach((root) => {
const button = root.querySelector('[data-dropdown-button]');
const menu = root.querySelector('[data-dropdown-menu]');
button.addEventListener('click', () => {
menu.hidden = !menu.hidden;
});
document.addEventListener('click', (event) => {
if (!root.contains(event.target)) menu.hidden = true;
});
});
}Читается нормально, работает нормально. Вызывается один раз при загрузке.
Тот же виджет на Stimulus:
export default class extends Controller {
static targets = ['menu'];
connect() {
this.outside = (event) => {
if (!this.element.contains(event.target)) this.close();
};
document.addEventListener('click', this.outside);
}
disconnect() {
document.removeEventListener('click', this.outside);
}
toggle() {
this.menuTarget.hidden = !this.menuTarget.hidden;
}
close() {
this.menuTarget.hidden = true;
}
}Вариант длиннее из-за явных connect() и disconnect(). Зато установка обработчика и его снятие стоят рядом, и это важнее разницы в числе строк.
Что происходит после навигации Turbo
Вот тот самый аргумент.
Turbo подменяет содержимое страницы, не перезагружая документ. Модуль из первого примера отработал один раз при загрузке. После перехода на другую страницу новые выпадающие меню обработчиков не получили — они мёртвые.
Лечится подпиской на событие Turbo:
document.addEventListener('turbo:load', initDropdowns);И вот тут начинается интересное. Обработчик на document, повешенный внутри initDropdowns, при каждом вызове добавляется заново. Старый никуда не делся — элемент, к которому он относился, удалён, но замыкание держит на него ссылку.
После десяти переходов по страницам таких обработчиков десять. Каждый клик по документу вызывает десять функций, девять из которых работают с элементами, которых нет в DOM.
Симптомы у этого разные и все неочевидные: меню, которое закрывается сразу после открытия; постепенное замедление интерфейса; растущее потребление памяти во вкладке, открытой полдня.
Я потратил на такой баг вечер, прежде чем понял, что смотрю не на логику меню, а на её десятикратное исполнение.
Контроллер Stimulus эту проблему не решает автоматически — снятие обработчика в disconnect() всё равно пишется руками. Но фреймворк даёт место, где это надо сделать, и вызывает его гарантированно. Это разница между «можно не забыть» и «негде забыть».
Сборка
В используемой связке AssetMapper и Symfony UX StimulusBundle контроллеры пакетов регистрируются через манифест:
{
"controllers": {
"@symfony/ux-live-component": {
"live": { "enabled": true, "fetch": "eager" }
}
},
"entrypoints": []
}StimulusBundle подхватывает локальные контроллеры из каталога по соглашению об именах: typeahead_controller.js становится data-controller="typeahead".
Обычные модули собираются своим скриптом. У меня это небольшой файл на esbuild, который склеивает десяток модулей в один:
await esbuild.build({
entryPoints: ['assets/app.js'],
bundle: true,
minify: true,
outfile: 'public/build/app.js',
target: ['es2020'],
});Два механизма сборки в одном проекте — плата за смешение подходов. Работает, но объяснять это новому человеку неловко.
Правило выбора, к которому я пришёл
Разделил не по «хорошо/плохо», а по признаку.
Stimulus, если виджет вешает обработчики за пределами своего элемента, обращается к серверу, держит таймеры или подписки, существует в нескольких экземплярах со своим состоянием.
Обычный модуль, если это одноразовая инициализация чего-то глобального: тема, обработчик горячих клавиш, настройка сторонней библиотеки на весь документ.
По этому признаку из десяти модулей четыре попали в кандидаты на переписывание: выпадающее меню, боковая панель, подсказки поиска, дерево навигации. Все четыре вешают обработчики на документ и все четыре имели вариацию описанной выше проблемы.
Оставшиеся шесть — действительно глобальная инициализация. Переписывать их в контроллеры значило бы завести фиктивный элемент-обёртку ради красоты.
Куда не дотягивается Stimulus
Честно про минусы.
Передача данных через атрибуты ограничена примитивами и JSON. Большой объект передавать неудобно, а после подмены разметки Turbo он приезжает заново.
Контроллеры могут взаимодействовать через события, но это не единственный официальный способ. Outlets дают одному контроллеру ссылку на экземпляр другого и подходят для прямой координации. События остаются удобнее для широковещательного сигнала, когда отправитель не должен знать получателей. Меню, которое закрывается при открытии другого, стоит сначала примерить к Outlets, а не сразу раскладывать на цепочку событий.
И static values со значениями по умолчанию имеют неочевидное поведение: в HTML значение хранится строкой, а getter контроллера приводит его к объявленному типу. При опечатке в имени атрибута молча берётся значение по умолчанию. Отладка — через вывод в консоль, потому что предупреждения нет.
Итог
Если бы начинал заново, заранее выбрал бы один механизм сборки, но не стал бы ради него превращать каждую глобальную инициализацию в контроллер. Правило осталось бы тем же: виджеты с жизненным циклом — в Stimulus, код уровня документа — в обычных модулях.
Но главный вывод не про выбор фреймворка. Он про то, что при серверном рендере с подменой разметки любой скрипт обязан уметь прибирать за собой. Stimulus заставляет об этом помнить. Обычный модуль — нет, и именно в этом разница, а не в синтаксисе.