Рабочее место веб-разработчика
Рабочее место веб‑разработчика

Я занимаюсь веб‑разработкой и пишу на чистом 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 килобайт рантайма.

Это нативный, быстрый и чертовски недооцененный инструмент.