Виджеты на JavaScript: от простых компонентов до Web Components
Виджеты на 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"><</button>
<span id="month-year"></span>
<button id="next">></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, что делает их легко переиспользуемыми и изолированными.