Все уроки
ФронтендНачальный14 мин чтенияВ моём стеке

HTML, CSS и JavaScript

Фундамент любого сайта: структура, стиль и поведение, сделанные как следует.

  • Semantic HTML
  • Flexbox & Grid
  • DOM
  • Events
  • fetch
Шаг 1/5
HTML — это структура: стены, крыша, дверь и окна. Он говорит, что есть что, а не как это выглядит.

За 30 секунд

Каждая веб-страница состоит из трёх слоёв. HTML описывает, чем является контент (заголовок, кнопка, форма), CSS решает, как он выглядит и как подстраивается под любой экран, а JavaScript добавляет поведение — например, реакцию на клики или загрузку данных. Фреймворки вроде React и Next.js стоят поверх этих трёх; хорошее их понимание делает вас быстрее, а ваши сайты — доступнее в любом фреймворке.

Почему я это использую

  • Каждый интерфейс, на каком бы фреймворке он ни был, я строю на семантическом HTML и принципах обычного CSS, потому что именно это реально понимают браузеры, поисковики и скринридеры.
  • Большинство «багов фреймворка», которые я отлаживаю, на деле оказываются вопросами HTML, CSS или DOM: отсутствующий label, схлопывающийся margin, событие, всплывающее там, где никто не ждал.
  • Я верстаю mobile-first на Flexbox, Grid и дизайн-токенах и проверяю страницы на ширине телефона, планшета и десктопа, прежде чем считать их готовыми.
  • Доступность для меня — часть качества, а не дополнение: семантическая разметка ничего не стоит и помогает каждому пользователю.

Как это работает

01Семантический HTML и основы доступности

HTML-элементы несут смысл. <button> получает фокус, работает с Enter и Space и озвучивается скринридерами как кнопка; <div> с обработчиком клика не делает ничего из этого. Используйте landmark-элементы (<header>, <nav>, <main>, <footer>), один <h1> на страницу с заголовками по порядку, <a> для навигации и <button> для действий, а списки — для списков. Каждому полю формы нужен видимый <label>, связанный через for и id. Изображениям нужен alt-текст, описывающий их назначение, или alt="", если они декоративные. Убедитесь, что всё работает с одной только клавиатурой и что обводка фокуса видна. Ориентируйтесь на WCAG 2.2 уровня AA, который включает контрастность не ниже 4.5:1 для обычного текста. Используйте ARIA, только когда никакой нативный элемент не подходит: первое правило ARIA — предпочитать нативный HTML.

html
<header>
  <nav aria-label="Main">
    <a href="/">Home</a>
    <a href="/shop" aria-current="page">Shop</a>
  </nav>
</header>
<main>
  <h1>Running shoes</h1>
  <img src="/img/shoe.webp" alt="Blue running shoe, side view" width="640" height="480" />
  <form action="/subscribe" method="post">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <button type="submit">Subscribe</button>
  </form>
</main>

02Блочная модель и каскад

Каждый элемент — прямоугольный блок из четырёх слоёв: содержимое, padding (отступ внутри рамки), border и margin (отступ снаружи). По умолчанию width применяется только к содержимому, поэтому добавление padding делает блок шире, чем вы просили. Установка box-sizing: border-box для всех элементов включает padding и border в width, что гораздо интуитивнее. Вертикальные margin'ы между блоками могут схлопываться в один — классический сюрприз; gap во Flexbox и Grid этого избегает. Когда несколько правил нацелены на один элемент, победителя определяет каскад: важность, затем специфичность (id сильнее класса, класс сильнее элемента), затем порядок в исходнике. Держите специфичность низкой и плоской, в основном одиночные классы, и избегайте !important. Современный CSS также предлагает custom properties (переменные) для дизайн-токенов, каскадные слои (@layer) для упорядочивания целых групп стилей и нативную вложенность.

css
:root {
  --space-2: 0.5rem;
  --space-4: 1rem;
  --radius: 12px;
  --color-surface: #ffffff;
}
*, *::before, *::after {
  box-sizing: border-box;
}
.card {
  padding: var(--space-4);
  border: 1px solid #dddddd;
  border-radius: var(--radius);
  background: var(--color-surface);
}
 
.card:focus-visible {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}

03Flexbox или Grid

Оба — системы раскладки, и современные сайты используют обе. Flexbox одномерный: он выстраивает элементы в одну строку или колонку и отлично распределяет пространство и выравнивает — навбар, кнопку с иконкой или переносящийся ряд тегов. Вы думаете от контента: элементы сами задают свой размер, а Flexbox делит оставшееся место. Grid двумерный: вы задаёте строки и колонки, и элементы размещаются в этой структуре, что подходит для раскладки страниц, галерей карточек и дашбордов. Вы думаете от раскладки. Полезный паттерн Grid — repeat(auto-fill, minmax(240px, 1fr)), который создаёт столько колонок, сколько помещается, вообще без media queries. Оба поддерживают gap для единообразных отступов. Практическое правило: Grid для общей структуры, Flexbox для компонентов внутри неё.

css
/* Flexbox: a toolbar with items on both ends */
.toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}
 
/* Grid: responsive cards without media queries */
.products {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 1.5rem;
}

04Адаптивный дизайн

Адаптивный сайт работает на любом экране — от телефона шириной 360 пикселей до широкого монитора. Начните с мета-тега viewport, иначе мобильные браузеры отрисуют уменьшенную десктопную страницу. Проектируйте mobile-first: пишите базовые стили для маленьких экранов и добавляйте media queries для больших, выбирая брейкпоинты там, где ломается ваш контент, а не под конкретные устройства. Используйте относительные единицы (rem для шрифтов и отступов, % и fr для ширин) и clamp() для «резиновых» размеров шрифта. Container queries, которые теперь поддерживаются во всех современных браузерах, позволяют компоненту подстраиваться под ширину родителя, а не всего окна, — идеально для переиспользуемых карточек. Для изображений используйте srcset и sizes, чтобы телефоны скачивали маленькие файлы, задавайте width и height, чтобы не было сдвигов макета, и лениво загружайте изображения ниже первого экрана. Тестируйте на реальных устройствах и в режиме устройств в браузере.

html
<meta name="viewport" content="width=device-width, initial-scale=1" />
 
<style>
  h1 { font-size: clamp(1.75rem, 1.2rem + 2.5vw, 3rem); }
 
  .layout { display: grid; gap: 1rem; }
  @media (min-width: 768px) {
    .layout { grid-template-columns: 240px 1fr; }
  }
 
  .card-wrap { container-type: inline-size; }
  @container (min-width: 420px) {
    .card { display: flex; gap: 1rem; }
  }
</style>
 
<img src="/img/hero-800.webp" srcset="/img/hero-400.webp 400w, /img/hero-800.webp 800w, /img/hero-1600.webp 1600w"
     sizes="(min-width: 768px) 50vw, 100vw" width="800" height="450" alt="Product on a desk" loading="lazy" />

05DOM и события

Разбирая HTML, браузер строит DOM (Document Object Model) — дерево объектов, которое JavaScript может читать и изменять. Элементы находят через querySelector, изменяют через свойства вроде textContent, classList и dataset, а на действия пользователя реагируют через addEventListener. События путешествуют: клик начинается на целевом элементе и всплывает вверх через всех его предков. Это делает возможным делегирование событий — один слушатель на родителе обрабатывает клики по всем его потомкам, включая добавленных позже. event.preventDefault() отменяет действие браузера по умолчанию, например отправку формы; event.target говорит, по чему на самом деле кликнули. Для пользовательских данных используйте textContent, а не innerHTML, чтобы избежать межсайтового скриптинга (XSS). Подключайте скрипты с defer или type="module", чтобы они не блокировали отрисовку страницы.

html
<ul id="cart">
  <li>Coffee beans <button data-id="101" class="remove">Remove</button></li>
  <li>Filter papers <button data-id="102" class="remove">Remove</button></li>
</ul>
<p id="status" role="status"></p>
 
<script type="module">
  const cart = document.querySelector("#cart");
  const status = document.querySelector("#status");
 
  // One listener for every current and future button (delegation)
  cart.addEventListener("click", (event) => {
    const button = event.target.closest("button.remove");
    if (!button) return;
    button.closest("li").remove();
    status.textContent = `Item ${button.dataset.id} removed`; // safe: no innerHTML
  });
</script>

06Загрузка данных через fetch

fetch() — встроенный в браузер способ делать HTTP-запросы; он доступен и в Node.js. Он возвращает промис с Response. Две детали часто сбивают с толку. Во-первых, fetch отклоняет промис только при сетевом сбое: 404 или 500 всё равно резолвятся, поэтому всегда проверяйте response.ok или response.status. Во-вторых, тело читается отдельно через response.json() или response.text(), и это тоже асинхронно. Чтобы отправить JSON, укажите method, заголовок Content-Type и JSON.stringify для тела. Используйте AbortController, чтобы отменить запрос, когда пользователь уходит со страницы или вводит новый поисковый запрос, и AbortSignal.timeout(), чтобы не ждать вечно. Показывайте в UI состояния загрузки и ошибки и блокируйте кнопку отправки, пока запрос выполняется, чтобы пользователи не отправили его дважды. Кросс-доменные запросы регулируются CORS, и разрешать их должен сервер, а не код в браузере.

js
async function searchProducts(query, signal) {
  const res = await fetch(
    `https://api.example.com/products?q=${encodeURIComponent(query)}`,
    { headers: { Accept: "application/json" }, signal }
  );
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  return res.json();
}
 
let controller;
document.querySelector("#search").addEventListener("input", async (e) => {
  controller?.abort(); // cancel the previous, now outdated request
  controller = new AbortController();
  try {
    const results = await searchProducts(e.target.value, controller.signal);
    console.log(results);
  } catch (err) {
    if (err.name !== "AbortError") console.error(err);
  }
});

Когда применять

  • Всегда: любой веб-фреймворк в итоге отрисовывает HTML, CSS и JavaScript.
  • Простые сайты, лендинги и контент в духе писем, где обычные HTML и CSS быстры и не требуют шага сборки.
  • Прогрессивное улучшение: форма, которая работает без JavaScript и становится удобнее с ним.
  • Отладка приложений на фреймворках, где панели Elements, Network и Console в браузере показывают, что происходит на самом деле.

Когда не стоит

  • Большие интерактивные приложения с большим количеством общего состояния, где рукописный DOM-код становится трудно поддерживать и компонентный фреймворк (React, Vue, Svelte) окупается.
  • Переписывать с нуля сложные виджеты (выбор даты, комбобоксы, модальные окна), когда уже есть хорошо протестированный доступный компонент.

Альтернативы

  • Компонентные фреймворки (React, Vue, Svelte)

    Описывают UI как компоненты с состоянием; на выходе всё равно HTML, CSS и обновления DOM.

  • Utility CSS (Tailwind)

    Стилизация маленькими классами прямо в разметке вместо отдельных таблиц стилей; быстро, но понимать блочную модель, Flexbox и Grid всё равно нужно.

  • htmx и HTML, отрисованный на сервере

    Сервер отдаёт HTML, а фрагменты подменяются на странице, почти без JavaScript; хорошо подходит для админок и CRUD-приложений.

Лучшие практики

  1. Сначала нативные элементы: <button> для действий, <a> для навигации, <label> для каждого поля ввода.
  2. Пишите CSS mobile-first с дизайн-токенами (custom properties) вместо захардкоженных цветов и размеров.
  3. Используйте Grid для структуры страницы, Flexbox для компонентов и gap для отступов.
  4. Задавайте изображениям width, height, alt-текст и адаптивный srcset; изображения ниже первого экрана грузите лениво.
  5. Держите фокус видимым, тестируйте только с клавиатуры и проверяйте контрастность цветов по WCAG 2.2 AA.
  6. Проверяйте response.ok после каждого fetch, показывайте состояния загрузки и ошибки и отменяйте устаревшие запросы.
  7. Учитывайте prefers-reduced-motion для анимаций.

Частые ошибки

  • Кликабельные <div> вместо кнопок, которыми не могут пользоваться люди с клавиатурой и скринридерами.
  • Поля ввода без label, где вся надежда на placeholder, который исчезает при вводе.
  • Забыть мета-тег viewport, из-за чего мобильная версия отображается уменьшенной.
  • Считать, что fetch бросает исключение на HTTP-ошибках, и показывать «успех» после ответа 500.
  • Вставлять пользовательский ввод через innerHTML и открывать дверь для XSS.

Глубже

07Конвейер рендеринга и Core Web Vitals

Чтобы отрисовать страницу, браузер разбирает HTML в DOM и CSS в CSSOM, объединяет их в render tree, вычисляет layout (размер и положение каждого бокса), рисует пиксели в слои (paint) и наконец компонует эти слои на GPU (composite). CSS в head блокирует рендеринг, а классические скрипты без defer блокируют парсинг — поэтому критический CSS должен быть маленьким, а скрипты отложенными. Изменение свойства, влияющего на layout, например width или top, заново запускает layout, иногда для всей страницы; анимация transform и opacity обычно требует только компоновки, а это гораздо дешевле. Чтение значения layout вроде offsetHeight сразу после записи стилей вызывает синхронный layout, а если делать это в цикле (layout thrashing), страница зависает. Core Web Vitals от Google превращают это в измеримые цели на 75-м перцентиле реальных пользователей: Largest Contentful Paint меньше 2.5 секунды, Interaction to Next Paint (который в марте 2024 года заменил First Input Delay) меньше 200 миллисекунд и Cumulative Layout Shift меньше 0.1.

js
// Layout thrashing: read, write, read, write... forces layout each time
for (const card of cards) {
  card.style.height = card.offsetWidth * 0.75 + "px";
}
 
// Better: batch all reads, then all writes
const widths = cards.map((card) => card.offsetWidth);
cards.forEach((card, i) => {
  card.style.height = widths[i] * 0.75 + "px";
});
 
// Best for this case: let CSS do it, no JavaScript at all
// .card { aspect-ratio: 4 / 3; }
 
// Yield to the browser during long work so input stays responsive (INP)
await new Promise((resolve) => setTimeout(resolve, 0));

08Современная архитектура CSS: слои, :where(), :has() и контроль специфичности

Большие таблицы стилей разваливаются из-за войн специфичности: каждое исправление требует селектора сильнее предыдущего, пока !important не окажется повсюду. Современный CSS даёт инструменты, чтобы управлять этим осознанно. Каскадные слои (@layer reset, base, components, utilities) определяют приоритет по порядку слоёв ещё до сравнения специфичности, поэтому простой утилитарный класс в более позднем слое побеждает сложный селектор компонента в более раннем. :where() сопоставляет так же, как :is(), но добавляет нулевую специфичность — идеально для reset'ов и библиотечных стилей, которые должно быть легко переопределить. :has() — долгожданный родительский селектор: стилизуйте карточку, содержащую изображение, или поле формы с невалидным input, без JavaScript. Custom properties наследуются по дереву и могут меняться в рантайме, так что тема — это просто другой набор значений на родительском элементе. Вместе это позволяет держать почти каждый селектор на уровне одного класса и при этом иметь предсказуемые переопределения.

css
@layer reset, base, components, utilities;
 
@layer reset {
  :where(ul, ol) { margin: 0; padding: 0; list-style: none; }
}
@layer components {
  .card { padding: var(--space-4); border-radius: var(--radius); }
  .card:has(> img) { padding-top: 0; }               /* parent selector */
  .field:has(input:user-invalid) { --border: #b91c1c; }
}
@layer utilities {
  .p-0 { padding: 0; } /* wins over .card because of layer order */
}
 
[data-theme="dark"] { --color-surface: #111318; --color-text: #e8e8ea; }
 
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition: none !important; }
}

09Нативный UI платформы: dialog, popover, inert и управление фокусом

Раньше, чтобы сделать модалки, меню и тултипы доступными, требовались сотни строк JavaScript. Теперь большую часть делает сама платформа. Элемент <dialog>, открытый через showModal(), удерживает фокус внутри, делает остальную страницу inert, закрывается по Escape и рисуется поверх всего в top layer. Popover API (атрибут popover вместе с popovertarget на кнопке, Baseline с 2024 года) создаёт всплывающие элементы с light-dismiss, например меню, вообще без скриптов. Атрибут inert убирает целое поддерево из фокуса и дерева доступности, что полезно для off-canvas навигации. Управление фокусом по-прежнему на вас: когда диалог закрывается, фокус должен вернуться на кнопку, которая его открыла (dialog делает это сам), а после клиентской навигации переведите фокус на заголовок новой страницы, чтобы пользователи скринридеров знали, что контент сменился. Сообщайте об асинхронных результатах через live region (role="status" или aria-live), а не полагайтесь только на визуальные изменения.

html
<button popovertarget="account-menu">Account</button>
<div id="account-menu" popover>
  <a href="/orders">Orders</a>
  <a href="/settings">Settings</a>
</div>
 
<button id="open-delete">Delete address</button>
<dialog id="confirm-delete" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete this address?</h2>
  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="confirm">Delete</button>
  </form>
</dialog>
<script type="module">
  const dialog = document.querySelector("#confirm-delete");
  document.querySelector("#open-delete").addEventListener("click", () => dialog.showModal());
  dialog.addEventListener("close", () => console.log(dialog.returnValue));
</script>

10Безопасность фронтенда: XSS, CSP и безопасные сторонние скрипты

Межсайтовый скриптинг (XSS) возникает, когда контролируемый атакующим текст интерпретируется как код: вставлен через innerHTML, подставлен в href как javascript: или склеен со встроенным обработчиком события. Экранирование по умолчанию (textContent, шаблонизация фреймворка) предотвращает большую часть случаев; когда действительно нужно отрисовать HTML от пользователей, например rich-text комментарии, санитизируйте его поддерживаемой библиотекой вроде DOMPurify. Content Security Policy — вторая линия обороны: HTTP-заголовок, который сообщает браузеру, какие источники скриптов разрешены, так что внедрённые скрипты не выполняются. Строгая политика использует nonce на каждый запрос или хэши вместо whitelist'а целых доменов. Trusted Types там, где поддерживаются, идут дальше: они заставляют все опасные DOM-синки проходить через проверенную политику. Каждый добавленный сторонний скрипт работает с полным доступом к вашей странице, включая поля форм, поэтому подключайте их как можно меньше, фиксируйте версии, используйте Subresource Integrity для статических файлов с CDN и никогда не ставьте аналитику на страницы оплаты.

http
# HTTP response header (one line in practice), nonce generated per request
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m123' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data: https://cdn.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
 
<!-- Static library from a CDN, verified by hash -->
<script src="https://cdn.example.com/[email protected]/lib.min.js"
        integrity="sha384-BASE64_HASH_OF_THE_FILE" crossorigin="anonymous" defer></script>

Реальные ситуации

01

Страница прыгает при загрузке, и пользователи жмут не ту кнопку

Проблема

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

Как решить

Сначала найдите смещающиеся элементы: панель Performance в Chrome DevTools отмечает layout shift'ы, а расширение Web Vitals показывает, какие узлы сдвинулись. Затем зарезервируйте место для всего, что приходит с опозданием. Укажите каждому изображению width и height или aspect-ratio в CSS. Зарезервируйте фиксированный min-height под слот баннера или выводите его ниже первого экрана. Используйте font-display: swap с подогнанным по размеру запасным шрифтом (size-adjust или загрузчик шрифтов фреймворка), чтобы текст не перекомпоновывался. Никогда не вставляйте контент над уже существующим, если это не ответ на действие пользователя. Задеплойте и следите за CLS на 75-м перцентиле в данных реальных пользователей, потому что лабораторные тесты пропускают сдвиги из-за рекламы и медленных сетей.

02

Аудит доступности проваливает checkout

Проблема

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

Как решить

Исправляйте по степени влияния, начиная с блокеров. Замените кликабельные div'ы настоящими кнопками и ссылками, убедитесь, что каждый шаг работает с Tab, Enter, Space и Escape, и сохраните видимый outline для :focus-visible. Свяжите каждый input с label, отмечайте ошибки через aria-invalid и привязывайте сообщение через aria-describedby; при отправке переводите фокус на сводку ошибок со ссылками на поля. Сообщайте об асинхронных результатах вроде «card declined» в области role="alert". Исправьте цветовые токены один раз, чтобы каждая кнопка проходила 4.5:1. Проверьте через axe DevTools, затем протестируйте вручную со скринридером, например VoiceOver или NVDA, потому что автоматические инструменты ловят лишь часть проблем.

03

Маркетинговый скрипт замедляет каждое взаимодействие

Проблема

После добавления чата, тепловой карты и двух трекинговых скриптов сайт стал ощущаться неповоротливым. INP в полевых данных вырос до 450 ms, а главный поток после загрузки занят несколько секунд.

Как решить

Профилируйте в панели Performance в Chrome с замедлением CPU и сгруппируйте активность по сторонним источникам, чтобы увидеть, какому скрипту принадлежат long task'и. Удалите скрипты, которыми никто не пользуется, — обычно такой находится. Остальные загружайте с defer или после события load, а виджет чата подгружайте только когда пользователь нажимает кнопку «Чат» (фасад). Уберите теги из tag manager за согласие пользователя, что всё равно обязательно. В собственном коде разбивайте длинные обработчики на небольшие части, уступайте главному потоку между ними (yield) и выносите тяжёлую работу вроде фильтрации больших списков из обработчиков ввода. Перепроверьте INP в real-user monitoring через несколько дней, так как это полевая метрика.

Шпаргалка

Модалка с ловушкой фокуса и Escape
dialog.showModal() // <form method="dialog"> closes it
Меню без JavaScript
<button popovertarget="menu">Menu</button> <div id="menu" popover>...</div>
Скрыть область от фокуса и скринридеров
<aside inert>...</aside>
Задать порядок каскада
@layer reset, base, components, utilities;
Reset с нулевой специфичностью
:where(ul, ol) { list-style: none; }
Стилизовать родителя по дочернему элементу
.card:has(> img) { padding-top: 0; }
Зарезервировать место под медиа
img, video { aspect-ratio: 16 / 9; width: 100%; height: auto; }
Озвучивать асинхронные обновления
<p role="status" aria-live="polite"></p>
Уменьшение анимации
@media (prefers-reduced-motion: reduce) { ... }
Базовый заголовок CSP
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'

В моих реальных проектах

  • Я с нуля пересобрал маркетинговый сайт агентства — с интерактивными эффектами, тёмной темой, sitemap и SEO-редиректами — именно на этих основах.
  • В интернет-магазине зоотоваров я записал правила дизайн-системы для всей команды: только дизайн-токены, никаких «сырых» значений цветов или пикселей и семь определённых брейкпоинтов.
  • Я проверяю страницы на ширине телефона, планшета и десктопа (390, 768 и 1920 пикселей) с автоматическим шагом проверки в браузере, прежде чем работа будет принята.
  • В наших внутренних пакетах есть дизайн-инспектор, который позволяет дизайнерам менять CSS-переменные прямо в браузере и сохранять их обратно в исходники.

Вопросы и ответы

Q1Что такое семантический HTML и почему он важен?

Семантический HTML — это выбор элементов по смыслу, а не по внешнему виду: <nav> для навигации, <button> для действий, <h2> для заголовка раздела, <table> для табличных данных. Браузер и вспомогательные технологии используют этот смысл. Скринридер может перечислить заголовки и landmark-области, чтобы пользователь прыгал по странице; настоящая кнопка получает фокус с клавиатуры и правильно озвучивается без дополнительного кода; поисковики понимают структуру и основной контент. Ещё это сокращает код, потому что поведение вы получаете бесплатно, а не воссоздаёте его через JavaScript и ARIA. Стилизация отделена, поэтому семантический элемент может выглядеть как угодно.

Q2Когда вы выбираете Flexbox, а когда Grid?

Flexbox — для одного измерения: ряд или колонка элементов, размер которых в основном определяется их содержимым, — навигационные панели, тулбары, группы кнопок или переносящиеся списки тегов. Grid — для двух измерений: когда нужно сначала задать строки и колонки, а потом разместить в них элементы, — раскладки страниц, галереи товаров и дашборды. Grid также даёт адаптивные колонки без media queries через repeat(auto-fill, minmax(...)). На практике я использую оба на каждой странице: Grid для общей структуры и списков карточек, Flexbox для выравнивания внутри каждого компонента. Оба поддерживают gap, который я предпочитаю margin'ам между элементами.

Q3Объясните всплытие и делегирование событий.

Когда вы кликаете по элементу, событие сначала спускается от document к целевому элементу (фаза перехвата), а затем всплывает обратно через каждого предка. Поэтому слушатели на родителях «слышат» клики по потомкам. Делегирование событий этим пользуется: вместо того чтобы вешать слушатель на каждую строку длинного списка, вы вешаете один слушатель на список и проверяете event.target, обычно через closest(), чтобы найти нужного потомка. Это экономит память и автоматически работает для элементов, добавленных позже. Остановить распространение можно через stopPropagation(), но делайте это осторожно, потому что это ломает другой код, который полагается на всплытие, — например, аналитику или обработчики клика вне элемента.

Q4Что меняет box-sizing: border-box?

По умолчанию (content-box) width и height задают только область содержимого, а padding и border добавляются сверху. Блок шириной 300 пикселей с padding 20 пикселей и рамкой в 1 пиксель на самом деле имеет ширину 342 пикселя, что запутанно ломает раскладку. С border-box ширина включает padding и border, поэтому блок ровно 300 пикселей, а содержимое сжимается, чтобы поместиться. Это совпадает с тем, как думают дизайнеры, и делает раскладки на процентах предсказуемыми. Почти каждый современный CSS-reset применяет border-box ко всем элементам и псевдоэлементам, и я делаю так же в каждом проекте.

Q5Почему fetch не бросает ошибку на ответ 404 или 500?

Потому что с точки зрения fetch запрос удался: он дошёл до сервера и получил валидный HTTP-ответ. fetch отклоняет промис, только когда запрос вообще не удалось выполнить — например, нет сети, не сработал DNS, запрос заблокировал CORS или его отменили. HTTP-статусы ошибок — это обычные ответы, которые нужно проверять самостоятельно через response.ok (true для 200–299) или response.status. Я оборачиваю fetch в небольшой хелпер, который проверяет ok, разбирает JSON, бросает типизированную ошибку со статусом и телом и поддерживает AbortSignal и таймаут, чтобы остальной код никогда не забывал эти проверки.

Вопросы уровня senior

S1Как бы вы построили дизайн-систему, которая работает в нескольких продуктах и фреймворках?

Я делю её на слои с разным сроком жизни. Основа — дизайн-токены: цвета, отступы, типографика, радиусы и анимация, хранящиеся как данные и экспортируемые как CSS custom properties, так что ими может пользоваться любой фреймворк или даже чистый HTML, а темы — это просто другие значения. Поверх идут соглашения CSS: каскадные слои, имена классов с низкой специфичностью и задокументированные брейкпоинты. Компоненты идут последними и сильнее всего завязаны на фреймворк; они строятся на нативных элементах ради доступности. Компромисс здесь между гибкостью и единообразием: строгие компоненты держат продукты одинаковыми, но раздражают команды с особыми потребностями, поэтому я разрешаю композицию и задокументированные «лазейки» вместо бесконечных props. Версионирование тоже важно: токены меняются редко и безопасно, а API компонентов требует semver и заметок о миграции, поэтому по возможности я выпускаю их отдельно.

S2У страницы плохой INP. Как вы диагностируете и исправляете проблему?

INP измеряет задержку от взаимодействия до следующего кадра, поэтому причина всегда в занятом главном потоке: до запуска обработчика (input delay), в самом обработчике (processing) или в рендеринге после него (presentation delay). Я начинаю с полевых данных — real-user monitoring или библиотека web-vitals с атрибуцией, — чтобы найти медленные взаимодействия и элементы, потому что лабораторные тесты редко их воспроизводят. Затем воспроизвожу с замедлением CPU в панели Performance. Исправления зависят от фазы: удалите или отложите сторонние скрипты, создающие long task'и; разбейте длинные обработчики и делайте yield между частями; сначала обновляйте UI, а дорогую работу выполняйте потом; избегайте layout thrashing; уменьшайте размер DOM и используйте content-visibility для длинных секций за пределами экрана. В React-приложениях так же помогают transitions и более узкие области перерендера.

S3Когда в 2026 году progressive enhancement стоит дополнительных усилий?

Оно того стоит везде, где отказ дорог, а JavaScript может не сработать: checkout, регистрация, формы поиска и обратной связи, а также контент, который должен индексироваться. JavaScript ломается чаще, чем думают команды: на медленных сетях запросы отваливаются по таймауту, расширения ломают скрипты, старым устройствам не хватает памяти, а одна ошибка в рантайме может остановить весь бандл. Форма, которая отправляется как обычный HTML и улучшается клиентской валидацией и мгновенной обратной связью, продолжает работать во всех этих случаях. Современные фреймворки делают это дешевле, чем раньше, потому что server actions и нативная отправка форм работают без гидрации. Компромисс в том, что высокоинтерактивные инструменты — редакторы, карты, дашборды — мало выигрывают от запасного варианта без JavaScript, поэтому я применяю progressive enhancement к критичным сценариям, а не как общее правило.

Глоссарий

Render tree
Объединение DOM и CSSOM, для которого браузер вычисляет layout и выполняет отрисовку; скрытые элементы в него не входят.
Reflow (layout)
Пересчёт размеров и положений боксов после изменения; дорог, когда затрагивает большие части страницы.
Композитинг
Объединение отрисованных слоёв на GPU; анимация transform и opacity обычно требует только этого шага.
LCP
Largest Contentful Paint: момент, когда завершается отрисовка самого большого видимого элемента. Хорошо — меньше 2.5 секунды.
INP
Interaction to Next Paint: насколько быстро страница визуально реагирует на клики, тапы и нажатия клавиш. Хорошо — меньше 200 ms.
CLS
Cumulative Layout Shift: насколько видимый контент неожиданно сдвигается во время использования страницы. Хорошо — меньше 0.1.
Каскадный слой
Именованная группа стилей; порядок слоёв определяет приоритет ещё до сравнения специфичности селекторов.
Top layer
Управляемый браузером слой поверх всего контента, где отрисовываются модальные диалоги и popover'ы независимо от z-index.
CSP
Content Security Policy: заголовок ответа, перечисляющий, из каких источников браузеру разрешено загружать скрипты, стили и фреймы.
Live region
Элемент, об изменениях содержимого которого сообщают скринридеры; помечается через aria-live или role="status".

Есть идея продукта?Давайте создадим его.

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