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

Stimulus — это легковесный JavaScript-фреймворк, ориентированный на управление поведением элементов DOM с помощью контроллеров. Одним из ключевых аспектов организации сложного интерфейса является композиция контроллеров. Композиция позволяет объединять несколько контроллеров, обеспечивая модульность, повторное использование кода и чистоту архитектуры.


Принципы композиции

1. Один контроллер — одна ответственность Каждый контроллер должен решать строго определённую задачу: обработка событий, управление состоянием или взаимодействие с API. Сложная логика распределяется между несколькими контроллерами, которые взаимодействуют через DOM или события.

2. Вложенные контроллеры Stimulus поддерживает вложенные контроллеры через DOM-структуру. Контроллер может находиться внутри элемента, управляемого другим контроллером. Взаимодействие реализуется через события или напрямую через публичные методы.

Пример:

<div data-controller="menu">
  <ul>
    <li data-controller="menu-item">Пункт 1</li>
    <li data-controller="menu-item">Пункт 2</li>
  </ul>
</div>

Здесь menu управляет общим состоянием, а menu-item отвечает за отдельный пункт. Контроллеры взаимодействуют независимо, сохраняя изоляцию логики.


Связывание контроллеров через события

Stimulus активно использует CustomEvent для связи между контроллерами. Вложенный контроллер может оповестить родительский о произошедшем событии:

// menu_item_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  SELECT() {
    this.element.dispatchEvent(new CustomEvent("menu:item:selected", {
      bubbles: true,
      detail: { text: this.element.textContent }
    }));
  }
}
// menu_controller.js
import { Controller } FROM "@hotwired/stimulus";

export default class extends Controller {
  connect() {
    this.element.addEventListener("menu:item:selected", this.handleSelection.bind(this));
  }

  handleSelection(event) {
    console.log("Выбран пункт:", event.detail.text);
  }
}

Ключевой момент: bubbles: true позволяет событию подняться по DOM, связывая вложенные контроллеры с родительскими без жесткой зависимости.


Использование публичных методов для взаимодействия

Контроллеры могут вызывать методы друг друга напрямую, если известна ссылка на элемент с другим контроллером:

// alert_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  show(message) {
    this.element.textContent = message;
    this.element.style.display = "block";
  }
}
// form_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["alert"]

  submit(event) {
    event.preventDefault();
    this.alertController().show("Форма отправлена!");
  }

  alertController() {
    return this.alertTarget.controller;
  }
}

Важно: Такой подход требует явного указания целей (data-target) и сохраняет строгую типизацию взаимодействия.


Контроллеры как миксины

Для уменьшения дублирования кода применяются миксины — функции, расширяющие поведение контроллеров:

export function Toggleable(Base) {
  return class extends Base {
    toggle() {
      this.element.classList.toggle("active");
    }
  };
}

Использование:

import { Controller } from "@hotwired/stimulus";
import { Toggleable } from "./mixins/toggleable";

export default class extends Toggleable(Controller) {
  connect() {
    console.log("Контроллер подключён");
  }
}

Миксины позволяют сохранять принцип единственной ответственности и использовать общий функционал без наследования.


Управление состоянием через композицию

Композиция упрощает управление состоянием сложного интерфейса:

  • Локальное состояние хранится внутри отдельного контроллера.
  • Глобальное состояние можно передавать через события или сервисы.
  • Изоляция состояния предотвращает непредсказуемые зависимости и облегчает тестирование.

Пример передачи состояния:

// tab_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["panel"]

  select(index) {
    this.panelTargets.forEach((panel, i) => {
      panel.hidden = i !== index;
    });
    this.element.dispatchEvent(new CustomEvent("tab:changed", { bubbles: true, detail: { index } }));
  }
}

Рекомендации по организации композиции

  1. Минимизировать связи между контроллерами через прямые вызовы.
  2. Использовать события для асинхронной и реактивной коммуникации.
  3. Делить контроллеры на небольшие и переиспользуемые единицы.
  4. Применять миксины для общих функций, не создавая глубоких цепочек наследования.
  5. Хранить только минимально необходимое состояние внутри контроллера, остальное — в DOM или внешних сервисах.

Композируемые контроллеры обеспечивают модульность интерфейса, масштабируемость и удобство поддержки даже сложных веб-приложений.