Паттерны взаимодействия компонентов

Lit — это современный фреймворк для создания веб-компонентов на основе стандарта Custom Elements. Центральной концепцией является компонент как самостоятельный элемент интерфейса, обладающий собственным состоянием и реактивной моделью обновлений. Для построения сложных интерфейсов необходимо грамотно организовать взаимодействие между компонентами.


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

Компоненты Lit обмениваются данными преимущественно через свойства (properties). Свойства объявляются с помощью декоратора @property:

import { LitElement, html, css } from 'lit';
import { property } from 'lit/decorators.js';

class UserCard extends LitElement {
  @property({ type: String }) name = '';
  @property({ type: Number }) age = 0;

  render() {
    return html`<div>${this.name}, ${this.age} лет</div>`;
  }
}

customElements.define('user-card', UserCard);

При использовании компонента можно передавать значения через HTML-атрибуты:

<user-card name="Иван" age="30"></user-card>

Особенности:

  • Присвоение свойств через HTML-атрибуты автоматически конвертируется в нужный тип, если указана опция type.
  • Изменение свойства внутри компонента вызывает перерисовку (reactive update), что обеспечивает актуальность интерфейса.

События для передачи информации вверх по иерархии

Для уведомления родительского компонента о событиях в дочернем компоненте используется CustomEvent. В Lit это оформляется следующим образом:

class CounterButton extends LitElement {
  @property({ type: Number }) count = 0;

  increment() {
    this.count++;
    this.dispatchEvent(new CustomEvent('count-changed', {
      detail: { count: this.count },
      bubbles: true,
      composed: true
    }));
  }

  render() {
    return html`<button @click=${this.increment}>${this.count}</button>`;
  }
}

customElements.define('counter-button', CounterButton);

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

  • bubbles: true позволяет событию всплывать по DOM-дереву.
  • composed: true делает событие видимым за пределами Shadow DOM.
  • detail содержит полезную информацию о событии.

На стороне родителя можно подписаться на событие:

<counter-button @count-changed=${e => console.log(e.detail.count)}></counter-button>

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

Слоты (<slot>) обеспечивают встраивание внешнего контента внутрь компонента. Это позволяет создавать гибкие и переиспользуемые компоненты:

class ModalDialog extends LitElement {
  render() {
    return html`
      <div class="overlay">
        <div class="content">
          <slot></slot>
        </div>
      </div>
    `;
  }
}

customElements.define('modal-dialog', ModalDialog);

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

<modal-dialog>
  <h2>Заголовок модального окна</h2>
  <p>Содержимое окна</p>
</modal-dialog>

Преимущества слотов:

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

Реактивные свойства и связывание компонентов

Lit поддерживает реактивность на уровне свойств, что упрощает связь компонентов без прямой манипуляции DOM. Пример передачи состояния от родителя к дочернему компоненту:

class ParentComponent extends LitElement {
  @property({ type: Number }) counter = 0;

  render() {
    return html`
      <counter-button .count=${this.counter} @count-changed=${this.updateCounter}></counter-button>
    `;
  }

  updateCounter(e) {
    this.counter = e.detail.count;
  }
}

customElements.define('parent-component', ParentComponent);

Важные моменты:

  • Префикс . перед свойством при передаче (.count=${this.counter}) гарантирует передачу не строки, а значения нужного типа.
  • Дочерний компонент автоматически получает новое значение, что поддерживает синхронизацию состояния.

Комбинация событий и свойств

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

  1. Родитель передает начальное состояние через свойства.
  2. Дочерний компонент изменяет своё состояние локально.
  3. Изменения уведомляются через события.
  4. Родитель при необходимости обновляет своё состояние, что может снова повлиять на дочерние компоненты.

Паттерн “Контейнер и Презентер”

Часто компоненты делятся на:

  • Контейнеры: управляют данными, подписываются на события, взаимодействуют с API.
  • Презентеры: отображают данные и формируют интерфейс, принимают только свойства и слоты.

Пример:

class TodoListContainer extends LitElement {
  @property({ type: Array }) todos = [];

  addTodo(todo) {
    this.todos = [...this.todos, todo];
  }

  render() {
    return html`
      <todo-list .items=${this.todos} @add-todo=${e => this.addTodo(e.detail)}></todo-list>
    `;
  }
}

class TodoList extends LitElement {
  @property({ type: Array }) items = [];

  render() {
    return html`
      <ul>
        ${this.items.map(item => html`<li>${item}</li>`)}
      </ul>
      <button @click=${() => this.dispatchEvent(new CustomEvent('add-todo', { detail: 'Новое задание' }))}>
        Добавить
      </button>
    `;
  }
}

customElements.define('todo-list-container', TodoListContainer);
customElements.define('todo-list', TodoList);

Преимущества:

  • Четкое разделение логики и отображения.
  • Легкость тестирования и переиспользования компонентов.
  • Минимизация нежелательных побочных эффектов.

Итоги по паттернам взаимодействия

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