Методы ускорения загрузки веб-приложения даже на обычном телефоне.

Как мы использовали разделение кода, встраивание кода и рендеринг на стороне сервера в PROXX.

На Google I/O 2019 Марико, Джейк и я представили PROXX , современный веб-клон Minesweeper. Что отличает PROXX, так это акцент на доступности (вы можете играть в него с помощью программы чтения с экрана!) и возможность запуска как на обычном телефоне, так и на настольном устройстве высокого класса. Функциональные телефоны ограничены несколькими способами:

  • Слабые процессоры
  • Слабые или несуществующие графические процессоры
  • Маленькие экраны без сенсорного ввода
  • Очень ограниченный объем памяти

Но они используют современный браузер и очень доступны по цене. По этой причине функциональные телефоны возрождаются на развивающихся рынках. Их цена позволяет совершенно новой аудитории, которая раньше не могла себе этого позволить, выйти в Интернет и воспользоваться современной сетью. Прогнозируется, что в 2019 году только в Индии будет продано около 400 миллионов функциональных телефонов , поэтому пользователи функциональных телефонов могут стать значительной частью вашей аудитории. Кроме того, скорость соединения, близкая к 2G, является нормой на развивающихся рынках. Как нам удалось заставить PROXX хорошо работать в условиях обычного телефона?

Геймплей PROXX.

Производительность важна, и она включает в себя как производительность загрузки, так и производительность во время выполнения. Было показано, что хорошая производительность коррелирует с увеличением удержания пользователей, улучшением конверсий и, что наиболее важно, повышением инклюзивности. Джереми Вагнер располагает гораздо большим объемом данных и пониманием того, почему производительность имеет значение .

Это первая часть серии из двух частей. Часть 1 посвящена производительности загрузки , а часть 2 — производительности во время выполнения.

Захват статус-кво

Тестирование производительности загрузки на реальном устройстве имеет решающее значение. Если у вас под рукой нет реального устройства, я рекомендую WebPageTest , а именно «простую» настройку . WPT проводит ряд нагрузочных тестов на реальном устройстве с эмулируемым соединением 3G.

3G — хорошая скорость для измерения. Хотя вы, возможно, уже привыкли к 4G, LTE или даже к 5G, реальность мобильного Интернета выглядит совсем иначе. Возможно, вы находитесь в поезде, на конференции, на концерте или в самолете. То, что вы там испытаете, скорее всего, ближе к 3G, а иногда даже хуже.

При этом в этой статье мы сосредоточимся на 2G, поскольку PROXX явно ориентирован на функциональные телефоны и развивающиеся рынки своей целевой аудитории. После того как WebPageTest запустит тест, вы получите водопад (похожий на тот, что вы видите в DevTools), а также диафильм вверху. Диафильм показывает, что видит ваш пользователь во время загрузки вашего приложения. В 2G загрузка неоптимизированной версии PROXX довольно плоха:

Диафильм показывает, что видит пользователь, когда PROXX загружается на реальном устройстве начального уровня через эмулируемое соединение 2G.

При загрузке через 3G пользователь видит 4 секунды белого небытия. В сети 2G пользователь не видит абсолютно ничего более 8 секунд. Если вы прочитали , почему важна производительность, вы знаете, что мы потеряли значительную часть наших потенциальных пользователей из-за нетерпения. Чтобы что-либо появилось на экране, пользователю необходимо загрузить все 62 КБ JavaScript. Положительным моментом в этом сценарии является то, что все, что появляется на экране, также является интерактивным. Или это?

[Первая значимая отрисовка][FMP] в неоптимизированной версии PROXX _технически_ [интерактивна][TTI], но бесполезна для пользователя.

После загрузки примерно 62 КБ gzip'd JS и создания DOM пользователь увидит наше приложение. Приложение технически интерактивно. Однако взгляд на картинку показывает другую реальность. Веб-шрифты по-прежнему загружаются в фоновом режиме, и пока они не будут готовы, пользователь не сможет видеть текст. Хотя это состояние квалифицируется как «Первое значимое рисование» (FMP) , оно, конечно же, не может считаться полностью интерактивным , поскольку пользователь не может сказать, о чем идет речь. Требуется еще одна секунда в 3G и 3 секунды в 2G, прежде чем приложение будет готово к работе. В целом приложению требуется 6 секунд в 3G и 11 секунд в 2G, чтобы стать интерактивным.

Водопадный анализ

Теперь, когда мы знаем , что видит пользователь, нам нужно выяснить, почему . Для этого мы можем посмотреть на водопад и проанализировать, почему ресурсы загружаются слишком поздно. В нашей трассировке 2G для PROXX мы видим два основных тревожных флажка:

  1. Есть несколько разноцветных тонких линий.
  2. Файлы JavaScript образуют цепочку. Например, второй ресурс начинает загружаться только после завершения работы первого ресурса, а третий ресурс начинается только после завершения работы второго ресурса.
Водопад дает представление о том, какие ресурсы загружаются, когда и сколько времени это занимает.

Уменьшение количества подключений

Каждая тонкая линия ( dns , connect , ssl ) означает создание нового HTTP-соединения. Установка нового соединения обходится дорого, поскольку занимает около 1 секунды в сети 3G и примерно 2,5 секунды в сети 2G. В нашем водопаде мы видим новое соединение для:

  • Запрос №1: Наш index.html
  • Пожелание 5. Стили шрифтов с fonts.googleapis.com .
  • Запрос №8: Google Analytics
  • Запрос №9: файл шрифта с fonts.gstatic.com
  • Запрос № 14. Манифест веб-приложения.

Новое соединение для index.html неизбежно. Браузер должен создать соединение с нашим сервером, чтобы получить содержимое. Нового подключения к Google Analytics можно избежать, встроив что-то вроде Minimal Analytics , но Google Analytics не блокирует отображение или интерактивность нашего приложения, поэтому нас не особо волнует, насколько быстро оно загружается. В идеале Google Analytics должен загружаться во время простоя, когда все остальное уже загрузилось. Таким образом, он не будет использовать полосу пропускания или вычислительную мощность во время начальной загрузки. Новое соединение для манифеста веб-приложения предписывается спецификацией выборки , поскольку манифест должен быть загружен через соединение без учетных данных. Опять же, манифест веб-приложения не препятствует рендерингу или интерактивности нашего приложения, поэтому нам не о чем беспокоиться.

Однако эти два шрифта и их стили представляют собой проблему, поскольку они блокируют рендеринг и интерактивность. Если мы посмотрим на CSS, предоставляемый fonts.googleapis.com , это всего лишь два правила @font-face , по одному для каждого шрифта. На самом деле стили шрифтов настолько малы, что мы решили встроить их в наш HTML, удалив одно ненужное соединение. Чтобы избежать затрат на настройку подключения для файлов шрифтов, мы можем скопировать их на наш собственный сервер.

Распараллеливание нагрузок

Глядя на водопад, мы видим, что как только первый файл JavaScript завершает загрузку, сразу же начинают загружаться новые файлы. Это типично для зависимостей модулей. Наш основной модуль, вероятно, имеет статический импорт, поэтому JavaScript не может работать, пока этот импорт не будет загружен. Здесь важно понимать, что такого рода зависимости известны во время сборки. Мы можем использовать теги <link rel="preload"> чтобы гарантировать, что все зависимости начнут загружаться в ту же секунду, когда мы получим наш HTML.

Полученные результаты

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

Мы используем диафильм WebPageTest, чтобы увидеть, чего достигли наши изменения.

Эти изменения уменьшили TTI с 11 до 8,5 , что примерно соответствует 2,5 с времени установки соединения, которое мы стремились убрать. Мы молодцы.

Предварительный рендеринг

Хотя мы только что уменьшили TTI , мы особо не повлияли на вечно длинный белый экран, который пользователю приходится терпеть в течение 8,5 секунд. Вероятно , наибольших улучшений для FMP можно добиться, отправив стилизованную разметку в ваш index.html . Распространенными методами достижения этой цели являются предварительный рендеринг и рендеринг на стороне сервера, которые тесно связаны между собой и описаны в разделе «Рендеринг в Интернете» . Оба метода запускают веб-приложение в Node и сериализуют полученный DOM в HTML. Рендеринг на стороне сервера делает это для каждого запроса на стороне сервера, а предварительный рендеринг делает это во время сборки и сохраняет выходные данные как ваш новый index.html . Поскольку PROXX — это приложение JAMStack и не имеет серверной части, мы решили реализовать предварительный рендеринг.

Есть много способов реализовать пререндерер. В PROXX мы решили использовать Puppeteer , который запускает Chrome без какого-либо пользовательского интерфейса и позволяет удаленно управлять этим экземпляром с помощью Node API. Мы используем это для внедрения нашей разметки и JavaScript, а затем считываем DOM как строку HTML. Поскольку мы используем модули CSS , мы получаем CSS-инлайнинг нужных нам стилей бесплатно.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

Благодаря этому мы можем ожидать улучшения нашего FMP. Нам по-прежнему нужно загружать и выполнять тот же объем JavaScript, что и раньше, поэтому не следует ожидать больших изменений TTI. Во всяком случае, наш index.html стал больше и может немного отодвинуть наш TTI. Есть только один способ это выяснить: запустить WebPageTest.

Диафильм показывает явное улучшение нашего показателя FMP. TTI практически не затрагивается.

Время нашей первой значимой окраски увеличено с 8,5 до 4,9 секунды, что является значительным улучшением. Наш TTI по-прежнему составляет около 8,5 секунд, поэтому это изменение практически не повлияло на него. То, что мы здесь сделали, — это изменение восприятия . Кто-то может даже назвать это ловкостью рук. Отрисовывая промежуточный визуальный элемент игры, мы меняем воспринимаемую производительность загрузки в лучшую сторону.

Встраивание

Еще одна метрика, которую нам дают DevTools и WebPageTest, — это время до первого байта (TTFB) . Это время, которое проходит от первого байта отправленного запроса до первого байта полученного ответа. Это время также часто называют временем приема-передачи (RTT), хотя технически между этими двумя числами есть разница: RTT не включает время обработки запроса на стороне сервера. DevTools и WebPageTest визуализируют TTFB светлым цветом внутри блока запроса/ответа.

Светлая часть запроса означает, что запрос ожидает получения первого байта ответа.

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

Именно для этой проблемы изначально был задуман HTTP/2 Push. Разработчик приложения знает , что необходимы определенные ресурсы, и может передать их по сети. К тому времени, когда клиент понимает, что ему необходимо получить дополнительные ресурсы, они уже находятся в кешах браузера. HTTP/2 Push оказался слишком сложным для правильной реализации и считается нежелательным. Эта проблемная область будет вновь рассмотрена во время стандартизации HTTP/3. На данный момент самое простое решение — инлайнить все критически важные ресурсы за счет эффективности кэширования.

Наш критический CSS уже встроен благодаря модулям CSS и нашему предварительному рендереру на основе Puppeteer. Для JavaScript нам необходимо встроить наши важные модули и их зависимости . Эта задача имеет различную сложность в зависимости от используемого вами упаковщика.

Благодаря встраиванию нашего JavaScript мы уменьшили TTI с 8,5 до 7,2 с.

Это сократило наш TTI на 1 секунду. Мы достигли точки, когда наш index.html содержит все, что необходимо для первоначального рендеринга и интерактивности. HTML может отображаться во время загрузки, создавая наш FMP. В тот момент, когда HTML-код завершает анализ и выполнение, приложение становится интерактивным.

Агрессивное разделение кода

Да, наш index.html содержит все необходимое, чтобы стать интерактивным. Но при ближайшем рассмотрении оказывается, что в нем есть и все остальное. Наш index.html весит около 43 КБ. Давайте сравним это с тем, с чем пользователь может взаимодействовать в начале: у нас есть форма для настройки игры, содержащая пару компонентов, кнопку запуска и, возможно, некоторый код для сохранения и загрузки пользовательских настроек. Вот и все. 43 КБ кажется много.

Целевая страница PROXX. Здесь используются только критически важные компоненты.

Чтобы понять, откуда берется размер нашего пакета, мы можем использовать проводник карт исходного кода или аналогичный инструмент, чтобы разобрать, из чего состоит пакет. Как и предполагалось, наш комплект содержит игровую логику, движок рендеринга, экран победы, экран поражения и кучу утилит. Для целевой страницы необходима лишь небольшая часть этих модулей. Перемещение всего, что не требуется строго для интерактивности, в лениво загружаемый модуль значительно уменьшит TTI.

Анализ содержимого файла `index.html` PROXX показывает множество ненужных ресурсов. Выделены критически важные ресурсы.

Что нам нужно сделать, так это разделить код . Разделение кода разбивает монолитный пакет на более мелкие части, которые можно лениво загружать по требованию. Популярные упаковщики, такие как Webpack , Rollup и Parcel , поддерживают разделение кода с помощью динамического import() . Сборщик проанализирует ваш код и встроит все модули, импортированные статически . Все, что вы импортируете динамически , будет помещено в отдельный файл и будет получено из сети только после выполнения вызова import() . Конечно, подключение к сети требует затрат и должно осуществляться только в том случае, если у вас есть свободное время. Мантра здесь — статически импортировать модули, которые критически необходимы во время загрузки, и динамически загружать все остальное. Но вам не следует ждать до самого последнего момента, чтобы отложить загрузку модулей, которые обязательно будут использоваться. «Idle Until Urgent » Фила Уолтона — отличный образец здоровой золотой середины между ленивой загрузкой и активной загрузкой.

В PROXX мы создали файл lazy.js , который статически импортирует всё, что нам не нужно. Затем в наш основной файл мы можем динамически импортировать lazy.js Однако некоторые из наших компонентов Preact оказались в lazy.js , что оказалось несколько усложнением, поскольку Preact не может обрабатывать лениво загружаемые компоненты «из коробки». По этой причине мы написали небольшую оболочку deferred компонента, которая позволяет нам отображать заполнитель до тех пор, пока не загрузится реальный компонент.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

Имея это в виду, мы можем использовать Promise компонента в наших функциях render() . Например, компонент <Nebula> , который отображает анимированное фоновое изображение, будет заменен пустым <div> во время загрузки компонента. Как только компонент будет загружен и готов к использованию, <div> будет заменен фактическим компонентом.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Приняв все это, мы уменьшили index.html всего до 20 КБ, что составляет менее половины исходного размера. Какое влияние это оказывает на FMP и TTI? WebPageTest покажет!

Диафильм подтверждает: наш TTI теперь составляет 5,4 с. Значительное улучшение по сравнению с нашими оригинальными 11-ми.

Наши FMP и TTI различаются всего 100 мс, поскольку речь идет только о синтаксическом анализе и выполнении встроенного JavaScript. Спустя всего 5,4 секунды в сети 2G приложение становится полностью интерактивным. Все остальные, менее важные модули загружаются в фоновом режиме.

Больше ловкости рук

Если вы посмотрите на наш список критических модулей выше, вы увидите, что механизм рендеринга не является частью критических модулей. Конечно, игра не может запуститься, пока у нас не будет нашего механизма рендеринга для рендеринга игры. Мы могли бы отключить кнопку «Пуск» до тех пор, пока наш механизм рендеринга не будет готов запустить игру, но, по нашему опыту, пользователю обычно требуется достаточно времени, чтобы настроить параметры игры, поэтому в этом нет необходимости. В большинстве случаев механизм рендеринга и другие оставшиеся модули загружаются к моменту, когда пользователь нажимает «Старт». В тех редких случаях, когда пользователь работает быстрее, чем его сетевое соединение, мы показываем простой экран загрузки, который ожидает завершения оставшихся модулей.

Заключение

Измерение важно. Чтобы не тратить время на решение несущественных проблем, мы рекомендуем всегда сначала проводить измерения, прежде чем приступать к оптимизации. Дополнительно измерения следует проводить на реальных устройствах при 3G-соединении или на WebPageTest , если реального устройства под рукой нет.

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

  • Доставляйте как можно больше ресурсов за одно соединение.
  • Предварительная загрузка или даже встроенные ресурсы, необходимые для первого рендеринга и интерактивности.
  • Предварительно визуализируйте свое приложение, чтобы улучшить воспринимаемую производительность загрузки.
  • Используйте агрессивное разделение кода , чтобы уменьшить объем кода, необходимого для интерактивности.

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