
Я занимаюсь веб‑разработкой и пишу на чистом JavaScript, и PHP уже больше 20 лет. За это время я пережил jQuery, Backbone, Ember, AngularJS и видел, как React и Vue захватили умы джунов. Долгое время я искренне считал, что <template> и <slot> — это какая‑то странная ерунда штука для полифиллов Web Components, которая в реальной жизни не нужна от слова «совсем».
Зачем клонировать узлы, если есть innerHTML? Зачем Shadow DOM, если есть BEM?
Оказалось, что я просто боялся нативных инструментов. Ага. Но вот, наконец‑то, перестал и моя жизнь стала проще, а бандлы легче.
Что такое template и почему innerHTML — это боль
Представь, что тебе нужно отрендерить список из 100 карточек товаров. Классический подход 2015 года был бы таким: собрать строку в цикле и воткнуть её в контейнер.
const data = [{name: 'Кеды', price: 3000}, {name: 'Футболка', price: 1500}]; let html = ''; data.forEach(item => { html += '<div class="card"><h3>${item.name}</h3><p>${item.price} ₽</p></div>'; }); document.getElementById('list').innerHTML = html;
Проблем здесь три. Во‑первых, парсер браузера каждый раз при innerHTML вынужден заново разбирать эту строку, создавать узлы и вешать их в дерево. Во‑вторых, если в строке есть изображения, браузер попытается их загрузить прямо в момент, даже если карточки скрыты. В‑третьих, это просто уродливо.
<template> решает это. Браузер видит этот тег, понимает, что это болванка, и не рендерит её содержимое. Скрипты внутри не выполняются, картинки не грузятся, стили не применяются. Это просто инертный кусок DOM‑дерева.
<template id="card-template"> <div class="card"> <h3 class="card__title"></h3> <p class="card__price"></p> </div> </template>
const template = document.getElementById('card-template'); const container = document.getElementById('list'); const fragment = document.createDocumentFragment(); data.forEach(item => { const node = template.content.cloneNode(true); // true — клонируем рекурсивно node.querySelector('.card__title').textContent = item.name; node.querySelector('.card__price').textContent = item.price + ' ₽'; fragment.appendChild(node); }); container.appendChild(fragment);
Мы клонируем готовую структуру. Парсер не напрягается, картинки не едят трафик, а DocumentFragment позволяет закинуть все 100 карточек в DOM за один рефлоу.
Что такое slot и почему это не просто плейсхолдер
Долгое время я думал, что <slot> — это просто именованный div внутри веб‑компонента. Но когда потрогал его руками сам, то понял, что это не так. <slot> — это механизм проекции контента.
Работает он только внутри Shadow DOM.
Shadow DOM — это параллельная вселенная внутри твоего тега. Стили оттуда не утекают наружу, стили снаружи не пробиваются внутрь.
А <slot> — это единственный легальный мостик, через который пользователь твоего компонента может протолкнуть свой HTML внутрь твоей инкапсуляции.
Для примера, сделаем переиспользуемый компонент карточки, где заголовок и подвал кастомизирует потребитель, а рамку и цену — мы.
<!-- Это мы пишем в HTML страницы --> <my-card> <h2 slot="header">Лимитированные кроссовки</h2> <p slot="footer">Доставка завтра</p> </my-card> <script> class MyCard extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` <style> .wrapper { border: 1px solid #ccc; padding: 16px; border-radius: 8px; } ... </style> <div class="wrapper"> <slot name="header">[Заголовок по умолчанию]</slot> <slot>Это основной контент, если слоты не заданы</slot> <slot name="footer"></slot> </div> `; } } customElements.define('my-card', MyCard); </script>
В итоге, браузер берет h2 из обычного DOM, видит атрибут slot="header" и физически перемещает этот узел внутрь Shadow DOM.
Важные моменты, которые я бы запомнил
Если ты не укажешь
slot="...", контент упадет в безымянный (дефолтный) <slot>.Стили внутри Shadow DOM не видят содержимое слотов. Если ты хочешь стилизовать то, что пользователь воткнул в слот, используй CSS‑псевдокласс
::slotted(.my-class). Но учти, он работает только с прямым потомком, а не с глубокой вложенностью.
Варианты использования
Я перестал тащить React и иже с ним, в проекты, где он не нужен, и теперь использую эту связку везде.
Во‑первых, микро‑виджеты на чужих сайтах. Мне нужно было встроить калькулятор доставки в старый сайт на Bitrix. Я завернул логику в Web Component. Shadow DOM гарантировал, что глобальные стили Bitrix (их ужасные!important на каждый класс) не сломают мой калькулятор, а <slot> позволил владельцу сайта вставить внутрь виджета свой баннер.
Во‑вторых, модальные окна без зоопарка z-index. Модалка в Shadow DOM живет в своем слое. Тебе не нужно бояться, что какой‑то плагин слайдера перекроет твое окно своим z-index: 100500.
В‑третьих, динамические формы. У меня есть конструктор анкет. Шаблон вопроса (текстовый, выбор, дата) лежит в <template>. Когда пользователь добавляет вопрос, я клонирую нужный шаблон, пробрасываю данные и цепляю делегирование событий на общий контейнер.
Ну и серверный рендеринг (SSR). Я отдаю с бэкенда голый <my-card></my-card> с пустыми слотами. Пользователь видит мгновенную болванку, а JS на клиенте уже подтягивает данные и заполняет слоты через textContent. Никаких морганий верстки.
Ну и к чему, в итоге, все это привело
Задача сделать в панели управления список пуш‑уведомлений. Список обновляется раз в 5 секунд, сообщения могут добавляться, удаляться, их может быть сотни. Раньше я бы написал класс на JS, который собирает строки.
А сейчас, я сделал иначе. На бэкенде (PHP) я генерирую только JSON с данными. В HTML страницы лежит один‑единственный <template id="notif-item">.
<template id="notif-item"> <li class="notif" data-id=""> <slot name="icon"><span class="icon-default">🔔</span></slot> <div class="notif__body"> <strong class="notif__title"></strong> <p class="notif__text"></p> </div> </li> </template> <ul id="notif-list"></ul>
В JS я создал класс. При получении массива, я не трогаю живой DOM. Я создаю DocumentFragment, прохожусь по массиву, для каждого элемента клонирую шаблон, заполняю поля через querySelector и кладу в фрагмент.
Сложность возникла с кнопкой «Прочитать». Так как cloneNode не копирует обработчики событий, вешать onclick на каждую из 200 карточек — самоубийство. Я повесил один слушатель.
В шаблоне у кнопки есть data-action="read". В слушателе я просто проверяю e.target.closest('[data-action="read"]') и читаю dataset.id. Работает мгновенно.
Нюансы и грабли, на которые я наступил
События из Shadow DOM
По умолчанию события, возникшие внутри Shadow DOM, «прорываются» наружу, но свойство event.target будет твоим кастомным тегом (my-card), а не тем, на что реально кликнули. Если тебе нужно поймать клик именно по внутреннему элементу для аналитики — ставь на внутренних элементах event.composedPath(). Это массив узлов от цели до document, который пробивает Shadow DOM.
Стили не пробиваются.
Если ты вложишь <my-card> внутрь другого компонента, стили родителя не увидят внутренности my-card. Это фича, а не баг. Если нужно разрешить стилизацию извне, используй CSS Custom Properties. В стилях компонента пиши background: var(--card-bg, #fff). Потребитель снаружи напишет my-card { --card-bg: #f00; } — и всё заработает.
<slot> в обычном HTML — это просто div.
Не пытайся использовать слоты без attachShadow(). Браузер их проигнорирует как неизвестные теги или сделает обычными инлайн‑элементами в старых движках. Слот — это инструкция для Shadow DOM API.
DevTools скрывают шаблоны.
Ты не увидишь содержимое <template> во вкладке Elements, пока не раскроешь его. А содержимое Shadow DOM по умолчанию тоже скрыто — нужно нажать на шестеренку в инспекторе и включить «Show user agent shadow DOM».
Массовое клонирование и память.
Если ты клонируешь тысячи узлов, убедись, что при их удалении из DOM ты не оставляешь висящих ссылок в JS. Shadow DOM помогает с инкапсуляцией, но сборщик мусора не спасет тебя от глобальных массивов, в которых ты хранишь ссылки на эти узлы.
Как итог
Стоит ли выбрасывать фреймворк? Нет. Если у тебя SPA с роутингом, сложной стейт‑машиной и десятком разработчиков — бери React или Vue.
Но если ты пишешь:
виджет для встраивания;
админский интерфейс, где важна скорость первого рендера;
легковесный лендинг с парой интерактивных блоков;
микро‑фронтенд, который должен жить в чужом враждебном окружении;
— бросай innerHTML. Это 100%.
<template> дает тебе готовые, инертные чертежи. <slot> и Shadow DOM дают инкапсуляцию, за которую мы так полюбили компоненты, но без 200 килобайт рантайма.
Это нативный, быстрый и чертовски недооцененный инструмент.


