Веб-платформа
Что браузер делает со страницей независимо от фреймворка: как раскладывает блоки, как делает интерфейс доступным, как превращает HTML в пиксели и как это ускорить. Angular и React работают поверх этого слоя, поэтому на fullstack-собеседовании его проверяют отдельно.
Перед этой темой: JavaScript и TypeScript.
Как устроена вёрстка в CSS: блочная модель, flexbox и grid?
Заголовок раздела «Как устроена вёрстка в CSS: блочная модель, flexbox и grid?»Что это
- Блочная модель. Элемент — это content, padding, border и margin. По умолчанию
widthне включает padding и border. display.blockзанимает всю строку,inlineтечёт в тексте,flexиgridраскладывают детей элемента.- Flexbox ставит детей в строку или колонку, grid — в сетку строк и колонок.
Как пользоваться
- Для всех элементов —
box-sizing: border-box. - Каркас страницы — grid, элементы внутри блока — flex, расстояния —
gap. - Стили по умолчанию — для телефона, для широких экранов —
@media (min-width: 768px). Префиксmd:в Tailwind работает так же.
Частая ошибка
Раскладывать блоки через float и position: absolute там, где хватает flex или grid. Такая вёрстка ломается от первого длинного текста.
Как устроено
- Каскад. Если к элементу подходят несколько правил, побеждает то, что выше по списку:
!important, inline-стиль, правило вне@layerили в более позднем слое, большая специфичность, более позднее место в коде. - Специфичность — тройка: id; классы, атрибуты и псевдоклассы; теги и псевдоэлементы. Сравнивается слева направо, поэтому
#nav a(1,0,1) сильнее.menu .item a(0,2,1). - Позиционирование.
relativeсдвигает элемент, сохраняя его место.absoluteвынимает его из потока и считает координаты от ближайшего позиционированного предка.fixed— от окна,stickyприлипает при прокрутке.
Когда что выбирать
| Flexbox | Grid | |
|---|---|---|
| Оси | одна: строка или колонка | две: строки и колонки |
| Размер задаёт | содержимое | контейнер |
| Пример | меню, кнопки в ряд, карточка | каркас страницы, галерея, форма |
Ловушки
- Схлопывание margin. Вертикальные margin соседних блоков в обычном потоке не складываются, берётся больший. Во flex и grid этого нет.
z-index«не работает». Он сравнивается внутри одного контекста наложения, а его создают, например,opacityменьше 1 илиtransformу предка.- Flex-элемент не сжимается. Минимальная ширина flex-элемента по умолчанию — ширина его содержимого; длинное слово распирает колонку, помогает
min-width: 0.
Правило
Grid — для каркаса, flex — для компонентов, gap — вместо margin между соседями, медиазапросы — mobile-first.
Под капотом
- Flex-алгоритм сначала берёт базовый размер элементов (
flex-basis, иначе по содержимому), затем раздаёт свободное место поflex-growили отнимает поflex-shrink. Поэтому колонки с разным текстом получаются разной ширины. - Grid сначала строит дорожки из
grid-template-columns, аfrделит оставшееся место.repeat(auto-fit, minmax(12rem, 1fr))даёт адаптивную галерею без медиазапросов. container-type: inline-sizeвключает size containment: ширина контейнера не зависит от содержимого. Иначе возник бы цикл «стиль зависит от размера, размер — от стиля».
Компромиссы
- Медиазапрос или container query. Медиазапрос подходит для каркаса страницы. Карточка, которая стоит и в сайдбаре, и в основной колонке, должна реагировать на свой контейнер. Container queries — Baseline с 2023 года, в Tailwind v4 они встроены (
@container,@md:). - Единицы.
remв шрифтах уважает настройку размера шрифта пользователя,px— нет.font-sizeтолько воvwне растёт при масштабировании страницы, поэтому вclamp()добавляютrem. На мобильных100vhвключает область под панелью браузера, точнее100dvh. - Управление каскадом.
@layer reset, library, appфиксирует порядок слоёв, а стили вне слоёв сильнее любого слоя.:where()обнуляет специфичность базовых стилей, и их легко переопределить. Утилитарный CSS вроде Tailwind и CSS-модули снимают гонку, потому что классы одного веса.
Как проверить в проде
- DevTools → Elements → Computed показывает, какое правило победило, а зачёркнутые — проигравшие. Значки
flexиgridв дереве элементов включают оверлей сетки. - Скриншотные тесты Playwright на нескольких ширинах ловят сломанную вёрстку до релиза.
Итог: grid задаёт размер сверху, flex — снизу; адаптивность компонента — через его контейнер, а приоритеты стилей — через слои, а не !important.
*, *::before, *::after { box-sizing: border-box; }
/* Каркас: mobile-first — одна колонка */.page { display: grid; grid-template-columns: 1fr; gap: 1rem;}@media (min-width: 768px) { .page { grid-template-columns: 16rem 1fr; } /* сайдбар + контент */}
/* Галерея без медиазапросов: столько колонок, сколько поместится */.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 1rem;}
/* Карточка подстраивается под свой контейнер, а не под окно */.card-slot { container-type: inline-size; }.card { display: flex; flex-direction: column; gap: 0.5rem; }@container (min-width: 30rem) { .card { flex-direction: row; } /* картинка слева, текст справа */}
/* От 24px до 40px при корневом шрифте 16px */h1 { font-size: clamp(1.5rem, 1rem + 2vw, 2.5rem); }- Чем
display: noneотличается отvisibility: hidden? — Первый убирает элемент из раскладки, второй прячет, но оставляет его место. - Что значит
flex: 1? —flex-grow: 1,flex-shrink: 1,flex-basis: 0%: элементы делят свободное место поровну. - Чем
auto-fitотличается отauto-fillв grid? —auto-fitсхлопывает пустые дорожки и растягивает элементы,auto-fillоставляет пустые дорожки. - От чего считает координаты
position: absolute? — От ближайшего предка сpositionнеstatic, иначе от начального содержащего блока. - Какая специфичность у
:is()и:where()? —:is()— как у самого сильного аргумента,:where()— всегда ноль.
Как сделать интерфейс доступным?
Заголовок раздела «Как сделать интерфейс доступным?»Что это
Доступность — интерфейсом могут пользоваться люди с нарушениями зрения, слуха и моторики: со скринридером, только с клавиатуры, с увеличенным шрифтом.
Как пользоваться
- Действие —
<button>, переход —<a href>, а неdivс обработчиком клика: они фокусируются и нажимаются с клавиатуры. - У каждого поля —
<label for="...">. Плейсхолдер подписью не считается: он исчезает при вводе. - У картинки —
altс её смыслом, у декоративной — пустойalt="". - Заголовки
h1–h6идут по порядку, области размеченыheader,nav,main. - Контраст обычного текста с фоном — не меньше 4,5:1.
Частая ошибка
Убрать рамку фокуса через outline: none, потому что «некрасиво». Пользователь клавиатуры перестаёт видеть, где он. Рамку не убирают, а оформляют через :focus-visible.
Как устроено
- Из DOM и CSS браузер строит дерево доступности: роль (
button), доступное имя («Сохранить») и состояние (disabled,expanded). - Скринридер — NVDA, JAWS, VoiceOver, TalkBack — получает это дерево через API доступности ОС и озвучивает.
- ARIA меняет только дерево, но не поведение:
role="button"наdivне даст ни фокуса, ни реакции на Enter и пробел.
Когда что выбирать
| Задача | Решение |
|---|---|
| Поле без видимой подписи | aria-label или aria-labelledby |
| Ошибка поля | aria-invalid="true" и aria-describedby на текст ошибки |
| Сообщение после действия | live region: role="status" или role="alert" |
| Модальное окно | <dialog> и showModal(): фон недоступен, Esc закрывает |
| Состояние виджета | aria-expanded, aria-selected, aria-current |
Ловушки
- ARIA поверх нативного. Первое правило ARIA: если есть HTML-элемент с нужной семантикой и поведением, берите его.
tabindexбольше нуля ломает естественный порядок обхода. Используют только0и-1.- Смена маршрута в SPA. Фокус и озвучка не меняются сами, их переводят на заголовок новой страницы вручную.
- Только цвет. Ошибку, выделенную лишь красной рамкой, не увидит дальтоник и не озвучит скринридер — нужен текст.
Правило
Сначала семантический HTML, затем ARIA для недостающего; каждый сценарий проходят с клавиатуры: Tab, Enter, пробел, Esc, стрелки.
Стандарты и закон
- WCAG 2.2 (W3C, октябрь 2023) — критерии трёх уровней: A — минимум, AA — цель законов и контрактов, AAA — для отдельных случаев.
- Новое в 2.2 на уровне AA: фокус не перекрыт липкими панелями (2.4.11), цель касания не меньше 24×24 CSS-пикселей (2.5.8), альтернатива перетаскиванию (2.5.7), вход без когнитивного теста — например, вставка пароля из менеджера (3.3.8). Критерий 4.1.1 Parsing удалён.
- EAA (директива 2019/882) применяется с 28 июня 2025 года к продуктам и услугам на рынке ЕС: интернет-магазины, банки, транспорт, электронные книги. Микропредприятия, оказывающие услуги, освобождены. Техническая опора — стандарт EN 301 549, который ссылается на WCAG 2.1 AA.
Компромиссы
- Свой виджет или готовый. Combobox, вкладки или дерево на ARIA — это роли, состояния и клавиатурная модель из ARIA Authoring Practices. Обычно дешевле взять проверенную библиотеку или Angular CDK
a11y, чем писать своё. - Автоматика или руки. axe-core и Lighthouse находят низкий контраст, пустые подписи и неверные роли. Понятен ли порядок фокуса и осмыслен ли
alt, они не скажут.
Как проверить в проде
- axe-core в e2e-тестах (
@axe-core/playwright) и Lighthouse CI в пайплайне ловят регрессии. - Вручную: основной сценарий проходят только с клавиатуры и со скринридером — NVDA на Windows, VoiceOver на macOS.
- DevTools → Elements → Accessibility показывает роль, имя и состояние узла и всё дерево доступности.
Итог: цель — WCAG 2.2 AA, закреплённая в компонентах дизайн-системы и в автотестах, а ручная проверка закрывает то, чего автоматика не видит.
<form novalidate> <!-- for/id связывает подпись с полем: клик по подписи ставит фокус в поле --> <label for="email">Email</label> <input id="email" type="email" autocomplete="email" aria-invalid="true" aria-describedby="email-error"> <!-- Скринридер прочитает текст ошибки вместе с полем --> <p id="email-error">Enter a valid email</p>
<!-- Кнопка-иконка: доступное имя через aria-label, иконку скрываем --> <button type="button" aria-label="Close"> <svg aria-hidden="true" width="16" height="16"><path d="M2 2l12 12M14 2L2 14"/></svg> </button>
<img src="chart.png" alt="Sales grew 20% in Q3"> <img src="divider.png" alt=""> <!-- декоративная: скринридер пропустит -->
<!-- Live region: вставленный сюда текст будет озвучен --> <div role="status"></div>
<button type="submit">Save</button></form>/* Рамка только при навигации с клавиатуры, а не при клике мышью */button:focus-visible,a:focus-visible { outline: 3px solid #1a73e8; outline-offset: 2px;}- Что такое первое правило ARIA? — Если нативный HTML-элемент даёт нужную семантику и поведение, используйте его, а не роль на
div. - Чем
aria-labelотличается отaria-labelledby? — Первый задаёт имя строкой, второй ссылается на id видимого текста. - Чем
role="alert"отличается отrole="status"? —alertозвучивается сразу, перебивая чтение,status— вежливо, после текущей фразы. - Какой контраст требует WCAG AA? — 4,5:1 для обычного текста, 3:1 для крупного и для элементов интерфейса.
- Что изменил Европейский акт о доступности? — С 28 июня 2025 года доступность обязательна для многих цифровых услуг в ЕС, ориентир — EN 301 549 и WCAG.
Как браузер превращает HTML в пиксели?
Заголовок раздела «Как браузер превращает HTML в пиксели?»Что это
Путь от HTML к пикселям:
- DOM. HTML разбирается в дерево узлов.
- CSSOM. CSS разбирается в дерево правил.
- Дерево отрисовки (render tree). Только видимые элементы с итоговыми стилями:
display: noneсюда не попадает. - Layout. Расчёт размеров и координат каждого блока.
- Paint. Заливка пикселей: текст, цвета, тени, картинки.
- Composite. Сборка слоёв в итоговый кадр.
Как пользоваться
- CSS — в
<head>, скрипты — с атрибутомdefer. - Анимировать
transformиopacity, а неtop,leftилиwidth.
Частая ошибка
Подключить большой скрипт в <head> без defer. Пока он загружается и выполняется, HTML не разбирается и экран остаётся белым.
Как устроено
- CSS блокирует отрисовку. Без CSSOM браузер не рисует, иначе пользователь увидел бы нестилизованную страницу. Синхронный скрипт ещё и ждёт стили перед собой: он может их читать.
- JS блокирует разбор. Обычный
<script>останавливает парсер до загрузки и выполнения. Тем временем preload scanner просматривает HTML дальше и заранее начинает грузить картинки и стили. - JS и отрисовка делят главный поток. Кадр рисуется между задачами, поэтому долгий обработчик его откладывает. Как устроен event loop — в теме JavaScript и TypeScript.
Когда что выбирать
Атрибут <script> |
Разбор HTML | Выполнение |
|---|---|---|
| нет | останавливается | сразу, по порядку |
async |
продолжается | как загрузится, без порядка |
defer |
продолжается | после разбора, по порядку, до DOMContentLoaded |
type="module" |
продолжается | как defer по умолчанию |
Что стоит изменение
| Изменение | Этапы |
|---|---|
width, top, font-size, новые узлы |
layout, paint, composite (reflow) |
color, background, box-shadow |
paint, composite (repaint) |
transform, opacity |
только composite |
Ловушки
- Layout thrashing. Запись стиля, а сразу за ней чтение
offsetHeightилиgetBoundingClientRect()заставляют пересчитать layout немедленно; в цикле — на каждой итерации. asyncдля зависимых скриптов. Порядок не гарантирован: библиотека может загрузиться позже кода, который её использует.
Правило
Скрипты — defer или модули, чтения layout — пачкой до записей, анимации — на transform и opacity.
Под капотом
- В Chromium этап paint лишь записывает команды отрисовки. Растеризацию и сборку кадра выполняют поток композитора и GPU, а не главный поток.
- Анимация
transformиopacityна отдельном слое целиком идёт в композиторе, поэтому она плавная, даже когда главный поток занят. Слой создают, например,will-change: transform,<video>и<canvas>. - Бюджет кадра при 60 Гц — около 16,7 мс на всё: JS, стили, layout и paint.
Цена
- Forced synchronous layout в цикле по N элементам — N пересчётов layout вместо одного.
- Каждый слой — текстура в памяти GPU. Сотни слоёв на слабом телефоне хуже лишнего repaint.
- Большой DOM удорожает каждый пересчёт стилей и layout.
Компромиссы
- Чтения и записи DOM разделяют: сначала все измерения, затем все изменения, визуальные изменения — в
requestAnimationFrame. content-visibility: autoпропускает рендеринг блоков вне экрана, но им нуженcontain-intrinsic-size, иначе прыгает полоса прокрутки.- Критический CSS, встроенный в
<head>, ускоряет первый кадр, но не кешируется отдельно и увеличивает каждый HTML-ответ.
Как проверить в проде
- DevTools → Performance: фиолетовые блоки Layout и Recalculate Style внутри длинных задач, а insight Forced reflow указывает на строку кода.
- Вкладка Rendering → Paint flashing подсвечивает перерисованные области, панель Layers показывает слои.
- В поле — Long Animation Frames API (Chromium): у каждого скрипта есть
forcedStyleAndLayoutDuration.
Итог: главный поток один на JS и рендеринг: не заставляйте его пересчитывать layout синхронно и отдавайте анимации композитору.
<head> <!-- CSS блокирует отрисовку: держим его небольшим и в head --> <link rel="stylesheet" href="/app.css"> <!-- media="print": на экране показ не блокирует --> <link rel="stylesheet" href="/print.css" media="print">
<script src="/legacy.js"></script> <!-- останавливает разбор HTML --> <script defer src="/app.js"></script> <!-- после разбора, по порядку --> <script async src="/analytics.js"></script> <!-- как загрузится, без порядка --> <script type="module" src="/main.js"></script> <!-- отложен, как defer --></head>const items = document.querySelectorAll('.item');
// Плохо: чтение после записи на каждой итерации — layout пересчитывается N разfor (const item of items) { item.style.width = item.parentElement.offsetWidth / 2 + 'px';}
// Хорошо: сначала все чтения, потом все записи — один пересчётconst widths = [...items].map((item) => item.parentElement.offsetWidth);requestAnimationFrame(() => { items.forEach((item, i) => { item.style.width = widths[i] / 2 + 'px'; });});/* Анимация только на композиторе: без layout и paint */.toast { transition: transform 200ms, opacity 200ms; }.toast.hidden { transform: translateY(100%); opacity: 0; }- Чем reflow отличается от repaint? — Reflow пересчитывает геометрию и тянет за собой paint, repaint только перерисовывает пиксели.
- Почему
display: noneиvisibility: hiddenпо-разному влияют на layout? — Первый убирает элемент из дерева отрисовки, второй оставляет его место. - Когда срабатывает
DOMContentLoaded, а когдаload? —DOMContentLoaded— после разбора HTML иdefer-скриптов,load— после всех картинок и стилей. - Блокирует ли отрисовку
<link rel="stylesheet" media="print">? — Нет, на экране он не нужен; файл всё равно загрузится с низким приоритетом. - Что делает preload scanner? — Пока парсер стоит на скрипте, он просматривает HTML дальше и заранее запускает загрузку ресурсов.
Как измерить и ускорить загрузку веб-приложения?
Заголовок раздела «Как измерить и ускорить загрузку веб-приложения?»Что это
| Метрика | Что измеряет | Хорошо |
|---|---|---|
| LCP | когда отрисован самый крупный элемент первого экрана | до 2,5 с |
| INP | задержку от клика или нажатия клавиши до следующего кадра | до 200 мс |
| CLS | сумму неожиданных сдвигов вёрстки | до 0,1 |
Как пользоваться
- Измерьте: Lighthouse в DevTools или PageSpeed Insights.
- Задайте картинкам
widthиheight: браузер заранее резервирует место, и вёрстка не прыгает. - Картинкам ниже первого экрана —
loading="lazy". - Тяжёлые страницы загружайте при переходе на них: ленивые маршруты в Angular,
React.lazyи динамическийimport().
Частая ошибка
Поставить loading="lazy" на главную картинку первого экрана. Она начнёт загружаться позже, и LCP вырастет.
Как устроено
| Метрика | Хорошо | Плохо |
|---|---|---|
| LCP | до 2,5 с | больше 4 с |
| INP | до 200 мс | больше 500 мс |
| CLS | до 0,1 | больше 0,25 |
Оценка — 75-й перцентиль визитов, отдельно для мобильных и десктопа. INP заменил FID в марте 2024 года: FID измерял только задержку первого ввода, INP учитывает все клики, касания и нажатия клавиш.
Лаборатория и поле
| Лабораторные данные | Полевые данные | |
|---|---|---|
| Источник | Lighthouse, DevTools Performance | CrUX, библиотека web-vitals, RUM |
| Условия | одно устройство, заданная сеть | реальные устройства и сети |
| Для чего | отладка, регрессии в CI | что видят пользователи |
Lighthouse при загрузке страницы INP не измеряет: в лаборатории его заменяет Total Blocking Time.
Ловушки
- Зелёный Lighthouse на мощном ноутбуке при плохих полевых данных с телефонов.
preloadдля всего подряд: ресурсы конкурируют за канал, и важное грузится позже.
Правило
LCP — картинка в HTML с fetchpriority="high", INP — меньше JS и короче задачи, CLS — размеры у всего, что загружается позже. Проблему ищут в поле, причину — в лаборатории.
Под капотом
- LCP = время до первого байта + задержка начала загрузки ресурса + длительность загрузки + задержка отрисовки. Почти всё время должно уходить на загрузку HTML и самого ресурса.
- INP = задержка ввода + обработка + задержка до отрисовки. Берётся худшее взаимодействие визита, а при большом их числе одно худшее на каждые 50 отбрасывается.
- HTTP/2 мультиплексирует запросы в одном соединении, поэтому склеивать всё в один файл больше не нужно. HTTP/3 работает поверх QUIC и при потере пакета не блокирует остальные потоки.
Цена
- Килобайт JS дороже килобайта картинки: его надо разобрать, скомпилировать и выполнить в главном потоке.
- Шрифты: WOFF2 и подмножество символов.
font-display: swapпоказывает текст сразу, но со сдвигом при подмене шрифта. - Статика с хешем в имени кешируется на год (
Cache-Control: max-age=31536000, immutable), HTML —no-cache. Серверная сторона — в теме Фоновые задачи и кеширование, CDN — в System design.
Компромиссы
- SSR и гидратация. Сервер отдаёт готовый HTML, и LCP наступает раньше. Но клиент всё равно скачивает JS и навешивает обработчики, и до конца гидратации страница видна, но не отвечает. Подробно — в теме React и Next.js.
- Код-сплиттинг. Стартовый бандл меньше, но переход на ленивый маршрут ждёт загрузки чанка. Помогает предзагрузка в простое или при наведении.
preconnectиpreload. Первый заранее открывает соединение с другим доменом, второй грузит поздно обнаруживаемый ресурс. Лишние подсказки отнимают канал у важного.
Как проверить в проде
- RUM: библиотека
web-vitalsотправляет LCP, INP и CLS с атрибуцией в вашу аналитику; CrUX и Search Console показывают итог за 28 дней. - Бюджеты в CI:
budgetsвangular.json, Lighthouse CI с порогами, анализ бандла черезsource-map-explorer.
Итог: измеряйте в поле, разбирайте метрику на части и чините самую длинную, а главный долгосрочный рычаг — меньше JavaScript на клиенте.
<head> <!-- Заранее открыть соединение с CDN картинок: DNS, TCP, TLS --> <link rel="preconnect" href="https://img.example.com"> <!-- Шрифт нужен сразу, а в CSS браузер обнаружит его поздно --> <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin> <style> @font-face { font-family: Inter; src: url(/fonts/inter.woff2) format("woff2"); font-display: swap; /* текст сразу, резервным шрифтом */ } </style></head><body> <!-- LCP-картинка: высокий приоритет, без lazy, размеры против CLS --> <img src="https://img.example.com/hero-800.avif" srcset="https://img.example.com/hero-800.avif 800w, https://img.example.com/hero-1600.avif 1600w" sizes="100vw" width="1600" height="900" fetchpriority="high" alt="Autumn sale">
<!-- Ниже первого экрана: загрузится при приближении к области просмотра --> <img src="/promo.webp" width="600" height="400" loading="lazy" alt="Gift cards"></body>import { onLCP, onINP, onCLS } from 'web-vitals';
// RUM: каждое значение уходит в свою аналитикуfunction send(metric) { const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating }); navigator.sendBeacon('/rum', body);}onLCP(send);onINP(send);onCLS(send);
// Код-сплиттинг: модуль графиков загрузится только по кликуdocument.querySelector('#report').addEventListener('click', async () => { const { renderChart } = await import('./chart.js'); renderChart();});- Почему INP заменил FID? — FID мерил только задержку до начала обработки первого ввода, INP — полную задержку до кадра для всех взаимодействий.
- Чем лабораторные данные отличаются от полевых? — Лаборатория воспроизводима и годится для отладки, поле показывает реальные устройства и сети.
- Чем
preloadотличается отprefetch? —preload— ресурс текущей страницы, грузится сразу;prefetch— для следующей навигации, в простое. - Как уменьшить CLS от веб-шрифтов? —
font-display: optionalили подгонка метрик резервного шрифта черезsize-adjust. - Можно ли встроить критический CSS при строгой CSP? — Да, через nonce или hash в
style-src; см. Основы веб-безопасности.