Ви вбудовуєте сторонній віджет на свій сайт і за годину виявляєте, що кнопки пофарбувалися в червоний, а скрипти почали конфліктувати з jQuery. Знайома ситуація? Ми пропонуємо рішення: кастомні HTML-елементи з Shadow DOM назавжди усувають цей головний біль. Клієнт вставляє три рядки коду й отримує працюючий віджет, ізольований від оточення хост-сайту. Сторонній віджет на чужому сайті — це чужа DOM, чужі стилі, потенційно конфліктуючі версії бібліотек. Custom Elements v1 вирішують цю проблему кардинально: жодних конфліктів CSS, жодних зламаних скриптів. Час завантаження такого віджета — 0.5–1.5 с (LCP), тоді як iframe-альтернатива потребуватиме 2–3 с. Це реалізація веб-компонентів, яка дає безшовну інтеграцію та повну ізоляцію.
Як кастомні HTML-елементи вирішують проблему конфліктів на сайті?
- Конфлікт стилів. Внутрішні стилі віджета можуть випадково перевизначити стилі сайту (наприклад,
button { background: red }). Shadow DOM гарантує ізоляцію в обидва боки. - Управління версіями. Віджет підключається одним скриптом з SRI-хешем. Клієнт не оновить віджет випадково — лише навмисно змінивши версію в URL.
- Кросбраузерність. Shadow DOM працює у всіх сучасних браузерах. Для IE11 підключаємо поліфіл. Тестуємо на реальних пристроях.
- SEO. На відміну від iframe, контент у Custom Element індексується пошуковиками. Це критично для віджетів відгуків, рейтингів, каталогів.
Що дає ізоляція Shadow DOM?
Shadow DOM — єдине надійне рішення. Ми створюємо <template> з інкапсульованими стилями та структурою. Ось мінімальний приклад:
<review-widget data-product-id="SKU-12345" data-theme="light" data-locale="ru" ></review-widget> Елемент сам завантажує дані через API та рендерить відгуки. Стилі ізольовані — жодні * { all: unset } хост-сторінки не зламають віджет.
Чому Custom Elements кращі за iframe для віджетів?
| Критерій | Custom Elements | iframe |
|---|---|---|
| Час завантаження (LCP) | < 1.5 с | ~3 с |
| SEO-індексація | Так | Ні |
| Адаптивність | Автоматична | Потрібна обгортка |
| Розмір скрипта | ~15 КБ (мініфікований) | > 50 КБ |
| Ізоляція стилів | Повна (Shadow DOM) | Повна (окремий документ) |
| Складність інтеграції | 3 рядки коду | Налаштування розмірів та postMessage |
| Розмір бандла після tree-shaking | ~8 КБ | ~30 КБ |
| Економія трафіку (на міс.) | до 2 000 $ | 0 $ |
Custom Elements в 2–3 рази швидші за iframe за часом завантаження і не приховують контент від пошуковиків. Економія трафіку — до 70% (до 2 000 $ на місяць).
| Браузер | Підтримка Custom Elements | Поліфіл |
|---|---|---|
| Chrome 54+ | Повна | Ні |
| Firefox 63+ | Повна | Ні |
| Safari 10.1+ | Повна | Ні |
| Edge 79+ | Повна | Ні |
| IE11 | Відсутня | Потрібен |
Як влаштований віджет зсередини
Архітектура включає три шари: Custom Element (реєструє тег, керує життєвим циклом), Shadow DOM (ізольовані стилі та розмітка), API-клієнт (завантажує дані з пагінацією). Нижче — повний код ReviewWidget:
// src/ReviewWidget.ts const TEMPLATE = document.createElement('template'); TEMPLATE.innerHTML = ` <style> :host { display: block; contain: content; font-family: var(--rw-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); font-size: var(--rw-font-size, 14px); color: var(--rw-text-color, #1a1a2e); } :host([hidden]) { display: none; } .container { border: 1px solid var(--rw-border-color, #e5e7eb); border-radius: var(--rw-radius, 8px); padding: 16px; background: var(--rw-bg, #ffffff); } .rating { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; } .star { color: #f59e0b; font-size: 18px; } .star.empty { color: #d1d5db; } .reviews-list { list-style: none; margin: 0; padding: 0; } .review-item { padding: 10px 0; border-top: 1px solid #f3f4f6; } .review-author { font-weight: 600; font-size: 13px; } .review-text { margin-top: 4px; line-height: 1.5; } .load-more { margin-top: 12px; width: 100%; padding: 8px; background: var(--rw-accent, #3b82f6); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; } .load-more:hover { opacity: .9; } .skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; height: 14px; margin: 6px 0; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } </style> <div class="container"> <div class="rating" aria-label="Рейтинг товара"></div> <ul class="reviews-list" role="list"></ul> <button class="load-more" style="display:none">Показати ще</button> </div> `; interface Review { id: string; author: string; rating: number; text: string; date: string; } export class ReviewWidget extends HTMLElement { static get observedAttributes() { return ['data-product-id', 'data-theme', 'data-locale']; } private shadow: ShadowRoot; private page = 1; private allLoaded = false; private apiBase = 'https://api.example.com/reviews'; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); this.shadow.appendChild(TEMPLATE.content.cloneNode(true)); } connectedCallback() { this.applyTheme(); this.fetchReviews(true); this.shadow.querySelector('.load-more') ?.addEventListener('click', () => this.fetchReviews(false)); } attributeChangedCallback(name: string, old: string, next: string) { if (old === next) return; if (name === 'data-product-id') { this.page = 1; this.allLoaded = false; this.fetchReviews(true); } if (name === 'data-theme') { this.applyTheme(); } } private applyTheme() { const theme = this.dataset.theme ?? 'light'; if (theme === 'dark') { const container = this.shadow.querySelector<HTMLElement>('.container'); if (container) { container.style.setProperty('--rw-bg', '#1f2937'); container.style.setProperty('--rw-text-color', '#f9fafb'); container.style.setProperty('--rw-border-color', '#374151'); } } } private showSkeleton() { const list = this.shadow.querySelector('.reviews-list')!; list.innerHTML = Array(3).fill( '<li><div class="skeleton"></div><div class="skeleton" style="width:70%"></div></li>' ).join(''); } private async fetchReviews(reset: boolean) { const productId = this.dataset.productId; if (!productId) return; if (reset) { this.page = 1; this.showSkeleton(); } try { const url = new URL(`${this.apiBase}/${productId}`); url.searchParams.set('page', String(this.page)); url.searchParams.set('per_page', '5'); url.searchParams.set('locale', this.dataset.locale ?? 'ru'); const res = await fetch(url.toString(), { headers: { 'X-Widget-Version': '2.1.0' }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: { reviews: Review[]; total: number; avg_rating: number } = await res.json(); this.renderRating(data.avg_rating, data.total); this.renderReviews(data.reviews, reset); this.allLoaded = data.reviews.length < 5; const btn = this.shadow.querySelector<HTMLElement>('.load-more'); if (btn) btn.style.display = this.allLoaded ? 'none' : 'block'; this.page++; // інформуємо хост-сторінку this.dispatchEvent(new CustomEvent('reviews:loaded', { detail: { total: data.total, avgRating: data.avg_rating }, bubbles: true, composed: true, })); } catch (err) { this.renderError(); } } private renderRating(avg: number, total: number) { const ratingEl = this.shadow.querySelector('.rating')!; const stars = Array.from({ length: 5 }, (_, i) => `<span class="star ${i < Math.round(avg) ? '' : 'empty'}">★</span>` ).join(''); ratingEl.innerHTML = `${stars} <span>${avg.toFixed(1)} (${total} відгуків)</span>`; ratingEl.setAttribute('aria-label', `Рейтинг ${avg.toFixed(1)} з 5, ${total} відгуків`); } private renderReviews(reviews: Review[], reset: boolean) { const list = this.shadow.querySelector('.reviews-list')!; if (reset) list.innerHTML = ''; const locale = this.dataset.locale ?? 'ru'; const dateFormatter = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric', }); reviews.forEach(r => { const li = document.createElement('li'); li.className = 'review-item'; li.innerHTML = ` <div class="review-author">${r.author} <time datetime="${r.date}" style="font-weight:400;color:#6b7280;margin-left:8px"> ${dateFormatter.format(new Date(r.date))} </time> </div> <div class="review-text">${r.text}</div> `; list.appendChild(li); }); } private renderError() { this.shadow.querySelector('.reviews-list')!.innerHTML = '<li style="color:#ef4444;padding:8px 0">Не вдалося завантажити відгуки</li>'; } } customElements.define('review-widget', ReviewWidget); Скрипт завантаження підключається один раз і обробляє вже існуючі в DOM екземпляри:
// src/loader.ts import { ReviewWidget } from './ReviewWidget'; if (!customElements.get('review-widget')) { customElements.define('review-widget', ReviewWidget); } if (!window.customElements) { console.warn('[review-widget] Custom Elements не підтримуються'); } Публікація через CDN з SRI-хешем гарантує цілісність ресурсу. Клієнт не оновить віджет випадково — лише навмисно змінивши версію в URL.
Коли без iframe не обійтися
Для IE11 та браузерів старших за 2017 рік Custom Elements потребують поліфіл. Якщо аудиторія вашого сайту включає таких користувачів (частка >5%), ми підключаємо поліфіл або пропонуємо гібридну схему: для старих браузерів — iframe з postMessage, для сучасних — Custom Elements. Термін розробки збільшується на 1–2 тижні.Як відбувається розробка та інтеграція?
- Аналітика. Вивчаємо оточення клієнта: браузери, бібліотеки, вимоги до SEO. Заміряємо поточний LCP та CLS.
- Проектування. Визначаємо API атрибутів, подій, структуру даних. Проектуємо Shadow DOM.
- Реалізація. Пишемо Custom Element, стилі, API-клієнт. Налаштовуємо збірку (Rollup + Terser) та CDN.
- Тестування. Перевіряємо на реальних хост-сайтах: Chrome 54+, Firefox 63+, Safari 10.1+, Edge 79+. Заміряємо LCP (< 1.5 с), CLS (< 0.1), INP (< 200 мс).
- Деплой. Завантажуємо на CDN з SRI-хешем, надаємо клієнту скрипт та документацію.
Що входить у роботу
- Розробка кастомного HTML-елемента з ізоляцією через Shadow DOM.
- Документація з інтеграції (скрипт, атрибути, події).
- Тестування в цільових браузерах.
- Розміщення на CDN з SRI-хешем та версіонуванням.
- Підтримка протягом 1 місяця після запуску (виправлення багів, консультації).
Терміни орієнтовно
Для одного віджета середньої складності — від 2 до 4 тижнів. Складність зростає, якщо потрібен iframe-режим як fallback: додається до 2 тижнів на postMessage-комунікацію. Вартість розраховується індивідуально на основі вашого стеку та вимог. Замовте попередній аудит — його вартість 15 000 ₽ списується при замовленні розробки. Ми проаналізуємо оточення та запропонуємо оптимальне рішення.
Згідно з MDN Custom Elements, елементи повинні бути зареєстровані через customElements.define.
Ми займаємося веб-розробкою 5+ років, реалізували 30+ проектів з вбудовування віджетів. Зв'яжіться з нами для оцінки вашого проекту — ми проаналізуємо оточення та запропонуємо оптимальне рішення. Отримайте консультацію або замовте розробку віджета вже сьогодні.







