Компонентный подход

Зачем нужно

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

Это фундаментальная концепция для React, Vue, Angular, Svelte и Web Components.

Где используется

  • Построение интерфейсов из переиспользуемых элементов (кнопки, карточки, формы)
  • Изоляция логики и стилей каждого блока
  • Декомпозиция сложных страниц на управляемые части
  • Создание UI-библиотек и дизайн-систем

Концепция компонента

Компонент = Шаблон (HTML) + Логика (JS) + Стили (CSS)

┌─────────────────────────────┐
│        <UserCard>           │
│  ┌───────────────────────┐  │
│  │  Props (входные данные) │ │
│  │  name: "Антон"         │ │
│  │  role: "admin"         │ │
│  └───────────────────────┘  │
│                             │
│  ┌───────────────────────┐  │
│  │  State (внутреннее)    │ │
│  │  isExpanded: false     │ │
│  └───────────────────────┘  │
│                             │
│  ┌───────────────────────┐  │
│  │  Render (вывод)        │ │
│  │  <div class="card">   │ │
│  │    <h3>Антон</h3>     │ │
│  │    <span>admin</span> │ │
│  │  </div>               │ │
│  └───────────────────────┘  │
└─────────────────────────────┘

Реализация компонента на чистом JS

// === Базовый класс компонента ===
class Component {
  constructor(props = {}) {
    this.props = props;
    this.state = {};
    this.element = document.createElement('div');
  }

  // Обновление состояния → перерисовка
  setState(newState) {
    const prevState = { ...this.state };
    this.state = { ...this.state, ...newState };
    this.onStateChange(prevState, this.state);
    this.update;
  }

  // Жизненный цикл: состояние изменилось
  onStateChange(prevState, newState) {}

  // Жизненный цикл: компонент добавлен в DOM
  onMount {}

  // Жизненный цикл: компонент удалён из DOM
  onUnmount {}

  // Шаблон — переопределяется в дочернем классе
  render {
    return '';
  }

  // Обновление DOM
  update {
    this.element.innerHTML = this.render;
    this.afterRender;
  }

  // Привязка событий после рендера
  afterRender {}

  // Монтирование в DOM
  mount(container) {
    this.update;
    container.appendChild(this.element);
    this.onMount;
  }

  // Удаление из DOM
  unmount {
    this.onUnmount;
    this.element.remove();
  }
}

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

// === Компонент TodoList ===
class TodoList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      items: props.initialItems || ,
      inputValue: '',
    };
  }

  addItem {
    if (!this.state.inputValue.trim()) return;
    this.setState({
      items: [
        ...this.state.items,
        { id: Date.now(), text: this.state.inputValue, done: false },
      ],
      inputValue: '',
    });
  }

  toggleItem(id) {
    this.setState({
      items: this.state.items.map(item =>
        item.id === id ? { ...item, done: !item.done } : item
      ),
    });
  }

  removeItem(id) {
    this.setState({
      items: this.state.items.filter(item => item.id !== id),
    });
  }

  render {
    const itemsHtml = this.state.items.map(item => `
      <li class="${item.done ? 'done' : ''}">
        <input type="checkbox" data-toggle="${item.id}"
               ${item.done ? 'checked' : ''}>
        <span>${item.text()}</span>
        <button data-remove="${item.id}">×</button>
      </li>
    `).join('');

    return `
      <div class="todo-list">
        <h2>${this.props.title || 'Задачи'}</h2>
        <div class="input-group">
          <input type="text" id="todo-input"
                 value="${this.state.inputValue}"
                 placeholder="Новая задача...">
          <button id="add-btn">Добавить</button>
        </div>
        <ul>${itemsHtml}</ul>
        <p>Всего: ${this.state.items.length},
           Выполнено: ${this.state.items.filter(i => i.done).length}</p>
      </div>
    `;
  }

  afterRender {
    // Привязываем события
    const input = this.element.querySelector('#todo-input');
    input?.addEventListener('input', (e) => {
      this.state.inputValue = e.target.value;
    });
    input?.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') this.addItem;
    });

    this.element.querySelector('#add-btn')
      ?.addEventListener('click', () => this.addItem);

    this.element.querySelectorAll('[data-toggle]').forEach(cb => {
      cb.addEventListener('change', () => {
        this.toggleItem(Number(cb.dataset.toggle));
      });
    });

    this.element.querySelectorAll('[data-remove]').forEach(btn => {
      btn.addEventListener('click', () => {
        this.removeItem(Number(btn.dataset.remove()));
      });
    });
  }
}

// Монтирование
const todoList = new TodoList({
  title: 'Мои задачи',
  initialItems: [
    { id: 1, text: 'Изучить компоненты', done: false },
  ],
});
todoList.mount(document.getElementById('app'));

Props — входные данные

// Props — данные от родителя, неизменяемые для компонента

class UserCard extends Component {
  render {
    // Используем props, переданные при создании
    const { name, email, avatar, role } = this.props;

    return `
      <div class="user-card">
        <img src="${avatar}" alt="${name}">
        <h3>${name}</h3>
        <p>${email}</p>
        <span class="role">${role}</span>
      </div>
    `;
  }
}

// Создание с разными props
const admin = new UserCard({
  name: 'Антон',
  email: 'anton@mail.ru',
  avatar: '/img/anton.jpg',
  role: 'admin',
});

const user = new UserCard({
  name: 'Мария',
  email: 'maria@mail.ru',
  avatar: '/img/maria.jpg',
  role: 'user',
});

Композиция (вложенность компонентов)

// Компонент может содержать другие компоненты

class App extends Component {
  constructor {
    super;
    this.header = new Header({ title: 'Мой сайт' });
    this.sidebar = new Sidebar({ items: menuItems });
    this.content = new Content();
  }

  mount(container) {
    container.innerHTML = `
      <div id="header"></div>
      <div class="layout">
        <div id="sidebar"></div>
        <div id="content"></div>
      </div>
    `;

    // Монтируем дочерние компоненты
    this.header.mount(container.querySelector('#header'));
    this.sidebar.mount(container.querySelector('#sidebar'));
    this.content.mount(container.querySelector('#content'));
  }

  unmount {
    // Размонтируем всё дерево
    this.header.unmount;
    this.sidebar.unmount;
    this.content.unmount;
  }
}

Паттерн Slot (children)

// Компонент-обёртка, принимающий содержимое
class Card extends Component {
  render {
    return `
      <div class="card">
        <div class="card-header">${this.props.title}</div>
        <div class="card-body">${this.props.children}</div>
        <div class="card-footer">${this.props.footer || ''}</div>
      </div>
    `;
  }
}

new Card({
  title: 'Уведомление',
  children: '<p>Ваш заказ отправлен!</p>',
  footer: '<button>OK</button>',
});

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

Создание           Обновление          Удаление
   │                    │                  │
   ▼                    ▼                  ▼
constructor      setState          unmount
   │                    │                  │
   ▼                    ▼                  ▼
render           onStateChange     onUnmount
   │                    │               (очистка)
   ▼                    ▼
mount            render
   │                    │
   ▼                    ▼
onMount          afterRender
(DOM доступен)     (привязка событий)
class Timer extends Component {
  constructor {
    super;
    this.state = { seconds: 0 };
    this.intervalId = null;
  }

  onMount {
    // Запускаем таймер когда компонент в DOM
    this.intervalId = setInterval(() => {
      this.setState({ seconds: this.state.seconds + 1 });
    }, 1000);
  }

  onUnmount {
    // Очищаем таймер при удалении из DOM
    clearInterval(this.intervalId);
  }

  render {
    return `<div>Прошло: ${this.state.seconds} сек</div>`;
  }
}

Web Components (нативные компоненты браузера)

// Custom Element — нативный компонент без фреймворков
class MyCounter extends HTMLElement {
  constructor {
    super;
    this.count = 0;
    // Shadow DOM — изоляция стилей
    this.attachShadow({ mode: 'open' });
  }

  // Вызывается при добавлении в DOM
  connectedCallback {
    this.render;
  }

  // Наблюдаемые атрибуты
  static get observedAttributes {
    return ['initial'];
  }

  // Вызывается при изменении атрибута
  attributeChangedCallback(name, oldVal, newVal) {
    if (name === 'initial') {
      this.count = Number(newVal);
      this.render;
    }
  }

  render {
    this.shadowRoot.innerHTML = `
      <style>
        button { padding: 8px 16px; font-size: 18px; }
        span { margin: 0 12px; font-size: 24px; }
      </style>
      <button id="dec">-</button>
      <span>${this.count}</span>
      <button id="inc">+</button>
    `;

    this.shadowRoot.getElementById('inc')
      .addEventListener('click', () => { this.count++; this.render; });
    this.shadowRoot.getElementById('dec')
      .addEventListener('click', () => { this.count--; this.render; });
  }
}

// Регистрация
customElements.define('my-counter', MyCounter);

// Использование в HTML:
// <my-counter initial="10"></my-counter>

Частые ошибки

  1. Мега-компоненты — один компонент на 500 строк вместо декомпозиции
  2. Мутация props — компонент меняет данные, полученные от родителя
  3. Утечки в жизненном циклеsetInterval, addEventListener без очистки в onUnmount
  4. Прямая работа с DOM — вместо обновления состояния и перерисовки, правят DOM руками
  5. Глубокая вложенность — передают props через 5 уровней, вместо использования store
  6. Нет ключей в списках — при перерисовке списков элементы перемешиваются

Практика

  1. Создать базовый класс Component с render, setState, mount, unmount
  2. Реализовать компонент Counter с кнопками +/-
  3. Реализовать компонент UserList с вложенными UserCard
  4. Добавить жизненный цикл: onMount, onUnmount — реализовать таймер
  5. Создать Web Component с Shadow DOM

Связанные темы

Ресурсы