Наследование контроллеров

Stimulus изначально проектировался как минималистичный фреймворк, поэтому в нём отсутствует собственная система классов, миксинов или декораторов. Наследование контроллеров реализуется средствами самого JavaScript — через extends и прототипную цепочку. Это делает модель прозрачной и предсказуемой, но требует строгой дисциплины в архитектуре.

Контроллер Stimulus — это класс, наследующийся от Controller, экспортируемого из пакета @hotwired/stimulus. Любой пользовательский контроллер может выступать базовым классом для других контроллеров.

import { Controller } from "@hotwired/stimulus"

export default class BaseController extends Controller {
  connect() {
    this.log("connected")
  }

  log(message) {
    console.log(`[${this.identifier}] ${message}`)
  }
}

Такой контроллер не обязан быть привязан к DOM напрямую. Его задача — инкапсулировать общее поведение.


Наследование пользовательских контроллеров

Производный контроллер наследуется стандартным образом:

import BaseController from "./base_controller"

export default class extends BaseController {
  connect() {
    super.connect()
    this.initializeState()
  }

  initializeState() {
    this.state = {}
  }
}

Ключевые моменты:

  • super.connect() необходимо вызывать вручную, если базовый класс реализует жизненный цикл.
  • identifier, element, targets, values автоматически доступны через цепочку наследования.
  • Методы базового класса полностью переопределяемы.

Жизненный цикл и переопределение методов

Stimulus использует фиксированный набор lifecycle-методов:

  • initialize
  • connect
  • disconnect

При наследовании важно понимать порядок вызовов. Stimulus вызывает метод самого производного класса. Если требуется логика базового класса, она должна быть вызвана явно.

initialize() {
  super.initialize()
  this.setup()
}

Отсутствие вызова super не считается ошибкой фреймворка и не вызывает предупреждений. Это осознанное решение, позволяющее полностью контролировать поведение.


Наследование targets

Targets в Stimulus объявляются статическим свойством класса:

static targets = ["input"]

При наследовании статические свойства не объединяются автоматически. Если производный класс объявляет свои targets, он полностью переопределяет список.

export default class extends BaseController {
  static targets = ["button"]
}

В результате inputTarget из базового класса будет недоступен.

Для объединения targets используется явное расширение:

export default class extends BaseController {
  static targets = [...super.targets, "button"]
}

Этот приём требует, чтобы базовый класс тоже определял static targets.


Наследование values

Аналогично targets, values объявляются как статическое свойство:

static values = {
  url: String,
  delay: Number
}

При переопределении static values происходит полная замена. Для сохранения значений базового класса используется объединение объектов:

static values = {
  ...super.values,
  mode: String
}

Типы значений, дефолты и кастомные геттеры работают корректно через цепочку наследования, если имена не конфликтуют.


Переиспользование actions

Actions в Stimulus не объявляются в классе, а задаются в HTML через data-action. Наследование влияет только на доступность методов.

Если базовый контроллер содержит методы-обработчики, они могут использоваться в производных без дополнительной настройки:

data-action="click->extended#handle"

При этом метод handle может быть определён в базовом классе.

Переопределение метода в производном классе автоматически изменяет поведение всех actions, указывающих на этот контроллер.


Шаблон абстрактного контроллера

Распространённая архитектурная практика — создание абстрактных контроллеров, не предназначенных для прямого использования в DOM.

Пример:

export default class FormController extends Controller {
  submit() {
    throw new Error("Method submit() must be implemented")
  }

  validate() {
    return true
  }
}

Производные контроллеры реализуют обязательные методы:

export default class extends FormController {
  submit() {
    // конкретная реализация
  }
}

Stimulus не имеет встроенной поддержки абстрактных классов, но этот паттерн хорошо работает при строгом контроле архитектуры.


Общие утилиты и сервисная логика

Базовые контроллеры часто используются как контейнеры для:

  • логирования
  • работы с API
  • debounce/throttle
  • управления состоянием
export default class BaseController extends Controller {
  debounce(fn, delay = 300) {
    let timeout
    return (...args) => {
      clearTimeout(timeout)
      timeout = setTimeout(() => fn.apply(this, args), delay)
    }
  }
}

Все производные контроллеры получают доступ к этим методам без дублирования кода.


Ограничения и подводные камни

  • Отсутствие множественного наследования. Использование миксинов возможно, но требует ручной реализации.
  • Конфликты имён. Совпадение имён targets, values или методов приводит к переопределению без предупреждений.
  • Сильная связанность. Глубокая иерархия усложняет сопровождение и отладку.

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


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

Вместо создания сложных иерархий часто используется композиция:

  • вынос логики в отдельные функции
  • сервисные классы без наследования от Controller
  • повторное использование через импорт
import { fetchJSON } from "../services/api"

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


Архитектурная роль наследования в Stimulus

Наследование в Stimulus — это не основной механизм расширения, а вспомогательный инструмент. Оно эффективно для:

  • унификации жизненного цикла
  • стандартизации работы с DOM
  • инкапсуляции повторяющихся паттернов

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