DevGang
Авторизоваться

Варианты стилизации веб-компонентов 

Когда я выпустил emoji-picker-element в прошлом году, я впервые написал универсальный веб-компонент, который можно было добавить в любой проект или фреймворк. Кроме того, это был мой первый раз, когда я действительно использовал shadow DOM.

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

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

Это привело меня к исследованию всех различных способов, с помощью которых автономный веб-компонент может предоставлять API стилей. В этом посте я рассмотрю каждую стратегию, а также ее сильные и слабые стороны с моей точки зрения.

Основы shadow DOM

(Не стесняйтесь пропустить этот раздел, если вы уже знаете, как работает shadow DOM.)

Основное преимущество модели shadow DOM, особенно для автономного веб-компонента, заключается в том, что все ваши стили инкапсулированы. Подобно фреймворкам JavaScript, которые автоматически создают стили с ограниченной областью видимости (например, Vue или Svelte), любые стили в вашем веб-компоненте не будут перетекать на страницу и наоборот. Таким образом, вы можете вставлять свои любимые сбросы, например:

* {
  box-sizing: border-box;
}
 
button {
  cursor: pointer;
}

Еще одно влияние shadow DOM заключается в том, что API-интерфейсы DOM не могут «пробить» теневое дерево. Так, например document.querySelectorAll('button'), не будет перечислять какие-либо кнопки внутри средства выбора смайлов.

Краткая интерлюдия: открытая и закрытая теневая DOM

Существует два типа теневой модели DOM: открытая и закрытая. Я решил пойти с открытым, и все приведенные ниже примеры предполагают открытый теневой DOM.

Короче говоря, закрытая теневая модель DOM, похоже, не используется широко, а ее недостатки перевешивают преимущества. По сути, «открытый» режим разрешает некоторый ограниченный доступ к JavaScript API через element.shadowRoot (например element.shadowRoot.querySelectorAll('button'), находит кнопки), тогда как «закрытый» режим блокирует весь доступ JavaScript (element.shadowRoot имеет значение null). Это может показаться победой в области безопасности, но на самом деле существует множество обходных путей даже для закрытого режима.

Таким образом, закрытый режим оставляет вам поверхность API, которую труднее тестировать, не дает пользователям возможности избежать выхода (см. Ниже) и не дает никаких преимуществ в плане безопасности. Кроме того, фреймворк, который я выбрал, Svelte, по умолчанию использует открытый режим и не имеет возможности его изменить. Так что хлопот не стоило.

Стратегии стилей

Вы, возможно, заметили в приведенном выше обсуждении, что shadow DOM кажется довольно изолированным - никакие стили не используются, никакие стили не выходят. Но на самом деле есть несколько четко определенных способов настройки стилей, и они дают вам возможность предложить пользователям эргономичный API стилей. Вот основные варианты:

  1. CSS переменные (также известные как настраиваемые свойства)
  2. Классы
  3. Shadow parts
  4. «escape hatch» (вставка произвольного css)

Следует отметить, что эти стратегии не являются «либо / или». Вы можете легко смешать их все в одном веб-компоненте! Это просто зависит от того, что больше всего подходит для вашего проекта.

Вариант 1. CSS переменные (также известные как настраиваемые свойства)

Для emoji-picker-element, я выбрал этот вариант, как мой основной подход, так как он имел лучшую поддержку браузеров в то время и на самом деле работал на удивление хорошо для ряда случаев применения.

Основная идея состоит в том, что переменные CSS действительно могут пробить shadow DOM. Нет, правда! Вы можете определить переменную в :root, а затем использовать эту переменную в любом shadow DOM, который вам нравится. CSS переменные в :root фактически являются глобальными по всему документу (вроде как window в JavaScript).

Вот CodePen для демонстрации:

Вначале я начал придумывать некоторые полезные переменные CSS для средства выбора смайлов:

  1. --background стилизовать цвет фона
  2. --emoji-padding стилизовать отступы вокруг каждого смайлика
  3. --num-columns выбрать количество столбцов в сетке

Они действительно работают! На самом деле, это некоторые из переменных в emoji-picker-element. Даже --num-columns работает, благодаря магии CSS grids.

Однако было бы довольно ужасно, если бы на вашей странице было несколько веб-компонентов, и каждый из них имел бы общие переменные, подобные --background, которые вы должны были определить в :root. Что, если они противоречат друг другу?

Конфликтующие CSS переменные

Одна из стратегий разрешения конфликтов - это префикс переменных. Вот как это делают Lightning Web Components, фреймворк, который мы создаем в Salesforce: все имеет префикс --lwc-.

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

Взгляните на компонент кнопки Ionic и модальный компонент. Оба они могут быть стилизованы с помощью общего свойства CSS --background. Но что, если вам нужен другой фон для каждого? Не проблема!

Вот упрощенный пример того, как это делает Ionic. Ниже у меня есть foo-component и bar-component. У каждого из них разный цвет фона, но оба стилизованы с помощью переменной --background:

С точки зрения пользователя CSS довольно интуитивно понятен:

foo-component {
  --background: hotpink;
}
 
bar-component {
  --background: lightsalmon;
}

И если эти переменные определены где-то еще, например, в :root, то они вообще не влияют на компоненты!

:root {
  --background: black; /* does nothing */
}

Вместо этого компоненты возвращаются к цветам фона по умолчанию, которые каждый из них определил (в данном случае lightgreen и lightblue).

Как это возможно? Уловка состоит в том, чтобы объявить значение по умолчанию для переменной с помощью псевдокласса :host() из shadow DOM. Например:

:host {
  --background: lightblue; /* default value */
}

А за пределами shadow DOM это можно переопределить, настроив таргетинг на foo-component, потому что он превосходит :host с точки зрения специфичности CSS:

foo-component {
  --background: hotpink; /* overridden value */
}

Немного сложно осознать, но для любого, кто использует ваш компонент, это очень просто! Также маловероятно, что возникнут какие-либо конфликты, так как вы должны нацеливаться на сам настраиваемый элемент, а не на его предков или :root.

Конечно, этого может быть недостаточно для вас. Пользователи все еще могут выстрелить себе в ногу, сделав что-то вроде этого:

* {
  --background: black; /* this will override the default */
}

Так что, если вы беспокоитесь, вы можете добавить префикс в название CSS переменных, как описано выше. Я лично считаю, что риск довольно низкий, но еще неизвестно, как экосистема веб-компонентов выйдет из строя.

Когда вам не нужны CSS переменные

Как оказалось, переменные CSS - не единственный случай, когда стили могут просочиться в shadow DOM: наследуемые свойства, такие как font-family и color, также будут просачиваться внутрь.

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

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

Вариант 2: классы

Основываясь на предыдущем разделе, мы переходим к следующей стратегии стилизации веб-компонентов: классам. Это еще один, который я использовал в emoji-picker-element: если вы хотите переключить темный режим или светлый режим, это так же просто, как добавить CSS класс:

<emoji-picker class="dark"></emoji-picker>
<emoji-picker class="light"></emoji-picker>

(Он также будет по умолчанию правильным на основе prefers-color-scheme, но я подумал, что люди могут захотеть настроить поведение по умолчанию.)

И снова уловка здесь в том, чтобы использовать псевдокласс :host. В этом случае мы можем передать другой селектор в сам псевдокласс :host():

:host(.dark) {
  background: black;
}
:host(.light) {
  background: white;
}

А вот CodePen, демонстрирующий это в действии:

Конечно, вы также можете смешать этот подход с переменными CSS: например, определение --background внутри блока :host(.dark). Поскольку вы можете помещать произвольные селекторы CSS внутрь :host(), вы также можете использовать атрибуты вместо классов или любой другой подход, который вам нужен.

Один потенциальный недостаток классов является то, что они также могут вызывать конфликты - например, если dark и light классы уже определены в другом месте. Так что вы можете избежать этой техники или использовать префиксы, если вас беспокоит риск.

Вариант 3: shadow parts

CSS Shadow parts являются новой спецификацией, поэтому поддержка браузеров не так широко распространена, как CSS переменные (тем не менее, все еще довольно хорошо и ежедневно улучшается).

Идея shadow parts заключается в том, что как автор веб-компонента вы можете определять «части» своего компонента, которые пользователи могут стилизовать. У CSS Tricks хорошая разбивка, но основная суть такова:

/* outside the shadow DOM */
custom-component::part(foo) {
  background: lightgray;
}
<!-- inside the shadow DOM -->
<span part="foo">
  My background will be lightgray!
</span>

А вот и демонстрация:

Я думаю, что эта стратегия хороша, но на самом деле я не стал ее использовать в emoji-picker-element (по крайней мере, пока). И вот почему.

Минусы shadow parts

Во-первых, трудно решить, какие «части» веб-компонента должны быть стилизованы. В случае средства выбора смайлов, должны ли это быть сами смайлы? А как насчет выбора цвета смайла, который также содержит эмодзи? Какие здесь правильные границы и имена?

Честно говоря, такая же критика может быть применена и к переменным CSS: именовать переменные по-прежнему сложно! Но, как оказалось, я уже использую переменные CSS для внутренней организации кода; это просто шутка с моей собственной ментальной моделью. Так что их публичное разоблачение для меня не требовало лишних имен.

Во-вторых, предлагая ::part API, я фактически ограничиваю себя определенными дизайнерскими решениями, что не всегда происходит с переменными CSS. Например, рассмотрим переменную --emoji-padding, которую я использую для управления отступом вокруг смайлика. Эквивалентный способ сделать это с shadow parts может быть следующим:

emoji-picker::part(emoji) {
  padding: 2em;
}

Но теперь, если я когда-нибудь решу определить отступы каким-либо другим способом (например, сквозным width или неявным позиционированием), или если я решу, что действительно хочу, чтобы обертка div обрабатывала отступы, я потенциально могу сломать любого, кто стилизует с помощью ::part API. В то время как с переменными CSS я всегда могу переопределить, что означает --emoji-padding, используя мою собственную внутреннюю логику.

Фактически, это именно то, чем я занимаюсь в emoji-picker-element--emoji-padding вовсе не заполнитель, а скорее часть оператора calc(), который устанавливает ширину. Я сделал это из соображений производительности - оказалось, что быстрее (по крайней мере, в Chrome) иметь фиксированные размеры ячеек в сетке CSS. Но пользователю не обязательно это знать; они могут просто использовать --emoji-padding, не заботясь о том, как я это реализовал.

Наконец, shadow parts расширяют поверхность API таким образом, что мне немного неудобно. Например, пользователь мог сделать что-то вроде:

emoji-picker::part(emoji) {
  margin: 1em;
  animation: 1s infinite some-animation;
  display: flex;
  position: relative;
}

С shadow parts есть много неожиданных способов сломать чей-то код, изменив одно из этих свойств. В то время как с помощью переменных CSS я могу явно определить, что я хочу, чтобы пользователи стилизовали (например, отступы), а что нет (например, display). Конечно, я мог бы использовать семантическое управление версиями, чтобы попытаться сообщить о критических изменениях, но на этом этапе любое изменение CSS для любого ::part потенциально может нарушиться.

В защиту shadow parts

Тем не менее, я определенно вижу, где shadow parts имеют свое место. Если вы посмотрите на определение элемента select Open UI, данное моим коллегой Грегом Уитвортом, в нем есть четко определенные части для всего, что составляет: кнопку, список, параметры и т.д. Фактически, одна из основных целей проекта заключается в стандартизации этих частей во фреймворках и спецификациях. Для такой ситуации естественным образом подходят shadow parts.

Shadow parts также увеличивают выразительность пользовательского CSS: то же самое, что заставляет меня проявлять брезгливость в отношении нарушения изменений, также позволяет пользователям сойти с ума со стилями ::part, которые они выбирают. Лично мне нравится ограничивать поверхность API кода, который я отправляю, но здесь есть неизбежный компромисс между настраиваемостью и ломкостью, поэтому я не верю, что есть один правильный ответ.

Кроме того, хотя я организовал свой собственный внутренний стиль с помощью переменных CSS, на самом деле это можно сделать и с shadow parts. Вы можете использовать внутри shadow модели DOM, ::part(foo) и она работает должным образом. Это может сделать ваши теневые части менее хрупкими, поскольку они не только общедоступный API, но и используются внутри компании. Вот пример:

Еще раз: эти стратегии не «либо / или». Если вы хотите использовать сочетание переменных, частей и классов, тогда, пожалуйста, продолжайте! Вы должны использовать все, что кажется наиболее естественным для проекта, над которым вы работаете.

Вариант 4: аварийный выход

Последняя стратегия, которую я упомяну, - это то, что я назову «аварийный выход». По сути, это способ для пользователей прикрепить любой CSS, который они хотят, к вашему пользовательскому элементу, независимо от любых других методов стилизации, которые вы используете. Выглядит это так:

const style = document.createElement('style')
style.innerHTML = 'h1 { font-family: "Comic Sans"; }'
element.shadowRoot.appendChild(style)

Из-за того, как работает открытая теневая модель DOM, пользователям не запрещается добавлять теги <style> в shadowRoot. Таким образом, используя эту технику, у них всегда есть «аварийный выход» на случай, если API стилей, который вы предоставляете, не соответствует их потребностям.

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

Конечно, эта техника тоже хрупкая. Если что-то изменится в структуре DOM вашего компонента или в классах CSS, код пользователя может сломаться. Но поскольку для пользователя очевидно, что он использует лазейку, я думаю, что это приемлемо. Очевидно, что добавление собственного тега <style> - это ситуация типа «вы его сломали, вы его купили».

Источник:

#JavaScript #CSS
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу