Существует огромное множество статей про оптимизацию загрузки веб-сайтов, но часто они обходятся лишь общими советами или абстрактными примерами. В этой статье я хочу поделиться своим опытом комплексной оптимизации реального проекта с конкретными примерами, в данном случае SPA, написанном на Vue 3 с использованием Vuetify для части UI компонентов и Firebase для авторизации.
Немного контекста: я работаю frontend-разработчиком в компании, которая в основном занимается разработкой MVP (Minimum Viable Product), но так же и разработкой и поддержкой долгоживущих продуктов. Как раз у MVP бывает много проблем, потому что минимум времени уделяется под рефакторинг и оптимизацию, а проблемы между проектами повторяются, потому что часто используется один стартовый шаблон.
Возможность долго и планомерно заниматься оптимизацией у меня появилась на волонтерском проекте компании - combat-sport.club, которому можно было уделять сколько угодно свободного времени. Так что в данной статье идет речь именно о нем, но решения с него мы переносили и на другие проекты компании, т.к. стэк технологий и проблемы были одинаковые.
Оцениваем Performance
Все начинается с метрик - чтобы что-то сделать лучше, надо понять, а как было «до». Для измерений я использовал три инструмента - PageSpeed, Lighthouse и rollup-plugin-visualizer (у нас проект на Vite).
Казалось бы, PageSpeed и Lighthouse это одно и то же, используется один движок, но на практике выдают они относительно разные результаты показателя Performance (или Производительность). Например, в моем случае PageSpeed всегда выдавал результат на 20 баллов хуже, чем Lighthouse. Тогда кому же из них верить?
И в этом заключается первая проблема - любая метрика Performance это всегда симуляция, а не реальность. Ваш сайт может прекрасно работать на устройствах реальных пользователей, но метрики движка Lighthouse будут показывать обратное. И наоборот.
Но если ваш сайт находится в продакшене и имеет достаточный трафик, то на помощь придет PageSpeed, где есть блок, который показывает фактическую производительность на основе данных от реальных пользователей.
Вот пример фактических данных, тогда как сам же PageSpeed через свою оценку выдавал результат в районе 45-55 баллов, оценив каждый из показателей заметно хуже:
Поэтому считаю важным заглядывать и на PageSpeed и на Lighthouse в DevTools, потому что так вы получите гораздо больше информации и представления о том, что то и другое лишь примерно оценивает Performance на основе симуляции определенного железа и сети, оказываясь например под влиянием того, где физически находится сервер.
Следующий инструмент - плагин rollup-plugin-visualizer, который визуально показывает из чего состоит ваш бандл, есть ли там что-то, чего вы не хотите видеть, и сколько что весит. Им я пользовался лишь потому, что Lighthouse не показывает дерево бандла для Vite (в отличие от VueCLI с webpack).
Итак, вот что мы имеем в начале:
Оптимизируем шрифты и иконки
Наибольшее влияние на скорость загрузки сайта оказывает то, что блокирует отрисовку страницы. Это могут быть скрипты, стили или шрифты, которые подключаются в head и блокируют рендер, пока они не загрузятся и не выполнятся.
В нашем случае, среди всего прочего, заметный эффект оказывали шрифты и библиотека иконок mdi (material design icons).
Шрифты подключены вот так:
Разбираемся что с ними не так:
Загружаются все возможные начертания от 100 до 900, хотя на сайте используется лишь 400, 500, 600 и 700.
Не указано правило display=swap, чтобы была возможность использовать fallback шрифт и показывать его, пока загружается наш основной шрифт (в ином случае пользователи не видели бы контент, пока шрифт не загрузится).
Шрифт загружается с внешнего ресурса Google Fonts, поэтому неплохо было бы добавить preconnect к этим ресурсам, чтобы сделать DNS/TCP/TLS соединения заранее.
В нашем случае запросы будут к https://fonts.googleapis.com и https://fonts.gstatic.com. Для внешнего ресурса, с которого будет происходить загрузка шрифта, необходимо добавить атрибут crossorigin (для Google Fonts это https://fonts.gstatic.com), иначе preconnect будет выполнять только соединение с DNS.
Кстати, сейчас Google Fonts предлагают все это по умолчанию, но видимо наш проект создавался задолго до этого.Можно делать preload, чтобы запросить шрифт в самом начале критического пути рендера страницы, не дожидаясь пока построится CSSOM (по умолчанию, браузер загружает шрифт только после того как построит CSSOM). Так как в этом случае шрифт загружается не после парсинга HTML и CSS, то может оказаться так, что нам даже не потребуется fallback шрифт, что сведет на нет сдвиг контента (Layout Shift), который возникает если эти шрифты не совпадают по размерам.
Но с preload нужно быть осторожным, так как мы откладываем загрузку и парсинг других ресурсов, а также загружаем шрифт в любом случае, даже если дальше он не используется на странице, поэтому нужно оценивать его влияние на скорость загрузки и отрисовки страницы и взвешивать, стоит ли оно того.
В итоге получаем такой код:
Следующая возможность для оптимизации это использование вариативного шрифта. Если кратко, то вариативные шрифты - это условно “программируемые” шрифты, которые можно очень гибко настраивать по разным параметрам, при этом не загружая все это изначально по отдельности, как это приходится делать с обычными шрифтами, тем самым увеличивая количество запросов и объем загружаемых данных.
На Google Fonts уже есть вариативный шрифт Roboto, который называется Roboto-flex, подключим его:
Для наглядности, столько запросов было при использовании обычного Roboto:
А столько при использовании Roboto-flex, благодаря тому что не нужно загружать разные начертания отдельно:
Далее разбираемся с библиотекой иконок. Подключена она так:
В чем проблема здесь? Загружается файл, который содержит в себе все иконки библиотеки, хотя подавляющее большинство из них даже не используется на проекте.
Решение проблемы довольно простое: вместо использования CDN подключаем библиотеку mdi/js, с помощью которой можно импортировать только используемые иконки, так что в итоговый бандл попадают только они.
Пример использования с Vuetify:
Первые результаты
Итак, на данный момент мы всего лишь оптимизировали подключение шрифтов и иконок, но именно они вносили немалый вклад в показатель Total Blocking Time. Снова сделаем замеры Performance и сравним это с изначальным результатом:
Получили прирост в 10 баллов и заметно снизили Total Blocking Time. Но это лишь первые шаги, так что движемся дальше.
Устраняем Layout Shift
Как я уже упоминал ранее в разделе про шрифты, Layout Shift - это, простыми словами, сдвиг контента веб-сайт, когда какие-то данные и части контента подгружаются с задержкой или меняют размер и сдвигают окружающий контент. Звучит как что-то, что вообще не относится к скорости загрузки, но начиная с 10 версии движка Lighthouse этот параметр вносит аж 25% (к слову, на первом месте Total Blocking Time с 30%) в общий показатель Performance, поэтому игнорировать его нельзя.
На данный момент этот показатель находится в красной зоне со значением около 0.5, а таймлайн загрузки страницы выглядит вот так:
Видим, что в первые секунды загрузки пользователь видит футер веб-сайта, а уже затем все резко встает на свои места… Это происходит из-за того, что между шапкой сайта, баннером с текстом и футером находится контент, который подгружается и отображается динамически. И пока его нет, то его место занимает футер.
Самым простым (но не всегда оптимальным, так как футер будет скрыт за скроллом внизу экрана, даже если основной контент занимает не всю высоту) решением будет добавить минимальную высоту основному контенту, который почти всегда динамический, например, 100vh, так чтобы он занимал всю доступную высоту экрана:
Проверяем, какое влияние это оказало на Layout Shift:
Мы свели его к 0, а общий результат стал 56 баллов, то есть получили прирост примерно в 10 баллов за счет одного небольшого изменения! И действительно, внизу на таймлайне видно, что в первые секунды загрузки сайта теперь не видно футер и все находится на своих местах.
Firebase
Если говорить о проблеме кратко:
Почти 2Mb JavaScript, которые занимают 70% бандла. Но причина здесь проста - мы используем Firebase 8 версии, в которой не поддерживается импорт только нужных модулей, в отличие от актуальной 9 версии.
Обновляем Firebase до 9 версии и переписываем наш код в соответствии с изменениями, снова смотрим на наш бандл и… мы уменьшили размер с 1.78Mb до 310Kb. Все еще много, но уже в 6 раз лучше.
Но это еще не все. Еще раз смотрим что у нас загружается и видим некий iframe.js от Firebase, который весит 279Kb. После небольшого расследования выясняется, что это нужно только для определенных видов авторизации, например, при использовании метода signInWithPopup для входа с аккаунта Google или Facebook. У нас этот метод не используется и получается весь этот код висит мертвым грузом на всякий случай, поэтому неплохо было бы это убрать.
Изначально имеем такой код, в котором используется метод getAuth()
, содержащий в себе много неиспользуемого кода:
Переписываем его и теперь вместо getAuth()
используем initializeAuth()
с только необходимым набором импортов, заодно оставляя комментарий потомкам, что нужно быть осторожным при использовании методов, для которых был нужен тот iframe.js:
Еще раз проверяем наш бандл и видим что теперь в нем нет ничего ненужного:
Промежуточные результаты
В последний раз мы остановились на достигнутой оценке Performance в 56, теперь же, после оптимизации Firebase, имеем уже 75:
Асинхронные компоненты и роуты
Если посмотреть на итоговый банлд, то можно заметить, что, хотя мы находимся на главной странице, в нем находятся другие страницы, которые нам сейчас не нужны - NotFoundPage и TermsPage:
К счастью, Vue Router поддерживает динамические импорты из коробки, благодаря чему ненужные страницы будут выделены в отдельные чанки и запрашиваться только тогда, когда они нужны, не попадая в общий бандл. И да, все другие страницы подключены именно так, видимо про эти две просто забыли или не доглядели.
Правим наш файл router.js, убирая статические импорты и заменяя их на динамические:
К слову, когда проект был на Vue CLI, то следствием динамических импортов было то, что каждый такой чанк создавал prefetch и тем самым, если их было достаточно много, оказывалось влияние на скорость загрузки сайта, потому что браузеру нужно было сделать много лишних запросов, которые не нужны здесь и сейчас:
Чтобы этого не происходило, я настраивал webpack так, чтобы он не делал prefetch:
Если же нужно сохранить prefetch для каких-то отдельных роутов, то можно настроить это именно для них, добавив комментарий webpackPrefetch: true
к динамическому импорту: import(/* webpackPrefetch: true */ './somePage.vue')
Другой проблемой с динамическими роутами было то, что если в компоненте была какая-то логика привязанная к информации о роуте (например, чтобы по $route.name
определять, что мы находимся на странице логина, поэтому надо скрыть футер), то это могло работать неправильно из-за того, что в момент проверки условия на $route.name
сам роут мог еще не подгрузится.
Поэтому в подобных местах приходилось писать такие условия, чтобы компонент или его часть рендерились только тогда, когда роут загружен и соответственно имеет свойство name, а уже затем отрабатывала логика, завязанная на это:
Теперь перейдем к асинхронным компонентам, с ними суть примерно та же, что и с роутами - мы можем загружать их только тогда, когда они нам нужны. Правда, этим мы стали пользоваться лишь после перехода на Vue 3, так как там это делается довольно просто и удобно.
Например, у нас есть глобальный компонент для уведомлений об ошибках, который большую часть времени никак не используется, а нужен только тогда, когда возникла ошибка (то же может касаться и многих модальных окон). Вполне подходящий случай для того, чтобы использовать асинхронный компонент.
Перепишем его импорт, используя метод defineAsyncComponent
:
К слову, после всех этих изменений итоговая оценка Performance не изменилась, поэтому тут показывать нечего. Но это лишь потому, что мы убрали всего парочку легких роутов и один небольшой компонент, так что тут чем больше и сложнее проект, тем больше может оказаться выгода от использования этих возможностей Vue и Vue Router.
Оптимизация запросов
Помимо всего прочего, мы можем сократить общее количество и объем запросов.
Например, на страницах, где выводится список спортсменов или тренеров на платформе, запрашивалось по 50 человек на одну страницу с пагинацией, хотя по факту, зайдя на эти страницы с телефона, пользователь сможет изначально увидеть лишь 3-5 карточек, а с десктопа 10-15, в зависимости от размера экрана. Напрашивается очевидное решение - сокращаем количество запрашиваемых спортсменов и тренеров до 15 на страницу:
Следующий момент, что на сайте очень много изображений (фотографии с соревнований и их превью, аватары пользователей), поэтому для изображений, которые не видны на экране при загрузке или там где есть пагинация, добавляю атрибут loading=”lazy”
. У нас есть общий компонент для изображений, меняю его:
Я решил не использовать специальные библиотеки для “ленивой” подгрузки картинок, потому что поддержка этого атрибута уже достигает практически 93%.
Также с целью оптимизации количества запросов к изображениям можно добавить SVG Sprite, так как помимо иконок из библиотеки mdi, у нас есть много своих кастомных иконок. Но пока что задача по добавлению спрайта находится в процессе реализации.
Далее проходимся по коду проекта и смотрим где есть цепочки запросов с использованием await
, когда каждый запрос ждет, пока выполнится предыдущий, хотя в некоторых случаях эти запросы не зависят друг от друга, а следовательно - могут выполняться параллельно с использованием Promise.all()
:
Не менее важно смотреть на содержимое ответов с бэкенда и общаться с бэкенд разработчиками, чтобы сообщать им, когда в запросах есть неиспользуемые поля, которые утяжеляют запрос, или же вовсе предложить разделить какие-то большие эндпоинты на несколько более мелких, если позволяет ситуация.
Vue 3
Одним из улучшений показателя Performance также оказалась миграция проекта с Vue 2 на Vue 3, поэтому если вы все еще принимаете решение о таком переходе, то дополнительным аргументом “за” может быть и это.
Информацию по разнице в скорости версий Vue я нашел здесь. Для нас же на практике разница в Performance оказалась в 10-15 баллов в пользу Vue 3. Уверен, что у всех будут разные результаты, но таков именно наш опыт перехода.
Редизайн
Свой вклад в оптимизацию внесло и то, что не зависело от меня, а именно - редизайн главной страницы (будем учитывать, что очевидно на других страницах показатель Performance от этого не изменился). Вместо одного большого баннера сверху на странице появилось несколько небольших более легких изображений, которые к тому же можно загружать с помощью loading="lazy"
. Это дало еще примерно +-10 баллов:
Подводим итоги
Итак, подводим итоги и делаем выводы из проделанной работы по оптимизации:
Получили двукратный прирост Performance (c 30-40 до 70-80 баллов).
Самый заметный результат дало грамотное подключение шрифтов (в том числе использование вариативного шрифта), библиотеки иконок и использование модульного Firebase 9.
Учитывая значимость шрифтов, нужно заранее обсуждать эту тему с дизайнером, чтобы по возможности он использовал вариативные шрифты.
Layout Shift является важным фактором в оценке Performance и сведение его к 0 дает значимый прирост этого показателя, а также улучшает опыт взаимодействия с вебсайтом для пользователей.
Довольно неплохой прирост дал Vue 3, что является еще одним аргументом в пользу миграции с Vue 2.
Async components, async routes, lazy loading, SVG sprite, оптимизация запросов - дают достаточно ограниченный результат, но тем больше, чем больше и сложнее проект.
Иногда для оптимизации достаточно и изменить дизайн, сделав его более облегченным. Но важно учитывать это изначально, иногда предостерегая дизайнера от использования, например, больших изображений.