Виджеты на JavaScript: от простых компонентов до Web Components

Раздел: Frontend -> Разработка виджетов

Виджеты на JavaScript: обзор подходов и реализация

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

Как создать изолированный виджет с собственными стилями и логикой?

Наиболее эффективное решение - использование Web Components с Shadow DOM. Это позволяет полностью инкапсулировать структуру, стили и поведение виджета, не влияя на остальную страницу и не подвергаясь внешним CSS-правилам.

class MyWidget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .widget { background: #f0f0f0; padding: 16px; border-radius: 8px; }
        button { background: #007bff; color: white; border: none; padding: 8px 16px; }
      </style>
      <div class="widget">
        <p>Привет, я виджет!</p>
        <button id="btn">Нажми</button>
      </div>
    `;
    this.shadowRoot.getElementById('btn').addEventListener('click', () => {
      alert('Сработал виджет!');
    });
  }
}
customElements.define('my-widget', MyWidget);

Widget js (виджеты на javascript)

После этого на странице используется тег <my-widget></my-widget>, стили не просачиваются наружу.

Проблема: старые браузеры не поддерживают Shadow DOM. Решение - полифиллы или использование режима 'closed' с осторожностью.

Типичная ошибка:

Забыть определить элемент до его использования в HTML. Важно вызвать customElements.define() до загрузки страницы или хотя бы до появления тега.

Как реализовать виджет через iframe для максимальной изоляции?

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

function createIframeWidget(containerId, url) {
  const container = document.getElementById(containerId);
  const iframe = document.createElement('iframe');
  iframe.src = url;
  iframe.style.width = '100%';
  iframe.style.height = '300px';
  iframe.style.border = 'none';
  container.appendChild(iframe);

  // Обмен данными с iframe через postMessage
  window.addEventListener('message', (event) => {
    if (event.origin !== 'https://widget-domain.com') return;
    console.log('Получено от iframe:', event.data);
  });

  iframe.contentWindow.postMessage({ type: 'init', data: 'hello' }, '*');
}

Проблемы:

Сложность взаимодействия (postMessage), лишний вес, проблемы с SEO. Также iframe не адаптивен по высоте без дополнительных сообщений.

Как создать простой виджет без Shadow DOM, используя контейнер?

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

function createCounterWidget(containerId) {
  const container = document.getElementById(containerId);
  container.innerHTML = `
    <div class="counter-widget">
      <p>Счётчик: <span id="count">0</span></p>
      <button id="inc">+1</button>
    </div>
  `;

  let count = 0;
  container.querySelector('#inc').addEventListener('click', () => {
    container.querySelector('#count').textContent = ++count;
  });
}

Проблема: конфликты имён классов и идентификаторов. Решение - использовать префиксы или случайные строки.

Типичная ошибка:

Использование глобальных идентификаторов (id) - при нескольких виджетах на странице id дублируются. Лучше использовать классы и поиск внутри контейнера.

Как сделать виджет с настраиваемыми параметрами через атрибуты?

Расширяем предыдущий подход: передаём настройки через data-атрибуты HTML-тега или через аргументы функции.

function createConfigurableWidget(containerId, config) {
  const defaults = { color: 'blue', text: 'Привет' };
  const options = { ...defaults, ...config };
  const container = document.getElementById(containerId);
  container.style.border = `2px solid ${options.color}`;
  container.innerHTML = `<p style="color:${options.color}">${options.text}</p>`;
}

Проблема: нет реактивности (при изменении атрибутов виджет не обновляется). Решение - использовать MutationObserver или прокси.

Как использовать микро-фреймворк (например, Preact) для виджета?

Preact - лёгкая замена React, отлично подходит для встраиваемых компонентов. Виджет монтируется в контейнер и использует виртуальный DOM.

// widget.js (сборщик bundled)
import { h, render } from 'preact';

export function initWidget(containerId, props) {
  const App = (props) => h('div', { className: 'widget' },
    h('p', null, `Привет, ${props.name}!`)
  );
  render(h(App, props), document.getElementById(containerId));
}

Проблема: нужно настраивать сборку (Webpack/Vite), увеличивается размер бандла. Оправдано для сложных виджетов с состоянием.

Расширенные примеры создания виджетов на JavaScript

Виджет погоды с внешним API

Пример
class WeatherWidget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    const city = this.getAttribute('city') || 'Moscow';
    this.shadowRoot.innerHTML = `
      <style>
        .weather { font-family: sans-serif; padding: 12px; background: #e3f2fd; }
        .city { font-weight: bold; }
        .temp { font-size: 1.5em; }
      </style>
      <div class="weather">
        <p class="city">${city}</p>
        <p class="temp">Загрузка...</p>
      </div>
    `;
    this.fetchWeather(city);
  }

  async fetchWeather(city) {
    try {
      const apiKey = 'YOUR_API_KEY';
      const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${apiKey}&units=metric`;
      const resp = await fetch(url);
      const data = await resp.json();
      const temp = data.main.temp;
      this.shadowRoot.querySelector('.temp').textContent = `${temp}°C`;
    } catch (e) {
      this.shadowRoot.querySelector('.temp').textContent = 'Ошибка загрузки';
    }
  }
}
customElements.define('weather-widget', WeatherWidget);
На странице используется <weather-widget city="London"></weather-widget>. Виджет сам загружает данные и отображает температуру.

Виджет чата с протоколом WebSocket

Пример
// chat-widget.js
class ChatWidget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .chat { border: 1px solid #ccc; height: 400px; display: flex; flex-direction: column; }
        .messages { flex: 1; overflow-y: auto; padding: 8px; }
        .input-area { display: flex; }
        input { flex: 1; border: none; padding: 8px; }
        button { background: #25d366; color: white; border: none; padding: 8px; }
      </style>
      <div class="chat">
        <div class="messages" id="messages"></div>
        <div class="input-area">
          <input id="input" placeholder="Введите сообщение" />
          <button id="send">Отправить</button>
        </div>
      </div>
    `;

    const ws = new WebSocket('wss://echo.websocket.org');
    const messages = this.shadowRoot.getElementById('messages');
    const input = this.shadowRoot.getElementById('input');

    ws.onmessage = (event) => {
      const msg = document.createElement('p');
      msg.textContent = `Сервер: ${event.data}`;
      messages.appendChild(msg);
    };

    this.shadowRoot.getElementById('send').addEventListener('click', () => {
      if (input.value) {
        ws.send(input.value);
        const msg = document.createElement('p');
        msg.textContent = `Вы: ${input.value}`;
        messages.appendChild(msg);
        input.value = '';
      }
    });
  }
}
customElements.define('chat-widget', ChatWidget);
Виджет создаёт WebSocket-соединение и позволяет отправлять/получать сообщения в реальном времени.

Виджет календаря с выбором даты (чистый JS)

Пример
// date-picker-widget.js
class DatePicker extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <style>
        .picker { display: inline-block; background: white; border: 1px solid #ddd; padding: 8px; }
        table { border-collapse: collapse; }
        td, th { width: 30px; height: 30px; text-align: center; cursor: default; }
        td:hover { background: #eee; }
        .selected { background: #007bff; color: white; }
        button { background: none; border: none; cursor: pointer; font-size: 1.2em; }
      </style>
      <div class="picker">
        <div>
          <button id="prev">&lt;</button>
          <span id="month-year"></span>
          <button id="next">&gt;</button>
        </div>
        <table id="calendar"></table>
      </div>
    `;

    this.date = new Date();
    this.render();

    this.shadowRoot.getElementById('prev').addEventListener('click', () => {
      this.date.setMonth(this.date.getMonth() - 1);
      this.render();
    });
    this.shadowRoot.getElementById('next').addEventListener('click', () => {
      this.date.setMonth(this.date.getMonth() + 1);
      this.render();
    });
  }

  render() {
    const year = this.date.getFullYear();
    const month = this.date.getMonth();
    this.shadowRoot.getElementById('month-year').textContent = `${month+1}/${year}`;
    const firstDay = new Date(year, month, 1).getDay();
    const daysInMonth = new Date(year, month+1, 0).getDate();
    let html = '<tr><th>Вс</th><th>Пн</th><th>Вт</th><th>Ср</th><th>Чт</th><th>Пт</th><th>Сб</th></tr><tr>';
    for (let i = 0; i < firstDay; i++) {
      html += '<td></td>';
    }
    for (let day = 1; day <= daysInMonth; day++) {
      if ((firstDay+day-1) % 7 === 0 && day !== 1) html += '</tr><tr>';
      html += `<td data-day="${day}">${day}</td>`;
    }
    html += '</tr>';
    this.shadowRoot.getElementById('calendar').innerHTML = html;

    this.shadowRoot.querySelectorAll('td[data-day]').forEach(td => {
      td.addEventListener('click', () => {
        this.shadowRoot.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
        td.classList.add('selected');
        const selectedDay = td.dataset.day;
        this.dispatchEvent(new CustomEvent('date-selected', {
          detail: { date: new Date(year, month, selectedDay) }
        }));
      });
    });
  }
}
customElements.define('date-picker', DatePicker);
Виджет отображает календарь на текущий месяц, позволяет переключать месяцы и выбирать дату. Событие 'date-selected' можно слушать на родительской странице.

Все примеры демонстрируют разные сценарии: работа с API, WebSocket, сложная DOM-логика. Все они построены на базе Web Components, что делает их легко переиспользуемыми и изолированными.

Виджеты на JavaScript - comments

En
Widget js (javascript)