Component composition

Композиция компонентов — базовый архитектурный принцип Stencil, позволяющий строить интерфейсы из независимых, переиспользуемых и слабо связанных элементов. Вместо монолитных компонентов применяется иерархия, где каждый уровень решает строго ограниченную задачу. Такой подход упрощает сопровождение, тестирование и развитие кода, а также делает веб-компоненты пригодными для использования вне зависимости от фреймворка.

Stencil опирается на стандарты Web Components, поэтому композиция реализуется через HTML-разметку, слоты, свойства, события и контекстное взаимодействие между компонентами.

Вложенность компонентов

Наиболее очевидная форма композиции — вложение одного компонента в другой. Любой компонент Stencil может использоваться как HTML-тег внутри JSX или шаблона другого компонента.

@Component({
  tag: 'app-layout',
  shadow: true,
})
export class AppLayout {
  render() {
    return (
      <div class="layout">
        <app-header></app-header>
        <main>
          <slot></slot>
        </main>
        <app-footer></app-footer>
      </div>
    );
  }
}

В этом примере app-layout не знает деталей реализации app-header и app-footer. Он лишь определяет структуру. Такая декомпозиция позволяет изменять внутреннее устройство вложенных компонентов без влияния на контейнер.

Использование <slot> как механизма композиции

Слоты — ключевой инструмент композиции в Stencil. Они позволяют передавать произвольное содержимое из родительского компонента во вложенный, не нарушая инкапсуляцию.

Простой слот

@Component({
  tag: 'ui-card',
  shadow: true,
})
export class UiCard {
  render() {
    return (
      <div class="card">
        <slot></slot>
      </div>
    );
  }
}

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

<ui-card>
  <h2>Заголовок</h2>
  <p>Произвольный контент</p>
</ui-card>

Компонент ui-card не зависит от структуры переданного содержимого, что делает его универсальным контейнером.

Именованные слоты

Именованные слоты позволяют точнее контролировать размещение дочернего контента.

@Component({
  tag: 'ui-modal',
  shadow: true,
})
export class UiModal {
  render() {
    return (
      <div class="modal">
        <header>
          <slot name="title"></slot>
        </header>
        <section>
          <slot></slot>
        </section>
        <footer>
          <slot name="actions"></slot>
        </footer>
      </div>
    );
  }
}

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

<ui-modal>
  <span slot="title">Подтверждение</span>
  <p>Удалить элемент?</p>
  <div slot="actions">
    <button>Отмена</button>
    <button>ОК</button>
  </div>
</ui-modal>

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

Передача данных через свойства

Композиция компонентов редко ограничивается только разметкой. Для передачи данных используется механизм @Prop.

@Component({
  tag: 'user-avatar',
  shadow: true,
})
export class UserAvatar {
  @Prop() name: string;
  @Prop() image: string;

  render() {
    return (
      <div class="avatar">
        <img src={this.image} alt={this.name} />
        <span>{this.name}</span>
      </div>
    );
  }
}

Родительский компонент управляет конфигурацией дочернего, не вмешиваясь в его логику:

<user-avatar
  name="Иван"
  image="/avatars/ivan.png">
</user-avatar>

Такой способ композиции подчеркивает однонаправленный поток данных сверху вниз.

Композиция через события

Для обратной связи от вложенных компонентов применяется система событий (@Event). Это позволяет дочерним элементам сообщать о действиях, не зная, кто и как будет их обрабатывать.

@Component({
  tag: 'ui-button',
  shadow: true,
})
export class UiButton {
  @Event() clicked: EventEmitter<void>;

  private onCl ick = () => {
    this.clicked.emit();
  };

  render() {
    return <button onCl ick={this.onClick}><slot></slot></button>;
  }
}

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

<ui-button onClic ked={() => this.save()}>
  Сохранить
</ui-button>

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

Контейнерные и презентационные компоненты

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

Контейнерные компоненты:

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

Презентационные компоненты:

  • получают данные через @Prop;
  • не хранят сложного состояния;
  • отвечают за отображение.

Пример контейнера:

@Component({
  tag: 'user-profile',
  shadow: true,
})
export class UserProfile {
  @State() user: User;

  componentWillLoad() {
    this.user = fetchUser();
  }

  render() {
    return (
      <profile-view user={this.user}></profile-view>
    );
  }
}

Пример презентационного компонента:

@Component({
  tag: 'profile-view',
  shadow: true,
})
export class ProfileView {
  @Prop() user: User;

  render() {
    return (
      <div>
        <h1>{this.user.name}</h1>
        <p>{this.user.email}</p>
      </div>
    );
  }
}

Такая композиция улучшает читаемость и снижает связность.

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

Stencil-компоненты легко комбинируются в разных конфигурациях без изменения исходного кода.

<ui-card>
  <user-avatar ...></user-avatar>
</ui-card>

<ui-modal>
  <ui-card>
    <user-avatar ...></user-avatar>
  </ui-card>
</ui-modal>

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

Композиция и Shadow DOM

Использование Shadow DOM влияет на композицию:

  • стили инкапсулированы;
  • разметка дочерних компонентов изолирована;
  • слоты становятся основным способом расширения.

Stencil позволяет отключать Shadow DOM (shadow: false), если требуется более тесная интеграция с глобальными стилями, но при проектировании композиции предпочтительно учитывать ограничения Shadow DOM и проектировать API компонентов через свойства, события и слоты.

Композиция как архитектурный инструмент

Композиция в Stencil — не просто способ вложить один тег в другой, а полноценный механизм проектирования интерфейсов. Грамотно спроектированные компоненты:

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

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