X

Кейс: как Pinterest перешёл на PWA и увеличил активность пользователей на 60 %

В какой-то момент Pinterest проанализировали свой трафик и решили заменить свой старый мобильный сайт прогрессивным веб-приложением (Progressive Web App, PWA). Это решение существенно улучшило метрики портала. В статье мы попытаемся разобраться с технической стороной такой миграции.

Почему PWA? Немного истории

Pinterest начали разработку PWA из-за планов на международный рост, которые привели их к мобильному вебу.

Проанализировав использование мобильной версии сайта неавторизованными пользователями, они увидели, что их старый и медленный сайт смог привлечь только 1 % пользователей к регистрации, авторизации или установке нативного приложения. У них была отличная возможность улучшить конверсию, поэтому они решили заняться созданием PWA.

Создание и запуск PWA в течение квартала

За 3 месяца Pinterest переделали свой сайт с помощью React, Redux и webpack. Его обновление привело к росту некоторых основных бизнес-показателей: время, которое пользователи проводят на сайте, увеличилось на 40 %, доходы от рекламы выросли на 44 %, а показатели активности — на 60 %.

Переписывание сайта также привело к нескольким важным улучшениям в производительности.

Быстрая загрузка на среднем мобильном устройстве через 3G

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

Пользователям часто приходилось ждать 23 секунды, прежде чем появлялась возможность использовать хоть какие-то элементы интерфейса.

Пользователям отсылалось более 2.5 МБ JavaScript (~1.5 МБ на основной бандл, 1 МБ ленивой загрузки), которые требуют много времени на парсинг и компиляцию, чтобы основной поток наконец мог стать интерактивным.

Их новый мобильный сайт стал значительно лучше. Они не только избавились от сотен килобайт JavaScript, снизив размер бандла ядра с 650 КБ до 150 КБ, но и улучшили основные показатели производительности. Время до первого значимого отображения снизилось с 4.2 до 1.8 секунд, а время до интерактивности — с 23 до 5.6 секунд.

И это на среднем Android-устройстве с медленным 3G-подключением. При повторных посещениях сайта ситуация была ещё лучше.

С помощью кэширования Service Worker их основного JavaScript, CSS и статических ресурсов UI, им удалось снизить время до интерактивности при повторных посещениях до 3.9 секунд:

Хотя у Pinterest есть приложения для iOS и Android, им удалось предоставить аналогичный опыт взаимодействия с домашней лентой на мобильном сайте небольшой ценой — загрузкой всего лишь ~150 КБ минифицированных и заархивированных данных. Это сильно отличается от 9.6 МБ, необходимых для предоставления этого опыта на Android, и 56 МБ — на iOS.

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

Фрагментация JavaScript для каждой страницы

Загрузка только необходимых пользователю в данный момент ресурсов положительно сказывается на скорости загрузки страницы и времени до интерактивности. Она уменьшает время, необходимое для передачи данных и парсинг/компиляцию JavaScript. Некритичные ресурсы можно лениво загрузить потом при необходимости.

В Pinterest начали разбивать их многомегабайтные JavaScript-бандлы на три категории фрагментов webpack, что дало положительный результат:

  • вендорный фрагмент, содержащий внешние зависимости (react, redux, react-router и т. д.) ~ 73 КБ;
  • главный фрагмент, содержащий основную часть кода, необходимую для отображения приложения (например, общие библиотеки, основная оболочка страницы, хранилище Redux) ~ 72 КБ;
  • асинхронные фрагменты для отдельных страниц ~13–18 КБ.

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

Для долгосрочного кэширования Pinterest также использует специфическую для фрагмента подстановку хэша для каждого имени файла.

Pinterest использует плагин webpack CommonsChunkPlugin, чтобы разбить вендорные бандлы на собственные кэшируемые фрагменты:

const bundles = {
  'vendor-mweb': [
    'app/mobile/polyfills.js',
    'intl',
    'normalizr',
    'react-dom',
    'react-redux',
    'react-router-dom',
    'react',
    'redux'
  ],
  'entryChunk-webpack': 'app/mobile/runtime.js',
  'entryChunk-mobile': 'app/mobile/index.js'
};

const chunkPlugins = [
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor-mweb',
    minChunks: Infinity,
    chunks: ['entryChunk-mobile']
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'entryChunk-webpack',
    minChunks: Infinity,
    chunks: ['vendor-mweb']
  }),
  new webpack.optimize.CommonsChunkPlugin({
    children: true,
    name: 'entryChunk-mobile',
    minChunks: (module, count) => {
      return module.resource && (isCommonLib(resource) || count >= 3);
    }
  })
];

Также используется React Router для разделения кода:

// Создаём загрузчик
const Closeup = () => import(/* webpackChunkName: "CloseupPage" */ 'app/mobile/routes/CloseupPage');

// Регистрируем его в странице
route('/pin/:pinId', routes.Closeup, { name: 'Closeup' }),

// Отображаем react-router-v4 Route с загрузчиком
<Route exact key="matched-route" path={path} render={matchProps =>
  <PageRoute
    bundleLoader={loader}
    routeName={name}
    {...matchProps}
    {...props}
  />}
/>

// Асинхронно загружаем бандл страницы
class PageRoute extends PureComponent {
  render() {
    const { bundleLoader, ...props } = this.props;
    return <Loader loader={bundleLoader} {...props} />;
  }
}

// Загружаем и отображаем
class Loader extends PureComponent {
  componentWillMount() {
    this.props.loader().then(module => {
      this.setState({ LoadedComponent: module.default });
    });
  }
}

Использование babel-preset-env для транспиляции только необходимых функций

Pinterest используют babel-preset-env для транспиляции функций ES2015+ в тех браузерах, которые их не поддерживают. Pinterest ориентируются на две последние версии современных браузеров, и .babelrc у них настроен так:

{
  "presets": [
    ["env", {
      "targets": {
        "browsers": ["last 2 versions"]
      }
    }]
  ]
}

Есть дальнейший простор для оптимизаций путём доставки полифиллов только при необходимости (например, Internationalization API для Safari), но она планируется в будущем.

Анализ пространства для улучшения с помощью Webpack Bundle Analyzer

Webpack Bundle Analyzer — отличный инструмент, если вам нужно разобраться, какие зависимости вы посылаете пользователям в JavaScript-бандлах.

Ниже вы можете увидеть в его выводе для старой сборки Pinterest много розовых и синих блоков. Это асинхронные загружающиеся лениво фрагменты для страниц. Webpack Bundle Analyzer наглядно показал Pinterest, что большинство из этих фрагментов содержат повторяющийся код.

Webpack Bundle Analyzer показал размер проблемы для всех фрагментов.

Получив эту информацию, Pinterest смогли принять соответствующие меры. Они переместили повторяющийся код из асинхронных фрагментов в основной. Это увеличило его размер на 20 %, но при этом уменьшило размер всех лениво загружаемых фрагментов вплоть до 90 %!

Оптимизация изображений

Значительная часть лениво загружаемого контента в Pinterest PWA обрабатывается бесконечной сеткой Masonry. У неё есть встроенная поддержка виртуализации, и она монтирует только те дочерние элементы, что находятся в области просмотра:

Pinterest также использует прогрессивную загрузку изображений в PWA. Сначала для каждого Pin’a используется плейсхолдер с доминантным цветом. Изображения Pin’ов являются прогрессивными JPEG’ами, качество которых улучшается с каждым сканом:

Проблемы с производительностью React

Pinterest столкнулись с определёнными проблемами производительности отображения React, который использовался для сетки Masonry. Монтирование и размонтирование больших деревьев компонентов (вроде Pin’ов) может быть медленным. Pin состоит из множества вещей:

Хотя на момент написания статьи в Pinterest использовали React 15.5.4, они надеются, что React 16 (Fiber) позволит сильно сократить время, необходимое на размонтирование. Тем временем, снизить это время позволила виртуализация сетки.

Pinterest также затормаживает вставку Pin’ов, чтобы быстрее отобразить первые Pin’ы, однако это прибавляет работы процессору устройства.

Навигационные переходы

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

Опыт использования Redux

Pinterest используют normalizr (нормализует вложенный JSON) для всех данных API. Это можно увидеть в Redux DevTools:

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

Например, их списки элементов сетки — это просто ID Pin’ов с компонентом Pin’а, денормализующим самого себя. Если с каким-то из Pin’ов происходят изменения, целой сетке не нужно полностью отображаться заново. Компромисс заключается в том, что в Pinterest PWA находится много подписчиков Redux, хотя это не вылилось в заметные проблемы с производительностью.

Впервые слышите о Redux? Тогда познакомьтесь с ним в нашем руководстве для начинающих.

Кэширование ресурсов с помощью Service Worker’ов

Pinterest используют библиотеки Workbox для генерации и управления Service Worker’ами:

/* global $VERSION, $Cache, importScripts, WorkboxSW */
importScripts('https://unpkg.com/workbox-sw@1.1.0/build/importScripts/workbox-sw.prod.v1.1.0.js');

// Добавляем оболочку приложения в генерируемый webpack список для предварительного кэширования
$Cache.precache.push({ url: 'sw-shell.html', revision: $VERSION });

// Регистрируем список с помощью Workbox
const workbox = new WorkboxSW({ handleFetch: true, skipWaiting: true, clientClaim: true });
workbox.precache($Cache.precache);

// Кэшируем весь запускаемый JS
workbox.router.registerRoute(/webapp\/js\/.*\.js/, workbox.strategies.cacheFirst());

// Отдаём предпочтение оболочке приложения при полностраничной загрузке
workbox.router.registerNavigationRoute('sw-shell.html', {
  blacklist: [
    // Страницы, не относящиеся к приложению
  ],
});

Сегодня Pinterest кэширует любые JavaScript или CSS-бандлы с помощью стратегии cache-first, а также кэширует пользовательский интерфейс (оболочка приложения).

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

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

Этот процесс можно представить в виде нескольких небольших итеративных шагов:

  1. Сначала Service Worker кэшировал только лениво загружаемые скрипты. Это было нужно для извлечения пользы из кэширования кода V8, что позволило снизить время загрузки во время повторных просмотров за счёт пропуска некоторых фрагментов при парсинге/компиляции. Скрипты, хранящиеся в Cache Storage, где находится Service Worker, могут инициировать кэширование кода, поскольку велик шанс того, что браузер знает, что пользователю понадобятся эти ресурсы при повторных просмотрах:
  2. После этого Pinterest предварительно кэширует вендорные и входные фрагменты.
  3. Затем начинается предварительное кэширование некоторых из наиболее часто используемых страниц вроде домашней страницы и страницы поиска.
  4. Наконец, начинается генерация локальных Service Worker’ов, чтобы можно было закэшировать локализованный бандл. Это не только было важно для производительности при повторной загрузке, но также предоставило базовые возможности офлайн-отображения для большей части аудитории:
/* Создаём локальных ServiceWorker'ов 
для предварительного кэширования локализованного бандла*/
const ServiceWorkerConfigs = locales.reduce((configs, locale) => {
  return Object.assign(configs, {
    [`mobile-${locale}`]: Object.assign({}, BaseConfig, {
      template: path.join(__dirname, 'swTemplates/mobileBase.js'),
      cache: {
        template: path.join(__dirname, 'swTemplates/mobileCache.js'),
        precache: [
          'vendor-mweb-.*\\.js$',
          'entryChunk-mobile-.*\\.js$',
          'entryChunk-webpack-.*\\.js$',
          `locale-${locale}-mobile.*js$`,
          'pjs-HomePage.*\\.js$',
          'pjs-SearchPage.*\\.js$',
          'pjs-CloseupPage.*\\.js$'
        ]
      }
    })
  });
}, {});

// Добавляем в webpack
plugins: [
  new ServiceWorkerPlugin(BaseConfig, ServiceWorkerConfigs);
]

Трудности с оболочкой приложения

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

Они спросили себя: «Нам действительно нужно кэшировать это в оболочке приложения? Или же мы можем получить преимущество от блокирования сетевого запроса перед отображением чего-либо, чтобы скачать всё сразу?».

В итоге было решено кэшировать эти данные в оболочке приложения, что требовало определённого контроля за тем, когда нужно перезагрузить оболочку приложения (выход из системы, обновление пользовательской информации из настроек и т. д.). Каждый ответ на запрос содержит поле appVersion; если оно меняется, то регистрируется новый Service Worker, и при следующей смене страницы происходит полная перезагрузка страницы.

Добавление этой информации в оболочку приложения — небольшое усложнение, но оно того стоит, потому что позволяет избавиться от запроса, блокирующего отображение.

Аудит с Lighthouse

Pinterest использовали Lighthouse для единоразовых проверок, чтобы быть уверенными, что они находятся на верном пути. Было полезно следить за показателями вроде «Time to Consistently Interactive».

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

Будущее

Недавно в Pinterest появилась поддержка уведомлений Web Push. Также ведётся работа над юзабилити для неавторизованных пользователей в PWA.

Кроме того, разработчики заинтересованы в поддержке <link rel=preload> для предварительной загрузки критичных бандлов и уменьшения неиспользуемого JavaScript, отправляемого пользователям при первой загрузке.

Перевод статьи «A Pinterest Progressive Web App Performance Case Study»

Также рекомендуем:

Рубрика: Переводы
Темы: Веб-разработка