Кто знает, может Палпатин поэтому был таким злым


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 (4x1.5 GHz Cortex-A53 & 4x1.2 GHz Cortex-A53)

В таких условиях разгуляться сложно, поэтому бюджет и выглядит так скромно. Однако с тех пор уже прошло четыре года и в поэтому в 2021 году исследование было повторено. Ситуация действительно улучшилась и теперь у нас есть 100Kb для HTML/CSS/Fonts и 300-350KB для пожатого JavaScript. Однако это произошло в основном благодаря улучшению качества связи. Вычислительные мощности устройств изменились не очень сильно. Взгляните на этот график и оцените прогресс с 2017 года.


Для мультикоров статистика получше, но JS в основном однопоточный


iPhone показывает стабильный рост, а вот остальные производители нажимают на педаль газа весьма осторожно. Но не iPhone-ом единым жив мобильный веб. Бюджетный сегмент ноутбуков тоже "не блещет". Вот несколько представителей в сегменте за 350 долларов:


А вы бы видели студенческие ноуты, какое там CPU, там одно АЛУ


Первая модель — всего 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 = () => (
  <ul>
    {["Don't be evil"].map((term, i) => (
      <li key={i}>{term}</li>
    ))}
  </ul>
);

А это его совсем чуть-чуть "оптимизированная" версия:


import { rules } from "./settings";
const TermsAndConditions = () => (
  <ul>
    {rules.map((term, i) => (
      <li key={term}>{term}</li>
    ))}
  </ul>
);

Скажите, разве это плохо (~С)? Я бы так не сказал. Зато теперь у нас статический контент лежит в настройках, а бонусом мы не создаем новый массив на каждый вызов функции (а это время на аллокацию и время на уборку мусора). Конечно, можно и лучше "оптмизировать", но даже это лучше чем было. Спички, скажете вы? Спички, да. Но что насчет вот такой популярной ошибки?


Было:


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-й аргумент против оптимизации веб приложений. Но с вебом есть один не очевидный нюанс. В вебе пользователь не жалуется, а просто молчаливо уходит. Поэтому, если вы не хотите остаться без пользователей, то оптимизацию я бы начал все-таки пораньше, во избежание. И тогда все будет хорошо.


Надеюсь, было полезно, всем доброй ночи.