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

Shadow DOM

Ты встраиваешь чат-виджет от стороннего сервиса на свой сайт. Без Shadow DOM его стили сломают твой CSS, а твои глобальные стили испортят виджет. С Shadow DOM — полная изоляция: стили снаружи не проникают внутрь, стили внутри не вытекают наружу. Именно так устроены <video>, <input type="range"> и другие нативные элементы браузера.

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

В крупных проектах CSS-конфликты — постоянная боль. Класс .button на одной странице ломает кнопку в другом компоненте. Shadow DOM создаёт изолированное дерево DOM, полностью независимое от глобальных стилей.

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

  • Custom Elements: Shadow DOM используется внутри Custom Elements
  • DOM: Shadow DOM — это отдельное дерево DOM внутри элемента
  • CSS свойства из JS: CSS Custom Properties — единственный способ пробить Shadow DOM
  • Подключение Shadow Root

    class MyWidget extends HTMLElement {
      connectedCallback() {
        // mode: 'open'   — shadow root доступен через element.shadowRoot
        // mode: 'closed' — shadow root недоступен снаружи (element.shadowRoot === null)
        const shadow = this.attachShadow({ mode: 'open' })
    
        shadow.innerHTML = `
          <style>
            /* Эти стили изолированы — не влияют на p вне компонента */
            p { color: red; font-size: 14px; }
          </style>
          <p>Содержимое виджета</p>
        `
      }
    }

    Изоляция стилей

    Ключевое свойство Shadow DOM:

    // Глобальный CSS страницы:
    // p { color: blue; font-size: 20px; }
    
    // Внутри Shadow DOM:
    // p { color: red; }
    
    // Параграф внутри компонента → красный (свои стили)
    // Параграфы снаружи → синие (не затронуты)
    
    // Аналогично: стили компонента не "вытекают":
    // .card { background: white } — не применится к .card вне компонента

    Селектор :host

    :host позволяет стилизовать элемент-хост изнутри Shadow DOM:

    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ccc;
        }
        :host([disabled]) {
          opacity: 0.5;
          pointer-events: none;
        }
        :host(.primary) {
          border-color: #0066cc;
        }
      </style>
      <slot></slot>
    `

    CSS Custom Properties — «пробивают» Shadow DOM

    CSS переменные (--variable) — стандартный способ передать стили снаружи внутрь:

    // На странице:
    // my-button { --btn-color: #0066cc; --btn-radius: 8px; }
    
    // Внутри Shadow DOM:
    shadow.innerHTML = `
      <style>
        button {
          background: var(--btn-color, #333);  /* снаружи или дефолт */
          border-radius: var(--btn-radius, 4px);
        }
      </style>
      <button><slot></slot></button>
    `

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

    Ошибка 1: Ожидать, что глобальные стили работают внутри Shadow DOM

    // Глобальный CSS: .primary { color: blue }
    // Это НЕ применится к элементу внутри Shadow DOM
    
    // Для стилизации изнутри используй :host(.primary) {}
    // Для передачи снаружи используй CSS Custom Properties

    Ошибка 2: mode: 'closed' для компонентов, которым нужен доступ

    // closed блокирует element.shadowRoot — никто не достучится
    // Даже сам компонент должен хранить ссылку на shadow root
    class MyEl extends HTMLElement {
      connectedCallback() {
        this._shadow = this.attachShadow({ mode: 'closed' })
        this._shadow.innerHTML = '<slot></slot>'
        // element.shadowRoot === null — нужно использовать this._shadow
      }
    }

    Ошибка 3: querySelector не находит элементы в Shadow DOM

    // НЕВЕРНО — document.querySelector не проникает в Shadow DOM
    const btn = document.querySelector('my-widget button')  // null!
    
    // ВЕРНО — ищем внутри shadow root
    const shadow = widget.shadowRoot
    const btn2   = shadow.querySelector('button')  // работает

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

  • Дизайн-системы: <ds-button>, <ds-modal> — изолированные компоненты без CSS-конфликтов
  • Встраиваемые виджеты: Intercom, Drift, чат-боты — полная изоляция от стилей хоста
  • Микрофронтенды: независимые команды, компоненты не конфликтуют
  • Нативные элементы: <video>, <input range>, <details> — всё это Shadow DOM под капотом
  • Примеры

    Симуляция Shadow DOM: изоляция стилей, :host, CSS-переменные, тематизация компонентов

    // Симуляция Shadow DOM без реального DOM
    // Демонстрируем концепцию инкапсуляции через объектную структуру
    
    // ===== Фабрика элементов =====
    function createElement(tagName) {
      return {
        tagName: tagName.toUpperCase(),
        _attrs:   {},
        _cssVars: {},
        shadowRoot: null,
        attachShadow(options) {
          const shadow = {
            mode: options.mode || 'open',
            host: this,
            _html: '',
            _styles: '',
            get innerHTML() { return this._html },
            set innerHTML(v) {
              this._html = v
              const m = v.match(/<style[^>]*>([sS]*?)</style>/i)
              this._styles = m ? m[1].trim() : ''
            },
          }
          this.shadowRoot = (options.mode === 'open') ? shadow : null
          return shadow
        },
        setAttribute(k, v) { this._attrs[k] = String(v) },
        getAttribute(k)    { return this._attrs[k] ?? null },
        setCSSVar(k, v)    { this._cssVars[k] = v },
        getCSSVar(k)       { return this._cssVars[k] ?? null },
      }
    }
    
    // ===== 1. Изоляция стилей =====
    console.log('=== Изоляция стилей ===')
    
    const PAGE_STYLES = { p: { color: 'blue', fontSize: '20px' } }
    console.log('Глобальный стиль страницы: p { color:', PAGE_STYLES.p.color, '}')
    
    const widget = createElement('my-widget')
    const shadow  = widget.attachShadow({ mode: 'open' })
    shadow.innerHTML = `
    <style>
      p { color: red; font-size: 14px; }
      .title { font-weight: bold; }
    </style>
    <p class="title">Заголовок виджета</p>
    <p>Тело виджета</p>`
    
    console.log('mode:', shadow.mode)   // open
    console.log('shadowRoot доступен:', widget.shadowRoot !== null)  // true
    console.log('Shadow DOM стили изолированы: p внутри → red (не blue от страницы)')
    
    // ===== 2. mode: closed =====
    console.log('\n=== mode: closed ===')
    const secret = createElement('secret-widget')
    const closedShadow = secret.attachShadow({ mode: 'closed' })
    closedShadow.innerHTML = '<p>Скрытое содержимое</p>'
    
    console.log('secret.shadowRoot:', secret.shadowRoot)  // null
    console.log('Снаружи shadowRoot недоступен — инкапсуляция работает')
    
    // ===== 3. CSS Custom Properties =====
    console.log('\n=== CSS Custom Properties ===')
    
    const btn = createElement('my-button')
    // Снаружи задаём CSS-переменные (в реальном CSS: my-button { --color: red })
    btn.setCSSVar('--btn-color', '#0066cc')
    btn.setCSSVar('--btn-radius', '8px')
    btn.setCSSVar('--btn-padding', '10px 20px')
    
    const btnShadow = btn.attachShadow({ mode: 'open' })
    btnShadow.innerHTML = `
    <style>
      :host { display: inline-block; }
      button {
        background: var(--btn-color, #333);
        border-radius: var(--btn-radius, 4px);
        padding: var(--btn-padding, 8px 16px);
        color: white; border: none; cursor: pointer;
      }
    </style>
    <button><slot>Кнопка</slot></button>`
    
    // CSS-переменные "пробивают" Shadow DOM
    function resolveVar(shadow, varName) {
      const hostVal = shadow.host.getCSSVar(varName)
      if (hostVal) return hostVal + ' (с хоста)'
    
      const fallbackMatch = shadow._styles.match(
        new RegExp('var\\(' + varName.replace(/[-[]{}()*+?.,\^$|#s]/g, '\\$&') + ',\\s*([^)]+)\\)')
      )
      return fallbackMatch ? fallbackMatch[1].trim() + ' (fallback)' : 'не задано'
    }
    
    console.log('--btn-color:', btn.getCSSVar('--btn-color'))    // #0066cc
    console.log('--btn-radius:', btn.getCSSVar('--btn-radius'))  // 8px
    
    // ===== 4. Тематизация: один компонент, разные темы =====
    console.log('\n=== Тематизация через CSS Custom Properties ===')
    
    class ThemedCard {
      constructor(theme = {}) {
        this.host   = createElement('themed-card')
        this.shadow = this.host.attachShadow({ mode: 'open' })
    
        Object.entries(theme).forEach(([k, v]) => this.host.setCSSVar(k, v))
        this._render()
      }
    
      _render() {
        this.shadow.innerHTML = `
    <style>
      :host {
        display: block;
        border: 1px solid var(--card-border, #ddd);
        border-radius: var(--card-radius, 8px);
        overflow: hidden;
      }
      .header {
        background: var(--card-header-bg, #f5f5f5);
        color: var(--card-header-color, #333);
        padding: 12px 16px;
        font-weight: bold;
      }
      .body {
        padding: 16px;
        color: var(--card-body-color, #555);
      }
    </style>
    <div class="header"><slot name="title">Без заголовка</slot></div>
    <div class="body"><slot>Нет содержимого</slot></div>`
      }
    
      describe(name) {
        const vars = Object.entries(this.host._cssVars)
        console.log(`  ${name}:`)
        console.log(`    mode: ${this.shadow.mode}`)
        console.log(`    CSS-переменные (снаружи): ${vars.map(([k,v]) => `${k}=${v}`).join(', ')}`)
        console.log(`    Изолирован от других карточек: ДА`)
      }
    }
    
    const lightCard = new ThemedCard({
      '--card-header-bg':    '#e8f4fd',
      '--card-header-color': '#0066cc',
      '--card-border':       '#b3d9f7',
    })
    
    const darkCard = new ThemedCard({
      '--card-header-bg':    '#1a1a2e',
      '--card-header-color': '#e0e0ff',
      '--card-border':       '#444',
      '--card-body-color':   '#bbb',
    })
    
    lightCard.describe('Light Theme')
    darkCard.describe('Dark Theme')
    console.log('\nОба компонента изолированы — стили не конфликтуют')

    Shadow DOM

    Ты встраиваешь чат-виджет от стороннего сервиса на свой сайт. Без Shadow DOM его стили сломают твой CSS, а твои глобальные стили испортят виджет. С Shadow DOM — полная изоляция: стили снаружи не проникают внутрь, стили внутри не вытекают наружу. Именно так устроены <video>, <input type="range"> и другие нативные элементы браузера.

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

    В крупных проектах CSS-конфликты — постоянная боль. Класс .button на одной странице ломает кнопку в другом компоненте. Shadow DOM создаёт изолированное дерево DOM, полностью независимое от глобальных стилей.

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

  • Custom Elements: Shadow DOM используется внутри Custom Elements
  • DOM: Shadow DOM — это отдельное дерево DOM внутри элемента
  • CSS свойства из JS: CSS Custom Properties — единственный способ пробить Shadow DOM
  • Подключение Shadow Root

    class MyWidget extends HTMLElement {
      connectedCallback() {
        // mode: 'open'   — shadow root доступен через element.shadowRoot
        // mode: 'closed' — shadow root недоступен снаружи (element.shadowRoot === null)
        const shadow = this.attachShadow({ mode: 'open' })
    
        shadow.innerHTML = `
          <style>
            /* Эти стили изолированы — не влияют на p вне компонента */
            p { color: red; font-size: 14px; }
          </style>
          <p>Содержимое виджета</p>
        `
      }
    }

    Изоляция стилей

    Ключевое свойство Shadow DOM:

    // Глобальный CSS страницы:
    // p { color: blue; font-size: 20px; }
    
    // Внутри Shadow DOM:
    // p { color: red; }
    
    // Параграф внутри компонента → красный (свои стили)
    // Параграфы снаружи → синие (не затронуты)
    
    // Аналогично: стили компонента не "вытекают":
    // .card { background: white } — не применится к .card вне компонента

    Селектор :host

    :host позволяет стилизовать элемент-хост изнутри Shadow DOM:

    shadow.innerHTML = `
      <style>
        :host {
          display: block;
          border: 1px solid #ccc;
        }
        :host([disabled]) {
          opacity: 0.5;
          pointer-events: none;
        }
        :host(.primary) {
          border-color: #0066cc;
        }
      </style>
      <slot></slot>
    `

    CSS Custom Properties — «пробивают» Shadow DOM

    CSS переменные (--variable) — стандартный способ передать стили снаружи внутрь:

    // На странице:
    // my-button { --btn-color: #0066cc; --btn-radius: 8px; }
    
    // Внутри Shadow DOM:
    shadow.innerHTML = `
      <style>
        button {
          background: var(--btn-color, #333);  /* снаружи или дефолт */
          border-radius: var(--btn-radius, 4px);
        }
      </style>
      <button><slot></slot></button>
    `

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

    Ошибка 1: Ожидать, что глобальные стили работают внутри Shadow DOM

    // Глобальный CSS: .primary { color: blue }
    // Это НЕ применится к элементу внутри Shadow DOM
    
    // Для стилизации изнутри используй :host(.primary) {}
    // Для передачи снаружи используй CSS Custom Properties

    Ошибка 2: mode: 'closed' для компонентов, которым нужен доступ

    // closed блокирует element.shadowRoot — никто не достучится
    // Даже сам компонент должен хранить ссылку на shadow root
    class MyEl extends HTMLElement {
      connectedCallback() {
        this._shadow = this.attachShadow({ mode: 'closed' })
        this._shadow.innerHTML = '<slot></slot>'
        // element.shadowRoot === null — нужно использовать this._shadow
      }
    }

    Ошибка 3: querySelector не находит элементы в Shadow DOM

    // НЕВЕРНО — document.querySelector не проникает в Shadow DOM
    const btn = document.querySelector('my-widget button')  // null!
    
    // ВЕРНО — ищем внутри shadow root
    const shadow = widget.shadowRoot
    const btn2   = shadow.querySelector('button')  // работает

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

  • Дизайн-системы: <ds-button>, <ds-modal> — изолированные компоненты без CSS-конфликтов
  • Встраиваемые виджеты: Intercom, Drift, чат-боты — полная изоляция от стилей хоста
  • Микрофронтенды: независимые команды, компоненты не конфликтуют
  • Нативные элементы: <video>, <input range>, <details> — всё это Shadow DOM под капотом
  • Примеры

    Симуляция Shadow DOM: изоляция стилей, :host, CSS-переменные, тематизация компонентов

    // Симуляция Shadow DOM без реального DOM
    // Демонстрируем концепцию инкапсуляции через объектную структуру
    
    // ===== Фабрика элементов =====
    function createElement(tagName) {
      return {
        tagName: tagName.toUpperCase(),
        _attrs:   {},
        _cssVars: {},
        shadowRoot: null,
        attachShadow(options) {
          const shadow = {
            mode: options.mode || 'open',
            host: this,
            _html: '',
            _styles: '',
            get innerHTML() { return this._html },
            set innerHTML(v) {
              this._html = v
              const m = v.match(/<style[^>]*>([sS]*?)</style>/i)
              this._styles = m ? m[1].trim() : ''
            },
          }
          this.shadowRoot = (options.mode === 'open') ? shadow : null
          return shadow
        },
        setAttribute(k, v) { this._attrs[k] = String(v) },
        getAttribute(k)    { return this._attrs[k] ?? null },
        setCSSVar(k, v)    { this._cssVars[k] = v },
        getCSSVar(k)       { return this._cssVars[k] ?? null },
      }
    }
    
    // ===== 1. Изоляция стилей =====
    console.log('=== Изоляция стилей ===')
    
    const PAGE_STYLES = { p: { color: 'blue', fontSize: '20px' } }
    console.log('Глобальный стиль страницы: p { color:', PAGE_STYLES.p.color, '}')
    
    const widget = createElement('my-widget')
    const shadow  = widget.attachShadow({ mode: 'open' })
    shadow.innerHTML = `
    <style>
      p { color: red; font-size: 14px; }
      .title { font-weight: bold; }
    </style>
    <p class="title">Заголовок виджета</p>
    <p>Тело виджета</p>`
    
    console.log('mode:', shadow.mode)   // open
    console.log('shadowRoot доступен:', widget.shadowRoot !== null)  // true
    console.log('Shadow DOM стили изолированы: p внутри → red (не blue от страницы)')
    
    // ===== 2. mode: closed =====
    console.log('\n=== mode: closed ===')
    const secret = createElement('secret-widget')
    const closedShadow = secret.attachShadow({ mode: 'closed' })
    closedShadow.innerHTML = '<p>Скрытое содержимое</p>'
    
    console.log('secret.shadowRoot:', secret.shadowRoot)  // null
    console.log('Снаружи shadowRoot недоступен — инкапсуляция работает')
    
    // ===== 3. CSS Custom Properties =====
    console.log('\n=== CSS Custom Properties ===')
    
    const btn = createElement('my-button')
    // Снаружи задаём CSS-переменные (в реальном CSS: my-button { --color: red })
    btn.setCSSVar('--btn-color', '#0066cc')
    btn.setCSSVar('--btn-radius', '8px')
    btn.setCSSVar('--btn-padding', '10px 20px')
    
    const btnShadow = btn.attachShadow({ mode: 'open' })
    btnShadow.innerHTML = `
    <style>
      :host { display: inline-block; }
      button {
        background: var(--btn-color, #333);
        border-radius: var(--btn-radius, 4px);
        padding: var(--btn-padding, 8px 16px);
        color: white; border: none; cursor: pointer;
      }
    </style>
    <button><slot>Кнопка</slot></button>`
    
    // CSS-переменные "пробивают" Shadow DOM
    function resolveVar(shadow, varName) {
      const hostVal = shadow.host.getCSSVar(varName)
      if (hostVal) return hostVal + ' (с хоста)'
    
      const fallbackMatch = shadow._styles.match(
        new RegExp('var\\(' + varName.replace(/[-[]{}()*+?.,\^$|#s]/g, '\\$&') + ',\\s*([^)]+)\\)')
      )
      return fallbackMatch ? fallbackMatch[1].trim() + ' (fallback)' : 'не задано'
    }
    
    console.log('--btn-color:', btn.getCSSVar('--btn-color'))    // #0066cc
    console.log('--btn-radius:', btn.getCSSVar('--btn-radius'))  // 8px
    
    // ===== 4. Тематизация: один компонент, разные темы =====
    console.log('\n=== Тематизация через CSS Custom Properties ===')
    
    class ThemedCard {
      constructor(theme = {}) {
        this.host   = createElement('themed-card')
        this.shadow = this.host.attachShadow({ mode: 'open' })
    
        Object.entries(theme).forEach(([k, v]) => this.host.setCSSVar(k, v))
        this._render()
      }
    
      _render() {
        this.shadow.innerHTML = `
    <style>
      :host {
        display: block;
        border: 1px solid var(--card-border, #ddd);
        border-radius: var(--card-radius, 8px);
        overflow: hidden;
      }
      .header {
        background: var(--card-header-bg, #f5f5f5);
        color: var(--card-header-color, #333);
        padding: 12px 16px;
        font-weight: bold;
      }
      .body {
        padding: 16px;
        color: var(--card-body-color, #555);
      }
    </style>
    <div class="header"><slot name="title">Без заголовка</slot></div>
    <div class="body"><slot>Нет содержимого</slot></div>`
      }
    
      describe(name) {
        const vars = Object.entries(this.host._cssVars)
        console.log(`  ${name}:`)
        console.log(`    mode: ${this.shadow.mode}`)
        console.log(`    CSS-переменные (снаружи): ${vars.map(([k,v]) => `${k}=${v}`).join(', ')}`)
        console.log(`    Изолирован от других карточек: ДА`)
      }
    }
    
    const lightCard = new ThemedCard({
      '--card-header-bg':    '#e8f4fd',
      '--card-header-color': '#0066cc',
      '--card-border':       '#b3d9f7',
    })
    
    const darkCard = new ThemedCard({
      '--card-header-bg':    '#1a1a2e',
      '--card-header-color': '#e0e0ff',
      '--card-border':       '#444',
      '--card-body-color':   '#bbb',
    })
    
    lightCard.describe('Light Theme')
    darkCard.describe('Dark Theme')
    console.log('\nОба компонента изолированы — стили не конфликтуют')

    Задание

    Используя функцию `createElement` из примера, реализуй компонент `BadgeElement`. Требования: - Атрибуты: `text` (текст), `color` (цвет, дефолт `"gray"`), `size` (`"small"`/`"medium"`/`"large"`, дефолт `"medium"`) - CSS-переменные хоста `--badge-color` и `--badge-size` имеют приоритет над атрибутами - Метод `render()` — логирует итоговые стили и возвращает объект стилей - Метод `describe()` — логирует все параметры компонента

    Подсказка

    attachShadow({ mode: "open" }). setAttribute(k, v) и setCSSVar(k, v) в конструкторе. describe(): getAttribute("text"), getAttribute("color"), getAttribute("size"). render() — getCSSVar имеет приоритет над getAttribute

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