- PVSM.RU - https://www.pvsm.ru -

Веб-разработка на ванильном HTML, CSS и JavaScript: стилизация и сайты

Веб-разработка на ванильном HTML, CSS и JavaScript: стилизация и сайты - 1


Это вторая статья из цикла переводов о веб-разработке на чистых (ванильных) технологиях — без фреймворков и сторонних инструментов, только HTML, CSS и JavaScript. В первой части [1] мы обсудили, почему такой подход может быть разумной альтернативой современным фреймворкам и рассмотрели использование веб-компонентов в качестве базовых строительных блоков для создания более сложных примитивов. В этот раз поговорим про стилизацию, а также деплой компонентов в продакшен без использования сборщиков, фреймворков или серверной логики.

Современный CSS

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

Сброс

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

Минимальный сброс используется следующим сайтом:

reset.css

/* обобщённый минималистичный сброс CSS
   источник вдохновения: https://www.digitalocean.com/community/tutorials/css-minimal-css-reset */

:root {
    box-sizing: border-box;
    line-height: 1.4;
    /* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */
    -moz-text-size-adjust: none;
    -webkit-text-size-adjust: none;
    text-size-adjust: none;
}

*, *::before, *::after {
    box-sizing: inherit;
}

body, h1, h2, h3, h4, h5, h6, p {
    margin: 0;
    padding: 0;
    font-weight: normal;
}

img {
    max-width:100%;
    height:auto;
}

Вот другие варианты в порядке по возрастанию сложности:

modern-normalize [2] — более подробное решение для сброса CSS в современных браузерах. Включение из CDN [3]

Kraken [4] — начальная точка для проектов фронтенда. Включает в себя сброс CSS, типографику, сетку и другие удобные инструменты. Включение из CDN [5]

Pico CSS [6] — готовый набор начинающего для стилизации семантического HTML, в том числе и для сброса CSS. Включение из CDN [7]

Tailwind [8] — если вы всё равно будете использовать Tailwind, то можете и использовать его сброс CSS. Включение из CDN [9]

Шрифты

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

В Modern Font Stacks [10] описываются разнообразные популярные шрифты и варианты отката, позволяющие не загружать пользовательские шрифты и не добавлять внешние зависимости.

На нашем сайте используется стек Geometric Humanist для обычного текста и стек Monospace Code для исходного кода.

Инструментарий

В реальном веб-проекте в случае отсутствия правильной структуры объём CSS быстро становится огромным. Давайте рассмотрим инструментарий для создания такой структуры, который предоставляет нам CSS в современных браузерах.

@import — самая базовая техника структурирования — это разбиение CSS на несколько файлов. Мы можем добавлять все эти файлы по порядку как теги <link> в index.html, но это быстро становится неудобным, если мы работаем с несколькими HTML-страницами. Вместо этого лучше импортировать их в index.css.

Например, вот основной файл CSS нашего сайта:

index.css

@import url("./styles/reset.css");
@import url("./styles/variables.css");
@import url("./styles/global.css");
@import url("./components/code-viewer/code-viewer.css");
@import url("./components/tab-panel/tab-panel.css");

Ниже показан рекомендованный способ упорядочивания файлов CSS.

Пользовательские свойства (переменные) — переменные CSS [11] можно использовать для централизованного определения шрифта и темы сайта.

Например, вот переменные для нашего сайта:

variables.css

:root {
    /* https://modernfontstacks.com/
       geometric humanist font */
    --font-system: Avenir, Montserrat, Corbel, source-sans-pro, sans-serif;
    /* monospace code font */
    --font-system-code: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
    --font-system-code-size: 0.8rem;

    --background-color: white;

    --text-color: black;
    --text-color-mute: hsl(0, 0%, 40%);

    --link-color: darkblue;

    --nav-separator-color: goldenrod;
    --nav-background-color: hsl(50, 50%, 95%);

    --border-color: black;

    --code-text-color: var(--text-color);
    --code-text-color-bg: inherit;

    --panel-title-color: black;
    --panel-title-color-bg: cornsilk;
}

Ещё более мощными переменные CSS становятся в сочетании с calc() [12].

Пользовательские элементы — область видимости стилей легко можно ограничить тегом пользовательского элемента. Например, все стили компонента аватара из предыдущей части [1] статьи имеют в качестве префикса селектор x-avatar:

avatar.css

x-avatar {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
}

x-avatar[size=lg] {
    width: 3.5rem;
    height: 3.5rem;
}

x-avatar img {
    border-radius: 9999px;
    width: 100%;
    height: 100%;
    vertical-align: middle;
    object-fit: cover;
}

Пользовательские элементы также могут иметь произвольные атрибуты, которые могут использоваться селекторами, как в случае со стилем [size=lg] из этого примера.

Shadow DOM — добавление shadow DOM к веб-компоненту ещё сильнее изолирует его стили от остальной части страницы. Например, компонент x-header из предыдущей части [1] стилизует свой элемент h1 внутри своего CSS, не влияя на содержащую его страницу и на дочерние элементы заголовка.

Все файлы CSS, которые нужно применить к shadow DOM, должны загружаться в неё явным образом, однако переменные CSS передаются в shadow DOM [13].

Ограничение shadow DOM заключается в том, что для использования внутри них пользовательских шрифтов их сначала нужно загрузить в «светлую» DOM.

Файлы

Существует множество способов упорядочивания файлов CSS в репозитории; на нашем сайте применён такой:

/index.css — корневой файл CSS, который импортирует все остальные при помощи @import.

/styles/reset.css — первым делом импортируется сброс таблицы стилей.

/styles/variables.css — все переменные CSS определены в отдельном файле, в том числе и система шрифтов.

/styles/global.css — глобальные стили, применяемые для веб-страниц сайта.

/components/example/example.css — все неглобальные стили относятся к конкретным компонентам и находятся в файле CSS, расположенном рядом с файлом JS компонента.

Область видимости

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

▍ Селекторы с префиксами

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

index.html

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <x-example></x-example>
    <p>This <p> is not affected, because it is outside the custom element.</p>
    <script type="module" src="index.js"></script>
</html>

index.js

import { registerExampleComponent } from './components/example/example.js';
const app = () => {
    registerExampleComponent();
}
document.addEventListener('DOMContentLoaded', app);

index.css

@import url("./components/example/example.css");

components/example/example.js

class ExampleComponent extends HTMLElement {
    connectedCallback() {
        this.innerHTML = '<p>For example...</p>';
    }
}
export const registerExampleComponent = () => {
    customElements.define('x-example', ExampleComponent);
}

components/example/example.css

x-example p {
    font-family: casual, cursive;
    color: darkblue;
}

Веб-разработка на ванильном HTML, CSS и JavaScript: стилизация и сайты - 2

▍ Подсказка: вложенность CSS

Если вам нужен более чистый синтаксис и вас устраивает браузерная поддержка [14], то подумайте над использованием вложенности CSS [15].

components/example/example.css

x-example {
    p {
        font-family: casual, cursive;
        color: darkblue;
    }
}

▍ Импорт Shadow DOM

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

index.html

<!doctype html>
<html lang="en">
<body>
    <x-example>
        <p>This <p> is not affected, even though it is slotted.</p>
    </x-example>
    <script type="module" src="index.js"></script>
</body>
</html>

index.js

import { registerExampleComponent } from './components/example/example.js';
const app = () => {
    registerExampleComponent();
}
document.addEventListener('DOMContentLoaded', app);

components/example/example.js

class ExampleComponent extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        this.shadowRoot.innerHTML = `
            <link rel="stylesheet" href="${import.meta.resolve('./example.css')}">
            <p>For example...</p>
            <slot></slot>
        `;
    }
}
export const registerExampleComponent = () => {
    customElements.define('x-example', ExampleComponent);
}

components/example/example.css

p {
    font-family: casual, cursive;
    color: darkblue;
}

Веб-разработка на ванильном HTML, CSS и JavaScript: стилизация и сайты - 3

Чтобы использовать стили из окружающей страницы внутри shadow DOM, можно выбрать один из вариантов:

  • Общие файлы CSS можно импортировать внутрь shadow DOM при помощи тегов <link> или @import.
  • На переменные CSS, определённые на окружающей странице, можно ссылаться изнутри стилей shadow DOM.
  • Для доминирования shadow DOM можно использовать псевдоэлемент ::part, чтобы раскрыть API для стилизации [16].

Замена модулей CSS

Локальную область видимости модулей CSS можно заменить одним из описанных выше способов изменения области видимости. Для образца возьмём каноничный пример модулей CSS [17] из документации Next.JS:

app/dashboard/layout.tsx

import styles from './styles.module.css'
 
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section className={styles.dashboard}>{children}</section>
}

app/dashboard/styles.module.css

.dashboard {
    padding: 24px;
}

В качестве ванильного веб-компонента он бы выглядел так:

components/dashboard/layout.js

class Layout extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <link rel="stylesheet" href="${import.meta.resolve('styles.css')}">
            <section class="dashboard"><slot></slot></section>
        `;
    }
}

export const registerLayoutComponent = 
    () => customElements.define('x-layout', Layout);

components/dashboard/styles.css

@import url("../shared.css");

.dashboard {
    padding: 24px;
}

Так как shadow DOM не наследует стили страницы, styles.css должен сначала импортировать стили, общие для страницы и «теневого» веб-компонента.

Замена PostCSS

Давайте рассмотрим список возможностей на главной станице PostCSS [18].

Добавление префиксов поставщика к правилам CSS с использованием значений из Can I Use — в большинстве сценариев использования префиксы поставщика больше не требуются. Показанный в примере псевдокласс :fullscreen теперь работает в браузерах без префиксов [19].

Преобразование современного CSS в то, что может понимать большинство браузеров — современный CSS, который вы хотите использовать, скорее всего, уже поддерживается. Показанное в примере правило color: oklch() теперь работает во всех популярных браузерах [20].

Модули CSS — см. альтернативы, описанные в предыдущем разделе. Организуйте согласованные форматы и избегайте ошибок в таблицах стилей при помощи stylelint. Можно добавить в Visual Studio Code расширение vscode-stylelint [21] для выполнения того же линтинга во время разработки без необходимости встраивания его в этап сборки.

Подведём итог: из-за отказа Microsoft от поддержки IE11 и постоянного совершенствования актуальных браузеров PostCSS по большей мере стал ненужным.

Замена SASS

Аналогично PostCSS, давайте разберём список основных возможностей SASS [22]:

Переменные — заменены пользовательскими свойствами CSS [11].

Вложенность — вложенность CSS недавно стала поддерживаться [14] всеми популярными браузерами, что вполне может покрыть ваши потребности.

Модули — можно аппроксимировать сочетанием @import, переменных CSS и описанных выше способов управления областями видимости.

Примеси (mixin) — к сожалению, функция CSS-примесей [23], которая может заменить их, по-прежнему находится на этапе спецификации.

Операторы — во многих случаях могут быть заменены встроенной функцией calc() [12].

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

Ванильные страницы

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

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

example.html

<!doctype html>
<html lang="en">
    <head>
        <title>Example</title>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width" />
        <link rel="stylesheet" href="index.css">
    </head>
    <body>
        <noscript><strong><font color="#000">Please enable JavaScript to view this page correctly.</font></strong></noscript>
        <header>
            title and navigation ...
        </header>
        <main>
            main content ...
        </main>
        <footer>
            byline and copyright ...
        </footer>    
        <script type="module" src="index.js"></script>
    </body>
</html>

Объяснение каждого элемента:

<!doctype html> — требуется, чтобы HTML парсился как HTML5, а не как более старая версия.

<html lang="en"> — атрибут lang рекомендован, чтобы язык страницы не определялся ошибочно.

<head><title> — используется для вкладки браузера и сохранения в закладки; то есть, по сути, он обязателен.

<head><meta charset="utf-8"> — это почти не требуется, но эту строку нужно добавить, чтобы страница точно интерпретировалась, как UTF-8. Очевидно, что в редакторе, используемом для создания этой страницы, тоже должна быть выбрана UTF-8.

<head><meta name="viewport"> — необходимо для того, чтобы структура страницы удобно просматривалась на мобильных устройствах.

<head><link rel="stylesheet" href="index.css"> — по стандарту таблица стилей загружается из <head> блокирующим образом, чтобы не возникала вспышка нестилизованного контента разметки страницы.

<body><noscript> — так как веб-компоненты не работают JavaScript, обычно рекомендуется добавлять уведомление noscript для пользователей, у которых отключен JavaScript. Это уведомление должно присутствовать только на страницах с веб-компонентами. Если вы не хотите показывать ничего, кроме уведомления, то см. показанный ниже шаблонный паттерн.

<body><header/main/footer> — разметка страницы должна быть упорядочена при помощи HTML-маркеров (landmark) [24]. При правильном использовании landmark помогают в разбиении страницы на логические блоки и обеспечении accessibility структуры страницы. Так как они созданы на основе стандартов, повышается вероятность их совместимости с уже существующими и новыми инструментами accessibility.

<body><script type="module" src="index.js"> — основной файл JavaScript находится в конце, он загружает веб-компоненты.

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

index.html

<!doctype html>
<html lang="en">
    <head>
        <title>Example</title>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width" />
        <link rel="stylesheet" href="index.css">
    </head>
    <body>
        <noscript><strong><font color="#000">Please enable JavaScript to view this page.</font></strong></noscript>
        <template id="page">
            <header>
                title and navigation ...
            </header>
            <main>
                main content ...
            </main>
            <footer>
                byline and copyright ...
            </footer>    
        </template>
        <script type="module" src="index.js"></script>
    </body>
</html>

index.js

const app = () => {
    const template = document.querySelector('template#page');
    if (template) document.body.appendChild(template.content, true);
}

document.addEventListener('DOMContentLoaded', app);

▍ Важность семантики

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

Освойте следующие аспекты семантического HTML:

Landmark (маркеры) [24] — как говорилось выше, landmark — это фундамент структуры страницы, по умолчанию обеспечивающие качественную структуру и accessibility.

Элементы [25] — хорошее знание множества встроенных элементов HTML сэкономит вам время благодаря отсутствию необходимости пользовательских элементов и упрощению реализации в случае необходимости пользовательских элементов. При правильном использовании HTML-элементов они по умолчанию обеспечивают accessibility.

Формы [26] — при их полнофункциональном использовании встроенные формы HTML способны реализовывать множество сценариев применения интерактивности. Изучите такие их возможности, как разнообразные типы ввода [27], валидация на стороне клиента [28] и псевдоклассы UI [29]. Если вы не можете найти подходящего для себя типа ввода, то можете использовать связанные с формами пользовательские элементы [30], но учитывайте поддержку браузерами ElementInternals [31].

▍ Фавиконки

Вероятно, вам захочется добавить в HTML элемент, не основанный на стандартах, а именно ссылку на фавиконку:

  • Чтобы не усложнять, поместите favicon.ico в корень сайта и добавьте на неё ссылку в свой HTML: <link rel="icon" href="favicon.ico">
  • Можете попробовать использовать фавиконки в SVG [32], но помните, что Safari их не поддерживает. Встройте тёмный режим [33] в сам SVG фавиконки или для большего удобства воспользуйтесь генератором наподобие RealFaviconGenerator [34].
  • Учтите, что поскольку фавиконки не основаны на опубликованных стандартах веба, будет довольно сложно полностью реализовать стандарт де-факто [35].

Проект

Рекомендуемая структура проекта для ванильного многостраничного веб-сайта такова:

/ — в корне проекта находятся файлы, которые не будут публиковаться, например, README.md, LICENSE и .gitignore.

/public — папка public публикуется в неизменном виде, без этапов сборки. В ней заключается весь веб-сайт.

/public/index.html — главная лендинг-страница веб-сайта, не особо отличающаяся от других страниц, за исключением пути.

/public/index.[js/css] — основная таблица стилей и javascript. Они содержат общие для всех страниц стили и код.

index.js загружает и регистрирует веб-компоненты, используемые на всех страницах. Если сделать его общим для нескольких HTML-страниц, то можно избежать ненужного дублирования и рассогласованности между страницами.

/public/pages/[имя].html — все прочие страницы сайта, каждая из которых включает в себя одинаковые index.js и index.css и, разумеется, содержит непосредственно контент в виде разметки HTML с использованием веб-компонентов.

public/components/[name]/ — по одной папке на каждый веб-компонент, содержащей файлы [имя].js и [имя].css. Файл .js импортируется в файл index.js для регистрации веб-компонента. Файл .css, как говорилось выше, импортируется в глобальный index.css или в shadow DOM.

/public/lib/ — для всех внешних библиотек, используемых как зависимости. Ниже мы расскажем о том, как добавлять и использовать эти зависимости.

/public/styles/ — глобальные стили, на которые ссылается index.css.

Файлы конфигурации для повышения удобства работы в редакторах программиста тоже размещаются в корне проекта. Благодаря расширениям редактора основная часть процесса разработки возможна без этапа сборки. Пример см. в статье о настройке Visual Studio Code [36].

Маршрутизация

«Олдскульный» способ маршрутизации стандартных HTML-страниц и связующих их тегов <a> обладает следующими преимуществами: простое индексирование поисковыми движками и изначальная полная поддержка функциональности истории браузера и закладок.

Зависимости

В процессе разработки вам могут потребоваться сторонние библиотеки. Их можно использовать без npm и бандлера.

Unpkg

Чтобы использовать библиотеки без бандлера, их предварительно нужно собрать в формат ESM или UMD. Такие библиотеки можно скачать с unpkg.com:

  1. Зайдите на unpkg.com/[library]/ (последняя косая черта важна), например, на unpkg.com/microlight/ [37]
  2. Найдите и скачайте файл библиотеки js, который может находиться в подпапке, например, в dist, esm или umd
  3. Поместите файл библиотеки в папку lib/

Или же библиотеку можно загрузить напрямую из CDN.

▍ UMD

Формат модулей UMD [38] — это старый формат для библиотек, загружаемых из тега script; он обладает самой широкой поддержкой, особенно среди старых библиотек. Его можно распознать по наличию typeof define === 'function' && define.amd в JS библиотеки.

Чтобы включить его в свой проект, нужно выполнить следующие действия:

  1. Включить его в теге script: <script src="lib/microlight.js"></script>
  2. Получить его у окна: const { microlight } = window;

▍ ESM

Формат модулей ESM [39] (также называемый модулями JavaScript) — это формат, определённый стандартом ECMAScript; у новых и популярных библиотек обычно есть ESM-сборка. Её можно распознать по использованию ключевого слова export.

Чтобы включить его в свой проект, нужно выполнить следующие действия:

  • Загрузить его из CDN:

    import('https://unpkg.com/web-vitals@4.2.2/dist/web-vitals.js').then((webVitals) => ...)

  • Или загрузить из локальной копии:

    import webVitals from 'lib/web-vitals.js'

▍ imports.js

Для удобного упорядочивания библиотек и отделения их от остальной кодовой базы их можно загружать и экспортировать из файла imports.js.

Например, вот страница, на которой используется UMD-сборка Day.js [40] и ESM-сборка web-vitals [41]:

Веб-разработка на ванильном HTML, CSS и JavaScript: стилизация и сайты - 4

Рендеринг текста выполнен компонентом <x-metrics>:

components/metrics.js

import { dayjs, webVitals } from '../lib/imports.js';

class MetricsComponent extends HTMLElement {
    #now = dayjs();
    #ttfb;
    #interval;

    connectedCallback() {
        webVitals.onTTFB(_ => this.#ttfb = Math.round(_.value));
        this.#interval = setInterval(() => this.update(), 500);
    }

    disconnectedCallback() {
        clearInterval(this.#interval);
        this.#interval = null;
    }

    update() {
        this.innerHTML = `
            <p>Page loaded ${this.#now.fromNow()}, TTFB ${this.#ttfb} milliseconds</p>
        `;
    }
}

export const registerMetricsComponent = () => {
    customElements.define('x-metrics', MetricsComponent);
}

В папке /lib находятся следующие файлы:

  • web-vitals.js — ESM-сборка web-vitals
  • dayjs/
    • dayjs.min.js — UMD-сборка Day.js
    • relativeTime.js — UMD-сборка этого плагина Day.js
  • imports.js

Изучив глубже последний файл, мы увидим, как он выполняет загрузку сторонних зависимостей:

lib/imports.js

// UMD-версия dayjs с https://unpkg.com/dayjs/
const dayjs = window.dayjs;
const dayjsRelativeTime = window.dayjs_plugin_relativeTime;
dayjs.extend(dayjsRelativeTime);

// ESM-версия web-vitals с https://unpkg.com/web-vitals/dist/web-vitals.js
import * as webVitals from './web-vitals.js';

export { dayjs, webVitals };

Он импортирует ESM-библиотеку напрямую, но подтягивает UMD-библиотеки из объекта Window. Они загружаются в HTML.

Вот объединённый пример:

index.html

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <script src="./lib/dayjs/dayjs.min.js"></script>
    <script src="./lib/dayjs/relativeTime.js"></script>

    <script type="module" src="index.js"></script>
    <x-metrics></x-metrics>
</body>
</html>

index.css

body { font-family: sans-serif; }

index.js

import { registerMetricsComponent } from './components/metrics.js';

const app = () => {
    registerMetricsComponent();
};

document.addEventListener('DOMContentLoaded', app);

components/metrics.js

import { dayjs, webVitals } from '../lib/imports.js';

class MetricsComponent extends HTMLElement {
    #now = dayjs();
    #ttfb;
    #interval;

    connectedCallback() {
        webVitals.onTTFB(_ => this.#ttfb = Math.round(_.value));
        this.#interval = setInterval(() => this.update(), 500);
    }

    disconnectedCallback() {
        clearInterval(this.#interval);
        this.#interval = null;
    }

    update() {
        this.innerHTML = `
            <p>Page loaded ${this.#now.fromNow()}, TTFB ${this.#ttfb} milliseconds</p>
        `;
    }
}

export const registerMetricsComponent = () => {
    customElements.define('x-metrics', MetricsComponent);
}

lib/imports.js

// UMD-версия dayjs с https://unpkg.com/dayjs/
const dayjs = window.dayjs;
const dayjsRelativeTime = window.dayjs_plugin_relativeTime;
dayjs.extend(dayjsRelativeTime);

// ESM-версия web-vitals с https://unpkg.com/web-vitals/dist/web-vitals.js
import * as webVitals from './web-vitals.js';

export { dayjs, webVitals };

К сожалению, не у всех библиотек есть UMD- или ESM-сборки, но их становится всё больше.

▍ Import Map

Альтернативой способу с imports.js могут стать import map [42]. Они определяют уникальное отображение между именем модуля, который можно импортировать, и соответствующим файлом библиотеки в особом теге script в head HTML. Они позволяют использовать в остальной части кодовой базы более традиционный синтаксис импорта на основе модулей.

Вот, как будет выглядеть предыдущий пример, адаптированный под использование import map:

index.html

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
    <script src="./lib/dayjs/dayjs.min.js"></script>
    <script src="./lib/dayjs/relativeTime.js"></script>
    <script type="importmap">
        {
            "imports": {
                "dayjs": "./lib/dayjs/module.js",
                "web-vitals": "./lib/web-vitals.js"
            }
        }
    </script>
</head>
<body>
    <script type="module" src="index.js"></script>
    <x-metrics></x-metrics>
</body>
</html>

lib/dayjs/module.js

// UMD-версия dayjs с https://unpkg.com/dayjs/
const dayjs = window.dayjs;
const dayjsRelativeTime = window.dayjs_plugin_relativeTime;
dayjs.extend(dayjsRelativeTime);

export default dayjs;

components/metrics.js

import dayjs from 'dayjs';
import * as webVitals from 'web-vitals';

class MetricsComponent extends HTMLElement {
    #now = dayjs();
    #ttfb;
    #interval;

    connectedCallback() {
        webVitals.onTTFB(_ => this.#ttfb = Math.round(_.value));
        this.#interval = setInterval(() => this.update(), 500);
    }

    disconnectedCallback() {
        clearInterval(this.#interval);
        this.#interval = null;
    }

    update() {
        this.innerHTML = `
            <p>Page loaded ${this.#now.fromNow()}, TTFB ${this.#ttfb} milliseconds</p>
        `;
    }
}

export const registerMetricsComponent = () => {
    customElements.define('x-metrics', MetricsComponent);
}

При использовании import map нужно учитывать следующие аспекты:

  • Import map могут отображаться только на ESM-модули, поэтому для библиотек UMD необходимы обёртки, как в случае с обёрткой module.js для dayjs в этом примере.
  • Внешние import map вида <script type="importmap" src="importmap.json"> пока поддерживаются не всеми браузерами [43]. Из-за этого import map должны дублироваться на каждой HTML-странице.
  • Import map должна определяться до загрузки скрипта index.js, предпочтительно из раздела &lthead>.
  • Import map можно использовать для более простой загрузки библиотек из папки node_modules или из CDN. Можно использовать JSPM generator [44] для быстрого создания import map для зависимостей for CDN. Однако при этом стоит иметь в виду, что из-за добавления таких внешних зависимостей ванильная кодовая база будет зависеть от постоянной доступности соответствующего сервиса.

Браузерная поддержка

Ванильные веб-сайты поддерживаются всеми современными браузерами. Но что это значит?

Чтобы следить за новыми веб-стандартами, изучайте следующие проекты:

  • Baseline [67] отслеживает фичи, широко доступные в браузерах, а также сообщает, когда их безопасно использовать.
  • Interop [68] — это ежегодная инициатива разработчиков браузеров по внедрению новых фич веб-платформы во все браузеры. Можно считать её предварительным обзором того, что вскоре войдёт в baseline.

Развёртывание

Для развёртывания сайта можно выбрать любого провайдера, способного хостить статические веб-сайты.

На примере GitHub Pages [69]:

  1. Загрузите проект как репозиторий на GitHub
  2. Перейдите в Settings, Pages
  3. Source: GitHub Actions
  4. Static Website, Configure
  5. Перейдите к path и измените его на ./public
  6. Выполните коммит изменений...
  7. Зайдите на страницу Actions репозитория и дождитесь развёртывания сайта

Тестирование

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

Например, вот юнит-тесты для компонента <x-tab-panel>, использованного для отображения вкладок панелей исходного кода на нашем веб-сайте:

Веб-разработка на ванильном HTML, CSS и JavaScript: стилизация и сайты - 5

А для того, чтобы ещё глубже разобраться в коде, покажу, как выглядит тестирование исходного кода:

tests/index.html

<!doctype html>
<html lang="en">
<head>
    <title>Plain Vanilla - Tests</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
    <link rel="icon" href="../favicon.ico">
    <link rel="stylesheet" href="../index.css">
    <!-- https://unpkg.com/mocha@9.2.2/mocha.css -->
    <link rel="stylesheet" href="./lib/mocha/mocha.css" />
    <!-- https://unpkg.com/chai@4.3.6/chai.js -->
    <script src="./lib/mocha/chai.js"></script>
    <!-- https://unpkg.com/mocha@9.2.2/mocha.js -->
    <script src="./lib/mocha/mocha.js"></script>
    <!-- https://unpkg.com/@testing-library/dom@8.17.1/dist/@testing-library/dom.umd.js -->
    <script src="./lib/@testing-library/dom.umd.js"></script>
</head>
<body>
    <div id="mocha"></div>

    <script type="module" class="mocha-init">
        mocha.setup('bdd');
        mocha.checkLeaks();
    </script>

    <script type="module" src="./tabpanel.test.js"></script>
    
    <!-- здесь другие тесты -->

    <script type="module" class="mocha-exec" src="./index.js"></script>
</body>
</html>

tests/index.js

import { registerTabPanelComponent } from "../components/tab-panel/tab-panel.js";

const app = () => {
    registerTabPanelComponent();
    mocha.run();
}

document.addEventListener('DOMContentLoaded', app);

tests/tabpanel.test.js

import { render, screen, waitFor, expect, fireEvent } from './imports-test.js';

const renderTabPanel = () => {
    const div = document.createElement('div');
    div.innerHTML = `
        <x-tab-panel>
            <x-tab title="Tab 1" active>
                <p>Tab 1 content</p> 
            </x-tab>
            <x-tab title="Tab 2">
                <p>Tab 2 content</p>
            </x-tab>
        </x-tab-panel>
    `;
    render(div);
}

describe('tabpanel', () => {
    it("renders a tabpanel with active tab", async () => {
        // ARRANGE
        renderTabPanel();
    
        // ASSERT
        // выбрана активная вкладка
        const activeTab = await screen.findByRole('tab', { name: 'Tab 1', selected: true });
        expect(activeTab).to.not.be.undefined;
        // активная tabpanel видима
        const activePanel = screen.getByText(/Tab 1 content/);
        expect(activePanel).to.not.be.undefined;
        // контент неактивной tabpanel скрыт
        const tab2 = screen.getByTitle('Tab 2');
        await waitFor(() => expect(tab2.offsetParent).to.be.null);
    });
    
    it("activates a different tab on click", async () => {
        // ARRANGE
        renderTabPanel();
        const tab2 = screen.getByTitle('Tab 2');
    
        // ASSERT
        // контент неактивной tabpanel скрыт
        await waitFor(() => expect(tab2.offsetParent).to.be.null);
        // находим кнопку неактивной вкладки и нажимаем на неё
        const tab2Button = await screen.findByRole('tab', { name: 'Tab 2' });
        expect(tab2Button).not.to.be.undefined;
        fireEvent.click(tab2Button);
        // делаем видимым контент неактивной tabpanel
        await waitFor(() => expect(tab2.offsetParent).not.to.be.null);
    });
});

tests/imports-test.js

const { expect } = window.chai;
const { getByText, queries, within, waitFor, fireEvent } = window.TestingLibraryDom;

let rootContainer;
let screen;

beforeEach(() => {
    // скрытый div, в который тест может рендерить элементы
    rootContainer = document.createElement("div");
    rootContainer.style.position = 'absolute';
    rootContainer.style.left = '-10000px';
    document.body.appendChild(rootContainer);
    // предварительная привязка вспомогательных @testing-library/dom к rootContainer
    screen = Object.keys(queries).reduce((helpers, key) => {
        const fn = queries[key]
        helpers[key] = fn.bind(null, rootContainer)
        return helpers
    }, {});
});

afterEach(() => {
    document.body.removeChild(rootContainer);
    rootContainer = null;
});

function render(el) {
    rootContainer.appendChild(el);
}

export { 
    rootContainer, 
    expect, 
    render, 
    getByText, screen, within, waitFor, fireEvent
};

Примечания по работе с таким подходом:

  • Весь код для юнит-тестов, в том числе и библиотеки тестирования, выделен в подпапку public/tests/. Поэтому тесты будут доступны интерактивно, если добавить /tests к URL развёрнутого сайта. Если вы не хотите развёртывать тесты на работающем веб-сайте, то исключите папку тестов на этапе развёртывания.
  • В качестве фреймворков тестирования и проверки применяются Mocha [70] и Chai [71], потому что они работают в браузере без этапа сборки.
  • Для более удобных запросов к DOM используется DOM Testing Library [72]. Файл imports-test.js конфигурирует её для ванильного использования.
  • Важное ограничение заключается в том, что DOM Testing Library не может выполнять запросы внутри корней shadow [73]. Чтобы выполнять тестирование внутри корней shadow, необходимо сначала выполнить запрос к содержащему их веб-компоненту, получить дескриптор его свойства shadowRoot, а затем выполнить запрос внутри него.
  • Веб-компоненты инициализируются асинхронным образом, поэтому тестировать их может быть непросто. Используйте методы async [74] DOM Testing Library.

Пример

Примером разработки может быть наш веб-сайт [75]. Его проект есть на GitHub [76].

Автор: ru_vds

Источник [77]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/shadow-dom/420476

Ссылки в тексте:

[1] первой части: https://habr.com/ru/companies/ruvds/articles/909390/

[2] modern-normalize: https://github.com/sindresorhus/modern-normalize

[3] Включение из CDN: https://www.jsdelivr.com/package/npm/modern-normalize

[4] Kraken: https://cferdinandi.github.io/kraken/

[5] Включение из CDN: https://cdnjs.com/libraries/Kraken

[6] Pico CSS: https://picocss.com/

[7] Включение из CDN: https://picocss.com/docs#usage-from-cdn

[8] Tailwind: https://tailwindcss.com/

[9] Включение из CDN: https://kopi.dev/tailwind-css-with-cdn-html/

[10] Modern Font Stacks: https://modernfontstacks.com/

[11] переменные CSS: https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties

[12] calc(): https://developer.mozilla.org/en-US/docs/Web/CSS/calc

[13] переменные CSS передаются в shadow DOM: https://javascript.info/shadow-dom-style

[14] браузерная поддержка: https://caniuse.com/css-nesting

[15] вложенности CSS: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting/Using_CSS_nesting

[16] раскрыть API для стилизации: https://meowni.ca/posts/part-theme-explainer/

[17] пример модулей CSS: https://nextjs.org/docs/app/building-your-application/styling/css-modules#example

[18] PostCSS: https://postcss.org/

[19] работает в браузерах без префиксов: https://caniuse.com/mdn-css_selectors_fullscreen

[20] работает во всех популярных браузерах: https://caniuse.com/?search=oklch

[21] расширение vscode-stylelint: https://marketplace.visualstudio.com/items?itemName=stylelint.vscode-stylelint

[22] SASS: https://sass-lang.com/guide/

[23] CSS-примесей: https://github.com/w3c/csswg-drafts/issues/9350

[24] HTML-маркеров (landmark): https://developer.mozilla.org/en-US/blog/aria-accessibility-html-landmark-roles/

[25] Элементы: https://developer.mozilla.org/en-US/docs/Web/HTML/Element

[26] Формы: https://developer.mozilla.org/en-US/docs/Learn/Forms

[27] разнообразные типы ввода: https://developer.mozilla.org/en-US/docs/Learn/Forms/HTML5_input_types

[28] валидация на стороне клиента: https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation

[29] псевдоклассы UI: https://developer.mozilla.org/en-US/docs/Learn/Forms/UI_pseudo-classes

[30] связанные с формами пользовательские элементы: https://web.dev/articles/more-capable-form-controls

[31] поддержку браузерами ElementInternals: https://caniuse.com/mdn-api_elementinternals

[32] фавиконки в SVG: https://medium.com/swlh/are-you-using-svg-favicons-yet-a-guide-for-modern-browsers-836a6aace3df

[33] Встройте тёмный режим: https://css-tricks.com/svg-favicons-in-action/

[34] RealFaviconGenerator: https://realfavicongenerator.net/

[35] стандарт де-факто: https://dev.to/masakudamatsu/favicon-nightmare-how-to-maintain-sanity-3al7

[36] настройке Visual Studio Code: https://plainvanillaweb.com/blog/articles/2024-10-20-editing-plain-vanilla/

[37] unpkg.com/microlight/: https://unpkg.com/microlight/

[38] Формат модулей UMD: https://jameshfisher.com/2020/10/04/what-are-umd-modules/

[39] Формат модулей ESM: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules

[40] Day.js: https://day.js.org/

[41] web-vitals: https://github.com/GoogleChrome/web-vitals

[42] import map: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap

[43] пока поддерживаются не всеми браузерами: https://github.com/WICG/import-maps/issues/235

[44] JSPM generator: https://generator.jspm.io

[45] caniuse.com: https://caniuse.com

[46] Import Map: https://caniuse.com/import-maps

[47] декларативных Shadow DOM: https://caniuse.com/declarative-shadow-dom

[48] HTTP/2: https://caniuse.com/http2

[49] семантические элементы HTML5: https://caniuse.com/html5semantic

[50] пользовательские элементы: https://caniuse.com/custom-elementsv1

[51] шаблоны: https://caniuse.com/template

[52] Shadow DOM: https://caniuse.com/shadowdomv1

[53] MutationObserver: https://caniuse.com/mdn-api_mutationobserver

[54] CustomEvent: https://caniuse.com/customevent

[55] FormData: https://caniuse.com/mdn-api_formdata

[56] Element.closest: https://caniuse.com/element-closest

[57] модули JavaScript: https://caniuse.com/es6-module

[58] ECMAScript 6 / 2015: https://caniuse.com/es6

[59] ECMAScript 8 / 2017: https://caniuse.com/async-functions,object-values,object-entries,mdn-javascript_builtins_object_getownpropertydescriptors,pad-start-end,mdn-javascript_grammar_trailing_commas_trailing_commas_in_functions

[60] ECMAScript 11 / 2020: https://caniuse.com/?feats=mdn-javascript_operators_optional_chaining,mdn-javascript_operators_nullish_coalescing,mdn-javascript_builtins_globalthis,es6-module-dynamic-import,bigint,mdn-javascript_builtins_promise_allsettled,mdn-javascript_builtins_string_matchall,mdn-javascript_statements_export_namespace,mdn-javascript_operators_import_meta

[61] @import: https://caniuse.com/mdn-css_at-rules_import

[62] переменные: https://caniuse.com/css-variables

[63] calc(): https://caniuse.com/calc

[64] flexbox: https://caniuse.com/flexbox

[65] grid: https://caniuse.com/css-grid

[66] display: contents: https://caniuse.com/css-display-contents

[67] Baseline: https://web.dev/baseline

[68] Interop: https://web.dev/blog/interop-2024

[69] GitHub Pages: https://pages.github.com/

[70] Mocha: https://mochajs.org/

[71] Chai: https://www.chaijs.com/

[72] DOM Testing Library: https://testing-library.com

[73] не может выполнять запросы внутри корней shadow: https://github.com/testing-library/dom-testing-library/issues/413

[74] методы async: https://testing-library.com/docs/dom-testing-library/api-async

[75] веб-сайт: https://plainvanillaweb.com/

[76] GitHub: https://github.com/jsebrech/plainvanilla/

[77] Источник: https://habr.com/ru/companies/ruvds/articles/910734/?utm_source=habrahabr&utm_medium=rss&utm_campaign=910734