Контроллеры и навигация по SPA

Stimulus строится вокруг контроллеров — небольших классов, отвечающих за поведение конкретного участка DOM. Контроллер не владеет разметкой и не управляет глобальным состоянием, а реагирует на события и изменения данных, уже присутствующих в HTML.

Контроллер связывается с DOM-элементом через атрибут data-controller:

<div data-controller="menu">
  <button data-action="click->menu#toggle">Toggle</button>
  <nav data-menu-target="panel"></nav>
</div>
// menu_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  toggle() {
    this.panelTarget.classList.toggle("hidden")
  }
}

Контроллер всегда ограничен своим корневым элементом (this.element). Это обеспечивает локальность поведения и предсказуемость взаимодействий.


Жизненный цикл контроллера

Контроллер проходит строго определённые этапы существования:

  • initialize — вызывается один раз при создании экземпляра
  • connect — вызывается при подключении элемента к DOM
  • disconnect — вызывается при удалении элемента из DOM
initialize() {
  this.state = "closed"
}

connect() {
  this.observe()
}

disconnect() {
  this.cleanup()
}

Жизненный цикл особенно важен в SPA-навигации, где элементы динамически добавляются и удаляются без полной перезагрузки страницы.


Targets: навигация внутри контроллера

Targets описывают значимые элементы внутри контроллера. Они объявляются статически:

static targets = ["panel", "item"]

Stimulus автоматически создаёт свойства:

  • this.panelTarget
  • this.itemTargets
  • this.hasPanelTarget

Это позволяет избежать ручного поиска элементов через querySelector и поддерживает согласованность структуры.


Values: состояние, связанное с DOM

Values позволяют хранить состояние контроллера, синхронизированное с HTML-атрибутами:

static values = {
  open: Boolean,
  index: Number
}
<div data-controller="tabs" data-tabs-open-value="true"></div>

Изменение значения автоматически отражается в DOM:

this.openValue = false

Values полезны при навигации между состояниями SPA, так как они сериализуются и легко восстанавливаются.


Actions и маршрутизация событий

Actions связывают DOM-события с методами контроллера:

<a href="/posts" data-action="click->navigation#visit"></a>
visit(event) {
  event.preventDefault()
  this.navigate(event.currentTarget.href)
}

Actions могут реагировать на любые события, включая кастомные:

<div data-action="modal:open->modal#show"></div>

Это позволяет выстраивать навигацию между компонентами без прямых зависимостей.


Навигация в SPA без полноценного роутера

Stimulus не включает встроенный роутер. Навигация реализуется через комбинацию:

  • History API
  • Fetch / Turbo
  • Контроллеров навигации

Пример базового навигационного контроллера:

export default class extends Controller {
  navigate(url) {
    fetch(url, { headers: { "Accept": "text/html" } })
      .then(r => r.text())
      .then(html => {
        this.replaceContent(html)
        history.pushState({}, "", url)
      })
  }

  replaceContent(html) {
    this.element.innerHTML = html
  }
}

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


Работа с History API

Для корректной SPA-навигации требуется обработка popstate:

connect() {
  window.addEventListener("popstate", this.restore)
}

disconnect() {
  window.removeEventListener("popstate", this.restore)
}

restore = () => {
  this.navigate(location.pathname)
}

Таким образом поддерживаются переходы вперёд и назад без перезагрузки.


Композиция контроллеров

Один элемент может иметь несколько контроллеров:

<div data-controller="navigation loader analytics"></div>

Каждый контроллер решает свою задачу:

  • navigation — загрузка контента
  • loader — индикаторы состояния
  • analytics — отслеживание переходов

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


Координация между экранами

При смене представлений важно:

  • корректно отключать контроллеры
  • избегать глобального состояния
  • не хранить ссылки на удалённые элементы

Stimulus автоматически вызывает disconnect, что позволяет освобождать ресурсы и отписываться от подписок.


Интеграция с Turbo

В реальных SPA Stimulus часто используется вместе с Turbo:

  • Turbo отвечает за загрузку и кэширование HTML
  • Stimulus — за поведение и навигацию внутри страницы

Контроллеры остаются неизменными при переходах, если элементы сохраняются Turbo. Это снижает количество повторных инициализаций.


Состояния загрузки и переходов

Навигационные контроллеры часто управляют состояниями загрузки:

static classes = ["loading"]

navigate(url) {
  this.element.classList.add(this.loadingClass)

  fetch(url)
    .then(...)
    .finally(() => {
      this.element.classList.remove(this.loadingClass)
    })
}

Такое поведение локализовано и не требует глобальных менеджеров состояния.


Доступность и навигация

При SPA-навигации важно обновлять:

  • document.title
  • фокус активного элемента
  • ARIA-атрибуты

Контроллер навигации может централизованно управлять этими аспектами после каждого перехода.


Тестирование контроллеров навигации

Контроллеры легко тестируются из-за отсутствия скрытых зависимостей:

  • DOM создаётся в тесте
  • контроллер инициализируется вручную
  • методы вызываются напрямую

Навигация тестируется через подмену fetch и history.


Производительность и масштабирование

Stimulus поощряет:

  • небольшие контроллеры
  • минимальное количество наблюдателей
  • отказ от глобальных слушателей

SPA-навигация, построенная на контроллерах, масштабируется за счёт композиции, а не усложнения архитектуры.


Типичные ошибки

  • Использование контроллера как глобального менеджера состояния
  • Прямая манипуляция DOM вне this.element
  • Связывание контроллеров через импорты
  • Игнорирование disconnect при навигации

Корректная работа со SPA в Stimulus достигается строгим соблюдением локальности и событийной модели.