Вбудувати сторонній віджет у звичайний статичний сайт нескладно. Але сучасні застосунки — це SPA (single-page application) зі своїм роутингом, глобальними стилями й життєвим циклом компонентів, і саме тут сторонні віджети починають конфліктувати: ламається верстка, стилі течуть в обидва боки, віджет зникає при навігації або дублюється при кожному ре-рендері. У цій статті — практичний розбір того, як вбудувати чат-віджет у SPA чисто: без конфліктів CSS, без витоків памʼяті і без сюрпризів при переході між сторінками.
Проблема не специфічна для якогось одного фреймворку — вона однаково стосується React, Vue, Angular чи Svelte. Джерело болю завжди одне: застосунок і віджет живуть в одному DOM-дереві й претендують на одні й ті самі CSS-класи, змінні та життєвий цикл. Розберемо, звідки беруться конфлікти і як архітектура віджета може їх зняти ще до того, як ви написали перший рядок інтеграційного коду.
Проблема: стилі, що течуть
Головний біль інтеграції — взаємне протікання стилів. Глобальні правила вашого застосунку (наприклад, скидання box-sizing чи стилі для всіх button) впливають на віджет, а стилі віджета можуть зачепити вашу сторінку. Результат — «поплила» верстка з обох боків, яку важко відлагодити.
Уявіть типове CSS-скидання на верхньому рівні застосунку:
button {
border: none;
background: transparent;
font-family: inherit;
}
Це правило абсолютно розумне для вашого застосунку — але якщо чат-віджет підключений просто як <div> з власними кнопками, він теж підпадає під це правило. Кнопка «Надіслати» в чаті раптом втрачає фон і рамку, і ви шукаєте причину годинами, бо в CSS-файлі самого віджета все виглядає правильно.
Симетрична проблема виникає і в інший бік: якщо віджет підключає власний глобальний reset.css або призначає стилі напряму елементам body чи *, це може непередбачувано змінити вигляд вашого застосунку. Специфічність CSS-селекторів і порядок підключення файлів перетворюються на лотерею.
Класичні обхідні шляхи — це неймспейси класів на кшталт .widget-xyz-button або CSS Modules з випадковими хешами в іменах. Вони працюють, але тільки якщо їх дотримуються і застосунок, і віджет, і жоден новий розробник не порушить цю домовленість у майбутньому. Це крихка угода, а не гарантія.
Рішення: Shadow DOM
Shadow DOM створює ізольований піддерево DOM із власною межею стилів. Усе всередині «тіні» не бачить глобальних стилів сторінки, а сторінка не бачить стилів віджета. Для вбудовуваного чату це майже ідеальне рішення: віджет виглядає однаково на будь-якому сайті незалежно від його CSS, і нічого не ламає.
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>/* стилі віджета */</style><div id="chat-root"></div>`;
Це спрощений приклад, але суть та сама: host — звичайний елемент на сторінці, а все, що всередині shadow, живе за власними правилами CSS. Жодне правило на кшталт button { border: none } з вашого застосунку туди не проникне.
Чому не iframe
Iframe теж ізолює стилі — навіть надійніше, бо це окремий документ і окремий JS-контекст. Але за ізоляцію доводиться платити: складніше передавати дані між сторінкою і віджетом (потрібен postMessage), важче адаптувати розмір під контент, повільніша ініціалізація через окремий HTTP-запит на цілий документ. Shadow DOM — компроміс: CSS-ізоляція є, а JS і DOM залишаються в одному контексті, тож передавати контекст користувача чи слухати події простіше.
Варто памʼятати один нюанс: CSS custom properties (--variable) і успадковувані властивості на кшталт font-family чи color за замовчуванням проникають крізь межу Shadow DOM. Це зручно, якщо хочете, щоб віджет підхопив шрифт сайту, але може здивувати, якщо очікуєте повної ізоляції. Про вибір між iframe, скриптом і Shadow DOM у ширшому контексті — стаття про додавання чату на сайт.
SPA-роутинг і життєвий цикл компонентів
На статичному сайті кожен перехід між сторінками — це повне перезавантаження, і віджет просто ініціалізується заново на кожній сторінці. У SPA все інакше: роутер міняє вміст сторінки без перезавантаження, а компоненти монтуються й розмонтовуються десятки разів за сесію користувача.
Це створює конкретну пастку: якщо контейнер віджета змонтовано всередині компонента, привʼязаного до конкретного роуту (наприклад, у Layout для однієї секції сайту), перехід на інший роут розмонтує цей компонент — і разом з ним зникне віджет. Користувач, який щойно почав писати повідомлення, побачить, як чат просто зникає.
Правильний підхід — монтувати контейнер віджета поза деревом рендерингу застосунку, напряму в document.body, один раз за весь час життя вкладки. У React це означає не робити <Widget /> частиною JSX-дерева сторінки, а ініціалізувати loader-скрипт один раз на рівні кореня застосунку (наприклад, у useEffect в компоненті верхнього рівня з порожнім масивом залежностей) і більше його не чіпати.
Ще один нюанс — StrictMode у React 18+ навмисно монтує ефекти двічі в розробці, щоб виявити проблеми з очищенням. Якщо ініціалізація віджета не ідемпотентна, ви отримаєте два віджети на сторінці в dev-режимі. Найпростіший захист — прапорець у window, який гарантує, що loader запускається лише один раз:
if (!window.__widgetLoaded) {
window.__widgetLoaded = true;
loadWidgetLoader();
}
Передача контексту користувача
У SPA ви зазвичай уже знаєте, хто ваш користувач. Гріх цим не скористатися: передайте у віджет імʼя, email і, наприклад, план — і оператор одразу бачить, з ким говорить, без зайвих питань. Технічно це просто виклик методу віджета з обʼєктом контексту після ініціалізації.
// ілюстративний приклад
widget.setUser({
name: user.name,
email: user.email,
plan: user.plan,
});
Головне — оновлювати контекст, коли користувач входить або виходить. Якщо ви передали контекст один раз при першому завантаженні сторінки, а потім користувач залогінився без перезавантаження (типова поведінка SPA), віджет так і вважатиме його анонімним, доки ви явно не оновите дані ще раз.
Тут же варто визначити межі: не передавайте у контекст нічого зайвого — паролі, токени доступу, платіжні дані. Контекст потрібен, щоб оператор зрозумів, з ким говорить і, можливо, на якому він плані, а не для того, щоб дублювати всю вашу базу користувачів у чат.
Лінива ініціалізація в SPA
- Вантажте loader, а не весь віджет. Спершу — крихітний скрипт (зазвичай кілька кілобайт); важка частина — UI, стилі, логіка чату — підтягується асинхронно, лише коли користувач реально відкриває вікно чату. Це означає, що присутність віджета на сторінці майже не впливає на метрики на кшталт LCP чи TTI.
- Ініціалізуйте після монтування застосунку, щоб не конкурувати за ресурси з першим рендером. У SPA перші секунди після завантаження — найдорожчі: браузер парсить і виконує бандл застосунку, гідратує компоненти, підвантажує дані. Додавання ще одного скрипта, що змагається за головний потік саме в цей момент, помітно вплине на відчуття швидкості. Простий прийом — відкласти ініціалізацію loader'а до події
loadабо навіть до першого простою (requestIdleCallback), якщо браузер це підтримує. - Не переініціалізовуйте на кожній навігації. У SPA роутинг клієнтський — віджет має пережити зміну сторінки, а не створюватися заново. Це повʼязано з пунктом про життєвий цикл вище: якщо ви підключаєте loader у кожному компоненті сторінки замість кореня застосунку, ви ризикуєте вантажити і монтувати віджет заново при кожному переході.
Типові помилки при інтеграції в SPA
- Монтування контейнера всередині компонента, повʼязаного з конкретним роутом, — після переходу на іншу сторінку віджет зникає разом з демонтованим компонентом.
- Відсутність захисту від подвійної ініціалізації — особливо помітно в React 18
StrictModeабо при гарячому перезавантаженні (HMR) під час розробки. - Контекст користувача, що передається лише один раз при завантаженні сторінки, а не оновлюється при вході чи виході, — оператор бачить застарілі або відсутні дані.
- Тестування інтеграції лише в dev-режимі. Продакшн-збірка часто має інші правила мінімізації CSS і інший порядок завантаження скриптів — конфлікт, якого не було локально, може зʼявитися лише на проді.
- Ігнорування z-index та
overflow: hiddenна батьківських контейнерах. Shadow DOM ізолює стилі, але не стек нашарування сторінки — якщо віджет змонтовано всередині елемента зoverflow: hidden, його спливаюче вікно може обрізатися, навіть якщо самі стилі не конфліктують.
Чек-лист: як вбудувати чат-віджет у SPA без конфліктів
Коротко: щоб вбудувати чат-віджет у SPA без конфліктів стилів і сюрпризів при навігації, перевірте пʼять речей.
- Віджет використовує Shadow DOM або iframe — а не сирі стилі в спільному DOM.
- Loader ініціалізується один раз, на рівні кореня застосунку, а не в компоненті окремої сторінки.
- Контейнер віджета змонтовано напряму в
document.body, поза деревом рендерингу роутера. - Контекст користувача оновлюється при кожній зміні авторизації, а не лише при першому завантаженні.
- Інтеграція перевірена саме на продакшн-збірці, а не тільки в dev-режимі.
Як це зроблено в Duck Float
Віджет Duck Float одразу побудований під ці вимоги: він працює на Shadow DOM, тож не конфліктує зі стилями SPA; ставиться одним рядком коду через легкий loader із лінивим завантаженням; і приймає контекст користувача — імʼя, email, план. Звернення з віджета потрапляють у той самий інбокс, що й Telegram-бот та API, а AI готує чернетку відповіді мовою клієнта — оператор лише натискає Tab, редагує або пише своє. На час бети Duck Float безкоштовний; хочете спробувати або маєте технічне питання — напишіть нам через віджет на цьому сайті.
