Меня зовут Михаил Сахнюк и я разрабатываю фронтенд уже более пяти лет. Сейчас я фронтенд разработчик в Miro. 

В статье рассмотрим:

  • как оптимизировать веб-приложение и ускорить его загрузку;

  • почему это важно;

  • какие инструменты помогут в работе над оптимизацией, замерами и контролем результатов;

  • преимущества работы с загружаемыми модулями в приложениях.

Ускорение загрузки приложения — это комплексная задача, которая решается всей командой разработчиков. В статье расскажу, что могут предпринять фронтенд разработчики, опуская варианты сетевой оптимизации с использованием HTTP2 или оптимизации скорости ответов сервера.

Статья — конспект моего доклада на конференции Mergeconf 2021 в Иннополисе. 

Проблема

Перед тем, как действовать, важно понять, какую задачу или проблему мы решаем. Зачем нам что-то оптимизировать, если приложение загружается и работает нормально? 

Скорость загрузки приложения сильно сказывается на том, будет пользователь использовать приложение или нет. Приведу в пример не самую новую, но актуальную аналитику от компании KissMetrics:

  • Каждые две дополнительные секунды загрузки приложения увеличивают количество отказов на 103%. Это значит, что если вы или ваша компания привлекаете клиентов, оплачивая каждое посещение ресурса, то, ускорив загрузку всего на 2 секунды, вы сможете сэкономить на рекламном бюджете и, как следствие, увеличить продажи и прибыль.

  • Каждая секунда загрузки приложения уменьшает конверсию в покупку на 7%. Например, если ваша компания зарабатывает 100 тысяч долларов в день — потеря одной секунды будет стоить вам 2,5 миллиона долларов в год. Такая сумма легко может равняться фонду оплаты труда всех разработчиков компании. И это всего лишь одна секунда.

Чтобы понять, где скрываются те самые секунды загрузки, посмотрим, как браузер загружает SPA. Если мы имеем типичное монолитное SPA приложение, загрузка будет выглядеть так:

  1. Получение и парсинг HTML (DOM). Браузер получит HTML документ, в котором не будет никакого контента, кроме ссылок на дополнительные ресурсы — JS бандл и стили. У пользователя в этот момент будет отображаться белый экран.

  2. Загрузка внешних ресурсов. На этом этапе будут загружаться JS и CSS файлы.

  3. Парсинг CSS и построение CSSOM. Полученные стили будут распарсены, но у пользователя до сих пор будет отображаться белый экран.

  4. Выполнение JavaScript. 

  5. Рендеринг страницы. И только на этом этапе пользователь увидит результат загрузки приложения. Отобразится содержимое и контент приложения.

Схематично загрузка приложения будет выглядеть как на рисунке ниже.

Обычно загрузка JS и CSS файлов может быть синхронной и асинхронной, но в ситуации с типичным SPA все этапы загрузки будут блокирующими, так как пользователь не сможет начать работать с приложением, пока весь необходимый контент не будет загружен браузером. На изображении это отмечено меткой TTI (Time To Interactive) — она означает время, когда пользователь сможет начать работу с приложением. 

Метка FCP (First Contentful Paint) означает момент, когда пользователь увидит контент вместо белого экрана. В зависимости от кейса, эта метка может располагаться на схеме как в процессе загрузки JS файла, так и в самом конце рядом с TTI. 

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

Оптимизация веб-страниц и их загрузки

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

Удаление неиспользуемого кода

Довольно частая проблема проектов — это наличие кода, который никогда не будет выполнен. Важно позаботиться о том, чтобы такой код не попадал в продакшн. Ниже приведу несколько примеров:

  • Моки. Несколько лет назад, работая на проекте, я смог уменьшить размер бандла приложения на 80%, удалив JSON файл со структурой компании на 5000 сотрудников. Этот файл временно заменял несколько API запросов, пока разрабатывался backend, и кто-то забыл убрать импорт. 

  • Старые модули. При разработке нового функционала мы часто делаем несколько прототипов и пишем целые модули, которые потом легко могут хвостом идти в прод. 

  • Стили. Такие библиотеки, как Bootstrap или новомодный Tailwind CSS, тянут сотни лишних классов. Для удаления неиспользуемых стилей даже существуют специальные инструменты, например, PurgeCSS.

  • Библиотеки. Помимо стилей, часто встречаются случаи, когда ради одной функции или компонента к проекту подключается огромная библиотека. В таких случаях стоит взять только импортируемый кусок кода из всей библиотеки, применив tree-shaking.

Сжатие кода

JS и CSS код, который мы пишем, собирается сборщиком в файлы. Они могут быть дополнительно сжаты путём сокращения имён переменных, удаления пробелов и комментариев. 

В большинстве случаев собранные приложения уже будут сжаты, так как все основные фреймворки по дефолту сжимают продакшн сборки. Если вы используете собственные конфигурации сборщика, стоит убедиться, что ваш код сжат. Только это позволит уменьшить размер приложения на 50-60%.

Сжатие картинок

Уверен, многие из вас натыкались на страницы в интернете, которые невероятно долго загружалась. Картинки на таких страницах могли занимать десятки мегабайт, так как были в исходном формате.

Существуют инструменты, которые позволяют сжимать картинки в десятки и даже сотни раз без особой потери качества. Статические изображения, которые вы подключаете к проекту напрямую, могут быть обработаны WebPack. Если загружаете картинки с сервера — достаточно поставить задачу backend разработчику, который решит её за пару часов.

Шрифты

Иногда размер шрифтов может доходить до 500кб. 

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

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

Существует ещё один подход сократить размер шрифта, удалив из файла неиспользуемые глифы. Этот способ подойдёт, если шрифт используется для некоторых заголовков или как логотип. 

Удалить глифы можно в специальных приложениях, и если вы используете в вашем проекте Google Fonts, вам очень повезло. Тогда в ссылке на шрифт можно передать параметр text с значением в виде символов, которые вы хотите использовать в шрифте. В результате серверы отдадут файл шрифта только с выбранными глифами. Итоговый размер такого шрифта может быть в десятки раз меньше исходного. Пример использования:

<link href=”https://fonts.googleapis.com/css?family=Roboto&text=Miro” rel=”stylesheet” >

Решаем проблему с размером бандла

В основе оптимизации размера приложения лежит Code-Splitting подход. Это процесс разделения основного модуля приложения на части или чанки (сhunks), которые могут быть подгружены, когда они необходимы. Причём это может происходить уже после того, как приложение загружено. 

Другими словами, Code-Splitting позволяет нам разделить приложение и отбросить код, который не требуется при запуске нашего SPA. 

Route-splitting

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

На схеме показано, как приложение будет разделено на чанки со страницами приложения, main модуль, в котором будет инициализироваться фреймворк, различные модули, данные и сам роутер. 

Очевидно, что в момент открытия приложения в браузере, пользователь загружает только одну страницу. Это значит, что в процессе запуска будет скачано только два файла (main и один chunk). Если учесть, что приложение может легко состоять из десятков страниц, то только этот подход позволит заметно сократить время запуска приложения.

Одноразовый код

После того, как SPA будет разделено на страницы, можно опуститься на уровень ниже и посмотреть, как устроена root часть и страницы внутри. Первое, что можно будет найти, — это одноразовый код. 

Под одноразовым кодом я имею в виду части приложения, которые пользователь увидит только один раз. К такому коду можно отнести:

  • формы регистрации и аутентификации, если вы не расположили их в отдельной странице;

  • онбординг и обучение нового пользователя;

  • подсказки и блоки информирования.

Редко используемый код

Это код разделов приложения, которые пользователь будет использовать редко. Здесь также применим подход выносить код в чанки, которые не нужны при загрузки SPA. Примером могут быть блоки уведомлений или справочная информация.

Уведомления в соцсетях
Уведомления в соцсетях
Справка Google Docs
Справка Google Docs

На картинке можно увидеть кусок devtool с загрузкой JS файлов. Открытие справки вызвало загрузку не одного файла, как можно было предположить, а сразу десятка, поэтому важно не перестараться при оптимизации. Например, если вы вынесли блок в отдельный чанк, а далее в нём еще с десяток компонентов будут вынесены в собственные чанки, это может привести к рекурсивной загрузке файлов и контента, и в итоге компонент будет загружаться ещё дольше, чем обычно. 

Поэтому не следует выносить в чанки всё подряд только для того, чтобы разгрузить размер страниц или root модуля, так как это не даст никакого результата. Для заметного прироста производительности стоит начать с блоков и модулей, которые весят 100кб и более. 

Скрытые блоки

Помимо постраничного роутинга приложения, мы часто разделяем контент внутри страниц, используя collapsed блоки или панели с табами. Такие блоки можно отнести к внутреннему роутингу страниц, поэтому они легко подходят для оптимизации.

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

На примере выше изображен раздел настроек Miro, где поочерёдно были открыты все табы страницы. В результате на каждое открытие таба, был загружен дополнительный JS код.

В дополнение к оптимизации существует ещё одно весомое преимущество разделения SPA на чанки — кеширование. После того, как браузер загружает файлы приложения, он сохраняет их на диск для дальнейшего использования. Если пользователь повторно захочет открыть страницу или её компонент, то чанк уже не будет загружен с сервера, а будет взят из памяти. Это позволяет моментально открывать страницы и работать с SPA. И самое главное: когда вы обновляете свое приложение, модули, которые вы не трогали, не будут обновлены и браузеру не потребуется загружать неизменённые страницы и компоненты новой версии приложения. 

Диалоговые окна

Мы размещаем контент в диалоговые окна, когда пользователю важно получить к нему доступ в любой странице приложения. Чаще всего такие компоненты будут размещены в Root модуле приложения и будут загружаться при первом запуске SPA. 

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

В крупных проектах диалоговые окна могут занимать весомую часть приложения. Например, у нас в Miro модуль Billing весит более 350кб и над ним работает отдельная команда разработчиков.

Ряд плюсов работы с загружаемыми модулями:

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

  • Изоляция работы над компонентами. Разным командам будет проще работать с проектом, особенно если вы будете выносить модули в отдельные репозитории.

  • А/Б тестирование. Динамическая загрузка компонентов по требованию позволяет заменять один модуль на другой под разными условиями. Это даёт широкие возможности бизнесу для тестирования гипотез и улучшения качества продукта. 

  • Легкая миграция старого модуля на новый с возможностью плавной раскатки, не влияя на само приложение в целом. Пользователь даже не поймёт, что ему открылось новое модальное окно, пока другим будет показываться старый модуль.

Локализация

Принцип оптимизации аналогичен: важно оставить только тот язык, который будет использовать пользователь — остальные отправляем ждать на сервере.

Техническая сторона решения

Мы рассмотрели продуктовую сторону решения проблемы и узнали, что именно и в каком порядке следует оптимизировать. Теперь рассмотрим, как это делать. Ниже буду приводить примеры кода.

import(“./module”)

Во главе всей темы статьи лежит динамический импорт. Он выглядит как функция, но по сути ей не является. Мы не можем передавать его как аргумент и делать другие вещи, кроме как вызывать. Вызов возвращает Promise, после разрешения которого мы получаем модуль, который импортировали:

import(“./foo”).then(foo => console.log(foo.default));

const module = await import(“./foo”)

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

const language = getUserLanguage();

import("./locale/${language}.json").then(locale => {/* ... */})

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

В мире React мы тоже можем с легкостью динамически импортировать целые компоненты и страницы. Для этого существует специальный метод lazy:

const Component = React.lazy(() => import("./Component"))
const App = () => (
    <Suspense fallback={<div>Загрузка...</div>} >
        <Component />
    </Suspense>
)

Используя Suspense, мы можем показывать другой компонент в процессе, пока загружается необходимый. Это даёт нам возможность консистентно обрабатывать загрузку. При этом пользователь понимает, что скоро будет отображён необходимый блок приложения.

Пример самого популярного подхода к оптимизации с разделением приложения на страницы:

const Home = React.lazy(() => import(“./Home”))
const Profile = React.lazy(() => import(“./Profile”))

const App = () => (
    <Router>
        <Suspense fallback={<div>Загрузка...</div>} >
            <Switch>
                <Route exact path=”/” component={Home} />
                <Route path=”/profile” component={Profile} />
        </Switch>
        </Suspense>
    </Router>
)

У React.lazy есть недостаток, который не позволяет использовать такой компонент в серверном рендеринге. Для этого потребуется воспользоваться аналогом, например, Loadable Components. 

Но команда React не стоит на месте — недавно они показали Server Components или zero-bundled components. Это компоненты, которые не попадут в сборку, а будут выполняться на сервере, и результат выполнения в виде куска Virtual DOM будет отправлен на клиент. Пока Server Components только ждут релиза и мы можем попробовать их в тестовой сборке React или в последней версии NextJS.

NextJS

Хотя NextJS часто называют инструментом для серверного рендеринга, я очень рекомендую попробовать его для разработки SPA, так как в нем из коробки собраны практически все основные оптимизации скорости загрузки, а именно:

SSG

Static Site Generation позволяет в процессе сборки отрендерить каждую страницу приложения. Как результат, при первой загрузке страницы браузер получит HTML, в котором уже будет контент, и моментально его отрендерит. Поэтому даже не важно, сколько JS кода будет загружено, — пользователь сразу увидит страницу. Это даёт ощущение, что приложение грузится очень быстро. А с оптимизированным JS бандлом вы получите идеальный результат. 

Routing with pre-fetch

NextJS сразу идёт со встроенным роутером, который реализован в виде файлов. Это даёт более чистую структуру проекта и автоматическое разделение проекта на чанки-страницы.

Основная фишка этого роутера в том, что в нём уже настроен pre-fetch. Он позволяет загрузить чанки страниц до того, как пользователь попадёт на эти страницы. Когда ссылки на внутренние ресурсы попадают во viewport или на них наведён курсор мыши, они автоматически начинают скачиваться и для пользователя открываются мгновенно.

Image, Font and Scripts optimization

Встроенные оптимизации картинок, шрифтов и подключаемых скриптов направлены на ускорение загрузки страниц. Ничего не придется настраивать, нужно только воспользоваться предоставленным API.

Бизнес логика и менеджмент состояния

Если мы имеем большое приложение, то скорее всего в нём есть центральное состояние с различными обработчиками, например, Redux и Redux Saga. 

Как мы знаем, центральное состояние мы регистрируем на самом верхнем уровне приложения. Важно не забыть о нём и разделить бизнес логику совместно с отделяемыми компонентами SPA. 

Для Redux существует множество решений для применения Code-Splitting. Если вы используете в своём приложении Mobx, то вам повезло, так как он разделим из коробки — достаточно позаботиться о правильной архитектуре состояния.

Как реализовать Code-Splitting в других библиотеках и фреймворках? Очевидный лайфхак — найти раздел документации, посвящённый code-splitting. Основная цель любого фреймворка Angular, Vue, React, Svelte — ускорить и оптимизировать загрузку приложений, достаточно только найти документацию о том, как правильно это делать.

Вышеописанные подходы могут быть применимы в любом другом стеке.

Как замерять и контролировать размер приложения

Закроем тему кратким обзором на инструменты, которые помогут замерять размер бандла и контролировать процесс оптимизации.

Lighthouse

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

Также у Lighthouse есть CLI версия, которую можно интегрировать в CI/CD и замерять показатели каждой новой сборки автоматически.

Webpack Bundle Analyzer

Плагин для Webpack анализирует итоговую сборку приложения и выводит результат в виде страницы, где отображены все чанки приложения и компоненты.

Он поможет найти модули с большим размером — это даст информацию о том, что в первую очередь стоит оптимизировать. 

Source-map-explorer

И последний инструмент — source-map-explorer. 

С его помощью вы сможете посмотреть, из каких компонентов состоят ваши чанки. Этот инструмент даёт информацию о размере приложения немного под другим углом, так как он парсит map файл и показывает результат в виде структуры проекта.

На этом всё — буду рад ответить на вопросы.

Комментарии (5)


  1. nin-jin
    13.12.2021 18:47
    +2

    Ваша статья грузилась дольше 1 секунды. Отказ. Не буду её читать.


  1. copperfox777
    14.12.2021 08:14

    Tailwind не тянет вообще ни каких лишних классов. Только те, которые действительно используются в разметке. Автор слышал звон.

    Начинать рекомендации с совета убрать то, что кто-то забыл? Как раз, чтобы это установить используется инструмент, который у вас в конце - source map explorer

    Next.js действительно очень хорош!


    1. sakhnyuk Автор
      14.12.2021 09:44

      Tailwind до появления JIT тянул все классы и требовалось прогонять их через PurgeCSS, но вы абсолютно правы если учесть что Tailwind с версии 3.0 по дефолту теперь идет с JIT engine. Правда эта версия вышла менее недели назад.


      1. Fragster
        14.12.2021 11:48

        Там по дефолту PurgeCSS (минимум с версии 2) включен безо всякого JIT. Мы же не говорим про использование tailwind через cdn?


  1. pavelsc
    15.12.2021 20:16

    Сферическая сова, на глобус натянутая, что-то не припомню чтоб кто-то, кроме этого исследования заявил, что он ушел с сайта, только потому что сайт грузится не 2 секунды, а 4.

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