W3docs

JavaScript Shadow DOM

Shadow DOM позволяет прикрепить изолированное DOM-дерево к элементу, чтобы его разметка, стили и скрипты не влияли на остальную страницу. Узнайте об открытых и закрытых корнях, слотах и многоразовых компонентах.

Shadow DOM — это ключевой строительный блок Web Components, позволяющий прикрепить к элементу инкапсулированное DOM-дерево с изолированной областью стилей. В этом руководстве рассматривается, что такое Shadow DOM, зачем он нужен, как создавать открытые и закрытые корни теней, задавать области видимости стилей, проецировать контент через слоты и собирать всё это в многоразовый пользовательский элемент.

Что такое Shadow DOM?

Shadow DOM позволяет прикрепить к элементу отдельное скрытое DOM-поддерево. Разметка и стили внутри этого поддерева инкапсулированы: они не утекают наружу, и глобальные стили не проникают внутрь. Это решает одну из старейших проблем во фронтенд-разработке — конфликты глобальных CSS-стилей и идентификаторов между компонентами.

Стоит заранее определить несколько терминов:

  • Shadow host — обычный элемент, к которому прикреплено теневое дерево.
  • Shadow root — корневой узел скрытого дерева, возвращаемый методом attachShadow().
  • Shadow tree — DOM внутри shadow root.
  • Light DOM — обычные дочерние элементы элемента, написанные в обычной разметке; они могут быть спроецированы в теневое дерево через слоты.

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

В приведённом ниже примере два элемента имеют одинаковый класс shadow-box, однако каждый сохраняет собственные стили, потому что один находится в основном документе, а другой — внутри shadow root.

<head>
  <style>
    .shadow-box {
      padding: 10px;
      border: 1px solid #000;
      background-color: lightcoral;
      color: white;
    }
  </style>
</head>
<body>
  <div class="shadow-box">This is styled by the main document</div>
  <div id="host"></div>
  <script>
    // Create a shadow root
    const hostElement = document.getElementById('host');
    const shadowRoot = hostElement.attachShadow({ mode: 'open' });

    // Attach shadow DOM content
    shadowRoot.innerHTML = `
      <style>
        .shadow-box {
          padding: 10px;
          border: 1px solid #000;
          background-color: lightblue;
          color: black;
        }
      </style>
      <div class="shadow-box">Hello, Shadow DOM!</div>
    `;
  </script>
</body>

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

Создание Shadow Root

Чтобы создать shadow root, используйте метод attachShadow для элемента. Shadow root может быть open (открытым) или closed (закрытым). Открытый shadow root доступен из JavaScript за пределами теневого дерева, закрытый — нет.

Открытый Shadow Root

Открытый shadow root допускает доступ и манипуляции из внешнего JavaScript. В примере ниже мы изменяем текстовое содержимое внутри shadow root после его создания.

<body>
  <div id="open-shadow-host"></div>
  <button id="open-shadow-btn">Change Shadow Content</button>

  <script>
    const openShadowHost = document.getElementById('open-shadow-host');
    const openShadowRoot = openShadowHost.attachShadow({ mode: 'open' });

    openShadowRoot.innerHTML = `
      <style>
        .shadow-content {
          color: blue;
          padding: 10px;
          border: 1px solid black;
        }
      </style>
      <div class="shadow-content">This is an open shadow root</div>
    `;

    document.getElementById('open-shadow-btn').addEventListener('click', () => {
      openShadowRoot.querySelector('.shadow-content').textContent = 'Open Shadow Root content updated!';
    });
  </script>
</body>

В этом примере предоставлена кнопка для изменения содержимого Shadow DOM. Поскольку shadow root открытый, мы можем обращаться к его содержимому и изменять его из основного документа.

Закрытый Shadow Root

Закрытый shadow root ограничивает доступ из внешних скриптов, обеспечивая лучшую инкапсуляцию. В примере ниже мы пытаемся изменить текстовое содержимое внутри shadow root после его создания, но это невозможно, поскольку он closed.

<body>
  <div id="closed-shadow-host"></div>
  <button id="closed-shadow-btn">Try to Change Shadow Content</button>

  <script>
    const closedShadowHost = document.getElementById('closed-shadow-host');
    const closedShadowRoot = closedShadowHost.attachShadow({ mode: 'closed' });

    closedShadowRoot.innerHTML = `
      <style>
        .shadow-content {
          color: red;
          padding: 10px;
          border: 1px solid black;
        }
      </style>
      <div class="shadow-content">This is a closed shadow root</div>
    `;

    // closedShadowHost.shadowRoot is null for closed roots, so this throws a TypeError
    document.getElementById('closed-shadow-btn').addEventListener('click', () => {
      try {
        closedShadowHost.shadowRoot.querySelector('.shadow-content').textContent = 'Attempted to update closed shadow root!';
      } catch (e) {
        alert('Cannot access shadow root content from outside!');
      }
    });
  </script>
</body>

Здесь попытка завершается неудачей, потому что shadow root закрытый: closedShadowHost.shadowRoot возвращает null, поэтому null.querySelector(...) выбрасывает TypeError, и выполняется блок catch. Ссылка, возвращённая attachShadow({ mode: 'closed' }), — это единственный способ добраться до этого дерева, поэтому держите её закрытой внутри компонента.

Распространённое заблуждение состоит в том, что режим closed делает компонент по-настоящему безопасным — это не так. Он лишь препятствует случайному внешнему доступу; код, имеющий исходную ссылку на корень (или переопределяющий attachShadow), всё равно может получить доступ. Используйте open, если у вас нет конкретной причины скрывать внутренности, поскольку open значительно упрощает отладку и тестирование.

Аспектmode: 'open'mode: 'closed'
host.shadowRootВозвращает shadow rootВозвращает null
Внешний доступРазрешён через host.shadowRootТолько через сохранённую ссылку
Типичное применениеБольшинство компонентов, удобная отладкаСкрытие внутренностей от скриптов страницы
Инспекция в DevToolsПолностью видимыйВидимый, но сложнее работать со скриптами

Стилизация в Shadow DOM

Внимание

При реализации JavaScript Shadow DOM обеспечьте надлежащую инкапсуляцию, чтобы предотвратить непреднамеренные конфликты стилей или скриптов.

Стили, определённые внутри shadow root, не влияют на элементы за его пределами, и наоборот. Такая инкапсуляция полезна при создании многоразовых компонентов.

<head>
  <style>
    .styled-box {
      color: red;
      background-color: yellow;
      padding: 10px;
      border: 1px solid green;
    }
  </style>
</head>
<body>
  <div class="styled-box">This is styled by the main document</div>
  <div id="styled-host"></div>

  <script>
    const styledHost = document.getElementById('styled-host');
    const shadowRoot = styledHost.attachShadow({ mode: 'open' });

    shadowRoot.innerHTML = `
      <style>
        .styled-box {
          color: white;
          background-color: black;
          padding: 10px;
          border-radius: 5px;
        }
      </style>
      <div class="styled-box">Styled by Shadow DOM</div>
    `;
  </script>
</body>

В этом примере два элемента имеют одинаковое имя класса styled-box. Первый оформлен CSS основного документа, второй — CSS Shadow DOM. Стили, определённые в Shadow DOM, не влияют на элементы основного документа, а стили основного документа не влияют на элементы Shadow DOM. Это демонстрирует, как Shadow DOM инкапсулирует стили, гарантируя отсутствие конфликтов между стилями компонента и глобальными стилями.

Специальные селекторы для Shadow DOM

Инкапсуляция не означает полной изоляции. Три селектора предоставляют контролируемые точки доступа через границу:

  • :host — используется внутри теневого дерева для стилизации самого host-элемента. :host(.active) срабатывает, только если host имеет этот класс.
  • ::slotted(selector) — используется внутри теневого дерева для стилизации узлов light DOM, спроецированных в слот. Может нацеливаться только на элементы верхнего уровня слотированного контента, но не на их потомков.
  • ::part(name) — используется во внешнем документе для стилизации внутреннего элемента, который компонент явно экспонирует с атрибутом part="name". Это санкционированный способ позволить потребителям задавать тему компонента, не залезая в его внутренности.
<body>
  <div id="theme-host">
    <span>Projected from the light DOM</span>
  </div>

  <style>
    /* Outer page can only reach parts the component exposes */
    #theme-host::part(label) {
      text-decoration: underline;
    }
  </style>

  <script>
    const host = document.getElementById('theme-host');
    const root = host.attachShadow({ mode: 'open' });

    root.innerHTML = `
      <style>
        :host { display: block; padding: 10px; border: 2px solid teal; }
        .label { font-weight: bold; color: teal; }
        ::slotted(span) { color: crimson; }
      </style>
      <div class="label" part="label">Styled with :host and ::part</div>
      <slot></slot>
    `;
  </script>
</body>

Правило :host обрамляет весь компонент, .label — внутренний и приватный, ::slotted(span) задаёт цвет спроецированному тексту light DOM, а ::part(label) позволяет внешней странице подчеркнуть метку, доступ к теме которой был предоставлен. Всё, что не экспонировано как part, остаётся недоступным снаружи.

Слоты: контент Light DOM в Shadow DOM

Слоты позволяют разработчикам передавать контент из light DOM (обычного DOM) в shadow DOM, делая shadow DOM более гибким и многоразовым.

<div id="slot-host">
  <span slot="title">Shadow DOM Slot Example</span>
</div>

<script>
  const slotHost = document.getElementById('slot-host');
  const shadowRoot = slotHost.attachShadow({ mode: 'open' });

  shadowRoot.innerHTML = `
    <style>
      .container {
        border: 1px solid #ccc;
        padding: 10px;
      }
    </style>
    <div class="container">
      <h1><slot name="title"></slot></h1>
      <p>This is a Shadow DOM component with a slot for the title.</p>
    </div>
  `;
</script>

В этом примере элемент <slot> используется для передачи контента из light DOM в shadow DOM. Атрибут slot элемента span совпадает с атрибутом name элемента slot в shadow DOM, что позволяет содержимому span проецироваться в shadow DOM.

Взаимодействие JavaScript с Shadow DOM

Взаимодействие с Shadow DOM через JavaScript требует понимания границ инкапсуляции. Прямые манипуляции внутри shadow root просты, но внешнее взаимодействие требует осторожного подхода.

Доступ к элементам Shadow DOM

Для доступа к элементам внутри shadow DOM используйте свойство shadowRoot.

<div id="interactive-host"></div>

<script>
  const interactiveHost = document.getElementById('interactive-host');
  const shadowRoot = interactiveHost.attachShadow({ mode: 'open' });

  shadowRoot.innerHTML = `
    <button id="shadow-btn">Click me</button>
  `;

  const shadowButton = shadowRoot.querySelector('#shadow-btn');
  shadowButton.addEventListener('click', () => {
    alert('Button inside Shadow DOM clicked!');
  });
</script>

В этом примере мы получаем доступ к кнопке внутри shadow DOM с помощью querySelector на shadow root. Поскольку shadow root открытый, мы можем назначать обработчики событий и управлять элементами напрямую из основного документа.

Перенацеливание событий

События, всплывающие из теневого дерева, перенацеливаются: для слушателей во внешнем документе event.target указывает на shadow host, а не на внутренний элемент, по которому реально кликнули. Это сохраняет внутреннюю структуру в тайне. Внутри теневого дерева реальная цель по-прежнему доступна через event.composedPath()[0] или event.target.

<div id="event-host"></div>

<script>
  const host = document.getElementById('event-host');
  const root = host.attachShadow({ mode: 'open' });
  root.innerHTML = '<button id="inner">Click me</button>';

  // Listener in the OUTER document
  document.addEventListener('click', (e) => {
    console.log('Outer target:', e.target.id || e.target.tagName);
    console.log('Real target:', e.composedPath()[0].id);
  });
</script>

Клик по кнопке выводит в лог Outer target: event-host (перенацелено на host), но Real target: inner из composedPath(). Обратите внимание, что пользовательские события пересекают границу теневого дерева только при создании с { bubbles: true, composed: true }.

Практические примеры Shadow DOM

Создание многоразового веб-компонента

Создание многоразового веб-компонента с использованием Shadow DOM предполагает определение пользовательского элемента и прикрепление к нему shadow root.

<body>
  <custom-card title="Hello World"></custom-card>

  <script>
    class CustomCard extends HTMLElement {
      constructor() {
        super();
        const shadowRoot = this.attachShadow({ mode: 'open' });
        shadowRoot.innerHTML = `
          <style>
            .card {
              padding: 10px;
              border: 1px solid #ddd;
              border-radius: 5px;
              box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            }
            .card-title {
              font-size: 1.2em;
              margin-bottom: 5px;
            }
          </style>
          <div class="card">
            <div class="card-title">${this.getAttribute('title')}</div>
            <div class="card-content"><slot></slot></div>
          </div>
        `;
      }
    }

    customElements.define('custom-card', CustomCard);
  </script>
</body>

В этом примере создаётся пользовательский элемент <custom-card> с shadow DOM. Shadow DOM инкапсулирует стили и структуру компонента, делая его многоразовым без беспокойства о конфликтах стилей с основным документом. Сочетание Shadow DOM с пользовательскими элементами и элементом <template> — стандартный рецепт для production Web Components.

Интеграция с фреймворками

Shadow DOM можно легко использовать с современными JavaScript-фреймворками, такими как React, Angular и Vue.

Пример с React

В React можно прикрепить shadow DOM к элементу-контейнеру следующим образом:

<body>
  <div id="root"></div>
  <!-- React and ReactDOM CDN links -->
  <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  <script type="text/babel">
    const { useRef, useLayoutEffect } = React;

    const CustomCard = ({ title, content }) => {
      const cardRef = useRef(null);

      useLayoutEffect(() => {
        if (cardRef.current) {
          const shadowRoot = cardRef.current.attachShadow({ mode: 'open' });

          shadowRoot.innerHTML = `
            <style>
              .card {
                padding: 10px;
                border: 1px solid #ddd;
                border-radius: 5px;
                box-shadow: 0 2px 5px rgba(0,0,0,0.2);
              }
              .card-title {
                font-size: 1.2em;
                margin-bottom: 5px;
              }
            </style>
            <div class="card">
              <div class="card-title">${title}</div>
              <div class="card-content">${content}</div>
            </div>
          `;
        }
      }, [title, content]);

      return <div ref={cardRef}></div>;
    };

    const App = () => (
      <CustomCard title="Hello World" content="This is content inside the shadow DOM.">
      </CustomCard>
    );

    const rootElement = document.getElementById('root');
    const root = ReactDOM.createRoot(rootElement);
    root.render(<App />);
  </script>
</body>

В этом примере создаётся React-компонент CustomCard, который прикрепляет shadow DOM к обычному div. Shadow DOM гарантирует, что стили и структура компонента инкапсулированы, обеспечивая бесшовную интеграцию с React.

Когда использовать Shadow DOM

Shadow DOM нужен не для каждого компонента, поэтому взвесьте его плюсы и минусы:

  • Применяйте, когда создаёте самодостаточный, многоразовый виджет — особенно используемый на страницах, глобальный CSS которых вы не контролируете (встраиваемые элементы, примитивы дизайн-системы, сторонние виджеты).
  • Пропустите, если компонент полностью живёт внутри приложения, которое уже ограничивает область стилей (CSS Modules, scoped styles, BEM) и вы хотите, чтобы глобальная тема свободно проникала внутрь.
  • Остерегайтесь этих распространённых подводных камней:
    • Глобальные таблицы стилей и шрифты не распространяются внутрь автоматически; объявляйте нужные стили внутри root или передавайте значения через CSS-переменные (--my-color), которые действительно пересекают границу.
    • Элементам, связанным с формами, требуется дополнительная настройка (API ElementInternals), чтобы участвовать в окружающем <form>.
    • Серверный рендеринг теневых деревьев требует Declarative Shadow DOM (<template shadowrootmode="open">).
Информация

Практическое правило: предпочитайте mode: 'open' и предоставляйте хуки для темизации через ::part() и CSS-переменные. Используйте closed только тогда, когда скрытие внутренностей — реальное требование.

Заключение

Владение Shadow DOM необходимо для современной веб-разработки, обеспечивая мощную инкапсуляцию и возможность повторного использования. Понимая и применяя рассмотренные концепции и примеры, вы сможете создавать надёжные, изолированные компоненты, которые повышают удобство сопровождения и масштабируемость ваших веб-приложений.

Это исчерпывающее руководство служит прочной основой для изучения и использования Shadow DOM в ваших проектах. Независимо от того, создаёте ли вы простые виджеты или сложные приложения, Shadow DOM предлагает инкапсуляцию и гибкость, необходимые для того, чтобы ваши компоненты оставались изолированными и управляемыми.

Практика

Практика
Какой метод используется для создания shadow root в JavaScript?
Какой метод используется для создания shadow root в JavaScript?
Практика
Что возвращает host.shadowRoot, если root был создан с mode: 'closed'?
Что возвращает host.shadowRoot, если root был создан с mode: 'closed'?
Практика
Какой селектор позволяет внешнему документу стилизовать только те внутренние части, которые компонент явно экспонирует?
Какой селектор позволяет внешнему документу стилизовать только те внутренние части, которые компонент явно экспонирует?
Was this page helpful?