
Это вторая статья из цикла переводов о веб-разработке на чистых (ванильных) технологиях — без фреймворков и сторонних инструментов, только HTML, CSS и JavaScript. В первой части мы обсудили, почему такой подход может быть разумной альтернативой современным фреймворкам и рассмотрели использование веб-компонентов в качестве базовых строительных блоков для создания более сложных примитивов. В этот раз поговорим про стилизацию, а также деплой компонентов в продакшен без использования сборщиков, фреймворков или серверной логики.
Современный 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 — более подробное решение для сброса CSS в современных браузерах. Включение из CDN
Kraken — начальная точка для проектов фронтенда. Включает в себя сброс CSS, типографику, сетку и другие удобные инструменты. Включение из CDN
Pico CSS — готовый набор начинающего для стилизации семантического HTML, в том числе и для сброса CSS. Включение из CDN
Tailwind — если вы всё равно будете использовать Tailwind, то можете и использовать его сброс CSS. Включение из CDN
Шрифты
Типографика — фундамент веб-сайта или приложения. Такой легковесный подход, как ванильная веб-разработка, должен согласоваться с легковесным подходом к типографике.
В Modern Font Stacks описываются разнообразные популярные шрифты и варианты отката, позволяющие не загружать пользовательские шрифты и не добавлять внешние зависимости.
На нашем сайте используется стек 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 можно использовать для централизованного определения шрифта и темы сайта.
Например, вот переменные для нашего сайта:
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().
Пользовательские элементы — область видимости стилей легко можно ограничить тегом пользовательского элемента. Например, все стили компонента аватара из предыдущей части статьи имеют в качестве префикса селектор
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
из предыдущей части стилизует свой элемент h1
внутри своего CSS, не влияя на содержащую его страницу и на дочерние элементы заголовка.Все файлы CSS, которые нужно применить к shadow DOM, должны загружаться в неё явным образом, однако переменные CSS передаются в shadow DOM.
Ограничение 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;
}

▍ Подсказка: вложенность CSS
Если вам нужен более чистый синтаксис и вас устраивает браузерная поддержка, то подумайте над использованием вложенности CSS.
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;
}

Чтобы использовать стили из окружающей страницы внутри shadow DOM, можно выбрать один из вариантов:
- Общие файлы CSS можно импортировать внутрь shadow DOM при помощи тегов
<link>
или@import
. - На переменные CSS, определённые на окружающей странице, можно ссылаться изнутри стилей shadow DOM.
- Для доминирования shadow DOM можно использовать псевдоэлемент
::part
, чтобы раскрыть API для стилизации.
Замена модулей CSS
Локальную область видимости модулей CSS можно заменить одним из описанных выше способов изменения области видимости. Для образца возьмём каноничный пример модулей CSS из документации 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.
Добавление префиксов поставщика к правилам CSS с использованием значений из Can I Use — в большинстве сценариев использования префиксы поставщика больше не требуются. Показанный в примере псевдокласс
:fullscreen
теперь работает в браузерах без префиксов.Преобразование современного CSS в то, что может понимать большинство браузеров — современный CSS, который вы хотите использовать, скорее всего, уже поддерживается. Показанное в примере правило
color: oklch()
теперь работает во всех популярных браузерах.Модули CSS — см. альтернативы, описанные в предыдущем разделе. Организуйте согласованные форматы и избегайте ошибок в таблицах стилей при помощи stylelint. Можно добавить в Visual Studio Code расширение vscode-stylelint для выполнения того же линтинга во время разработки без необходимости встраивания его в этап сборки.
Подведём итог: из-за отказа Microsoft от поддержки IE11 и постоянного совершенствования актуальных браузеров PostCSS по большей мере стал ненужным.
Замена SASS
Аналогично PostCSS, давайте разберём список основных возможностей SASS:
Переменные — заменены пользовательскими свойствами CSS.
Вложенность — вложенность CSS недавно стала поддерживаться всеми популярными браузерами, что вполне может покрыть ваши потребности.
Модули — можно аппроксимировать сочетанием
@import
, переменных CSS и описанных выше способов управления областями видимости.Примеси (mixin) — к сожалению, функция CSS-примесей, которая может заменить их, по-прежнему находится на этапе спецификации.
Операторы — во многих случаях могут быть заменены встроенной функцией calc().
Подведём итог: 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="#3AC1EF">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). При правильном использовании 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="#3AC1EF">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 (маркеры) — как говорилось выше, landmark — это фундамент структуры страницы, по умолчанию обеспечивающие качественную структуру и accessibility.
Элементы — хорошее знание множества встроенных элементов HTML сэкономит вам время благодаря отсутствию необходимости пользовательских элементов и упрощению реализации в случае необходимости пользовательских элементов. При правильном использовании HTML-элементов они по умолчанию обеспечивают accessibility.
Формы — при их полнофункциональном использовании встроенные формы HTML способны реализовывать множество сценариев применения интерактивности. Изучите такие их возможности, как разнообразные типы ввода, валидация на стороне клиента и псевдоклассы UI. Если вы не можете найти подходящего для себя типа ввода, то можете использовать связанные с формами пользовательские элементы, но учитывайте поддержку браузерами ElementInternals.
▍ Фавиконки
Вероятно, вам захочется добавить в HTML элемент, не основанный на стандартах, а именно ссылку на фавиконку:
- Чтобы не усложнять, поместите
favicon.ico
в корень сайта и добавьте на неё ссылку в свой HTML:<link rel="icon" href="favicon.ico">
- Можете попробовать использовать фавиконки в SVG, но помните, что Safari их не поддерживает. Встройте тёмный режим в сам SVG фавиконки или для большего удобства воспользуйтесь генератором наподобие RealFaviconGenerator.
- Учтите, что поскольку фавиконки не основаны на опубликованных стандартах веба, будет довольно сложно полностью реализовать стандарт де-факто.
Проект
Рекомендуемая структура проекта для ванильного многостраничного веб-сайта такова:
/
— в корне проекта находятся файлы, которые не будут публиковаться, например, 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.
Маршрутизация
«Олдскульный» способ маршрутизации стандартных HTML-страниц и связующих их тегов
<a>
обладает следующими преимуществами: простое индексирование поисковыми движками и изначальная полная поддержка функциональности истории браузера и закладок.Зависимости
В процессе разработки вам могут потребоваться сторонние библиотеки. Их можно использовать без npm и бандлера.
Unpkg
Чтобы использовать библиотеки без бандлера, их предварительно нужно собрать в формат ESM или UMD. Такие библиотеки можно скачать с unpkg.com:
- Зайдите на
unpkg.com/[library]/
(последняя косая черта важна), например, на unpkg.com/microlight/ - Найдите и скачайте файл библиотеки js, который может находиться в подпапке, например, в
dist
,esm
илиumd
- Поместите файл библиотеки в папку
lib/
Или же библиотеку можно загрузить напрямую из CDN.
▍ UMD
Формат модулей UMD — это старый формат для библиотек, загружаемых из тега
script
; он обладает самой широкой поддержкой, особенно среди старых библиотек. Его можно распознать по наличию typeof define === 'function' && define.amd
в JS библиотеки.Чтобы включить его в свой проект, нужно выполнить следующие действия:
- Включить его в теге script:
<script src="lib/microlight.js"></script>
- Получить его у окна:
const { microlight } = window;
▍ ESM
Формат модулей ESM (также называемый модулями 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 и ESM-сборка web-vitals:

Рендеринг текста выполнен компонентом
<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. Они определяют уникальное отображение между именем модуля, который можно импортировать, и соответствующим файлом библиотеки в особом теге 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">
пока поддерживаются не всеми браузерами. Из-за этого import map должны дублироваться на каждой HTML-странице. - Import map должна определяться до загрузки скрипта
index.js
, предпочтительно из раздела<head>
. - Import map можно использовать для более простой загрузки библиотек из папки
node_modules
или из CDN. Можно использовать JSPM generator для быстрого создания import map для зависимостей for CDN. Однако при этом стоит иметь в виду, что из-за добавления таких внешних зависимостей ванильная кодовая база будет зависеть от постоянной доступности соответствующего сервиса.
Браузерная поддержка
Ванильные веб-сайты поддерживаются всеми современными браузерами. Но что это значит?
- Весь наш сайт работает в текущих версиях Safari, Chrome, Edge и Firefox.
- Весь наш сайт имеет уровень поддержки 95% или выше на caniuse.com, за исключением Import Map (92%), декларативных Shadow DOM (89%) и вложенности CSS (88%), но вскоре их поддержка расширится.
- В свою очередь, это означает, что можно без проблем использовать HTTP/2, семантические элементы HTML5, пользовательские элементы, шаблоны, Shadow DOM, MutationObserver, CustomEvent, FormData и API Element.closest.
- Также можно гарантированно использовать модули JavaScript, ECMAScript 6 / 2015, ECMAScript 8 / 2017 и ECMAScript 11 / 2020.
- В CSS можно гарантированно использовать @import, переменные, calc(), flexbox, grid, display: contents и многое другое.
Чтобы следить за новыми веб-стандартами, изучайте следующие проекты:
- Baseline отслеживает фичи, широко доступные в браузерах, а также сообщает, когда их безопасно использовать.
- Interop — это ежегодная инициатива разработчиков браузеров по внедрению новых фич веб-платформы во все браузеры. Можно считать её предварительным обзором того, что вскоре войдёт в baseline.
Развёртывание
Для развёртывания сайта можно выбрать любого провайдера, способного хостить статические веб-сайты.
На примере GitHub Pages:
- Загрузите проект как репозиторий на GitHub
- Перейдите в Settings, Pages
- Source: GitHub Actions
- Static Website, Configure
- Перейдите к
path
и измените его на./public
- Выполните коммит изменений...
- Зайдите на страницу Actions репозитория и дождитесь развёртывания сайта
Тестирование
Все популярные фреймворки тестирования предназначены для работы в конвейерах сборки.
Однако у ванильного веб-сайта нет этапа сборки. Для тестирования веб-компонентов можно применить старомодный подход: тестирование в браузере при помощи фреймворка Mocha.
Например, вот юнит-тесты для компонента
<x-tab-panel>
, использованного для отображения вкладок панелей исходного кода на нашем веб-сайте:
А для того, чтобы ещё глубже разобраться в коде, покажу, как выглядит тестирование исходного кода:
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 и Chai, потому что они работают в браузере без этапа сборки.
- Для более удобных запросов к DOM используется DOM Testing Library. Файл
imports-test.js
конфигурирует её для ванильного использования. - Важное ограничение заключается в том, что DOM Testing Library не может выполнять запросы внутри корней shadow. Чтобы выполнять тестирование внутри корней shadow, необходимо сначала выполнить запрос к содержащему их веб-компоненту, получить дескриптор его свойства
shadowRoot
, а затем выполнить запрос внутри него. - Веб-компоненты инициализируются асинхронным образом, поэтому тестировать их может быть непросто. Используйте методы async DOM Testing Library.
Пример
Примером разработки может быть наш веб-сайт. Его проект есть на GitHub.
Telegram-канал со скидками, розыгрышами призов и новостями IT ?
