Stimulus с нуля: JavaScript, который живёт в разметке
Эта статья для тех, кто пишет серверный HTML и хочет добавить к нему интерактивность, не заводя отдельное клиентское приложение. Никакого предварительного знания Hotwire не требуется: разберём всё от первого контроллера до общения контроллеров между собой.
Примеры взяты из живого кода интернет-магазина, но упрощены до сути. Все они запускаются в обычном браузере из одного HTML-файла - в конце каждого раздела есть, что попробовать руками.
Если вы уже читали Turbo Frames с нуля, то Stimulus - это вторая половина той же истории: Turbo отвечает за HTML, приезжающий по сети, Stimulus - за поведение элементов уже в браузере.
Начнём с боли
Представим карточку товара с кнопкой «в корзину». Первая версия кода почти всегда выглядит так:
<button onclick="addToCart(42)">В корзину</button>
или чуть «взрослее»:
$(document).ready(function () {
$('.add-to-cart').on('click', function () {
// ...
});
});
Работает. Проблемы приходят позже, и их четыре.
Первая: код не знает, когда элемент появился. $(document).ready срабатывает один раз. Если карточку подгрузили аяксом, отрисовали после фильтрации или вернули в модальном окне, обработчик на неё не навесится. Начинается ручное «переинициализировать после загрузки», и каждый новый кусок динамики про это надо помнить.
Вторая: обработчики дублируются. Если инициализацию всё же вызвать дважды, клик отработает два раза, и в корзину попадут два товара. Найти это тяжело, потому что баг зависит от истории навигации пользователя, а не от кода на экране.
Третья: JS ищет элементы по всей странице. $('.add-to-cart') - это глобальный поиск. Переименовали класс в вёрстке ради стилей - сломали логику. Отрисовали две карточки рядом - обработчик не знает, к какой из них относится нажатая кнопка, и начинается путешествие по closest() и parentNode.
Четвёртая: непонятно, что на элементе висит. Открываешь вёрстку, видишь <div class="card"> и не знаешь, есть ли у него поведение. Чтобы выяснить, надо грепать по всему JS.
Stimulus решает ровно эти четыре вещи. Он не рисует HTML и не хранит состояние приложения. Он занимается одним: связывает уже существующую разметку с классами JavaScript и следит за тем, чтобы связь появлялась и исчезала вместе с элементами.
Небольшая историческая справка: как индустрия пришла к Stimulus
Чтобы понять, почему Stimulus выглядит именно так, полезно вспомнить, от чего он отталкивается.
2006-2010: эпоха jQuery. Изначальная задача была не в архитектуре, а в том, что браузеры несовместимы между собой. jQuery дал одинаковый API поверх IE6 и Firefox, и на годы стал стандартом де-факто. Модель работы простая: сервер рисует HTML, скрипт находит нужные элементы селекторами и что-то с ними делает. Ровно ту модель мы и разбирали в предыдущем разделе - вместе с её четырьмя проблемами.
2010-2012: появляется состояние на клиенте. Интерфейсы начали жить дольше одной страницы, и оказалось, что «найти элемент и поменять его» плохо масштабируется. Backbone.js (2010), Knockout, AngularJS (2010), Ember (2011) принесли модели, привязку данных и роутер в браузере. Логика начала переезжать с сервера на клиент.
2013-2019: фронтенд становится отдельным приложением. React (2013) предложил формулу «UI - функция состояния» и виртуальный DOM, Vue (2014) сделал ту же идею дружелюбнее. К моменту появления хуков (2019) типичный проект выглядел так: сервер отдаёт JSON API, клиент - самостоятельное приложение со сборкой, своим роутером, стором и, при желании, серверным рендерингом с гидратацией. Для сложных продуктов это оправдано. Для интернет-магазина, где 90% страниц - обычные списки и карточки, цена такого разделения оказалась заметной: два приложения, две модели данных, две команды.
2018-2021: маятник качается обратно. Почти одновременно появляются подходы, возвращающие HTML на сервер, но оставляющие интерфейс живым: Phoenix LiveView (2018) в мире Elixir, Stimulus (2018) и Turbo/Hotwire (2020) в Rails, Livewire (2019) в Laravel, Alpine.js (2019) как «jQuery с состоянием», htmx (2020, выросший из intercooler.js) как расширение HTML гипермедиа-атрибутами. Показательно, что и сам React пришёл к похожей мысли: React Server Components и app router в Next.js - это тоже про «рендерим на сервере, отправляем разметку».
Stimulus в этой картине - не революция, а сознательный откат к модели jQuery, из которой убрали её четыре родовые травмы.
Где Stimulus находится сейчас
Проект вырос внутри Basecamp: репозиторий открыли в декабре 2016, публичный релиз 1.0 состоялся в январе 2018.
| Версия | Дата | Что произошло |
|---|---|---|
| 1.0 | январь 2018 | Публичный релиз, извлечён из кода Basecamp |
| 1.1 | август 2018 | Стабилизация API |
| 2.0 | декабрь 2020 | Выходит вместе с анонсом Hotwire |
| 3.0 | сентябрь 2021 | Переезд в пакет @hotwired/stimulus, отказ от обязательного Webpacker |
| 3.2 | ноябрь 2022 | Появляются outlets |
| 3.2.2 | август 2023 | Последний на сегодня релиз |
Три года без нового релиза выглядят тревожно, пока не посмотреть, что именно не выпускается. Репозиторий активен - правки в документации, обновления зависимостей, - но API считается законченным: у фреймворка около десяти публичных понятий, и добавлять туда нечего. Для библиотеки, которая сознательно не растёт, отсутствие мажорных версий - скорее признак завершённости, чем заброшенности. С Rails 7 (декабрь 2021) Hotwire стал вариантом по умолчанию, так что каждое новое Rails-приложение приезжает со Stimulus из коробки.
Насчёт популярности иллюзий строить не стоит. Цифры на август 2026 года:
| Пакет | Загрузок с npm в неделю | Звёзд на GitHub |
|---|---|---|
| React | ~115 млн | - |
| jQuery | ~14,7 млн | ~60 тыс. |
@hotwired/turbo |
~925 тыс. | ~7,4 тыс. |
@hotwired/stimulus |
~746 тыс. | ~13 тыс. |
| Alpine.js | ~464 тыс. | ~32 тыс. |
| htmx | ~197 тыс. | ~49 тыс. |
Читать эту таблицу нужно осторожно: у htmx и Alpine значительная часть использования идёт через CDN мимо npm, поэтому их доля занижена, а звёзды на GitHub измеряют скорее интерес, чем внедрение. Но порядок величин ясен: Stimulus - это примерно один процент от масштаба React и прочно нишевая история, почти целиком совпадающая с миром Rails.
Практический вывод такой. Stimulus не выигрывает по популярности и не пытается: у него нет ни экосистемы компонентов, ни рынка готовых решений, ни толпы кандидатов в резюме. Его берут не вместо React, а вместо самописного $(document).ready в проекте, где HTML и так рисует сервер. Если это ваш случай - дальше по тексту разберёмся, как он устроен.
Ментальная модель: HTML - источник истины
В React или Vue главный - JavaScript: состояние живёт в компоненте, а DOM является его производной. Изменили состояние - фреймворк перерисовал разметку.
В Stimulus всё наоборот. Главный - HTML, который прислал сервер. JavaScript только «подключается» к нему через data-атрибуты. Состояние по возможности хранится в самом DOM: в значении инпута, в наличии CSS-класса, в атрибуте.
Под капотом это устроено предельно просто. При старте Stimulus вешает на документ MutationObserver - браузерный механизм слежения за изменениями DOM. Дальше он реагирует на появление и исчезновение элементов с нужными атрибутами:
sequenceDiagram
participant S as Сервер
participant D as DOM
participant M as MutationObserver
participant C as Контроллер
S->>D: HTML с data-controller="hello"
D->>M: узел добавлен
M->>C: создать экземпляр класса
C->>C: initialize()
C->>C: connect()
C->>D: навесить слушатели из data-action
Note over D,C: элемент живёт и реагирует на события
D->>M: узел удалён
M->>C: disconnect()
C->>D: снять слушатели из data-action
Отсюда следует главное свойство: вам всё равно, откуда взялся элемент. Пришёл он в первичном HTML, приехал внутри Turbo Frame, был вставлен через innerHTML из другого кода - connect() вызовется в любом случае, ровно один раз на элемент. Проблемы «первая» и «вторая» из предыдущего раздела исчезают не потому, что вы их аккуратно обошли, а потому что их больше нельзя воспроизвести.
Первый контроллер за две минуты
Сохраните это в файл index.html и откройте в браузере. Никакой сборки, Node и Rails не нужно.
<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<div data-controller="hello">
<input data-hello-target="name" type="text" placeholder="Ваше имя">
<button data-action="click->hello#greet">Поздороваться</button>
<p data-hello-target="output"></p>
</div>
<script type="module">
import { Application, Controller } from 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js'
// кладём в window, чтобы дёргать из консоли браузера
window.Stimulus = Application.start()
Stimulus.register('hello', class extends Controller {
static targets = ['name', 'output']
greet() {
this.outputTarget.textContent = `Привет, ${this.nameTarget.value}!`
}
})
</script>
</body>
</html>
Здесь уже видны все три базовых понятия.
data-controller="hello" отмечает элемент, за который отвечает контроллер. Этот элемент доступен внутри класса как this.element, и он же ограничивает зону видимости: контроллер не видит ничего снаружи себя.
data-hello-target="name" помечает интересный элемент внутри. В классе он превращается в this.nameTarget. Обратите внимание на формат атрибута: data-[имя контроллера]-target. Это нужно, чтобы вложенные контроллеры не воровали друг у друга элементы.
data-action="click->hello#greet" читается как «по событию click вызвать метод greet контроллера hello». Ни addEventListener, ни отписки писать не нужно - Stimulus навесит слушатель при подключении и снимет при удалении элемента.
Ключевая мысль: посмотрев только на HTML, вы уже знаете, какое поведение у блока и по какому событию что вызывается. Не нужно искать это в JS-файлах.
Попробуйте: откройте консоль браузера и выполните Stimulus.debug = true. Stimulus начнёт логировать каждое подключение элемента и каждый вызов метода - очень удобно, когда что-то «не срабатывает».
Соглашения об именах
В реальном проекте контроллеры не регистрируют руками: сборщик подхватывает все файлы из папки controllers/ и выводит идентификатор из имени файла. Правило простое - подчёркивания и слэши превращаются в дефисы:
| Файл | Идентификатор | В разметке |
|---|---|---|
hello_controller.js |
hello |
data-controller="hello" |
cart_badge_controller.js |
cart-badge |
data-controller="cart-badge" |
car_picker_plate_controller.js |
car-picker-plate |
data-controller="car-picker-plate" |
Внутри класса имена уже camelCase: target countryIso2 в разметке пишется как data-car-picker-plate-target="countryIso2", а в коде читается как this.countryIso2Target.
На один элемент можно повесить несколько контроллеров через пробел: data-controller="analytics tooltip". Каждый получит свой независимый экземпляр. Это основной способ переиспользования: не наследование, а композиция мелких контроллеров.
Жизненный цикл: три метода, которые надо знать
У контроллера есть несколько зарезервированных методов, вызываемых самим фреймворком:
export default class extends Controller {
initialize() {
// один раз за всё время жизни экземпляра
}
connect() {
// каждый раз, когда элемент попадает в DOM
}
disconnect() {
// каждый раз, когда элемент уходит из DOM
}
}
Порядок вызовов проще всего увидеть на живом сценарии: пользователь открыл страницу, ушёл по ссылке и вернулся назад. С Turbo это происходит без перезагрузки документа, поэтому весь цикл укладывается в одну сессию:
sequenceDiagram
autonumber
actor U as Пользователь
participant T as Turbo
participant D as DOM
participant C as Экземпляр контроллера
U->>D: открыл страницу
D->>C: initialize()
D->>C: connect()
Note right of C: приводим блок в нужное состояние
U->>T: клик по ссылке
T->>D: заменяет содержимое страницы
D->>C: disconnect()
Note right of C: снимаем свои слушатели,<br/>гасим таймеры и запросы
U->>T: кнопка «назад»
T->>D: восстанавливает разметку из кэша
D->>C: initialize() и connect() для нового экземпляра
Note right of C: свойства класса обнулены,<br/>состояние надо брать из DOM
Ключевой момент на последнем шаге: Turbo вставляет новые узлы, а не оживляет старые, поэтому контроллер создаётся заново. Всё, что вы сохранили в this.какое-то_поле, до этого момента не доживает.
connect() - место для всего, что должно произойти при появлении блока на экране. Например, привести кнопку в состояние, соответствующее выбранному пункту списка:
export default class extends Controller {
static targets = ['select', 'button', 'addedIcon', 'notAddedIcon']
connect() {
this.sync()
}
sync() {
const option = this.selectTarget.selectedOptions[0]
const added = option.dataset.added === 'true'
this.buttonTarget.classList.toggle('btn-primary', added)
this.addedIconTarget.classList.toggle('d-none', !added)
this.notAddedIconTarget.classList.toggle('d-none', added)
}
}
Здесь важна деталь, которая многих удивляет: connect() вызывается не один раз. Если элемент вырезали из DOM и вставили обратно, будет disconnect(), затем снова connect(). Поэтому код в connect() должен быть идемпотентным: повторный вызов не должен ломать состояние и не должен вешать второй слушатель на то же событие.
disconnect() - это уборка. Всё, что вы завели вручную и что переживает элемент, надо остановить именно здесь:
connect() {
this.onOutsideClick = this.onOutsideClick.bind(this)
document.addEventListener('click', this.onOutsideClick)
}
disconnect() {
clearTimeout(this.debounceTimer)
this.abortController?.abort()
document.removeEventListener('click', this.onOutsideClick)
}
Правило: если слушатель повешен на document или window, если запущен setInterval, если создан объект сторонней библиотеки - за ними надо убрать. Всё, что объявлено через data-action, Stimulus снимает сам.
Типичная ошибка новичка: повесить в connect() слушатель на document и забыть про disconnect(). На обычном сайте это незаметно, а с Turbo пользователь за сессию посещает десятки страниц без перезагрузки - и к концу у него сотня мёртвых обработчиков, каждый из которых удерживает в памяти давно удалённый DOM.
Targets: ссылки на элементы вместо селекторов
Targets заменяют querySelector. Объявляем список имён, получаем три вещи на каждое:
export default class extends Controller {
static targets = ['child']
toggle() {
this.childTarget // первый найденный (упадёт, если его нет)
this.childTargets // массив всех
this.hasChildTarget // есть ли хотя бы один
}
}
Множественная форма нужна чаще, чем кажется. Классический пример - раскрывающийся блок с несколькими скрытыми частями:
export default class extends Controller {
static targets = ['child', 'child2']
toggleChild() {
this.childTargets.forEach((child) => child.classList.toggle('d-none'))
this.child2Targets.forEach((child2) => child2.classList.add('d-none'))
}
}
Три важных свойства targets:
- Поиск идёт только внутри
this.element. Два одинаковых блока на странице не мешают друг другу: каждый контроллер видит свои элементы. Это решает проблему «третью» из начала статьи. - Список динамический. Добавили в DOM новый элемент с нужным атрибутом - он сразу окажется в
this.childTargets, никакого обновления кэша не требуется. this.fooTargetбросает исключение, если элемента нет. Это сделано намеренно: лучше явная ошибка, чем молчаливоеundefined. Если элемент опциональный - проверяйтеthis.hasFooTarget.
Есть и обратная сторона границы контроллера: если переместить target из поддерева контроллера куда-то в body (например, чтобы вынести панель поверх всего), он перестанет быть target. Сохраните ссылку на элемент до переноса или пересоберите разметку иначе.
Values: как передать данные с сервера в JavaScript
Часто JS нужны данные, которые знает только сервер: цена, валюта, идентификатор, настройки виджета. Соблазн - отрендерить <script> с переменной. Правильный способ - values.
export default class extends Controller {
static values = {
price: Number,
currency: String,
discount: { type: Number, default: 0 },
item: Object
}
report() {
const total = (this.priceValue - this.discountValue)
console.log(total, this.currencyValue, this.itemValue.name)
}
}
В разметке это выглядит так (формат: data-[контроллер]-[имя]-value):
<div data-controller="product-card"
data-product-card-price-value="19.90"
data-product-card-currency-value="EUR"
data-product-card-item-value='{"id":42,"name":"Фильтр"}'>
</div>
Что тут происходит: Number автоматически превращает строку в число, Object и Array парсят JSON, Boolean понимает "true"/"false". Вам не нужно писать parseInt и JSON.parse руками. Для каждого value доступен this.hasFooValue, а необъявленное значение возвращает нейтральный дефолт своего типа (0, "", false, {}).
В Rails-шаблоне это обычно строится хелпером, и на выходе получается тот же атрибут:
%div{data: {controller: 'product-card',
'product-card-price-value': variant.price,
'product-card-currency-value': current_currency}}
Второе, более мощное применение values - реакция на изменение. Для каждого значения можно объявить колбэк:
static values = { open: Boolean }
openValueChanged() {
this.element.classList.toggle('is-open', this.openValue)
}
Теперь достаточно где угодно написать this.openValue = true, и разметка обновится. Получается маленький однонаправленный поток данных: значение живёт в атрибуте DOM (его видно в инспекторе, оно переживает копирование элемента), а рендер описан в одном месте. Колбэк вызывается и при первичном подключении, так что начальное состояние тоже применится.
Попробуйте: добавьте в первый пример static values = { count: Number } и countValueChanged(), который пишет счётчик в outputTarget. Затем в greet() делайте this.countValue++. Обратите внимание, что вы нигде не вызываете рендер вручную - и что атрибут в инспекторе меняется на глазах.
Actions: подробнее про стрелочку
Полный синтаксис дескриптора действия выглядит так:
data-action="событие->контроллер#метод"
Часть про событие можно опустить, если оно очевидно для элемента: у button это click, у input - input, у select - change, у form - submit. То есть data-action="hello#greet" на кнопке равнозначно записи с click->.
Несколько действий разделяются пробелом, порядок сохраняется:
<button data-action="click->cart#add click->analytics#track">Купить</button>
Дальше начинается самое полезное.
Глобальные события. Через @ можно слушать window или document, оставаясь в своём контроллере:
<nav data-controller="nav" data-action="scroll@window->nav#onScroll">
Так делается «прилипающая» шапка: контроллер живёт на элементе меню, но реагирует на прокрутку всей страницы. И, что важно, при уходе элемента из DOM слушатель на window снимется сам.
Тот же приём работает с событиями Turbo:
<div data-controller="search-results"
data-action="turbo:load@window->search-results#onLoad">
Модификаторы клавиш и опций. Частые случаи вынесены в синтаксис:
<input data-action="keydown.esc->search#dismiss
keydown.enter->search#submit
click->menu#open:once">
Поддерживаются :prevent (вызовет preventDefault), :stop (остановит всплытие), :once, :passive, :capture. То есть привычное
greet(event) {
event.preventDefault()
// ...
}
сокращается до data-action="click->hello#greet:prevent".
Метод получает событие. Первым аргументом всегда приходит event, и чаще всего вам нужен event.currentTarget - элемент, на котором висит действие (в отличие от event.target, который может быть вложенной иконкой). Отсюда простой приём: положить данные прямо на кнопку и прочитать их из dataset:
<button data-action="click->picker#choose" data-code="EE">Эстония</button>
choose(event) {
this.inputTarget.value = event.currentTarget.dataset.code
}
Как контроллеры общаются между собой
Рано или поздно одному блоку нужно сообщить что-то другому. Классика: карточка товара добавила товар в корзину, а счётчик в шапке должен обновиться. Это разные места DOM и разные контроллеры. У Stimulus есть два ответа.
Outlets: прямая ссылка на другой контроллер
Outlet - это способ получить экземпляр другого контроллера по CSS-селектору:
<div id="cart-badge" data-controller="cart-badge">
<span data-cart-badge-target="count">0</span>
</div>
<div data-controller="product-card"
data-product-card-cart-badge-outlet="#cart-badge">
<button data-action="click->product-card#add">В корзину</button>
</div>
// cart_badge_controller.js
export default class extends Controller {
static targets = ['count']
set count(value) {
this.countTarget.textContent = value
this.countTarget.classList.add('badge-pop')
setTimeout(() => this.countTarget.classList.remove('badge-pop'), 600)
}
}
// product_card_controller.js
export default class extends Controller {
static outlets = ['cart-badge']
add(event) {
this.cartBadgeOutlet.count = 12 // вызывается сеттер соседнего контроллера
}
}
Обратите внимание: карточка не лезет в чужой DOM и ничего не знает про вёрстку значка. Она вызывает публичный метод, а как отрисовать число и анимацию - дело значка. Это обычная инкапсуляция, просто через DOM.
Как и у targets, есть this.hasCartBadgeOutlet и this.cartBadgeOutlets для множественной формы, а также колбэки cartBadgeOutletConnected() / cartBadgeOutletDisconnected().
События: когда отправитель не должен знать получателя
Outlet создаёт зависимость: карточка знает, что где-то есть корзина. Иногда это лишнее - например, когда автокомплит сообщает «пользователь выбрал вариант», а кто это слушает, зависит от страницы. Тогда лучше событие:
// внутри контроллера автокомплита
selectResult(item) {
this.dispatch('selecting', { detail: { id: item.id } })
}
this.dispatch - обёртка над CustomEvent, которая автоматически добавляет к имени префикс контроллера. В примере выше на элементе контроллера возникнет событие variant-search:selecting. Подписаться на него можно тем же дескриптором действия:
<div data-controller="variant-search"
data-action="variant-search:selecting->order-form#fill">
fill(event) {
this.idInputTarget.value = event.detail.id
}
Событие всплывает, поэтому слушать можно и на любом родителе. Отправитель при этом не знает ни про какие order-form: он просто объявляет, что произошло. Это самый развязанный способ соединить два куска интерфейса.
Правило выбора простое: нужно позвать конкретный известный блок - outlet; нужно сообщить о факте неизвестно кому - dispatch.
Практический пример: поле поиска с подсказками
Соберём то, что обычно и пишут на Stimulus в реальном проекте: поле с автодополнением. Здесь встречаются debounce, отмена устаревших запросов и навигация клавишами.
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['input', 'results']
connect() {
this.items = []
this.activeIndex = -1
this.requestId = 0
this.onOutsideClick = this.onOutsideClick.bind(this)
document.addEventListener('click', this.onOutsideClick)
}
disconnect() {
this.requestId++ // все ответы «в полёте» станут неактуальными
this.cancel()
document.removeEventListener('click', this.onOutsideClick)
}
onInput() {
const query = this.inputTarget.value.trim()
const requestId = ++this.requestId
this.cancel()
this.clear()
if (query.length < 4) return
// Ждём паузу в наборе, чтобы не слать запрос на каждую букву
this.timer = setTimeout(() => this.fetchResults(query, requestId), 100)
}
async fetchResults(query, requestId) {
this.abortController = new AbortController()
const response = await fetch(`/search.json?q=${encodeURIComponent(query)}`,
{ signal: this.abortController.signal })
const items = await response.json()
// Ответ мог прийти позже следующего запроса - тогда он больше не нужен
if (requestId !== this.requestId) return
this.items = items
this.render()
}
onKeydown(event) {
if (this.items.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
this.activeIndex = (this.activeIndex + 1) % this.items.length
this.render()
} else if (event.key === 'Enter' && this.activeIndex > -1) {
event.preventDefault()
this.dispatch('selecting', { detail: this.items[this.activeIndex] })
this.clear()
}
}
cancel() {
clearTimeout(this.timer)
this.abortController?.abort()
}
clear() {
this.items = []
this.activeIndex = -1
this.resultsTarget.innerHTML = ''
}
onOutsideClick(event) {
if (!this.element.contains(event.target)) this.clear()
}
render() {
this.resultsTarget.innerHTML = this.items.map((item, i) => `
<button role="option" data-index="${i}"
class="${i === this.activeIndex ? 'active' : ''}">
${item.title}
</button>`).join('')
}
}
<div data-controller="autocomplete">
<input data-autocomplete-target="input"
data-action="input->autocomplete#onInput keydown->autocomplete#onKeydown">
<div data-autocomplete-target="results" role="listbox"></div>
</div>
Три момента, ради которых стоит перечитать этот код.
Счётчик requestId вместо блокировки. Сетевые ответы приходят не в том порядке, в каком ушли запросы. Если пользователь напечатал «фил», потом «фильтр», ответ на «фил» может прийти позже и затереть правильные подсказки. Каждому запросу выдаётся номер, и при отрисовке мы проверяем, что он всё ещё последний. AbortController дополнительно обрывает соединение, но одного его недостаточно: гонка возможна и между «отменён» и «уже разобран JSON».
sequenceDiagram
autonumber
actor U as Пользователь
participant C as Контроллер
participant S as Сервер
U->>C: набрал «фильт»
C->>S: запрос №1
U->>C: дописал «фильтр»
C->>S: запрос №2
S-->>C: ответ №2 (быстрый)
C->>C: 2 === requestId, рисуем
S-->>C: ответ №1 (медленный)
C->>C: 1 !== requestId, выбрасываем
Note over C: без этой проверки на экране<br/>оказались бы подсказки для «фильт»
Инкремент requestId в disconnect(). Пользователь ушёл со страницы, пока запрос летел. Без этой строки колбэк попытается писать в удалённый DOM.
Ручной removeEventListener только для document. Слушатели на input объявлены в разметке, ими занимается Stimulus. А клик вне блока приходится ловить глобально, поэтому и убирать его надо самому.
Попробуйте: замените fetch на Promise с искусственной задержкой (new Promise(r => setTimeout(() => r(fake), Math.random() * 2000))) и уберите проверку requestId. Наберите текст быстро и посмотрите, как подсказки «прыгают» назад к устаревшему варианту. Это самый наглядный способ понять, зачем нужен счётчик.
Stimulus и Turbo: почему обычный <script> перестаёт работать
Если в проекте есть Turbo, обычные inline-скрипты начинают вести себя странно. Причина в том, что Turbo подменяет содержимое страницы, а не перезагружает документ: DOMContentLoaded больше не наступает при переходах, а <script> внутри заменённого фрагмента может не выполниться повторно.
С контроллерами этой проблемы нет по построению: любая новая разметка проходит через MutationObserver, и connect() вызывается.
Но есть более тонкий эффект - кэш Turbo. Уходя со страницы, Turbo сохраняет копию её текущего DOM и показывает её мгновенно, когда пользователь нажимает «назад». Копия делается с изменённого состояния, включая то, что пользователь напечатал в поля. Поэтому вернувшись назад, человек может увидеть свой старый поисковый запрос там, где ожидал пустое поле.
Лечится это подпиской на событие рендера:
export default class extends Controller {
// Перед каждым рендером возвращаем полям значение по умолчанию,
// иначе из кэша Turbo приезжает то, что пользователь напечатал ранее.
beforeRender(event) {
event.detail.newBody.querySelectorAll('[data-default]').forEach((input) => {
input.value = input.dataset.default
})
}
}
<form data-controller="search"
data-action="turbo:before-render@window->search#beforeRender">
<input name="q" data-default="">
</form>
Из того же семейства - виджеты и аналитика, которые не должны срабатывать дважды. Если элемент отправляет событие покупки, самый надёжный способ не отправить его повторно при возврате назад - удалить сам элемент:
onPurchase() {
track(this.eventValue)
// Убираем элемент, чтобы перезагрузка или возврат по истории не отправили событие второй раз
this.element.remove()
}
Обратите внимание, насколько это в духе Stimulus: состояние «событие уже отправлено» хранится не в переменной JS, а в наличии элемента в DOM.
Грабли, на которые наступают все
Состояние в свойствах класса. this.selectedIds = [] живёт ровно до тех пор, пока элемент в DOM. Turbo заменил фрагмент - контроллер пересоздан, массив пуст. Если состояние должно пережить перерисовку, его место в DOM (атрибут, value, скрытое поле) или на сервере.
Работа через document.querySelector. Технически работает, но вы теряете весь смысл: контроллер перестаёт быть переиспользуемым и ломается, когда таких блоков на странице станет два. Внутри своего поддерева - targets, за его пределами - outlets или события.
Слишком крупный контроллер. Если в классе пятнадцать методов и он отвечает за модалку, форму и таблицу сразу, его почти невозможно переиспользовать. Разделите на несколько и повесьте на один элемент через пробел.
Забытый префикс в атрибуте. data-target="name" вместо data-hello-target="name" - самая частая ошибка первых дней. Ничего не происходит, ошибок в консоли нет. Включайте Stimulus.debug = true.
Дефис против camelCase. Идентификатор контроллера в разметке всегда через дефис (cart-badge), имя target и value - всегда camelCase (countryIso2). Перепутать легко, найти трудно.
Тяжёлая работа в connect(). Он вызывается для каждого экземпляра, а карточек на странице может быть пятьдесят. Всё, что можно сделать один раз глобально, делайте один раз глобально.
Когда Stimulus - неправильный выбор
Stimulus сознательно ничего не умеет: у него нет шаблонов, реактивности, роутера и хранилища. Это преимущество ровно до тех пор, пока разметку рисует сервер.
Признаки, что вы упёрлись в границу:
- в контроллере накопилась своя система шаблонов и сборка строк HTML на десятки строк;
- одно и то же состояние приходится синхронизировать между несколькими контроллерами;
- интерфейс должен работать офлайн или без обращений к серверу;
- нужна сложная анимация переходов между экранами, drag-and-drop сложных списков, редактор.
В этих случаях уместнее полноценный фреймворк или специализированная библиотека - и это нормально: Stimulus спокойно уживается с островками React или Vue на той же странице.
Обратный признак, что вы на верном пути: контроллер помещается на экран, состояние читается из DOM, а сервер остаётся единственным владельцем правды о данных.
Что попробовать дальше
Небольшие упражнения по возрастанию сложности, все делаются в том же одиночном HTML-файле:
- Счётчик символов. Контроллер на форме, target на
textareaи на подсказку. Поinputпоказывать остаток из лимита. Лимит передать через value с дефолтом. - Раскрывающийся блок. Кнопка переключает
openValue, аopenValueChanged()добавляет и убирает класс. Убедитесь, что состояние видно в атрибуте элемента в инспекторе. - Два независимых блока. Скопируйте разметку из упражнения 2 два раза на одну страницу и убедитесь, что блоки не мешают друг другу.
- Общение. Добавьте контроллер-счётчик открытых блоков в шапку и свяжите с ним блоки через
dispatch. Затем перепишите на outlets и сравните, какой вариант читается лучше. - Проверка жизненного цикла. Напишите
connect()иdisconnect()с логом, затем в консоли выполнитеdocument.querySelector('#block').remove()и вставьте элемент обратно. Убедитесь, что порядок вызовов именно такой, как вы ожидали.
Ссылки
- Официальная документация Stimulus - короткая, читается за вечер
- Справочник по targets, values, outlets и actions
- Turbo Frames с нуля - вторая половина Hotwire
- MutationObserver на MDN - механизм, на котором всё построено