Уско­ря­ем­ся с по­мо­щью Browserslist

Редактура Ирина Питаева Вадим Макеев

На сегодняшний день имеется большое количество разных браузеров и ещё большее количество версий каждого из них. Если раньше новые фичи добавлялись в браузеры редко, то сейчас их можно обнаружить практически с каждым новым релизом. Как итог, у разных версий браузера — разная поддержка фич, не говоря уже о разном уровне поддержки среди разных вендоров.

Разработчикам хочется использовать новые фичи, так как зачастую это упрощает жизнь. Благодаря инструментам разработки можно использовать фичи до их появления в браузерах путём транспиляции и использования полифилов. Также эти инструменты гарантируют работу сайта в любом браузере независимо от того, имеется ли в нём поддержка той или иной фичи. Из примеров: Autoprefixer и postcss-preset-env для CSS, Babel для JavaScript. Но нужно понимать, что при этом размер кода увеличивается.

В итоге мы имеем сайт, который работает в любом браузере, но из-за этого загружается медленнее, чем мог бы. Напомню, что скорость загрузки и работы сайта напрямую влияет на количество и глубину просмотров. Что с этим можно сделать? На самом деле нам незачем транспилировать и полифилить абсолютно все фичи — достаточно это делать только с теми, которые не поддерживаются актуальными браузерами (или актуальными для аудитории вашего сайта). Например, промисы не поддерживаются только очень старыми версиями браузеров.

Browserslist Скопировать ссылку

Browserslist — это тот инструмент, с помощью которого можно описать целевые браузеры веб-приложения, используя простые выражения:

last 2 years
> 1%
not dead

Этот пример файла .browserslistrc означает, что вам нужны: браузеры за последние два года, плюс браузеры у которых больше 1% пользователей, и все эти браузеры должны быть «живыми». Посмотреть в какие конкретные браузеры это разрезолвится можно на сайте browserl.ist, а более подробно узнать про синтаксис выражений можно на странице проекта.

Уже упомянутые Autoprefixer, postcss-preset-env и babel-preset-env под капотом используют Browserslist, и если в вашем проекте есть конфиг Browserslist, то код проекта будет собран под эти браузеры.

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

Варианты таргетирования браузеров Скопировать ссылку

1. Более узкое таргетирование Скопировать ссылку

По умолчанию, если в проекте нет конфига, то Browserslist будет использовать defaults браузеры. Это выражение является сокращением для > 0.5%, last 2 versions, Firefox ESR, not dead. В целом, можно остановиться на использовании этого выражения, и со временем браузеры, подходящие под это выражение, станут поддерживать большинство актуальных фич.

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

2. Анализ аудитории Скопировать ссылку

Если ваш сайт подразумевает использование только в определенных регионах, то можно попробовать использовать выражение вида > 5% in US, которое вернёт браузеры, подходящие по статистике использования в указанной стране.

Семейство Browserslist полно разными дополнительными инструментами, один из них — Browserslist-GA (также есть browserslist-adobe-analytics), который позволяет выгрузить из сервиса аналитики данные о браузерах, используемые посетителями вашего сайта. После этого становится возможным использовать эти данные в конфиге Browserslist и применять на них выражения:

> 0.5% in my stats

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

3. Условная загрузка ресурсов Скопировать ссылку

В марте 2019 Матиас Байненс из Google предложил добавить в браузеры дифференциальную загрузку скриптов (differential script loading, далее DSL):

<script type="module"
        srcset="2018.mjs 2018, 2019.mjs 2019"
        src="2017.mjs"></script>
<script nomodule src="legacy.js"></script>

До сих пор его предложение остается лишь предложением, и неизвестно, будет ли это в том или ином виде реализовано в браузерах. Но концепт понятен, и в семействе Browserslist есть инструменты, с помощью которых можно реализовать что-то подобное, один из них — browserslist-useragent. Этот инструмент позволяет проверить User-Agent браузера на соответствие вашему конфигу.

Browserslist-useragent Скопировать ссылку

В интернете уже есть несколько статей на эту тему, вот пример одной из них — «Smart Bundling: How To Serve Legacy Code Only To Legacy Browsers». Коротко пробежимся по реализации. Для начала необходимо настроить ваш процесс сборки для вывода, например, двух версий бандлов для новых и старых браузеров. Здесь вам поможет Browserslist с его возможностью объявления нескольких сред в конфигурационном файле:

[modern]
last 2 versions
last 1 year
not safari 12.1

[legacy]
defaults

Далее необходимо настроить сервер для отдачи нужного бандла под браузер пользователя:

/* … */
import { matchesUA } from 'browserslist-useragent'
/* … */
app.get('/', (request, response) => {
    const userAgent = request.get('User-Agent')
    const isModernBrowser = matchesUA(userAgent, {
        env: 'modern',
        allowHigherVersions: true
    })
    const page = isModernBrowser
        ? renderModernPage(request)
        : renderLegacyPage(request)

    response.send(page)
})

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

Module/nomodule Скопировать ссылку

С появлением в браузерах поддержки ES-модулей появился другой способ реализовать DSL, но уже на стороне клиента:

<script type="module" src="index.modern.js"></script>
<script nomodule src="index.legacy.js"></script>

Этот паттерн имеет название module/nomodule, и его суть состоит в том, что старые браузеры без поддержки ES-модулей не будут обрабатывать скрипты с типом module, так как этот тип им неизвестен. А браузеры, которые поддерживают ES-модули, будут загружать скрипт с типом module и игнорировать скрипт с атрибутом nomodule. Браузеры с поддержкой ES-модулей, описываются следующим конфигом:

[esm]
edge >= 16
firefox >= 60
chrome >= 61
safari >= 11
opera >= 48

Главным плюсом паттерна module/nomodule является отсутствие необходимости в собственном сервере — всё работает полностью на клиентской части. Загрузку стилей в зависимости от браузера таким образом не сделать, но можно реализовать загрузку ресурсов с помощью JavaScript, используя проверку вида:

if ('noModule' in document.createElement('script')) {
    // Новые браузеры
} else {
    // Старые браузеры
}

Из минусов: этот паттерн имеет кое-какие кроссбраузерные проблемы. Также уже начинают появляться фичи, которые имеют разный уровень поддержки, даже среди браузеров с поддержкой ES-модулей, например, optional chaining operator. С появлением новых фич этот вариант DSL потеряет свою актуальность.

Прочитать ещё больше про паттерн module/nomodule можно в статье «Modern Script Loading». Если вас заинтересовал этот вариант DSL и хочется попробовать внедрить его в свой проект, то вы можете воспользоваться плагином для Webpack: webpack-module-nomodule-plugin.

Browserslist-useragent-regexp Скопировать ссылку

Относительно недавно появился ещё один инструмент для Browserslist: browserslist-useragent-regexp. Этот инструмент позволяет получить из конфига регулярное выражение для проверки User-Agent браузера. Регулярные выражения работают в любой среде исполнения JavaScript, что даёт возможность проверять User-Agent браузера не только на стороне сервера, но и на стороне клиента. Таким образом, можно реализовать работающий в браузере DSL:

// last 2 firefox versions
var modernBrowsers = /Firefox\/(73|74)\.0\.\d+/
var script = document.createElement('script')

script.src = modernBrowsers.test(navigator.userAgent)
    ? 'index.modern.js'
    : 'index.legacy.js'

document.all[1].appendChild(script)

Можно добавить, что генерируемые регулярные выражения работают быстрее, чем функция matchesUA из browserslist-useragent, так что есть смысл использовать browserslist-useragent-regexp и на стороне сервера:

> matchesUA('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0', { browsers: ['Firefox > 53']})
first time: 21.604ms
> matchesUA('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0', { browsers: ['Firefox > 53']})
warm: 1.742ms

> /Firefox\/(5[4-9]|6[0-6])\.0\.\d+/.test('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0')
first time: 0.328ms
> /Firefox\/(5[4-9]|6[0-6])\.0\.\d+/.test('Mozilla/5.0 (Windows NT 10.0; rv:54.0) Gecko/20100101 Firefox/54.0')
warm: 0.011ms

Все это выглядит очень здорово, но хочется иметь удобный способ встроить это в процесс сборки проекта… И такой способ есть!

Browserslist Differential Script Loading Скопировать ссылку

Bdsl-webpack-plugin это плагин для Webpack, работающий в паре с html-webpack-plugin и использующий browserslist-useragent-regexp, который помогает автоматизировать добавление в бандл дифференциальной загрузки. Вот пример конфига для Webpack с применением этого плагина:

const {
    BdslWebpackPlugin,
    getBrowserslistQueries,
    getBrowserslistEnvList
} = require('bdsl-webpack-plugin')

function createWebpackConfig(env) {
    return {
        name: env,
        /* … */
        module: {
            rules: [{
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader',
                options: {
                    cacheDirectory: true,
                    presets: [
                        ['@babel/preset-env', {
                            /* … */
                            targets: getBrowserslistQueries({ env })
                        }]
                    ],
                    plugins: [/* … */]
                }
            }]
        },
        plugins: [
            new HtmlWebpackPlugin(/* … */),
            new BdslWebpackPlugin({ env })
        ]
    };
}

module.exports = getBrowserslistEnvList().map(createWebpackConfig)

В этом примере экспортируется несколько конфигов для вывода бандлов для каждой среды из конфига Browserslist. На выходе мы получим HTML-файл со встроенным DSL-скриптом:

<!DOCTYPE html>
<html>
    <head>
        <title>Example</title>
        <script>function dsl(a,s,c,l,i){c=dsld.createElement('script');c.async=a[0];c.src=s;l=a.length;for(i=1;i<l;i++)c.setAttribute(a[i][0],a[i][1]);dslf.appendChild(c)}var dsld=document,dslf=dsld.createDocumentFragment(),dslu=navigator.userAgent,dsla=[[]];if(/Firefox\/(73|74)\.0\.\d+/.test(dslu))dsl(dsla[0],"/index.modern.js")
else dsl(dsla[0],"/index.legacy.js");dsld.all[1].appendChild(dslf)</script>
    </head>
    <body></body>
</html>

Помимо загрузки скриптов есть поддержка загрузки стилей. Также имеется возможность использовать этот плагин на стороне сервера.

Но, к сожалению, есть и кое-какие нюансы, о которых нужно знать при использовании bdsl-webpack-plugin: так как загрузка скриптов и стилей инициализируется из JavaScript, то они загружаются асинхронно без блокировки рендеринга и т. д. К примеру, в случае скриптов — это означает невозможность использования атрибута defer, а для стилей — необходимость скрывать контент страницы до момента полной загрузки стилей. Про то, как можно обойти эти нюансы, и про другие возможности данного плагина можно прочитать в документации и в примерах использования.

Транспиляция зависимостей Скопировать ссылку

Из уже прочитанной части статьи мы узнали несколько способов того, как с помощью Browserslist можно уменьшить размер собственного кода сайта, но остаётся другая часть итогового бандла — зависимости. В веб-приложениях размер зависимостей в итоговом бандле может занимать весомую часть.

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

  1. c транспилированным кодом;
  2. c транспилированным и исходным кодом;
  3. c кодом на актуальном синтаксисе только для новых браузеров.

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

Так как общепринятого способа делать пакеты с несколькими версиями бандла нет, то опишу как я делаю такие пакеты: обычная транспилированная версия имеет расширение .js, и главный файл записывается в поле main файла package.json, а версия бандла без транспиляции имеет расширение .babel.js, и главный файл записывается в поле raw. Вот реальный пример — пакет Canvg. Но можно делать и по-другому, например, как это сделано в пакете Preact — исходники выделены в отдельную папку, а в package.json есть поле source.

Для того, чтобы Webpack мог работать с такими пакетами, нужно модифицировать секцию resolve:

{
    /* … */
    resolve: {
        mainFields: [
            'raw',
            'source',
            'browser',
            'module',
            'main'
        ],
        extensions: [
            '.babel.js',
            '.js',
            '.jsx',
            '.json'
        ]
    }
    /* … */
}

Таким образом мы указываем Webpack как он должен искать файлы в пакетах, которые он должен использовать для сборки. Дальше нам остаётся настроить babel-loader:

{
    /* … */
    test: /\.js$/,
    exclude: _ => /node_modules/.test(_) && !/(node_modules\/some-modern-package)|(\.babel\.js$)/.test(_),
    loader: 'babel-loader'
    /* … */
}

Логика простая: просим игнорировать всё из node_modules, за исключением конкретных пакетов и файлов с определенным расширением.

Результаты Скопировать ссылку

Я замерил размеры бандла и скорости загрузки до и после применения дифференциальной загрузки вместе с транспиляцией зависимостей, на примере сайта DevFest Siberia 2019:

Размеры бандла
Без сжатия После сжатия
Без DSL 1,08 Мб 292 Кб
С плагином BDSL для Webpack 0,80 Мб 218 Кб
Среднее время загрузки, мс
Обычный интернет Обычный 4G Хороший 3G
Без DSL 1,511 4,240 8,696
С плагином BDSL для Webpack 1,594 3,409 8,561
Лучшее время загрузки, мс
Обычный интернет Обычный 4G Хороший 3G
Без DSL 1,266 3,366 8,349
С плагином BDSL для Webpack 1,143 3,142 6,673

В итоге получился прирост скорости загрузки и размер бандла уменьшился на ≈20%, читайте более подробный отчёт. Также вы можете самостоятельно провести подобные замеры — необходимый скрипт есть в репозитории bdsl-webpack-plugin.

Источники Скопировать ссылку