javascript

Веб. К черту фреймворки! Пишем свой starter-kit с роутером и сторами. Часть 3

  • четверг, 6 ноября 2025 г. в 00:00:05
https://habr.com/ru/articles/962778/

Часть 1. Создаем роутер и настраиваем webpack для поддержки ленивой загрузки страниц и их стилей.

Часть 2. Создание реактивного хранилища.

В этой статье мы рассмотрим основные подходы борьбы со сложностью с помощью веб-компонентов. Статья рассчитана на более-менее подготовленных читателей, знакомых с данной технологией - на тех, кто хотя бы читал соответствующие главы вот этого учебника , либо статьи на MDN. Ну или на тех, кто их собирается прочитать. Учебник Ильи я буду далее называть просто "учебник". Это классика, это знать надо. Я не буду повторяться и останавливаться на элементарных вещах. Я лишь рассмотрю не рассмотренное, а так же подводные камни и методы их обхождения. Пример более-менее сложного компонента, построенного на принципах, описанных в этой статье можно посмотреть здесь. Выпадашки синхронизированы с помощью менеджера состояний, описанном в предыдущей части. Код страницы здесь.

Введение

Еще несколько лет назад технология казалась незрелой, слабо поддерживаемой. Да что там говорить, разработчики тогда еще не понимали, что они хотят и экспериментировали как могли. Вот пример статьи, где рассмотрены основные нововведения на то время... Сейчас она, конечно, читается с улыбкой. Не в том смысле, что она наивно написана, нет. В том смысле, что половина, если не больше, рассмотренных там фич уже не действуют. Но в любом случае, советую прочитать, статья довольно интересная.

Сейчас ситуация кардинально изменилась. Стандарт уже созрел, а браузеры их поддерживают очень хорошо (более 95%). Даже в React появилась полная поддержка кастомных элементов. На данный момент уже около 20% сайтов используют их тем или иным образом. Поэтому, отказываться от них в ближайшей перспективе - все равно, что стрелять себе в ногу. Однако, пока в России я что-то не увидел фирм, которые их используют на полную катушку. По-крайней мере, на Хабр Карьере я таких не нашел. Чтож.. Когда-нибудь и до нас дойдет прогресс. Это лишь вопрос времени. Хотя, конечно, иронично, что один из самых популярных фреймворков в нашем IT создан компанией с, мягко говоря, непростой репутацией в нашей стране.

SEO

Что там насчет SEO? Дело в том, что поисковики уже давно научились выполнять наш любимый JS у себя. Внимательно посмотрите это видео (а лучше, все 8 штук).

Если коротко, то страница рендерится (но не сразу) вместе с JS. Поисковый робот видит наш контент, сгенерированный с помощью JS. Однако, Googlebot имеет лимиты времени выполнения JS. Поэтому в тяжелых страницах, часть содержимого может быть упущена. Не виден также контент, который генерируется от пользовательских действий. Поэтому старые хаки, когда страница строится по нажатию на ссылку с хешем, не работают.

Веб-компоненты уже встроены в браузер и очень хорошо оптимизированы, a их построение не вызывает заметных трудностей для роботов. Это снижает острую необходимость в добавлении дополнительных SSR/SSG - фреймворков, вроде Next.js, ведь одно дело выполнить тяжелую логику рендеринга React, другое дело - примитивную логику web-components. Кроме того, ведется дальнейшая работа по еще лучшему индексированию веб-компонентов, например, declarative shadow DOM . Хотя я так и не понял, для чего нужно в каждом компоненте прописывать это дерево, может, в комментариях объяснят как оно работает в случае целого ряда однотипных компонент на странице.

Выполнять JS умеет не только Google, но и, к примеру, Bing. Насчет остальных поисковиков я не узнавал. Вряд ли там по-другому.

Поэтому пользователь все-таки сможет узнать, что содержится в вашем сайте сам или с ИИ-помощником. Возможно, ему даже посещать ваш сайт не понадобится. Здорово, правда?

Впрочем, если ваше приложение сделано с применением лучших современных практик, и грузится секунд по 10-15 на современной мощной машине, то вам действительно нужен отдельный SSR-фреймворк (ну или, возможно, другая профессия). Вряд ли робот будет находиться на вашей странице более 5-7 секунд.

Жизненный цикл компонентов

connected/disconnectedCallback

Генерировать содержимое мы могли и раньше из JS, в чем принципиальное отличие этих компонентов? Понятный жизненный цикл. До этого нам приходилось каким-то дополнительным образом отлавливать появление элементов на странице, например, с помощью прослушки события "load":

window.addEventListener("load", () => {
  const elements = document.querySelectorAll(".your-element");
  // логика инициализации
});

Опять же возникали вопросы с очисткой памяти при удалении элемента из DOM. Теперь за это отвечают соответствующие методы connectedCallbackdisconnectedCallback. Это - громадный шаг вперед.

Вообще, методы жизненного цикла этих компонентов чрезвычайно, гениально просты... Несравнимо проще, чем в React. К примеру, каждый ли из React-разработчиков сможет сказать, в каком порядке срабатывают эффекты, в которых не указаны зависимости (т.е. в самом простом варианте)? От родителя к детям или наоборот? Или в какой последовательности инициализируются рефы? Там все гораздо, ГОРАЗДО сложнее. Для пользователей React обязательно к прочтению.

Рендер сверху вниз

В учебнике есть секция "порядок рендеринга". Хочу заметить, что в случае, когда у вас JS грузится с атрибутом defer или type="module", вы можете смело считывать внутреннее содержимое вашего кастомного тега, и оно будет определено в connectedCallback. К моменту срабатывания этих скриптов DOM-дерево уже построено. Отсюда следует важный вывод: мы можем передавать любое содержимое в наш кастомный компонент. Вычисление будет происходить сверху вниз. Сначала вычислится родитель, а потом дети. Пример (несколько надуманный, конечно):

<p-with-title>
  Hello, my friend!
</p-with-title>

<script>
  class PWithTitle extends HTMLElement {
    connectedCallback() {
      const content = this.textContent;
      this.innerHTML = `
        <h2>Some custom title</h2>
        <p>${content}</p>
        `
    }
  };

  customElements.define("p-with-title", PWithTitle);
</script>

Причем это работает как с обычным светлым DOM, так и с тенями. Вот чуть более полный пример.

Регистрация компонента

Отдельно нужно рассмотреть инициализацию компонента. Тот самый customElements.define. Дело в том, что в современных приложениях мы можем загружать наши компоненты в произвольном порядке на разных страницах. Может случиться, что мы пытаемся зарегистрировать уже зарегистрированный компонент. Тогда приложение упадет с ошибкой. Для того, чтобы избежать подобных недоразумений, я предлагаю использовать чуть более безопасную функцию инициализации.

export function initCustomElement(name, constructor) {
  if (!customElements.get(name)) {
    customElements.define(name, constructor);
  }
}

Так же хочу заметить, что в реальных проектах не стоит использовать наследование от встроенных элементов

  customElements.define('hello-button', HelloButton, {extends: 'button'});

Safari их отказывается поддерживать принципиально. И, скорее всего, эту поддержку удалят когда-нибудь из всех браузеров. К сожалению, я не могу подтвердить это, я не сохранил ссылку на обсуждение.

Реактивность компонентов

Для придания компоненту реактивности мы используем методы observedAttributes + attributeChangedCallback. С первым все ясно. Не за всеми атрибутами нужно следить. Что со вторым? Когда он сработает? Будет ли это срабатывание синхронным или асинхронным? Для объяснения его работы я создал демку .

Во-первых, он сработает самым первым, даже до connectedCallback.

Во-вторых, установка некоторых (в основном стандартных) свойств вызовет запуск колбека. К примеру, наши data-свойства так же являются стандартными, поэтому их установка вызывает обновление соответствующего атрибута. Более подробно о свойствах, которые синхронизируются с атрибутами можно почитать здесь . На примере видно, что установка свойства content никак не влияет на срабатывание колбека, поскольку оно нестандартное.

В-третьих, колбек срабатывает при ЛЮБОМ запуске setAttribute без сравнения новых и старых значений.

Отсюда возникает интересный вопрос. А какие атрибуты нам использовать? Стандартные или нестандартные? Гугл, к примеру настоятельно рекомендует синхронизировать атрибуты со свойствами... Aim to keep primitive data attributes and properties in sync, reflecting from property to attribute, and vice versa в своих best practice. Так стоит ли для синхронизации данных использовать стандартный механизм data-атрибутов (через dataset), или же следует писать свою, более сложную логику? Каждый решает сам. Я, к примеру, предпочел все-таки реализовать свою логику синхронизации. Она получилась не совсем тривиальной, поэтому приводить примеры в рамках данной статьи не буду. Любопытные могут посмотреть здесь. Это логика выпадашки, которая используется на этой странице . Замечу, что синхронизация свойств и атрибутов - прям боль. Нужно избегать появлений бесконечных циклов, когда ты устанавливаешь свойства в своем методе отрисовки. А сделать его - раз плюнуть. Так же нужно различать, когда ты устанавливаешь атрибуты в рамках обработки действий пользователя, а когда в рамках реагирования на внешние воздействия (программная установка атрибутов). Отдельная проблема заставить наш компонент рендериться только ОДИН раз при изменении атрибута или свойства.

Генерация кастомных событий

Здесь все тоже не так просто. К примеру, должны ли мы генерировать событие при программных изменениях, или только при пользовательских? Большинство встроенных элементов, вроде input не генерируют никаких событий при изменении атрибута value. Другие, вроде detailsaudio генерируют... Опять же, должны ли мы создавать на каждое изменение атрибута свое событие, или диспатчить общее? А может ли быть ситуация, когда у нас потребуется какое-либо событие, не связанное с изменением атрибута?

Чтож.. Тут нужно хорошенько подумать. На данный момент лично мне кажется, что достаточно просто добавить общее событие для изменения всех атрибутов и указать кто является источником события. Пусть для этого у нас будет флаг _isInnerAttrSet, который устанавливается в true при обработке пользовательских действий. Что-то вроде такого:

attributeChangedCallback(name, oldValue, newValue) {
  if (!this._isRendered) return;

  if (oldValue !== newValue) {
    this.dispatchEvent(
      new CustomEvent(events.change, {
        bubbles: true,
        composed: true,
        detail: {
          oldValue,
          newValue,
          attribute: name,
          source: this._isInnerAttrSet ? "user" : "program",
        },
      }),
    );
  }

  if (this._isInnerAttrSet) return;

  syncPropsWithAttrs(this, name, newValue);
  this.render(name, newValue);
}

Правда, придется тогда фильтровать события по атрибуту, но не думаю, что это прям проблема.

Вообще события, происходящие в shadow DOM имеют свои особенности , которые нужно учитывать. К примеру, у нас немного меняются некоторые стандартные подходы проектирования . В этом случае, можно использовать метод event.composedPath для точного определения элемента, на котором сработали обработчики.

Light DOM vs Shadow DOM

Инкапсуляция - один из столпов ООП. Это то, что нам обеспечивает shadow DOM из коробки. Многие молились на возможности изолирования стилей в элементах. Разрабатывались целые методологии вроде БЭМ, библиотеки вроде Styled components, добавлялись модули для работы с React-компонентами... Особенно забавно, что когда нам действительно подвезли реальную инкапсуляцию стилей, сообщество посчитало, что это не очень-то и удобно. Оказывается, стандартный каскад в CSS вовсе не был злом. Многие не особо горят желанием использовать теневое дерево именно из-за сложной настройки стилей. Чтож, давайте посмотрим, в чем заключаются основные проблемы и как их можно решить.

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

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

На этом этапе резонно возникает вопрос: а так ли уж необходимо нам теневое дерево? Почему бы не использовать старый добрый БЭМ или другие подобные методологии? При компонентном подходе количество верстки в одном компоненте сравнительно небольшое. Можно и так. Никто вообще не заставляет использовать shadow DOM. Если вдруг возникает конфликт стилей или совпадение имен при подключении внешних библиотек, то это тоже решается относительно просто. Навскидку можно использовать слои или запихнуть компоненты конфликтующей библиотеки в теневое дерево. В общем, действительно серьезных проблем не возникает даже для больших проектов.

Но многим БЭМ все-таки не нравится, поэтому рассмотрим стилизацию в shadow DOM. Вот пример сайта, использующего этот подход. Посмотрите на метрики в разделе Performance в консоли, если есть сомнения, что компоненты в shadow DOM действительно рендерятся БЫСТРО.

Стилизация

Есть несколько способов стилизации веб-компонентов извне. Для начального ознакомления читаем эту страницу .

Самый первый способ - стилизация самого элемента снаружи. Мы можем настраивать только то, что находится до теневого дерева, т.е. непосредственно сам контейнер. Изнутри контейнер мы можем настраивать с помощью :host/:host(selector). Обратите внимание на то, что стили документа являются приоритетными. По мне так это только запутывает... Проблемы возникают, когда используешь глобальный сброс стилей. Например, такой код:

* { padding: 0; }

перезапишет стили здесь

:host { padding: 1rem; }

Решается это добавлением дополнительной обертки в нашем элементе. Тогда внешние стили не будут просачиваться в эту обертку. Альтернативно можно вообще не использовать сброс стилей на уровне страниц, а только на уровне shadow DOM.

Второй способ - стилизация слотов. Они вообще находятся в светлом DOM, поэтому к ним применяются его стили. Но здесь уже возникает проблема настройки их из теневого дерева... Особо добавить здесь нечего, все описано в учебнике и на MDN .

Пользовательские CSS-свойства. Что-то настроить они позволяют (они игнорируют границы), но толку от них немного.

CSS parts. Нет в учебнике, но есть на MDN . Уже что-то, но настраивать можно только непосредственно помеченный элемент. Детей нельзя. По сути, это все тот же :slotted, но настройка идет из светлого дерева. Можно, конечно, каждый элемент пометить как part, но это не то, что хотелось бы видеть в коде. Да и БЭМ без shadow DOM, кажется, будет все-таки попроще...

Ну, в общем-то и все... Не густо. Хотя этого хватит для подавляющего большинства настроек наших компонентов. С другой стороны мы можем заложить основы для дополнительного расширения стилей непосредственно при построении наших компонентов.

Пример обходных путей

Давайте примем такой подход:

  • Пусть наши элементы автоматически загружают таблицу со сбросом стилей, общую для всего проекта. Зачем нам дублировать одно и то же везде? К примеру, box-sizing: border-box? Нам достаточно будет одной закешированной таблицы для всех элементов.

  • Добавим возможность указывать дополнительные стили, которыми мы хотим расширить (извне) или вообще заменить уже существующие. Тогда мы сможем настраивать абсолютно любой стиль в теневом дереве, используя стандартные CSS-селекторы.

  • Создадим способ подмены используемых нод в теневом дереве на те, что требуются непосредственно нам. Мы сможем, например, подменять стандартные иконки в компоненте. Слоты не всегда удобны, поскольку сами могут быть сложными компонентами со множеством атрибутов. Передавать их каждый раз в элемент не очень удобно. Особенно когда много однотипных элементов.

Как это сделать? Ну, не так уж и сложно. Можно использовать наследование и в connectedCallback подменять ноды и добавлять стили. Но это не всегда удобно, например, это придется прописывать для каждого отдельного элемента. Предлагаю рассмотреть альтернативный вариант.

Давайте посмотрим на тег template . Он уникален тем, что мы можем пихать туда все что нам заблагорассудится. Стили, например. В этом случае, они не будут загружены и не будут применены до тех пор, пока мы их не вставим в DOM. Так же мы можем добавлять туда различные элементы с любыми ID, уникальными только в рамках того дерева, в котором мы намерены их использовать. Поэтому, к примеру, мы можем для каждого нашего элемента добавить атрибут, который ссылается на этот шаблон. Наш элемент будет искать там стили, добавлять их себе, а так же замещать элементы с совпадающими ID.

Я создал страничку для примера. Там содержится элемент custom-autocomplete, в котором такая разметка:

<div class="input-wrapper">
  <input type="text" 
         name="" 
         aria-autocomplete="list" 
         role="textbox" 
         autocomplete="off">
  <!-- хотим полностью заменить этот элемент  -->
  <span id="arrow" class="arrow"></span>
</div>

<ul role="listbox">
  <!-- ... и перекрасить этот -->
  <li id="option-0" data-value="value" role="option">value</li>
</ul>

Давайте стрелочку заменим на другую и поменяем стилизацию элемента списка. Для этого поместим в шаблон наши стили и новую стрелочку.

<template id="example">
  <style>
    li { background-color: blueviolet }

    .arrow-custom { /* наши стили*/ }

    :host([open]) { .arrow-custom { /* наши стили */ } }
  </style>

  <div id="arrow" class="arrow-custom"></div>
</template>

<!-- Используем так -->
<custom-autocomplete name="styles-example" template="example"></custom-autocomplete>

Пропишем в connectedCallback нашего элемента:

connectedCallback() {
  this.shadowRoot.innerHTML = template;

  const templateAttr = this.getAttribute("template");
  const customTemplate = templateAttr && findById(templateAttr, this);
  attachStyles(this, styles, customTemplate);
  replaceToCustomIds(this.shadowRoot, customTemplate);
  // остальная логика
}

Естественно, в дальнейшем это может быть перенесено в основной класс, от которого будут наследоваться элементы библиотеки. Если в нашем компоненте есть атрибут "template" - мы рассматриваем его как шаблон расширения. Функция findById будет искать этот шаблон в самом элементе, потом в его родителях и выше. То есть проходить по предкам и искать его по ID. Код функции приводить не буду.

Функция attachStyles будет принимать контекст, начальные стили нашего элемента (в моем случае - обычная строка) и ссылку на шаблон расширения. Если в шаблоне есть стили - применяем их автоматически, иначе, для чего их там вообще указали? Так же она будет искать файл сброса стилей (общий для всех, дадим ему ID = "reset-css"). Может получиться что-то вроде этого:

function attachStyles(ctx, initialStyles, customTemplate) {
  const resultStyles = [];
  const reset = document.getElementById("reset-css");
  if (reset) resultStyles.push(reset.cloneNode(true));

  const style = document.createElement("style");
  style.textContent = initialStyles;
  resultStyles.push(style);

  if (customTemplate) {
    const templateContent = customTemplate.content;
    const customStyles = templateContent.querySelector("style,link[rel=stylesheet]");
    if (customStyles) resultStyles.push(customStyles.cloneNode(true));
  }

  ctx.shadowRoot.prepend(...resultStyles);
}

Функция replaceToCustomIds будет просто смотреть на ID-шники в шаблоне и заменять соответствующие элементы с тем же ID в исходном шаблоне.

function replaceToCustomIds(shadowRoot, customTemplate) {
  if (!customTemplate) return;

  const templateIds = Array.from(shadowRoot.querySelectorAll("[id]"), elem => elem.id);
  if (templateIds.length === 0) return;

  for (const id of templateIds) {
    const elementToReplace = customTemplate.content.getElementById(id);
    if (elementToReplace) {
      shadowRoot.getElementById(id).replaceWith(elementToReplace);
    }
  }
}

Основу для расширения наших стилей и элементов заложили. Однако, этими возможностями не стоит пользоваться очень уж часто.

Accessibility

Свои элементы создавать сложно, очень сложно. Нужно неплохо знать CSS, HTML и JS - непозволительная роскошь для идущего в ногу со временем разработчика. Еще необходимо знать, как вообще люди привыкли пользоваться похожими элементами, а так же не стоит забывать и о вопросах доступности.

Доступность - это прежде всего про удобство пользователей. Для ВСЕХ пользователей. Понятие доступности очень сильно переплетается с понятием семантики. Если ваш <div class="button"> не поддерживает навигацию с клавиатуры, а ваша ссылка <div class="link"> не позволяет открыть себя в отдельной вкладке с помощью контекстного меню, то у вас явно не очень хорошая библиотека элементов.

Я не собираюсь занудствовать, что нужно учитывать и людей с ограниченными возможностями. Не всегда и не везде. Вряд ли каким-нибудь визуальным редактором будут пользоваться люди со слабым зрением. А если и будут, то они явно не принесут прибыли создателю продукта.

Я за практический подход. Консорциум W3C создал превосходный сайт, в котором описываются самые распространенные шаблоны взаимодействия с компонентами, к которым привыкли пользователи. Добавьте его себе в закладки. Возможно, это одна из лучших шпаргалок в вебе для построения своих компонентов. Кроме того, там есть превосходные примеры. Можно посмотреть их исходный код и получить эстетическое удовольствие. По крайней мере это верно для некоторых примеров... Если вы будете пользоваться ей, то, из уважения к авторам, все-таки следует применять и ARIA атрибуты, которые там указаны.

Дополнительная литература

Одна из статей целого цикла, где описаны различные подходы, предложения по улучшению стандарта компонентов от довольно матерого дядьки.

Best practice можно посмотреть здесь.