← JavaScript/Template и слоты Shadow DOM#163 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Template и слоты Shadow DOM

В React ты пишешь {children} — содержимое, которое пользователь передаёт в компонент. В Vue — <slot>. В нативных Web Components этот механизм называется слоты Shadow DOM. А `<template>` — это способ хранить HTML-структуру, которая не рендерится сразу, но готова к клонированию. Вместе они дают полный механизм переиспользуемых компонентов без фреймворков.

Какую проблему решает

Без шаблонов и слотов каждый экземпляр компонента пересобирает HTML с нуля через innerHTML. Шаблоны парсятся один раз и клонируются — это быстрее. Слоты позволяют пользователям компонента передавать произвольный контент внутрь, как children в React.

На основе предыдущих уроков

  • Custom Elements: шаблоны используются внутри Custom Elements
  • Shadow DOM: слоты — механизм Shadow DOM
  • RegExp: для симуляции слотов используем регулярные выражения
  • Тег <template>

    <template> — инертный HTML-фрагмент. Не рендерится, не загружает ресурсы, не выполняет скрипты:

    // В HTML:
    // <template id="card-tmpl">
    //   <div class="card">
    //     <h2 class="card-title"></h2>
    //     <p class="card-body"></p>
    //   </div>
    // </template>
    
    const tmpl    = document.getElementById('card-tmpl')
    const clone   = tmpl.content.cloneNode(true)  // глубокое клонирование
    
    // Заполняем клон данными
    clone.querySelector('.card-title').textContent = 'Заголовок'
    clone.querySelector('.card-body').textContent  = 'Тело карточки'
    
    document.body.appendChild(clone)

    Преимущества перед innerHTML:

  • Быстрее: HTML парсится один раз, клонирование O(n)
  • Безопаснее: работаем с DOM-узлами, а не строками
  • Повторное использование: один шаблон — много экземпляров
  • Слоты (<slot>)

    Слоты — механизм композиции: позволяют вставлять внешний контент в точки теневого DOM:

    <!-- Shadow DOM компонента -->
    <div class="card">
      <slot name="header">Заголовок по умолчанию</slot>
      <div class="body">
        <slot></slot>  <!-- дефолтный слот — принимает всё остальное -->
      </div>
      <slot name="footer"></slot>
    </div>
    
    <!-- Использование (light DOM) -->
    <my-card>
      <h2 slot="header">Мой заголовок</h2>
      <p>Основной контент (идёт в дефолтный слот)</p>
      <span slot="footer">Подвал</span>
    </my-card>

    Именованные и дефолтный слоты

  • Именованный: <slot name="header"> — принимает элементы с slot="header"
  • Дефолтный: <slot> — принимает всё, что не назначено именованным слотам
  • Резервный контент: то, что внутри <slot>, отображается если слот не заполнен
  • Template + Shadow DOM = полный компонент

    class CardComponent extends HTMLElement {
      connectedCallback() {
        const shadow   = this.attachShadow({ mode: 'open' })
        const template = document.createElement('template')
    
        template.innerHTML = `
          <style>
            :host { display: block; border: 1px solid #ddd; }
            .header { background: #f5f5f5; padding: 8px; }
          </style>
          <div class="header"><slot name="title">Без заголовка</slot></div>
          <div class="body"><slot></slot></div>
        `
    
        shadow.appendChild(template.content.cloneNode(true))
      }
    }

    Типичные ошибки

    Ошибка 1: Изменение template.content вместо клона

    // НЕВЕРНО — изменяем оригинальный шаблон
    const content = tmpl.content
    content.querySelector('h2').textContent = 'Заголовок'
    document.body.appendChild(content)  // Шаблон теперь пустой!
    
    // ВЕРНО — всегда клонируем
    const clone = tmpl.content.cloneNode(true)  // true = глубокое
    clone.querySelector('h2').textContent = 'Заголовок'
    document.body.appendChild(clone)

    Ошибка 2: Слот без Shadow DOM

    // Слоты работают ТОЛЬКО в Shadow DOM
    // В light DOM <slot> — просто неизвестный тег, не функционирует
    class Wrong extends HTMLElement {
      connectedCallback() {
        this.innerHTML = '<slot name="title"></slot>'  // Не работает!
      }
    }
    
    // Нужен Shadow DOM:
    class Right extends HTMLElement {
      connectedCallback() {
        const shadow = this.attachShadow({ mode: 'open' })
        shadow.innerHTML = '<slot name="title"></slot>'  // Работает!
      }
    }

    Ошибка 3: Попытка querySelector внутри слота

    // Контент в слотах остаётся в light DOM, не в shadow DOM
    const shadow = element.shadowRoot
    shadow.querySelector('[slot="header"]')  // null! Это в light DOM
    
    // Правильно — ищем в хосте
    element.querySelector('[slot="header"]')  // работает

    В реальных проектах

  • UI-библиотеки: <sl-card> (Shoelace), <mwc-button> (Material Web Components)
  • Лэйаут-компоненты: <page-layout> с слотами header/sidebar/main/footer
  • Таблицы: <data-table> принимает <column> как слоты
  • Модалы: <modal-dialog> принимает заголовок, тело и футер через слоты
  • Примеры

    Симуляция template + slots: генерация карточек, статей и таблиц из шаблонов

    // Симуляция template + slots (без реального DOM)
    // В браузере: tmpl.content.cloneNode(true) + slot-механизм Shadow DOM
    
    // ===== Движок шаблонов =====
    
    // Клонирует шаблон и подставляет {{переменные}}
    function cloneTemplate(templateHTML, variables = {}) {
      let result = templateHTML
      for (const [key, value] of Object.entries(variables)) {
        result = result.replace(new RegExp('\\{\\{\\s*' + key + '\\s*\\}}', 'g'), String(value))
      }
      return result
    }
    
    // Заполняет именованные слоты <slot name="X">fallback</slot>
    function fillSlots(templateHTML, slots = {}) {
      let html = templateHTML
    
      // Именованные слоты
      for (const [name, content] of Object.entries(slots)) {
        html = html.replace(
          new RegExp('<slot\\s+name="' + name + '"[^>]*>([\\s\\S]*?)<\/slot>', 'g'),
          String(content)
        )
      }
    
      // Дефолтный слот (без имени)
      if (slots.default !== undefined) {
        html = html.replace(/<slot(?![\s\S]*?name=)[^>]*>([sS]*?)</slot>/g, String(slots.default))
      }
    
      // Незаполненные слоты → оставляем fallback-контент
      html = html.replace(/<slot[^>]*>([sS]*?)</slot>/g, '$1')
    
      return html
    }
    
    // ===== 1. Карточки товаров из шаблона =====
    console.log('=== Карточки товаров ===')
    
    const productTemplate = `
    <article class="product">
      <div class="badge-area">
        <slot name="badge"></slot>
      </div>
      <h3>{{name}}</h3>
      <p class="desc">{{description}}</p>
      <div class="price">
        <slot name="price">{{defaultPrice}} руб.</slot>
      </div>
      <slot name="actions"><button>Купить</button></slot>
    </article>`
    
    const products = [
      {
        vars:  { name: 'Ноутбук Pro 15"', description: 'Core i7, 16GB RAM', defaultPrice: 85000 },
        slots: { badge: '<span class="new">Новинка</span>', price: '<strong>85 000 руб.</strong>' }
      },
      {
        vars:  { name: 'Мышь беспроводная', description: 'Bluetooth, 3 кнопки', defaultPrice: 1200 },
        slots: { badge: '<span class="sale">-20%</span>' }
        // price и actions не заданы → fallback
      },
      {
        vars:  { name: 'Монитор 27"', description: '4K IPS, 144Hz', defaultPrice: 35000 },
        slots: {} // все слоты из fallback
      }
    ]
    
    for (const { vars, slots } of products) {
      // Шаг 1: клонируем шаблон с переменными
      const withVars = cloneTemplate(productTemplate, vars)
      // Шаг 2: заполняем слоты
      const rendered = fillSlots(withVars, slots)
    
      // Извлекаем заголовок для вывода
      const titleM = rendered.match(/<h3>([sS]*?)</h3>/)
      const badgeM = rendered.match(/<div class="badge-area">([sS]*?)</div>/)
      const priceM = rendered.match(/<div class="price">([sS]*?)</div>/)
    
      const title = titleM?.[1] ?? '?'
      const badge = (badgeM?.[1] ?? '').replace(/<[^>]+>/g, '').trim() || 'нет бейджа'
      const price = (priceM?.[1] ?? '').replace(/<[^>]+>/g, '').trim()
    
      console.log(`  ${title} | badge: ${badge} | ${price}`)
    }
    
    // ===== 2. Генерация страниц из слотов =====
    console.log('\n=== Страничный шаблон со слотами ===')
    
    const pageTemplate = `
    <html>
    <head>
      <title><slot name="title">Без заголовка</slot></title>
    </head>
    <body>
      <header><slot name="header"><nav>Дефолтная навигация</nav></slot></header>
      <main><slot>Контент страницы</slot></main>
      <footer><slot name="footer">© 2024 Company</slot></footer>
    </body>
    </html>`
    
    const pages = [
      {
        name: 'Главная',
        slots: {
          title:  'Главная страница',
          header: '<nav><a>Главная</a> | <a>О нас</a></nav>',
          default: '<h1>Добро пожаловать!</h1><p>Лучший сайт в мире.</p>',
        }
      },
      {
        name: 'Контакты',
        slots: {
          title:   'Контакты',
          default: '<h1>Свяжитесь с нами</h1>',
          // header и footer из fallback
        }
      }
    ]
    
    for (const page of pages) {
      const html = fillSlots(pageTemplate, page.slots)
      const titleM = html.match(/<title>([sS]*?)</title>/)
      const hasCustomNav = html.includes('<a>Главная</a>')
      const hasDefaultFooter = html.includes('© 2024')
      console.log(`  ${page.name}:`)
      console.log(`    title: "${titleM?.[1] ?? '?'}"`)
      console.log(`    nav кастомный: ${hasCustomNav}, footer дефолтный: ${hasDefaultFooter}`)
    }
    
    // ===== 3. Таблица из шаблона строк =====
    console.log('\n=== Таблица транзакций ===')
    
    const rowTemplate = `<tr>
      <td>${'{{'}id{{'}}'}}</td>
      <td>${'{{'}client{{'}}'}}</td>
      <td class="${{status_class}}">${'{{'} status{{'}}'}}</td>
      <td>${'{{'} amount{{'}}'}}</td>
    </tr>`
    
    // Упрощаем шаблон
    const rowTmpl = '<tr><td>{{id}}</td><td>{{client}}</td><td class="{{statusClass}}">{{status}}</td><td>{{amount}}</td></tr>'
    
    const transactions = [
      { id: 'TXN-001', client: 'Иван Петров',    statusClass: 'ok',      status: 'Выполнен', amount: '1 500 ₽' },
      { id: 'TXN-002', client: 'Мария Сидорова', statusClass: 'pending', status: 'В обработке', amount: '2 300 ₽' },
      { id: 'TXN-003', client: 'Алексей Козлов', statusClass: 'error',   status: 'Ошибка', amount: '850 ₽' },
    ]
    
    console.log('ID       | Клиент           | Статус       | Сумма')
    console.log('-'.repeat(58))
    
    for (const tx of transactions) {
      const row   = cloneTemplate(rowTmpl, tx)
      const cells = [...row.matchAll(/<td[^>]*>([sS]*?)</td>/g)].map(m => m[1])
      console.log(cells.map((c, i) => c.padEnd(i === 1 ? 17 : 14)).join('| '))
    }

    Template и слоты Shadow DOM

    В React ты пишешь {children} — содержимое, которое пользователь передаёт в компонент. В Vue — <slot>. В нативных Web Components этот механизм называется слоты Shadow DOM. А `<template>` — это способ хранить HTML-структуру, которая не рендерится сразу, но готова к клонированию. Вместе они дают полный механизм переиспользуемых компонентов без фреймворков.

    Какую проблему решает

    Без шаблонов и слотов каждый экземпляр компонента пересобирает HTML с нуля через innerHTML. Шаблоны парсятся один раз и клонируются — это быстрее. Слоты позволяют пользователям компонента передавать произвольный контент внутрь, как children в React.

    На основе предыдущих уроков

  • Custom Elements: шаблоны используются внутри Custom Elements
  • Shadow DOM: слоты — механизм Shadow DOM
  • RegExp: для симуляции слотов используем регулярные выражения
  • Тег <template>

    <template> — инертный HTML-фрагмент. Не рендерится, не загружает ресурсы, не выполняет скрипты:

    // В HTML:
    // <template id="card-tmpl">
    //   <div class="card">
    //     <h2 class="card-title"></h2>
    //     <p class="card-body"></p>
    //   </div>
    // </template>
    
    const tmpl    = document.getElementById('card-tmpl')
    const clone   = tmpl.content.cloneNode(true)  // глубокое клонирование
    
    // Заполняем клон данными
    clone.querySelector('.card-title').textContent = 'Заголовок'
    clone.querySelector('.card-body').textContent  = 'Тело карточки'
    
    document.body.appendChild(clone)

    Преимущества перед innerHTML:

  • Быстрее: HTML парсится один раз, клонирование O(n)
  • Безопаснее: работаем с DOM-узлами, а не строками
  • Повторное использование: один шаблон — много экземпляров
  • Слоты (<slot>)

    Слоты — механизм композиции: позволяют вставлять внешний контент в точки теневого DOM:

    <!-- Shadow DOM компонента -->
    <div class="card">
      <slot name="header">Заголовок по умолчанию</slot>
      <div class="body">
        <slot></slot>  <!-- дефолтный слот — принимает всё остальное -->
      </div>
      <slot name="footer"></slot>
    </div>
    
    <!-- Использование (light DOM) -->
    <my-card>
      <h2 slot="header">Мой заголовок</h2>
      <p>Основной контент (идёт в дефолтный слот)</p>
      <span slot="footer">Подвал</span>
    </my-card>

    Именованные и дефолтный слоты

  • Именованный: <slot name="header"> — принимает элементы с slot="header"
  • Дефолтный: <slot> — принимает всё, что не назначено именованным слотам
  • Резервный контент: то, что внутри <slot>, отображается если слот не заполнен
  • Template + Shadow DOM = полный компонент

    class CardComponent extends HTMLElement {
      connectedCallback() {
        const shadow   = this.attachShadow({ mode: 'open' })
        const template = document.createElement('template')
    
        template.innerHTML = `
          <style>
            :host { display: block; border: 1px solid #ddd; }
            .header { background: #f5f5f5; padding: 8px; }
          </style>
          <div class="header"><slot name="title">Без заголовка</slot></div>
          <div class="body"><slot></slot></div>
        `
    
        shadow.appendChild(template.content.cloneNode(true))
      }
    }

    Типичные ошибки

    Ошибка 1: Изменение template.content вместо клона

    // НЕВЕРНО — изменяем оригинальный шаблон
    const content = tmpl.content
    content.querySelector('h2').textContent = 'Заголовок'
    document.body.appendChild(content)  // Шаблон теперь пустой!
    
    // ВЕРНО — всегда клонируем
    const clone = tmpl.content.cloneNode(true)  // true = глубокое
    clone.querySelector('h2').textContent = 'Заголовок'
    document.body.appendChild(clone)

    Ошибка 2: Слот без Shadow DOM

    // Слоты работают ТОЛЬКО в Shadow DOM
    // В light DOM <slot> — просто неизвестный тег, не функционирует
    class Wrong extends HTMLElement {
      connectedCallback() {
        this.innerHTML = '<slot name="title"></slot>'  // Не работает!
      }
    }
    
    // Нужен Shadow DOM:
    class Right extends HTMLElement {
      connectedCallback() {
        const shadow = this.attachShadow({ mode: 'open' })
        shadow.innerHTML = '<slot name="title"></slot>'  // Работает!
      }
    }

    Ошибка 3: Попытка querySelector внутри слота

    // Контент в слотах остаётся в light DOM, не в shadow DOM
    const shadow = element.shadowRoot
    shadow.querySelector('[slot="header"]')  // null! Это в light DOM
    
    // Правильно — ищем в хосте
    element.querySelector('[slot="header"]')  // работает

    В реальных проектах

  • UI-библиотеки: <sl-card> (Shoelace), <mwc-button> (Material Web Components)
  • Лэйаут-компоненты: <page-layout> с слотами header/sidebar/main/footer
  • Таблицы: <data-table> принимает <column> как слоты
  • Модалы: <modal-dialog> принимает заголовок, тело и футер через слоты
  • Примеры

    Симуляция template + slots: генерация карточек, статей и таблиц из шаблонов

    // Симуляция template + slots (без реального DOM)
    // В браузере: tmpl.content.cloneNode(true) + slot-механизм Shadow DOM
    
    // ===== Движок шаблонов =====
    
    // Клонирует шаблон и подставляет {{переменные}}
    function cloneTemplate(templateHTML, variables = {}) {
      let result = templateHTML
      for (const [key, value] of Object.entries(variables)) {
        result = result.replace(new RegExp('\\{\\{\\s*' + key + '\\s*\\}}', 'g'), String(value))
      }
      return result
    }
    
    // Заполняет именованные слоты <slot name="X">fallback</slot>
    function fillSlots(templateHTML, slots = {}) {
      let html = templateHTML
    
      // Именованные слоты
      for (const [name, content] of Object.entries(slots)) {
        html = html.replace(
          new RegExp('<slot\\s+name="' + name + '"[^>]*>([\\s\\S]*?)<\/slot>', 'g'),
          String(content)
        )
      }
    
      // Дефолтный слот (без имени)
      if (slots.default !== undefined) {
        html = html.replace(/<slot(?![\s\S]*?name=)[^>]*>([sS]*?)</slot>/g, String(slots.default))
      }
    
      // Незаполненные слоты → оставляем fallback-контент
      html = html.replace(/<slot[^>]*>([sS]*?)</slot>/g, '$1')
    
      return html
    }
    
    // ===== 1. Карточки товаров из шаблона =====
    console.log('=== Карточки товаров ===')
    
    const productTemplate = `
    <article class="product">
      <div class="badge-area">
        <slot name="badge"></slot>
      </div>
      <h3>{{name}}</h3>
      <p class="desc">{{description}}</p>
      <div class="price">
        <slot name="price">{{defaultPrice}} руб.</slot>
      </div>
      <slot name="actions"><button>Купить</button></slot>
    </article>`
    
    const products = [
      {
        vars:  { name: 'Ноутбук Pro 15"', description: 'Core i7, 16GB RAM', defaultPrice: 85000 },
        slots: { badge: '<span class="new">Новинка</span>', price: '<strong>85 000 руб.</strong>' }
      },
      {
        vars:  { name: 'Мышь беспроводная', description: 'Bluetooth, 3 кнопки', defaultPrice: 1200 },
        slots: { badge: '<span class="sale">-20%</span>' }
        // price и actions не заданы → fallback
      },
      {
        vars:  { name: 'Монитор 27"', description: '4K IPS, 144Hz', defaultPrice: 35000 },
        slots: {} // все слоты из fallback
      }
    ]
    
    for (const { vars, slots } of products) {
      // Шаг 1: клонируем шаблон с переменными
      const withVars = cloneTemplate(productTemplate, vars)
      // Шаг 2: заполняем слоты
      const rendered = fillSlots(withVars, slots)
    
      // Извлекаем заголовок для вывода
      const titleM = rendered.match(/<h3>([sS]*?)</h3>/)
      const badgeM = rendered.match(/<div class="badge-area">([sS]*?)</div>/)
      const priceM = rendered.match(/<div class="price">([sS]*?)</div>/)
    
      const title = titleM?.[1] ?? '?'
      const badge = (badgeM?.[1] ?? '').replace(/<[^>]+>/g, '').trim() || 'нет бейджа'
      const price = (priceM?.[1] ?? '').replace(/<[^>]+>/g, '').trim()
    
      console.log(`  ${title} | badge: ${badge} | ${price}`)
    }
    
    // ===== 2. Генерация страниц из слотов =====
    console.log('\n=== Страничный шаблон со слотами ===')
    
    const pageTemplate = `
    <html>
    <head>
      <title><slot name="title">Без заголовка</slot></title>
    </head>
    <body>
      <header><slot name="header"><nav>Дефолтная навигация</nav></slot></header>
      <main><slot>Контент страницы</slot></main>
      <footer><slot name="footer">© 2024 Company</slot></footer>
    </body>
    </html>`
    
    const pages = [
      {
        name: 'Главная',
        slots: {
          title:  'Главная страница',
          header: '<nav><a>Главная</a> | <a>О нас</a></nav>',
          default: '<h1>Добро пожаловать!</h1><p>Лучший сайт в мире.</p>',
        }
      },
      {
        name: 'Контакты',
        slots: {
          title:   'Контакты',
          default: '<h1>Свяжитесь с нами</h1>',
          // header и footer из fallback
        }
      }
    ]
    
    for (const page of pages) {
      const html = fillSlots(pageTemplate, page.slots)
      const titleM = html.match(/<title>([sS]*?)</title>/)
      const hasCustomNav = html.includes('<a>Главная</a>')
      const hasDefaultFooter = html.includes('© 2024')
      console.log(`  ${page.name}:`)
      console.log(`    title: "${titleM?.[1] ?? '?'}"`)
      console.log(`    nav кастомный: ${hasCustomNav}, footer дефолтный: ${hasDefaultFooter}`)
    }
    
    // ===== 3. Таблица из шаблона строк =====
    console.log('\n=== Таблица транзакций ===')
    
    const rowTemplate = `<tr>
      <td>${'{{'}id{{'}}'}}</td>
      <td>${'{{'}client{{'}}'}}</td>
      <td class="${{status_class}}">${'{{'} status{{'}}'}}</td>
      <td>${'{{'} amount{{'}}'}}</td>
    </tr>`
    
    // Упрощаем шаблон
    const rowTmpl = '<tr><td>{{id}}</td><td>{{client}}</td><td class="{{statusClass}}">{{status}}</td><td>{{amount}}</td></tr>'
    
    const transactions = [
      { id: 'TXN-001', client: 'Иван Петров',    statusClass: 'ok',      status: 'Выполнен', amount: '1 500 ₽' },
      { id: 'TXN-002', client: 'Мария Сидорова', statusClass: 'pending', status: 'В обработке', amount: '2 300 ₽' },
      { id: 'TXN-003', client: 'Алексей Козлов', statusClass: 'error',   status: 'Ошибка', amount: '850 ₽' },
    ]
    
    console.log('ID       | Клиент           | Статус       | Сумма')
    console.log('-'.repeat(58))
    
    for (const tx of transactions) {
      const row   = cloneTemplate(rowTmpl, tx)
      const cells = [...row.matchAll(/<td[^>]*>([sS]*?)</td>/g)].map(m => m[1])
      console.log(cells.map((c, i) => c.padEnd(i === 1 ? 17 : 14)).join('| '))
    }

    Задание

    Реализуй систему шаблонов для генерации HTML-контента. Реализуй: - `renderTemplate(template, slots, variables)` — сначала заменяет `{{переменные}}`, затем заполняет именованные слоты `<slot name="X">fallback</slot>`, незаполненные слоты оставляет с fallback-содержимым - `renderList(template, items)` — применяет шаблон к каждому элементу массива как набор переменных, возвращает массив HTML-строк

    Подсказка

    renderTemplate: шаг 1 — replace(regex, value), шаг 2 — replace(slotRegex, content), шаг 3 — replace(/<slot[^>]*>([\s\S]*?)<\/slot>/g, "$1"). renderList: items.map(item => renderTemplate(template, {}, item))

    Загружаем среду выполнения...
    Загружаем AI-помощника...