Оптимизации в вебе — дорого, сложно, и уже не нужно?
2021 год. 4к и 8к трансляции уже не новость. Ryzen выпустил 64-ядровый процессор. Наконец-то все забыли об оптимизациях в вебе, потому что это сложно, дорого, и попросту уже не нужно.
Если вы думаете именно так, мне есть что вам сказать.
Давайте начнем с простого. Конфигурация ноутбука, с которого я пишу эту статью — Сore-I5 7200U (2.5GHz-3.10GHz), 12GB оперативной памяти, SSD + нечто вроде 100 МБит сети, если мой провайдер не врет. И, честно говоря, я не часто вижу совсем уж медленные сайты и веб приложения, когда серфлю в интернете. Несмотря на это, каждый раз, когда я пишу код, я трачу некоторое время на обдумывание того, насколько сильно мои изменения повлияют на моих пользователей. И это вовсе не потому, что я такой красивый, а потому, что согласно данным аналитики, большинство клиентов используют наше веб-приложение с помощью мобильного телефона. Что, в свою очередь означает нестабильное хG соедениение (где x находится в пределах 2–4), меньший объем памяти (1–4 GB вместо моих 12) и процессор с условной частотой в 1.7GHz — 2GHz который и так чуточку занят десятком-другим вкладок и пятеркой приложений. Поэтому то, что у меня работает нормально — может подтормаживать и раздражать наших пользователей. А учитывая размер минимального чека моего пользователя — я очень не хочу никого раздражать. Да и по-человечески просто приятно, когда твое приложение работает быстро.
Именно поэтому, каждое код-ревью, я обращаю внимание на разные моменты связанные с производительностью. Моя команда — большие молодцы, потому что они терпят мои замечания и даже иногда с ними соглашаются. Но, естественно, я общаюсь не только с со своей командой, и часто, когда я говорою что производительность в вебе все еще важна, в ответ я слышу аргументы, о которых уже упоминал — поддержка, цена работы и возросшая вычислительная мощность. И вот о них я хочу поговорить.
640kb хватит всем
Безусловно, мощность современных вычислительных систем просто поражает. Процессор моего смартфона в 16,(26) раз быстрее процессора моего первого компьютера с которого я вышел в интернет. И это, не говоря уже о том, что там было только одно ядро, а в телефоне их целых четыре плюс четыре. А ведь сайты по большому счету не изменились. HTML для содержимого, CSS для красивостей, и JS для взаимодействия. Из серебра не родилась молния, флеш не быстро, но тоже похоронили, а WebAssembly все еще в сборке. Да и пропускная способность современных сетей позволяет больше не требовать формат изображения, который бы рендерился снизу вверх. Расходимся? Увы, но нет.
В 2019 году, была опубликована любопытная статья, авторы которой утверждают, что первые пять секунд являются ключевыми, если вы хотите добиться высокой конверсии.
К выводам есть некоторые вопросы (в частности, очень любопытны причины роста конверсии на 6 и 9 секундах), но в целом идея логична. Чем дольше пользователь ждет контента, тем выше вероятность того, что он уйдет. За два года до этого, в далеком 2017, другая группа исследователей опубликовала еще одну статью согласно которой, для того чтобы успеть показать контент в перые пять секунд, размер загружаемых ресурсов не должен превышать 170KB (уже сжатых), если JavaScript-а «немного», или 130KB для сайтов/приложений построенных с помощью JS фреймоворках. Причины были названы следующие:
- 45% мобильных соединений используют 2G
- 75% всех соединений используют 2G или 3G
- Усредненный телефоном — Motorola Moto G4 с процессором Octa-core (4×1.5 GHz Cortex-A53 & 4×1.2 GHz Cortex-A53)
В таких условиях разгуляться сложно, поэтому бюджет и выглядит так скромно. Однако с тех пор уже прошло четыре года и в поэтому в 2021 году исследование было повторено. Ситуация действительно улучшилась и теперь у нас есть 100Kb для HTML/CSS/Fonts и 300–350KB для пожатого JavaScript. Однако это произошло в основном благодаря улучшению качества связи. Вычислительные мощности устройств изменились не очень сильно. Взгляните на этот график и оцените прогресс с 2017 года.
iPhone показывает стабильный рост, а вот остальные производители нажимают на педаль газа весьма осторожно. Но не iPhone-ом единым жив мобильный веб. Бюджетный сегмент ноутбуков тоже «не блещет». Вот несколько представителей в сегменте за 350 долларов:
Первая модель — всего 4GB DDR4, двухъядерный Intel Celeron N4020 (1.1–2.8 ГГц). Наверняка можно найти что-то и получше на этот бюджет, но если такие модели продаются, значит это кому-нибудь надо? А ведь еще есть огромное количество просто устаревшей, 10-и и даже 15-летней техники в офисах и домах. Можно сказать, что, игнорируя производительность своего сайта или приложения, добровольно отказываемся от части пользователей, и делаем жизнь остальных чуточку сложнее. Что и сказывается на конверсии.
Поэтому оптимизация ресурсов с точки зрения размера начального бандла — все еще чрезвычайно важна. И меня сильно огорчает, когда, опытные разработчики, в обучающих статьях про, например, логгирование (а не про Moment), пишут нечто вроде такого:
import * as moment from "moment";
//...
const date = moment();
const requestDuration = moment().diff(startMoment, "milliseconds");
Конечно, я все понимаю. Скорее всего в проекте у автора уже стоит moment, он к нему давно привык, поэтому он его и использовал. Но… это же учебный пример. Кто-то не посмотрел, как всегда, не подумал, скопировал и вот вам, в ваш любимый проект влетает от 18KB до 72KB JavaScript (и это уже после сжатия). А ради чего? Ради единственного метода diff
. А потом, людям приходится даже писать спеицальные плагины что бы как-то это оптимизировать. И вот таких примеров, без предупреждений, без сносок о том, почему это может быть плохо — легион. Авторы статей, блогов и курсов, в погоне за простотой кода и аудиторией, приучают других разработчиков игнорировать производительность как класс.
А ведь помимо простого размера есть много других нюансов. У нас еще есть стили, которые парсятся хоть и быстро, зато умеют блокировать JS. Есть еще шрифты, которые могут заставить браузер вообще не показывать текст, пока не загрузятся. А самое мое любимое, так называемый холодный старт TCP, который просто игнорирует весь ваш 100МБит канал и позволяет отправить первый пакет не более чем в 14KB.
Поэтому, несмотря на весь прогресс, если мы не хотим терять/раздражать среднего пользователя, наш бюджет все еще довольно ограничен. Но, загрузка — ведь это тоже еще не все. Для SPA приложений просто загрузить ресурсы мало. Еще нужно выполнить JavaScript, и только потом, основываясь на вычислениях, отрисовать пользователю какой-то контент. На слабых устройствах это тоже будет вызывать проблемы. К примеру, вот сравнительный график загрузки нашего SPA приложения, написаного на React, без замедления CPU и с 6-кратным замедлением.
Общий объем загруженого JavaScript — 253Kb, что не так и много. А время работы скриптов увеличилось с 362 милисекунд до 2102 милисекунд, т. е. в примерно в 5.8 раза. Largest-Contentful-Paint (не лучший ориентир, но подойдет) сдвинулся с 2.2 секунд до 4.7 секунд и немного подросли другие метрики.
Хотел еще так же померять Хабр, но там сейчас какая-то совсем безумная мешанина скриптов для аналитики, пришлось отказаться. И, к тому же, для статики и рендера на стороне сервера (как раз наш Хабр) CPU менее важен, браузер DOM строит довольно быстро, если ему не мешать. Но если совсем интересно — вот моя статья как раз про Хабр.
Почему это важно? Потому что JavaScript язык в основном однопоточный и интенсивное его выполнение, блокирует основной поток и останавливает отрисовку HTML. Простыми словам, while(true){}
установленный где-то в начале документа убивает все настолько качественно, что вы не то что на кнопку нажать не сможете, вы даже статический контент после этого кода не увидите даже на каком-нибудь Фугаку. Конечно, в здравом уме бесконечный цикл писать никто не будет, как и синхронно загружать его в страницу, но способов выстрелить в ногу в современном вебе все еще остается предостаточно. Тут и последовательные запросы вместо параллельных, и N+1 прямо с фронта, и тяжелые regex-ы во время рендера приложения, и чрезмерная работа с DOM-ом и многие, многие, многие. Да и сам фреймворк может вам подкинуть задачку.
Например, для меня до сих пор остается загадкой, почему, по умолчанию, функциональные компоненты в React вызываются всякий раз, когда вызывается их родитель. Да, это легко поменять, использовав React.memo
или перейти на PureComponent
, но библиотека, которая позиционируется быстрой (или уже нет?), почему-то решила пойти именно таким путем. Особенно проблемным это стало после популяризации хуков, что привело к усложнению функциональных компонентов. Кстати, раз уж речь зашла про React — вы конечно же знаете, что React встраивает изображения до 10kb в JS код?
Правда если вы думаете, что у Angular с этим всем легче то — не совсем. Утечки памяти из-за сложности Rx.JS, постоянный перерендер компонентов из-за дефолтной Change Detection Strategy и многое другое приводит к тому, что некоторые вообще отключают NgZone.
Резюмируя сказанное: несмотря на то что, мой смартфон может просчитать траекторию полета Теслы на Марс, с отображением «простого текста» у него могут быть проблемы.
Теперь перейдем к остальным аргументам.
Оптимизированный код сложнее поддерживать
В качестве обоснования этого аргумента часто приводят нечто вроде вот такого примера:
Нормальный код:
const names = users
.map((user) => user.name)
.filter((name) => name[0] === "a")
.join(" ");
Оптимизированный:
let names = "";
for (let i = 0; i < users.length; i++) {
const userName = users[i].name;
if (userName[0] === "a") {
names += `${userName} `;
}
}
const result = names.trimEnd();
— Мол, что тебе легче читать и поддерживать?
Конечно, я отвечаю, что первый читается легче и мой оппонент радостный уходит в закат. Но давайте будем справедливыми — не все оптимизации выглядят именно так (хотя если у вас миллион пользователей, то и такому вы будете рады). Например, вот ошибка, которую я периодически встречаю при отображении полностью статических компонентов:
const TermsAndConditions = () => (
{["Don't be evil"].map((term, i) => (
- {term}
))}
);
А это его совсем чуть-чуть «оптимизированная» версия:
import { rules } from "./settings";
const TermsAndConditions = () => (
{rules.map((term, i) => (
- {term}
))}
);
Скажите, разве это плохо (~С)? Я бы так не сказал. Зато теперь у нас статический контент лежит в настройках, а бонусом мы не создаем новый массив на каждый вызов функции (а это время на аллокацию и время на уборку мусора). Конечно, можно и лучше «оптмизировать», но даже это лучше чем было. Спички, скажете вы? Спички, да. Но что насчет вот такой популярной ошибки?
Было:
const user = await fetch(userId);
const car = await fetch(carId);
Стало:
const userRequest = fetch(userId);
const carRequest = fetch(carId);
const [user, car] = await Promise.all([userRequest, carRequest]);
Код не стал хуже. Просто теперь, где-то пользователь будет ждать немного меньше, а нам это почти ничего не стоило (кроме полифила для IE). И таких мест может быть много. Чего стоит один code-splitting. Почти никакой магии и куска бандла как небывало. Его больше не надо грузить, парсить, и вы ничего не потеряли — все просто стало немного быстрее. Или пресжать бандлы, что бы ваш сервер этим не занимался на лету. Тоже спички, но тут даже код менять не надо.
К чему я веду — код, который не написан чтобы быть медленным, вполне может работать быстро. Звучит как призыв Капитана Очевидность, но часть проблем с производительностью я исправлял именно так — не ускоряя код, а просто устраняя медленный. Возможно, с этим подходом вы и не попадете в топ 1 самых быстрых сайтов или приложений, но, как известно, 20% усилий приводят к 80% результату, а именно это нам и надо.
Мы не можем тратить на это время, нам уже в продакшн надо
И, наконец, последний пункт. Многие считают, что оптимизации производительности — это дорого или по времени (долго, клиент не выделит) или по деньгам (нужен спец, бюджета нет, когда-нибудь потом). И это, на самом деле, самый болезненный момент. Если беклог выше Фудзиямы (куда-то меня на восток потянуло), релиз завтра, менеджер пингует каждые два часа с прекрасным «ну как там», заниматься оптимизацией вам будет тяжело. Но есть два «но», которые я хочу проговорить.
Во-первых, многие «оптимизации» — это вовсе не оптимизации. Это просто корректно организованный код, который не тормозит. Да, есть сложные/не очевидные моменты, особенно на стыке приложение/инфраструктура, но, если мы будем грамотно использовать ресурсы которые у нас есть, все уже будет гораздо лучше. В конце концов JavaScript это основной инструмент в веб разработке и понимание того, что const really = () => ({})
создает в памяти новый объект на каждый вызов должно быть очевидно даже коту фронтенд программиста.
А во-вторых, (внимание, холивар) объяснять заказчику или ПМ-у что производительный веб нужен в первую очередь проекту это именно наша задача как специалиста. Я понимаю что вы можете со мной не согласиться, но заказчик то вообще не знает, что-такое эти ваши FCP, LCP, TTI, не знает, что можно уменьшить latency на 0.3 секунды и заработать на этом 8_000_000 фунтов стерлингов в год. У него в голове может просто не быть понимания того, что быстрый веб — это точно такая же фича как, например, список рекомендуемых товаров. Почему? Потому что они оба напрямую влияют на продажи, а значит и на доход. И вот этот аргумент заказчик понимает прекрасно.
Так что же делать
Как ни странно, но я не предлагаю тут же бежать, удалять проект и все переделывать. Я так же не призываю душить ПМ-а или продакта. Скорее всего это не сработает. Вместо этого я предлагаю вам, когда будет немного свободного времени, выбрать ключевые метрики производительности вашего приложения и просто начать их мониторить. А о результатах, информировать лиц, принимающих решения. Если у вас на проекте все хорошо — вам скажут, что команда молодцы и вы со спокойной совестью и документальным подтверждением своих прямых рук пойдете пить нефильтрованное. А если плохо — скорее всего ПМ/Продакт сами придут к вам за решением проблемы и тогда уже вы будете говорить о сроках и объемах.
Ну, а о том, как измерять есть не одна статья. Например, можно поставить в пайплайн lighthouse и замерять производительность прямо во время сборки/тестов. Можно на крон повесить ежедневные тесты прода с тем же lighthouse или perfrunner. Кстати, оба инструмента позволяют вам задать и параметры сети, и замедление процессора, чтобы понимать, как покажет себя ваш вебсайт в более сложных условиях. Можно подключить PageSpeed Insights. Можно даже написать свои тесты производительности на голом puppeteer, и мерять вообще все что хотите и как хотите, это тоже не так сложно. Главное, если ваш основной рынок это, например Северная Америка, то не пытайтесь тестировать ее из Киева, может неловко получиться.
Послесловие — преждевременная оптимизация — корень всех зол
Цитата из заглавия известна всем и часто приводится как 0-й аргумент против оптимизации веб приложений. Но с вебом есть один не очевидный нюанс. В вебе пользователь не жалуется, а просто молчаливо уходит. Поэтому, если вы не хотите остаться без пользователей, то оптимизацию я бы начал все-таки пораньше, во избежание. И тогда все будет хорошо.
Надеюсь, было полезно, всем доброй ночи.