В прошлом году мы запустили новый интерфейс для MDN. Самые заметные изменения коснулись стилей: мы упростили и унифицировали дизайн MDN на всех страницах. По правде говоря, самые масштабные изменения были не видны читателям — они кроются в полностью переработанном коде, который обеспечивает работу нашего фронтенда. В этой статье рассказывается о том, что мы сделали, какие технологии выбрали и зачем вообще мы это предприняли.
Архитектура MDN
Чтобы полностью понять изменения, внесенные во фронтенд MDN, мне нужно дать небольшой контекст о том, как контент MDN собирается в тот веб-сайт, который вы все знаете и любите. Архитектура MDN, пожалуй, заслуживает отдельной статьи в блоге, но если упростить ради этой публикации, страницы публикуются на сайт через следующие основные этапы:
- Документация пишется и поддерживается в формате Markdown в нескольких репозиториях git нашей фантастической командой технических писателей, партнеров и приглашенных экспертов, а также огромным сообществом контрибьюторов и переводчиков.
- Инструмент сборки считывает эти файлы Markdown, преобразует их в HTML и сохраняет в виде набора JSON-файлов с дополнительными метаданными для каждой страницы.
- Наш фронтенд обрабатывает эти JSON-файлы и компилирует полноценные страницы, дополненные таблицами совместимости с браузерами, поддержкой локализации, меню навигации и т. д. — на этапе, который мы называем (возможно, не совсем корректно) серверным рендерингом (SSR).
- В этот момент полученные файлы HTML, CSS и JavaScript загружаются в облачные хранилища и доставляются нашим читателям по всему миру.
Зачем мы переписали фронтенд MDN
Необходимость пересобрать наш фронтенд назревала давно из-за того, насколько сложно было работать с пользовательским интерфейсом MDN. Наш предыдущий фронтенд (называемый yari) представлял собой React-приложение, которое, к сожалению, накопило немало технического долга. Поддерживать его было не то чтобы невозможно, но определенно мучительно. Всякий раз, когда мы исправляли баги или добавляли новый функционал сайта, мы неизбежно наращивали еще больше технического долга. Но как мы до этого докатились?
Это React-приложение изначально создавалось с помощью «Create React App», но целый ряд встроенных настроек по умолчанию нам не подошел. Разумеется, это привело к череде обходных путей, и в итоге нам пришлось «извлечь» (eject) конфигурацию. Мы получили чрезвычайно сложный файл конфигурации Webpack, а также несколько очень костыльных скриптов сборки.
С CSS дела тоже начали выходить из-под контроля. Мы активно использовали Sass, а затем добавили современные функции CSS, такие как CSS-переменные, что привело к появлению странной смеси обоих подходов, разбросанной по нашим файлам.
Кроме того, CSS был невероятно запутанным, со слабым охватом областей видимости или его полным отсутствием. Когда мы вносили изменения в один компонент интерфейса, мы часто замечали непредвиденные изменения в других. Эти проблемы, а также отсутствие инструментов сборки для разделения CSS привели к тому, что нам приходилось отправлять пользователям огромный блокирующий рендеринг CSS-пакет, содержащий стили для компонентов, которые они могли никогда не загрузить.
Но главным недостатком было то, что наше React-приложение являлось лишь оберткой вокруг нашего статического контента. Чтобы React-приложение знало об HTML-контенте, сгенерированном нашим инструментом сборки, потребовалось бы дорогостоящее повторное синтаксическое анализирование HTML и огромное количество логики, которую нам пришлось бы отправлять пользователям в составе клиентского JavaScript. Мы не хотели этого делать, поэтому границы React-приложения по сути заканчивались там, где начиналась наша документация — для вставки контента мы использовали метод dangerouslySetInnerHTML в React.
Наш контент в основном статичен (текст и примеры кода), однако в нем было множество мест, где требовалось добавить интерактивность (например, кнопка «Копировать» у блоков кода). Для этих интерактивных частей мы в итоге использовали обычные API DOM, что выглядело не очень элегантно, особенно учитывая, что остальной сайт был написан на React. Мы не могли использовать JSX (похожий на HTML синтаксис React), что ограничивало поддерживаемость более сложных элементов интерактивности, и иногда мы сталкивались с худшим сценарием — необходимостью поддерживать дублирующие реализации: одну на React, а другую на DOM API.
Появление веб-компонентов
В качестве возможного решения этой проблемы в 2024 году мы начали экспериментировать с Lit и веб-компонентами, чтобы проверить, смогут ли они улучшить опыт разработчиков при работе с подобной интерактивностью внутри контента. Наш первый полноценный прототип и последующая реализация для продакшена выросли из работы над учебной программой MDN Curriculum, когда мы объединили усилия с Scrimba.
Scrims в качестве концептуального доказательства (PoC)
У Scrimba есть функция под названием «Scrims» — это интерактивная среда обучения, которую мы внедряем на MDN через тег <iframe>. Scrims позволяют учащимся смотреть короткий туториал по кодированию, а затем самостоятельно редактировать код прямо в том же окне — представьте себе интерактивные скринкасты.
На наших страницах мы не хотели отправлять какие-либо данные пользователя в Scrimba до тех пор, пока пользователь сам не решит взаимодействовать с их контентом, поэтому мы не загружали <iframe> до тех пор, пока пользователь не кликнет для его открытия. Мы также хотели иметь возможность развернуть Scrim на весь экран, не покидая MDN, поэтому мы использовали элемент <dialog>.
Мы рассудили, что создание веб-компонента позволит нам использовать пользовательский элемент (custom element) для вставки этих Scrims напрямую в наш контент, тем самым пропустив ряд этапов рендеринга и избежав сложной в обслуживании реализации на DOM API.
Наш компонент начинается с наследования от LitElement:
Внутри него нам нужно определить некоторое состояние. В Lit мы можем сделать это с помощью атрибута статических свойств static properties:
А значения по умолчанию мы задаем в конструкторе класса:
Мы хотим манипулировать URL, переданным в качестве атрибута нашему пользовательскому элементу. Lit предоставляет для этого методы жизненного цикла. Мы хотим вычислить значение, как только станет известно, что обновление компонента будет отрендеревлено:
Затем мы можем использовать это состояние для рендеринга нашего компонента:
Шаблонный литерал html в Lit так же удобен, как и JSX, позволяя писать похожий на HTML синтаксис прямо в JavaScript. Огромное преимущество перед JSX заключается в том, что для его использования не требуется никакой компиляции: это нативный JavaScript.
Я говорю «похожий на HTML», потому что вы заметите несколько аннотаций перед определенными атрибутами в шаблоне выше, а именно @close и @click. Это синтаксис Lit, который позволяет нам привязывать прослушиватели событий к этим элементам: событие close для <dialog> и события click для пары кнопок. Мы определяем их и в самом классе:
Когда пользователь кликает, чтобы открыть Scrim, срабатывает метод #open, который обновляет значения _scrimLoaded и _fullscreen. Lit замечает изменения этих свойств, так как мы определили их в static properties, и автоматически перерисовывает компонент, загружая внутри него <iframe> и сам Scrim.
Я немного упростил компонент для краткости, исходный код MDNScrimInline можно посмотреть на GitHub. Там есть ряд дополнений, таких как телеметрия и динамически рендеримый миниатюрный превью-эскиз (это заняло меньше байт и оказалось проще в реализации, чем предварительный рендеринг кучи изображений). Как вы можете догадаться, разрабатывать это было очень просто благодаря вспомогательным функциям, которые мы получаем с Lit; реализация всего этого напрямую с использованием традиционного DOM API стала бы огромной головной болью.
Во многих отношении я обнаружил, что реализация на Lit проще, чем на React: вы можете заметить, что состояние, с которым мы работаем, не отличается особой сложностью и не требует сложной архитектуры компонентов для его отображения. Но что еще важнее, это дает нам пользовательский элемент, который мы можем встроить в учебные материалы везде, где нам нужно добавить Scrim:
Интерактивные примеры
Реализация Scrimba стала для команды хорошим знакомством с написанием небольших компонентов, но как насчет чего-то более сложного? Интерактивные примеры — это компоненты, которые появляются в разделах «Попробуйте» в верхней части многих страниц CSS, JavaScript и HTML.
Совершенствование инфраструктуры для них уже некоторое время находилось в бэклоге команды разработчиков; техническим писателям и нашему сообществу было сложно поддерживать их и создавать. Существующая реализация была разделена между четырьмя репозиториями git, а создание или отладка примера могли потребовать синхронизации изменений во всех из них. Хуже того, примеры приходилось писать отдельно от контента, в который они будут включены, поэтому было невозможно показать интерактивный предварительный просмотр, содержащий одновременно и изменения в примере, и содержимое страницы MDN, на которой он будет размещен.
Эта сложность возникла по веским причинам: эти интерактивные примеры были слишком сложны для простой разработки и поддержки непосредственно с помощью DOM API. Вместо этого у нас была отдельная система сборки и репозиторий примеров, которые рендерили эти примеры в виде отдельных HTML-страниц, которые мы могли напрямую загружать в <iframe>.
Мы хотели упростить эту архитектуру, чтобы авторам было проще писать интерактивные примеры, поэтому мы снова обратились к Lit, чтобы создать веб-компонент, который мы могли бы напрямую встраивать в наш контент. Это была гораздо более сложная с технической точки зрения реализация, чем Scrims. Во-первых, нам потребовался ряд шаблонов для различных способов отображения интерактивных примеров:
- Редактор кода и консоль для примеров на JavaScript, а также дополнительные вкладки для примеров на WASM.
- Редактор кода с вкладками и рендерингом вывода для примеров HTML (обычно вкладки для HTML и CSS).
- Таблица редакторов кода, которые можно выбирать, с рендерингом вывода для примеров CSS (см., например, свойство CSS background-clip).
Во-вторых, нам нужен был способ рендеринга примеров и изменений, которые в них вносят пользователи. Мы уже написали эту логику для создания нашей интерактивной Песочницы (Playground), но она была на React, так что нам нужно было перенести и ее.
Поэтому мы приступили к выполнению этой задачи. Ситуацию значительно упростило то, что интеграция Lit с React позволила нам рендерить эти веб-компоненты в нашем существующем приложении на React. Таким образом, мы могли переносить нужные нам элементы Песочницы в веб-компоненты поэтапно, без необходимости переписывать все целиком и без необходимости поддерживать две реализации одновременно.
Если говорить в общих чертах, мы разделили наш единый запутанный компонент Песочницы на React на ряд пользовательских элементов:
- <play-editor>: редактор на базе CodeMirror.
- <play-console>: элемент для форматирования и рендеринга сообщений консоли.
- <play-runner>: элемент, отвечающий за рендеринг текущего состояния каждого редактора.
- <play-controller>: элемент, отвечающий за передачу событий и состояния между каждым из вышеуказанных элементов.
Это сделало логику в Песочнице более простой и независимой, что позволило нам повторно использовать эти элементы в созданном нами элементе <interactive-example>. Это включало логику поиска на странице элементов <code>, которые должен поглотить компонент интерактивного примера, отправки их содержимого в <play-controller> и определения того, какой из вышеуказанных шаблонов ему нужно отрендерить, используя для этого ту или иную комбинацию элементов <play-*>.
Это означало, что теперь авторы могли добавлять в контент макрос — который за кулисами рендерит наш пользовательский элемент <interactive-example> — за которым следуют блоки кода, которые должен использовать пример:
Вы можете посмотреть полный исходный код для этого примера на странице CSS background-repeat на GitHub. Мы пока не до конца уверены, стоит ли помещать пользовательские элементы непосредственно в наш Markdown-контент, не относящийся к учебным материалам, что могло бы сделать нашу архитектуру еще проще; это тема для отдельного обсуждения.
Серверные компоненты и веб-компоненты (на сервере)
В общем и целом все это замечательно, веб-компоненты выглядят довольно круто и решают некоторые наши проблемы, связанные с добавлением интерактивности в наш статический контент. Но разве этот пост в блоге не должен был быть посвящен тому, как мы переписали весь наш фронтенд-стек: что с этим стало? Чтобы ответить на этот вопрос, давайте перейдем к другой проблеме, которая была у нас со старым фронтендом:
Серверные компоненты React
Я уже упоминал нашу проблему с тем, что наше приложение на React является «оберткой» и не может взаимодействовать с нашим контентом. Фундаментальная проблема заключается в том, что (по крайней мере, классические) приложения на React являются одностраничными приложениями (SPA), которые вы сначала настраиваете для рендеринга на сервере, а затем пытаетесь придумать, как избежать отправки пользователям поистине огромного пакета JavaScript.
Эта последняя часть необходима. Дело в том, что все, что вы рендерите в SPA, даже если это можно было бы отрендерить статически на сервере или на этапе компиляции, должно быть отправлено в вашем клиентском пакете JavaScript и перерендерено на клиенте — просто чтобы убедиться, что ничего не изменилось. Сама документация React резюмирует это лучше, чем смог бы я:
Этот паттерн означает, что пользователям необходимо загрузить и проанализировать дополнительные 75 КБ (в сжатом виде) библиотек, а также дождаться второго запроса на получение данных после загрузки страницы, просто чтобы отрендерить статический контент, который не изменится в течение всего времени жизни страницы. Серверные компоненты на react.dev
Этот паттерн означает, что пользователям необходимо загрузить и проанализировать дополнительные 75 КБ (в сжатом виде) библиотек, а также дождаться второго запроса на получение данных после загрузки страницы, просто чтобы отрендерить статический контент, который не изменится в течение всего времени жизни страницы. Серверные компоненты на react.dev
Это цитата из документации по серверным компонентам React (RSC): таким образом, проект признает эту проблему и упорно работает над ее решением. К сожалению, эффективное использование RSC требует применения фреймворка, который мы еще не используем. Миграция на него в любом случае потребовала бы переписывания значительной части фронтенда.
Поскольку для решения этой фундаментальной проблемы требовалось масштабное переписывание, мы также могли переосмыслить то, каким сайтом является MDN и насколько сложным он должен быть. По правде говоря, MDN не является особенно сложным сайтом, по крайней мере, с точки зрения «вещей, требующих интерактивности». Подавляющее большинство контента на странице документации MDN — это HTML и CSS: нам не нужен сложный фреймворк, управляющий большей частью сайта. У нас по сути есть островки интерактивности, которые все вместе можно легко реализовать в виде веб-компонентов.
И если мы реализуем всю нашу функциональность в изолированных веб-компонентах, не имеет особого значения, как они собираются: нам просто нужно объединять HTML с помощью шаблонизаторов, и это может происходить многократно, в разных местах нашей общей системы сборки. Нет никакого высокоуровневого «приложения», которое должно понимать состояние всей страницы, поэтому проблемы «обёртки» просто не может возникнуть — наш инструмент сборки из уцененного текста (markdown) в HTML является таким же полноправным участником процесса, как и любое шаблонизирование, необходимое нам во фронтенде.
Этот подход решил все три проблемы одновременно: SPA не отправляет избыточный JavaScript для повторного рендеринга статического контента, нет «обёртки», которая не может получить доступ к HTML нашей документации, а каждый интерактивный элемент представляет собой самодостаточный веб-компонент, который загружается только тогда, когда это необходимо. Оставалось решить, как выполнять статическую шаблонизацию, которая объединяет всё остальное.
Наши серверные компоненты
Мы рассматривали возможность использования специализированного языка шаблонизации, такого как EJS, для создания шаблонов во фронтенде, но поняли, что компонентная архитектура дает множество преимуществ. Хотя создание шаблонов статического HTML на сервере позволяет избежать отправки логики пользователям в клиентском JavaScript-бандле, этот HTML всё равно требует стилизации. И, как вы помните, CSS в нашем старом фронтенде был в полном беспорядке, и мы хотели избежать отправки ненужного CSS, если он не требуется для рендеринга текущей страницы.
Сначала мы создали собственную концепцию серверных компонентов, используя шаблоны литералов HTML от Lit, с которыми мы уже были знакомы. Вот пример нашего компонента верхней панели навигации:
Как видите, логика обрабатывается в методе render, точно так же, как это было бы в компоненте Lit. Нам не нужны никакие методы жизненного цикла, так как это запускается только один раз. Этот компонент может отображать другие серверные компоненты, такие как Logo и Menu, а также веб-компоненты, такие как <mdn-search-button> и <mdn-search-modal>.
Мы рендерим это в HTML в NodeJS, используя удобную функцию, предоставляемую Lit. Это также преобразует эти веб-компоненты Lit в декларативный Shadow DOM (Declarative Shadow DOM), поэтому в совместимых браузерах Shadow DOM и CSS наших пользовательских элементов отображаются до загрузки JavaScript.
Отправка только необходимого
Как я уже упоминал ранее, одна из главных проблем подхода SPA к созданию веб-сайтов заключается в том, что всё необходимое для рендеринга страницы должно быть включено в клиентский JavaScript-бандл. Другая похожая проблема заключается в том, что очень легко (и поэтому очень распространено) отправить в одном огромном клиентском бандле кучу кода, который не нужен для рендеринга текущей страницы, а необходим только для отображения других страниц. Мы попали в эту ловушку со старым фронтендом, как с JavaScript, так и с CSS.
Со временем мы действительно разделили определенные маршруты на отдельные чанки, но это было возможно только с нашим JavaScript. Наш CSS был слишком запутан, чтобы сделать это, и наш инструмент сборки также не был настроен для этого.
Кроме того, это было возможно делать только для каждого маршрута в целом, а не для компонентов на одной и той же странице. Если существовала вероятность того, что определенный маршрут может загрузить компонент, его нужно было включить в бандл для серверного рендеринга, даже если он не использовался, и этот JavaScript никогда не выполнялся на стороне клиента.
Мы хотели избежать всего этого в нашем новом фронтенде: загружать только самые минимальные бандлы CSS и JavaScript, необходимые для рендеринга страницы и придания ей интерактивности; и я хотел добиться этого на архитектурном уровне, чтобы этого почти невозможно было избежать. Мы добились этого несколькими способами, но ключом к их реализации стала плоская структура компонентов на основе имен.
Каждый компонент находится в плоской иерархии в каталоге ./components/, причем следующие имена файлов зарезервированы для определенных частей компонента:
- element.js — веб-компонент, экспортирующий класс MDNExampleComponent и определяющий элемент <mdn-example-component>.
- server.js — серверный компонент, расширяющий ServerComponent из components/server/index.js.
- server.css — CSS для серверного компонента, который будет автоматически загружаться для этого компонента с сервера.
- global.css — CSS для компонента, который загружается везде и всегда.
Мы принудительно соблюдаем некоторые из этих правил с помощью линтера, а в некоторых случаях выдаем ошибки при несоблюдении требований к имеменованию.
Веб-компоненты
Поскольку мы знаем, где находится каждый веб-компонент, основываясь на его имени, мы можем делать некоторые хитроумные вещи. Когда наша страница загружается, мы запускаем на стороне клиента такую логику:
Это приводит к отложенной (lazy-loading) загрузке каждого пользовательского элемента, присутствующего в DOM во время загрузки, полностью асинхронно и параллельно. Преимущества здесь многочисленны:
- Инженерам не нужно помнить об импорте веб-компонентов в серверные компоненты; они могут использовать их так, будто это обычные HTML-элементы.
- Мы можем добавлять пользовательские элементы в наш контент в формате markdown (напрямую или через макрос) без необходимости настраивать экспорт в другом месте.
- Мы автоматически загружаем JavaScript для каждого веб-компонента только тогда, когда он присутствует на странице. Инженерам не нужно думать о том, увеличивает ли их новый компонент размер бандла — если он отсутствует на странице, которую просматривает пользователь, этот код не будет загружен браузером пользователя.
- Изменения в одном компоненте должны оказывать минимальное влияние на остальную часть бандла: исправление ошибки в одном компоненте потребует перезагрузки JavaScript этого компонента, но другие компоненты должны кэшироваться браузером и становиться интерактивными практически мгновенно, поскольку они загружаются асинхронно и параллельно.
Мы также автоматически загружаем каждый веб-компонент в наш SSR-бандл, где Lit рендерит их в декларативный Shadow DOM (если только компонент не отказался от этого, так как его рендеринг на сервере не имеет смысла). Это помогает избежать сдвигов макета при загрузке JavaScript. В результате небольшая задержка интерактивности при загрузке JavaScript после первоначального рендеринга становится незаметной, поскольку компонент уже находится на странице, просто еще не интерактивен.
По крайней мере, не полностью интерактивен: эта архитектура достаточно гибкая, чтобы мы могли применять хитрые приемы с определенными компонентами. Один из них — <mdn-dropdown>.
Это компонент, который, как можно догадаться по названию, реализует выпадающий список. Метод рендеринга довольно прост:
Мы рендерим два слота, которые можно использовать следующим образом:
Поскольку мы используем здесь слоты, теневой DOM (shadow DOM) компонента <mdn-dropdown> практически не имеет значения, и любые его дочерние элементы могут стилизоваться абсолютно нормально: элемент добавляет только интерактивность. К нему привязаны некоторые стили, но они предназначены не для внешнего вида: они также нужны для интерактивности. Видите ли, у нас также есть метод жизненного цикла:
И определяем наше свойство loaded следующим образом:
Если проследить за логикой, вы увидите, что слот выпадающего списка не скрыт по умолчанию и, следовательно, виден при рендеринге в DSD на сервере. И как только компонент получает loaded=true, мы отражаем этот атрибут в DOM, так что он выглядит следующим образом:
Причина, по которой нам это нужно, заключается в том, что мы также применяем к элементу следующий CSS:
Логику здесь немного сложно разобрать — и я говорю это как человек, который ее написал, — но фактически она сводится к следующему:
- Если JavaScript для mdn-dropdown загрузился, стили не применяются.
- Если JavaScript для mdn-dropdown еще не загрузился, то: если фокус находится вне элемента, скрыть слот выпадающего списка. Если фокус находится внутри элемента, показать слот выпадающего списка. И что это значит? Что ж, мы получаем нативный выпадающий список на CSS сразу после рендеринга страницы, который постепенно улучшается до JavaScript-выпадающего списка после загрузки этого кода; другим разработкам не нужно знать о происходящем, достаточно того, что компонент выпадающего списка работает.
- Если фокус находится вне элемента, скрыть слот выпадающего списка.
- Если фокус находится внутри элемента, показать слот выпадающего списка. И что это значит? Что ж, мы получаем нативный выпадающий список на CSS сразу после рендеринга страницы, который постепенно улучшается до JavaScript-выпадающего списка после загрузки этого кода; другим разработчикам не нужно знать о происходящем, достаточно того, что компонент выпадающего списка работает.
Это чрезвычайно важно для нашего верхнего меню навигации, где большинство ссылок спрятаны в выпадающих списках: они полностью доступны для использования сразу после рендеринга на странице. В других компонентах, таких как переключатель тем, хотя выбор тем невозможен до загрузки его JavaScript, интерактивность выпадающего списка дает нам еще несколько секунд на загрузку этого JavaScript до того, как пользователь нажмет на что-либо, требующее его.
Что, если у нас нет DSD
Теперь, поскольку Declarative Shadow DOM еще не получил широкого распространения, мы должны убедиться, что всё работает и в чуть более старых браузерах. Здесь на помощь приходит файл global.css: любой CSS, написанный в нем, включается на все страницы постоянно.
Это, очевидно, необходимо для настройки таких вещей, как глобальные CSS-переменные, глобальные стили сброса и тому подобное. Но для компонентов, когда DSD недоступен и до загрузки его JavaScript, они будут выглядеть для браузера как пустые встроенные элементы без привязанных стилей. Это не всегда оптимально и может вызывать смещение макета при загрузке JavaScript, поэтому мы задаем глобальные стили для определенных элементов, например, для нашего компонента кнопки:
Этого вполне достаточно, чтобы кнопки не смещали макет до загрузки. Здесь есть небольшая оптимизация: загружать этот стиль только тогда, когда на странице присутствует mdn-button, а не на каждой странице, но она настолько незначительна, что, вероятно, не стоит добавленной сложности. Это также важно для упомянутого ранее компонента выпадающего списка: мы все равно хотим, чтобы он был интерактивным, если DSD не загрузился, поэтому мы включаем аналогичный стиль в файл global.css.
Серверные компоненты
Для серверных компонентов соображения немного другие. Сам JavaScript не нужно лениво загружать или сокращать, так как он используется только для SSR нашего HTML. Но то, что требует тщательной загрузки, — это CSS, используемый в каждом серверном компоненте. Мы хотим загружать его только в том случае, если компонент рендерится на странице. Но как нам это узнать?
Наши серверные компоненты расширяют класс ServerComponent, поэтому мы помещаем логику отслеживания в его статический метод render, который выполняется до и после создания каждого серверного компонента:
Это дает нам Set, componentsUsed, который содержит только те компоненты, которые что-либо отрендерили. Затем мы используем его в нашем компоненте OuterLayout:
Объект compilationStats здесь поступает из нашего инструмента сборки Rspack (подробнее об этом позже), и этот блок кода дает нам список тегов <link> для включения в наш <head>, содержащий только CSS, необходимый для рендеринга страницы. Мы также загружаем один CSS-файл, в который объединены все упомянутые ранее файлы global.css.
