← JavaScript/Custom Elements (Веб-компоненты)#161 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Custom Elements (Веб-компоненты)

GitHub использует Custom Elements повсеместно: <relative-time>, <details-dialog>, <clipboard-copy> — десятки компонентов в их дизайн-системе. YouTube, Salesforce, Google Maps — все они на Custom Elements. Это браузерный стандарт, работающий с React, Vue, Angular или без фреймворков.

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

Фреймворки создают компоненты, которые работают только внутри экосистемы. Custom Elements — браузерный стандарт: твой компонент работает в любом фреймворке или без него. Это критично для дизайн-систем, шаринга компонентов между командами, и для сред, где нет фреймворка.

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

  • Классы: Custom Elements — это классы, расширяющие HTMLElement
  • Прототипы: цепочка прототипов HTMLElement → Element → Node
  • DOM события: connectedCallback вызывается при добавлении в DOM
  • Создание Custom Element

    class MyButton extends HTMLElement {
      connectedCallback() {
        // Вызывается когда элемент добавлен в DOM
        this.innerHTML = '<button>Нажми меня</button>'
      }
    
      disconnectedCallback() {
        // Вызывается при удалении из DOM — убираем слушатели!
        console.log('Компонент удалён')
      }
    }
    
    // Регистрируем — имя ДОЛЖНО содержать дефис
    customElements.define('my-button', MyButton)
    // Использование: <my-button></my-button>

    Жизненный цикл

    | Callback | Когда вызывается | Типичное использование |

    |---|---|---|

    | constructor | При создании экземпляра | Инициализация приватного состояния |

    | connectedCallback | Добавлен в DOM | Рендеринг, подписка на события |

    | disconnectedCallback | Удалён из DOM | Очистка таймеров, отписка |

    | attributeChangedCallback | Атрибут изменился | Перерендеринг |

    | adoptedCallback | Перенесён в другой документ | Редко нужен |

    Реакция на атрибуты

    class UserCard extends HTMLElement {
      // Список атрибутов для наблюдения
      static get observedAttributes() {
        return ['name', 'role']
      }
    
      attributeChangedCallback(name, oldValue, newValue) {
        // Вызывается только для атрибутов из observedAttributes
        console.log(`${name}: ${oldValue} → ${newValue}`)
        if (this.isConnected) this.render()
      }
    
      render() {
        const name = this.getAttribute('name') ?? 'Аноним'
        const role = this.getAttribute('role') ?? 'user'
        this.textContent = `${name} (${role})`
      }
    }
    
    customElements.define('user-card', UserCard)
    // <user-card name="Иван" role="admin"></user-card>

    Симуляция без реального DOM

    В sandbox-среде симулируем жизненный цикл через базовый класс:

    class BaseCustomElement {
      constructor() {
        this._attrs     = {}
        this._connected = false
        this._innerHTML = ''
      }
    
      connect() {
        this._connected = true
        this.connectedCallback?.()
      }
    
      disconnect() {
        this._connected = false
        this.disconnectedCallback?.()
      }
    
      setAttribute(name, value) {
        const old = this._attrs[name] ?? null
        this._attrs[name] = String(value)
        if (this.constructor.observedAttributes?.includes(name)) {
          this.attributeChangedCallback?.(name, old, String(value))
        }
      }
    
      getAttribute(name) {
        return Object.prototype.hasOwnProperty.call(this._attrs, name)
          ? this._attrs[name]
          : null
      }
    }

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

    Ошибка 1: Манипуляции с DOM в constructor

    // НЕВЕРНО — элемент ещё не подключён к DOM
    class Bad extends HTMLElement {
      constructor() {
        super()
        this.innerHTML = '<p>Привет</p>'  // Ошибка в некоторых браузерах!
      }
    }
    
    // ВЕРНО — делай это в connectedCallback
    class Good extends HTMLElement {
      connectedCallback() {
        this.innerHTML = '<p>Привет</p>'
      }
    }

    Ошибка 2: Забыть очистить ресурсы

    class Timer extends HTMLElement {
      connectedCallback() {
        this._interval = setInterval(() => this.update(), 1000)
      }
    
      disconnectedCallback() {
        clearInterval(this._interval)  // Обязательно очищаем!
      }
    }

    Ошибка 3: Имя тега без дефиса

    // НЕВЕРНО — браузер выбросит ошибку
    customElements.define('mybutton', MyButton)
    
    // ВЕРНО — обязательно наличие дефиса
    customElements.define('my-button', MyButton)

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

  • Дизайн-системы: <ds-button>, <ds-input>, <ds-modal> — совместимы с любым фреймворком
  • Микрофронтенды: каждая команда пишет компоненты независимо
  • Встраиваемые виджеты: чат, карта, форма обратной связи — без зависимости от стека хоста
  • GitHub: <relative-time>, <include-fragment>, <details-dialog>
  • Примеры

    Симуляция Custom Elements: жизненный цикл, наблюдаемые атрибуты, компонент ProductCard с состоянием

    // Симуляция Custom Elements (в браузере: class X extends HTMLElement)
    // Базовый класс реализует жизненный цикл
    
    class BaseCustomElement {
      constructor() {
        this._attrs     = {}
        this._connected = false
        this._innerHTML = ''
      }
    
      connect() {
        this._connected = true
        this.connectedCallback?.()
      }
    
      disconnect() {
        this._connected = false
        this.disconnectedCallback?.()
      }
    
      setAttribute(name, value) {
        const old     = this._attrs[name] ?? null
        this._attrs[name] = String(value)
        const observed = this.constructor.observedAttributes
        if (Array.isArray(observed) && observed.includes(name)) {
          this.attributeChangedCallback?.(name, old, String(value))
        }
      }
    
      getAttribute(name) {
        return Object.prototype.hasOwnProperty.call(this._attrs, name)
          ? this._attrs[name]
          : null
      }
    
      removeAttribute(name) {
        const old = this._attrs[name] ?? null
        delete this._attrs[name]
        const observed = this.constructor.observedAttributes
        if (Array.isArray(observed) && observed.includes(name)) {
          this.attributeChangedCallback?.(name, old, null)
        }
      }
    
      get isConnected() { return this._connected }
      get innerHTML() { return this._innerHTML }
      set innerHTML(v) { this._innerHTML = v }
    }
    
    // ===== ProductCard компонент =====
    console.log('=== ProductCard ===')
    
    class ProductCard extends BaseCustomElement {
      static get observedAttributes() {
        return ['name', 'price', 'currency', 'in-stock']
      }
    
      constructor() {
        super()
        this._renderCount = 0
        console.log('[ProductCard] constructor')
      }
    
      connectedCallback() {
        console.log('[ProductCard] connectedCallback')
        // Значения по умолчанию
        if (!this.getAttribute('currency')) this.setAttribute('currency', 'RUB')
        this.render()
      }
    
      disconnectedCallback() {
        console.log('[ProductCard] disconnectedCallback — очищаем ресурсы')
      }
    
      attributeChangedCallback(name, oldVal, newVal) {
        console.log(`  attr "${name}": ${JSON.stringify(oldVal)} → ${JSON.stringify(newVal)}`)
        if (this._connected) this.render()
      }
    
      render() {
        this._renderCount++
        const name     = this.getAttribute('name')     ?? 'Товар'
        const price    = this.getAttribute('price')    ?? '0'
        const currency = this.getAttribute('currency') ?? 'RUB'
        const inStock  = this.getAttribute('in-stock') !== 'false'
    
        this._innerHTML = `
    <div class="product-card">
      <h3>${name}</h3>
      <div class="price">${parseFloat(price).toLocaleString('ru')} ${currency}</div>
      <div class="stock ${inStock ? 'ok' : 'out'}">${inStock ? 'В наличии' : 'Нет в наличии'}</div>
    </div>`
    
        console.log(`  [render #${this._renderCount}] ${name} | ${price} ${currency} | ${inStock ? 'в наличии' : 'нет'}`)
      }
    }
    
    // --- Жизненный цикл ---
    console.log('--- 1. Создание ---')
    const card = new ProductCard()
    
    console.log('\n--- 2. Подключение к DOM ---')
    card.connect()
    
    console.log('\n--- 3. Установка атрибутов ---')
    card.setAttribute('name', 'Ноутбук Pro 15"')
    card.setAttribute('price', '85000')
    card.setAttribute('in-stock', 'true')
    
    console.log('\n--- 4. Обновление цены ---')
    card.setAttribute('price', '79000')  // скидка!
    
    console.log('\n--- 5. Товар закончился ---')
    card.setAttribute('in-stock', 'false')
    
    console.log('\n--- 6. Не наблюдаемый атрибут ---')
    card.setAttribute('data-id', 'SKU-001')  // attributeChangedCallback НЕ вызовется
    
    console.log('\n--- 7. Чтение атрибутов ---')
    console.log('name:', card.getAttribute('name'))
    console.log('price:', card.getAttribute('price'))
    console.log('data-id:', card.getAttribute('data-id'))  // 'SKU-001' — хранится, но не отслеживается
    
    console.log('\n--- 8. Отключение ---')
    card.disconnect()
    
    console.log('\n--- Итог ---')
    console.log('Рендеров:', card._renderCount)
    
    // ===== NotificationBadge компонент =====
    console.log('\n=== NotificationBadge ===')
    
    class NotificationBadge extends BaseCustomElement {
      static get observedAttributes() { return ['count', 'max', 'hidden'] }
    
      connectedCallback() {
        if (!this.getAttribute('max')) this.setAttribute('max', '99')
        this.render()
      }
    
      attributeChangedCallback(name, old, val) {
        if (this._connected) this.render()
      }
    
      render() {
        const count  = parseInt(this.getAttribute('count') ?? '0')
        const max    = parseInt(this.getAttribute('max')   ?? '99')
        const hidden = this.getAttribute('hidden') === 'true'
    
        const display = hidden ? '' : (count > max ? `${max}+` : String(count))
        console.log(`  Badge: ${hidden ? 'скрыт' : display || '0'}`)
        this._innerHTML = `<span class="badge ${hidden ? 'hidden' : ''}">${display}</span>`
      }
    }
    
    const badge = new NotificationBadge()
    badge.connect()
    
    badge.setAttribute('count', '5')   // Badge: 5
    badge.setAttribute('count', '42')  // Badge: 42
    badge.setAttribute('count', '150') // Badge: 99+
    badge.setAttribute('hidden', 'true')  // Badge: скрыт
    badge.setAttribute('hidden', 'false') // Badge: 99+

    Custom Elements (Веб-компоненты)

    GitHub использует Custom Elements повсеместно: <relative-time>, <details-dialog>, <clipboard-copy> — десятки компонентов в их дизайн-системе. YouTube, Salesforce, Google Maps — все они на Custom Elements. Это браузерный стандарт, работающий с React, Vue, Angular или без фреймворков.

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

    Фреймворки создают компоненты, которые работают только внутри экосистемы. Custom Elements — браузерный стандарт: твой компонент работает в любом фреймворке или без него. Это критично для дизайн-систем, шаринга компонентов между командами, и для сред, где нет фреймворка.

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

  • Классы: Custom Elements — это классы, расширяющие HTMLElement
  • Прототипы: цепочка прототипов HTMLElement → Element → Node
  • DOM события: connectedCallback вызывается при добавлении в DOM
  • Создание Custom Element

    class MyButton extends HTMLElement {
      connectedCallback() {
        // Вызывается когда элемент добавлен в DOM
        this.innerHTML = '<button>Нажми меня</button>'
      }
    
      disconnectedCallback() {
        // Вызывается при удалении из DOM — убираем слушатели!
        console.log('Компонент удалён')
      }
    }
    
    // Регистрируем — имя ДОЛЖНО содержать дефис
    customElements.define('my-button', MyButton)
    // Использование: <my-button></my-button>

    Жизненный цикл

    | Callback | Когда вызывается | Типичное использование |

    |---|---|---|

    | constructor | При создании экземпляра | Инициализация приватного состояния |

    | connectedCallback | Добавлен в DOM | Рендеринг, подписка на события |

    | disconnectedCallback | Удалён из DOM | Очистка таймеров, отписка |

    | attributeChangedCallback | Атрибут изменился | Перерендеринг |

    | adoptedCallback | Перенесён в другой документ | Редко нужен |

    Реакция на атрибуты

    class UserCard extends HTMLElement {
      // Список атрибутов для наблюдения
      static get observedAttributes() {
        return ['name', 'role']
      }
    
      attributeChangedCallback(name, oldValue, newValue) {
        // Вызывается только для атрибутов из observedAttributes
        console.log(`${name}: ${oldValue} → ${newValue}`)
        if (this.isConnected) this.render()
      }
    
      render() {
        const name = this.getAttribute('name') ?? 'Аноним'
        const role = this.getAttribute('role') ?? 'user'
        this.textContent = `${name} (${role})`
      }
    }
    
    customElements.define('user-card', UserCard)
    // <user-card name="Иван" role="admin"></user-card>

    Симуляция без реального DOM

    В sandbox-среде симулируем жизненный цикл через базовый класс:

    class BaseCustomElement {
      constructor() {
        this._attrs     = {}
        this._connected = false
        this._innerHTML = ''
      }
    
      connect() {
        this._connected = true
        this.connectedCallback?.()
      }
    
      disconnect() {
        this._connected = false
        this.disconnectedCallback?.()
      }
    
      setAttribute(name, value) {
        const old = this._attrs[name] ?? null
        this._attrs[name] = String(value)
        if (this.constructor.observedAttributes?.includes(name)) {
          this.attributeChangedCallback?.(name, old, String(value))
        }
      }
    
      getAttribute(name) {
        return Object.prototype.hasOwnProperty.call(this._attrs, name)
          ? this._attrs[name]
          : null
      }
    }

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

    Ошибка 1: Манипуляции с DOM в constructor

    // НЕВЕРНО — элемент ещё не подключён к DOM
    class Bad extends HTMLElement {
      constructor() {
        super()
        this.innerHTML = '<p>Привет</p>'  // Ошибка в некоторых браузерах!
      }
    }
    
    // ВЕРНО — делай это в connectedCallback
    class Good extends HTMLElement {
      connectedCallback() {
        this.innerHTML = '<p>Привет</p>'
      }
    }

    Ошибка 2: Забыть очистить ресурсы

    class Timer extends HTMLElement {
      connectedCallback() {
        this._interval = setInterval(() => this.update(), 1000)
      }
    
      disconnectedCallback() {
        clearInterval(this._interval)  // Обязательно очищаем!
      }
    }

    Ошибка 3: Имя тега без дефиса

    // НЕВЕРНО — браузер выбросит ошибку
    customElements.define('mybutton', MyButton)
    
    // ВЕРНО — обязательно наличие дефиса
    customElements.define('my-button', MyButton)

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

  • Дизайн-системы: <ds-button>, <ds-input>, <ds-modal> — совместимы с любым фреймворком
  • Микрофронтенды: каждая команда пишет компоненты независимо
  • Встраиваемые виджеты: чат, карта, форма обратной связи — без зависимости от стека хоста
  • GitHub: <relative-time>, <include-fragment>, <details-dialog>
  • Примеры

    Симуляция Custom Elements: жизненный цикл, наблюдаемые атрибуты, компонент ProductCard с состоянием

    // Симуляция Custom Elements (в браузере: class X extends HTMLElement)
    // Базовый класс реализует жизненный цикл
    
    class BaseCustomElement {
      constructor() {
        this._attrs     = {}
        this._connected = false
        this._innerHTML = ''
      }
    
      connect() {
        this._connected = true
        this.connectedCallback?.()
      }
    
      disconnect() {
        this._connected = false
        this.disconnectedCallback?.()
      }
    
      setAttribute(name, value) {
        const old     = this._attrs[name] ?? null
        this._attrs[name] = String(value)
        const observed = this.constructor.observedAttributes
        if (Array.isArray(observed) && observed.includes(name)) {
          this.attributeChangedCallback?.(name, old, String(value))
        }
      }
    
      getAttribute(name) {
        return Object.prototype.hasOwnProperty.call(this._attrs, name)
          ? this._attrs[name]
          : null
      }
    
      removeAttribute(name) {
        const old = this._attrs[name] ?? null
        delete this._attrs[name]
        const observed = this.constructor.observedAttributes
        if (Array.isArray(observed) && observed.includes(name)) {
          this.attributeChangedCallback?.(name, old, null)
        }
      }
    
      get isConnected() { return this._connected }
      get innerHTML() { return this._innerHTML }
      set innerHTML(v) { this._innerHTML = v }
    }
    
    // ===== ProductCard компонент =====
    console.log('=== ProductCard ===')
    
    class ProductCard extends BaseCustomElement {
      static get observedAttributes() {
        return ['name', 'price', 'currency', 'in-stock']
      }
    
      constructor() {
        super()
        this._renderCount = 0
        console.log('[ProductCard] constructor')
      }
    
      connectedCallback() {
        console.log('[ProductCard] connectedCallback')
        // Значения по умолчанию
        if (!this.getAttribute('currency')) this.setAttribute('currency', 'RUB')
        this.render()
      }
    
      disconnectedCallback() {
        console.log('[ProductCard] disconnectedCallback — очищаем ресурсы')
      }
    
      attributeChangedCallback(name, oldVal, newVal) {
        console.log(`  attr "${name}": ${JSON.stringify(oldVal)} → ${JSON.stringify(newVal)}`)
        if (this._connected) this.render()
      }
    
      render() {
        this._renderCount++
        const name     = this.getAttribute('name')     ?? 'Товар'
        const price    = this.getAttribute('price')    ?? '0'
        const currency = this.getAttribute('currency') ?? 'RUB'
        const inStock  = this.getAttribute('in-stock') !== 'false'
    
        this._innerHTML = `
    <div class="product-card">
      <h3>${name}</h3>
      <div class="price">${parseFloat(price).toLocaleString('ru')} ${currency}</div>
      <div class="stock ${inStock ? 'ok' : 'out'}">${inStock ? 'В наличии' : 'Нет в наличии'}</div>
    </div>`
    
        console.log(`  [render #${this._renderCount}] ${name} | ${price} ${currency} | ${inStock ? 'в наличии' : 'нет'}`)
      }
    }
    
    // --- Жизненный цикл ---
    console.log('--- 1. Создание ---')
    const card = new ProductCard()
    
    console.log('\n--- 2. Подключение к DOM ---')
    card.connect()
    
    console.log('\n--- 3. Установка атрибутов ---')
    card.setAttribute('name', 'Ноутбук Pro 15"')
    card.setAttribute('price', '85000')
    card.setAttribute('in-stock', 'true')
    
    console.log('\n--- 4. Обновление цены ---')
    card.setAttribute('price', '79000')  // скидка!
    
    console.log('\n--- 5. Товар закончился ---')
    card.setAttribute('in-stock', 'false')
    
    console.log('\n--- 6. Не наблюдаемый атрибут ---')
    card.setAttribute('data-id', 'SKU-001')  // attributeChangedCallback НЕ вызовется
    
    console.log('\n--- 7. Чтение атрибутов ---')
    console.log('name:', card.getAttribute('name'))
    console.log('price:', card.getAttribute('price'))
    console.log('data-id:', card.getAttribute('data-id'))  // 'SKU-001' — хранится, но не отслеживается
    
    console.log('\n--- 8. Отключение ---')
    card.disconnect()
    
    console.log('\n--- Итог ---')
    console.log('Рендеров:', card._renderCount)
    
    // ===== NotificationBadge компонент =====
    console.log('\n=== NotificationBadge ===')
    
    class NotificationBadge extends BaseCustomElement {
      static get observedAttributes() { return ['count', 'max', 'hidden'] }
    
      connectedCallback() {
        if (!this.getAttribute('max')) this.setAttribute('max', '99')
        this.render()
      }
    
      attributeChangedCallback(name, old, val) {
        if (this._connected) this.render()
      }
    
      render() {
        const count  = parseInt(this.getAttribute('count') ?? '0')
        const max    = parseInt(this.getAttribute('max')   ?? '99')
        const hidden = this.getAttribute('hidden') === 'true'
    
        const display = hidden ? '' : (count > max ? `${max}+` : String(count))
        console.log(`  Badge: ${hidden ? 'скрыт' : display || '0'}`)
        this._innerHTML = `<span class="badge ${hidden ? 'hidden' : ''}">${display}</span>`
      }
    }
    
    const badge = new NotificationBadge()
    badge.connect()
    
    badge.setAttribute('count', '5')   // Badge: 5
    badge.setAttribute('count', '42')  // Badge: 42
    badge.setAttribute('count', '150') // Badge: 99+
    badge.setAttribute('hidden', 'true')  // Badge: скрыт
    badge.setAttribute('hidden', 'false') // Badge: 99+

    Задание

    Используя базовый класс `BaseCustomElement` из примера, реализуй компонент `CounterElement`. Требования: - Атрибуты: `count` (текущее значение), `step` (шаг, по умолчанию 1), `min` и `max` (ограничения, необязательные) - Методы: `increment()`, `decrement()`, `reset()` - При изменении `count` вызывает `render()`, который логирует состояние - Соблюдает границы min/max при increment/decrement

    Подсказка

    observedAttributes: ["count", "step", "min", "max"]. increment: next = count + step, clamped = max ? Math.min(next, parseInt(max)) : next. decrement: next = count - step, clamped = min ? Math.max(next, parseInt(min)) : next. reset: setAttribute("count", min ?? "0")

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