Уско­ря­ем­ся с по­мо­щью 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:

Размеры бандла
Без сжатияПосле сжатия
Без DSL1,08 Мб292 Кб
С плагином BDSL для Webpack0,80 Мб218 Кб
Среднее время загрузки, мс
Обычный интернетОбычный 4GХороший 3G
Без DSL1,5114,2408,696
С плагином BDSL для Webpack1,5943,4098,561
Лучшее время загрузки, мс
Обычный интернетОбычный 4GХороший 3G
Без DSL1,2663,3668,349
С плагином BDSL для Webpack1,1433,1426,673

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

Источники#