Каждый квартал в Homeday проходит что-то под названием PEW. PEW расшифровывается как Product Engineering Week, что означает неделю, когда вы отменяете все встречи и работаете над темой, которую хотели бы изучить. Это можно делать группами или в одиночку, решать вам. Последние PEW Я работал над тестами, сжатием ресурсов и некоторыми Puppeteer как услугой. В этом квартале я решил заняться оптимизацией сборки и хотел бы изучить эту тему в этой статье.

Моя идея

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

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

Жизненный цикл разработки

В Homeday мы в основном создаем SPA с использованием Vue. Итак, к концу жизненного цикла разработки мы создаем набор ресурсов, которые загружаются в S3 (в нашем случае) и работают как приложение.

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

Говоря «разные браузеры будут использовать одну и ту же версию», я имею в виду, что мы должны быть готовы к использованию некоторых старых браузеров (некоторые приложения все еще должны поддерживать IE 11, который занимает для нас значительную долю рынка). Так что в основном наша версия должна поддерживать IE 11, а также, например, последнюю версию для Chrome. IE 11 не имеет такой же поддержки Javascript / CSS, как последняя версия Chrome, поэтому в конце наш код откатывается к чему-то, что работает в IE 11, добавляя полифилы и транспилируя то, что необходимо. Это добавляет к нашим ресурсам дополнительный килобайт, который последним пользователям Chrome не нужен, но в конечном итоге они загружаются.

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

Ориентация на браузеры при создании

Когда мы создаем наше приложение с помощью Webpack, существуют разные загрузчики, которые гарантируют, что наш код в конце станет одним (или несколькими) файлом JS / CSS. Хорошо известные загрузчики, такие как babel-loader и postcss-loader, обеспечивают кроссбраузерность работы нашего кода. Открытый вопрос: как они узнают, к каким браузерам им нужно вернуться? У них могут быть свои собственные значения по умолчанию, но каким-то образом должен быть способ указать, какие браузеры следует учитывать.

Существует один файл с именем .browserslistrc (или запись в package.json), в котором указаны браузеры, которые вы ожидаете от своего проекта. Этот файл имеет четко определенную структуру и собственный проект: browserslist. Загрузчики, такие как babel-loader и postcss-loader, используют браузеры, указанные в вашем .browserslistrc файле, чтобы знать, к каким браузерам они должны вернуться.

Вы можете определить не только один браузер, но и ряд браузеров с browserslist. Я рекомендую вам проверить проект, если вы не знаете, как определять эти запросы.

Теперь, когда мы можем указать браузеры, которые хотим поддерживать, нам нужно проверить распределение браузеров по нашим проектам и проверить экономию, которую мы могли бы получить при нацеливании на них в процессе сборки. В нашем случае браузеры распространяются через Google Analytics. Я проделал этот процесс для трех наших проектов и резюмировал его ниже:

Проект 1:

  • Текущая сборка (которая поддерживает IE 11, но не требует этого): 273 КБ
  • Chrome 84: 241 КБ (12% - 32 КБ)
  • Safari 13: 250 КБ (9% - 23 КБ)

Проект 2:

  • Текущая сборка (которая поддерживает IE 11 и необходима): 302Kb
  • Chrome 84: 269 КБ (11% - 33 КБ)
  • Safari 13: 277 КБ (8% - 25 КБ)

Проект 3:

  • Текущая сборка (которая поддерживает IE 11 и необходима): 544Кб
  • Chrome 83+: 504 КБ (8% - 40 КБ)
  • Safari 13: 516 КБ (5% - 28 КБ)

Все значения являются GZIP и учитываются для всех файлов JS + CSS, созданных в сборке

В целом современные браузеры могут сэкономить от ~ 20 КБ до ~ 40 КБ, что определенно является хорошим числом (это не так хорошо, как результаты, которые я получил от сжатия Brotli во время другой работы PEW, но это определенно то, над чем мы можем поработать) .

Теперь, когда идея актуальна, пора ее реализовать. Первый шаг - сделать несколько сборок наших проектов.

Множественные сборки

Используя список браузеров, мы можем указать разные среды, что позволяет нам установить переменную среды (BROWSERSLIST_ENV), чтобы выбрать, в какой среде мы хотим построить.

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

const { readConfig } = require('browserslist/node');
const browserslistConfig = readConfig('.browserslistrc');
const browserslistConfigKeys = Object.keys(browserslistConfig).filter((_) => _ !== 'defaults'); // Browserslist default is removed and built separately
browserslistConfigKeys.forEach((key) => {
  // Here we build the app like: BROWSERSLIST_ENV=${key} npm run build:production
});

Я удалил части кода, которые не нужны для этого примера.

Итак, прямо сейчас происходит следующее:

  • У нас есть .browserslistrc файл с установленными средами
since 2019
[chrome]
chrome 84
[safari]
safari 13
  • Мы строим для каждой среды
  1. Загрузите первое envionment, в данном случае chrome.
  2. Позвоните BROWSERSLIST_ENV=chrome npm run build:production
  3. Точка входа будет в /dist/chrome, поэтому у нас будет /dist/chrome/index.html и /dist/js/....
  4. Загрузите второй envionment, в данном случае safari.
  5. Звоните BROWSERSLIST_ENV=safari npm run build:production
  6. Точка входа будет в /dist/safari, поэтому у нас будет /dist/safari/index.html и /dist/js/....
  7. Построить корпус по умолчанию
  8. Позвоните npm run build:production
  9. Точка входа будет в /dist, поэтому у нас будет /dist/index.html и /dis/js/...

Отсюда мы можем заметить, что у нас по-прежнему используется /dist/index.html по умолчанию, работающий должным образом, и все ресурсы находятся в общих папках, например /dist/js. На изображении ниже представлен этот процесс.

Давай проверим, куда мы идем. Сейчас у нас несколько index.html файлов. Каждый index.html указывает на другую точку входа, в данном случае на .js файл. Этот .js файл находится в /dist/js. Итак, что нам нужно сделать сейчас, это направить браузер к конкретному index.html, который использует встроенную версию нашего приложения для этого браузера.

Маршрутизация нескольких сборок

Когда мы закончили с несколькими сборками нашего приложения, мы можем просто развернуть его. Развертывание означает копирование файлов под /dist куда-нибудь, в нашем случае это S3. Теперь наше приложение работает точно так же, как и раньше. Причина в том, что наш default build создает /dist/index.html, именно так мы строили наш проект.

Теперь нам нужно направить некоторые запросы в новые index.html файлы в подкаталогах /chrome и /safari. Нам нужно маршрутизировать только index.html, поскольку все ресурсы находятся в тех же подкаталогах (/js и /css), что и раньше.

В Homeday у нас есть CloudFront перед S3, что означает, что мы можем усилить возможности Lambda @ Edge. Lambda @ Edge позволяет запускать функцию Lambda (если вы не знакомы, проверьте официальную документацию в событиях жизненного цикла CloudFront. Вы также можете проверить Официальную документацию Lambda @ Edge, если хотите глубже изучить тема.

Мы можем разместить лямбда-функцию между CloudFront и S3, что позволяет нам направлять запрос на S3 на основе User-Agent, которое мы получаем из запроса. Мы можем сравнить User-Agent с нашими запросами в определении списка браузеров и решить, какой маршрут выбрать или просто перейти к маршруту по умолчанию (что было бы в случае без этой лямбда-функции). Этот процесс должен происходить только для index.html и service-workers.js, поскольку здесь у нас есть PWA. Лямбда-функция может выглядеть следующим образом:

const { matchesUA } = require('browserslist-useragent');
const { readConfig } = require('browserslist/node');
const INDEX_HTML_REGEX = /\/index\.html/;
const SERVICE_WORKER_REGEX = /\/service-worker\.js/;
const BROWSERSLIST_CONFIG = readConfig('.browserslistrc');
const BROWSERSLIST_KEYS = Object.keys(BROWSERSLIST_CONFIG).filter((_) => _ !== 'defaults');
exports.handler = async (event) => {
  const { request } = event.Records[0].cf;
  const { uri, headers } = request;
  if (INDEX_HTML_REGEX.test(uri) || SERVICE_WORKER_REGEX.test(uri)) { // You can do it in the same Regex or leave it explicit as we do
    const userAgent = getUserAgentFromHeaders(headers);
    console.log('uri', uri);
    console.log('userAgent', userAgent);
    const path = BROWSERSLIST_KEYS.find((key) => {
      const browsers = BROWSERSLIST_CONFIG[key];
      const options = {
        browsers,
        allowHigherVersions: true,
      };
      return matchesUA(userAgent, options);
    });
    if (path) {
      console.log(`Redirect to ${path} version`);
      return { ...request, uri: `/${path}${uri}` };
    }
    console.log('Serving default version');
  }
  return request;
};

Как только пользователь загрузит «правильный» index.html, он получит необходимые ресурсы и предоставит нужную версию приложения для этого пользователя. Ниже представлены 3 изображения, которые представляют сценарии запроса. Учтите, что ни один из файлов не кэшируется в CloudFront / Browser.

Запрос index.html из случайного браузера, отличного от Chrome / Safari, что означает возврат к значениям по умолчанию (или к тому, что у нас было раньше). Лямбда-функция сейчас не выполняет никакой работы по маршрутизации, а просто пересылает запрос.

Запрос index.html из браузера Chrome, что означает, что мы должны перенаправить на /chrome/index.html. Лямбда-функция обнаруживает User-Agent и направляет запрос в нужный файл, в данном случае /chrome/index.html.

Запрос app.1.js из браузера Chrome. Поскольку это не index.html, мы не должны ничего делать. Лямбда-функция сейчас не выполняет никакой работы по маршрутизации, а просто пересылает запрос.

Что нужно учитывать

В целом описанный подход работает так, как ожидалось. Тем не менее, есть и другие вещи, которые я бы порекомендовал сделать:

  • Не создавайте для определенных браузеров. Создан для различных браузеров. Например, если вы создаете для Chrome 83 и Chrome 84, изменения в том, что результат будет одинаковым, довольно высоки. Поиграйте с запросами списка браузеров и найдите тот, который вам больше всего подходит, а также изучите свою аналитику, чтобы понять, какой подход лучше всего использовать.
  • Ваше время сборки увеличится. Вы также можете строить параллельно, но в конце концов оно будет увеличиваться. Так что используйте желаемое количество сборок.
  • Если вы используете CDN, как мы используем CloudFront, пересылка заголовка будет означать «ослабление» вашей стратегии кэширования, поэтому имейте это в виду и не пересылайте все заголовки. В данном случае нам понадобится всего User-Agent.
  • Автоматизируйте и используйте IaC (инфраструктура как код). Поскольку у нас все есть в AWS, я в конечном итоге использую CloudFormation. На самом деле я использовал AWS SAM, так как он упрощает определение функций Lambda, но в конце мне все равно нужно использовать синтаксис CloudFormation, например, для распространения CloudFront.
  • Этот шаг можно полностью выполнить в следующей итерации, но я определенно рекомендую вам проверить его. Представьте, что вы обновляете свой .browserslistrc файл. Вам нужно снова развернуть Lambda. Опубликуйте это. Обновите дистрибутив CloudFront, чтобы использовать его. И все, что будет после. Если все автоматизировано, в конце вы запускаете команду, которая выполняет все эти шаги за вас.
  • Если вы также используете CloudFront и находитесь за пределами us-east-1, у вас будет мало проблем с Lambda @ Edge, поскольку эта функция должна быть в us-east-1, а не в каком-либо другом регионе, чтобы работать.
  • Другой способ добиться чего-то подобного - использовать bdsl-webpack-plugin. У этого подхода есть некоторые недостатки, и он становится громоздким при использовании Service Workers. Тем не менее, реализовать этот способ проще.

Спасибо, что зашли так далеко =]

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

Мы, Homeday, в настоящее время не используем его в продакшене, и я очень хочу попробовать и собрать некоторые показатели. Мне нравится исследовать эту тему и работать за пределами кода, изучать улучшения архитектуры и так далее. Я надеюсь, что в следующих PEW я смогу изучить похожие темы и поделиться своими знаниями!