Привет, Хабр!

Каждый современный браузер сейчас позволяет работать с ES6 Modules.

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

Под катом рассказ о том, как я смог уменьшить размер приложения на 11% без ущерба для старых браузеров и своих нервов.



Особенности ES6 Modules


ES6 Modules — это всем уже известная и широко используемая модульная система:

/* someFile.js */

import { someFunc } from 'path/to/helpers.js'

/* helpers.js */

export function someFunc() {
  /* ... */
}

Для использования этой модульной системы в браузерах необходимо добавить тип module к каждому скрипт-тегу. Старые браузеры увидят, что тип отличается от text/javascript, и не станут исполнять файл как JavaScript.

<!-- Будет выполнен только в браузерах с поддержкой ES6 Modules -->
<script type="module" src="/path/to/someFile.js"></script>

В спецификации еще есть атрибут nomodule для скрипт-тегов. Браузеры, поддерживающие ES6 Modules, проигнорируют этот скрипт, а старые браузеры скачают его и выполнят.

<!-- Будет загружен только в старых браузерах -->
<script nomodule src="/path/to/someFileFallback.js"></script>

Получается, можно просто сделать две сборки: первая с типом module для современных браузеров (Modern Build), а другая — с nomodule для старых (Fallback build):

<script type="module" src="/path/to/someFile.js"></script>
<script nomodule src="/path/to/someFileFallback.js"></script>

Зачем это нужно


Прежде чем отправить проект в production, мы должны:

  • Добавить полифилы.
  • Транспилировать современный код в более старый.

В своих проектах я стараюсь поддерживать максимальное количество браузеров, иногда даже IE 10. Поэтому мой список полифилов состоит в том числе и из таких базовых вещей, как es6.promise, es6.object.values и т.п. Но браузеры с поддержкой ES6 Modules имеют все ES6 методы, и им не нужны лишние килобайты полифилов.

Транспиляция тоже оставляет заметный след на размере файлов: для покрытия большинства браузеров babel/preset-env использует 25 трансформаторов, каждый из которых увеличивает размер кода. В это же время для браузеров с поддержкой ES6 Modules количество трансформаторов уменьшается до 9.

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

Как добавлять полифилы


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



Обычно, в проектах используют core-js для добавления всех возможных полифилов.

Конечно, вы не хотите все 88 Кбайт полифилов из этой библиотеки, а только те, которые нужны для вашего browserslist. Такая возможность доступна с помощью babel/preset-env и его опции useBuiltIns. Если установить ей значение entry, то импорт core-js заменится на импорты отдельных модулей, необходимых вашим браузерам:

/* .babelrc.js */

module.exports = {
  presets: [
    ['@babel/preset-env', {
      useBuiltIns: 'entry',
      /* ... */
    }]
  ],
  /* ... */
};

/* Исходный файл */

import 'core-js';

/* Транспилированный файл */

import "core-js/modules/es6.array.copy-within";
import "core-js/modules/es6.array.fill";
import "core-js/modules/es6.array.find";
/* И еще много-много импортов */

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

Чтобы полностью победить эту проблему, для опции useBuiltIns я ставлю значение usage. На этапе компиляции babel/preset-env проанализирует файлы на использование фич, которые отсутствуют в выбранных браузерах, и добавит полифилы к ним:

/* .babelrc.js */

module.exports = {
  presets: [
    ['@babel/preset-env', {
      useBuiltIns: 'usage',
      /* ... */
    }]
  ],
  /* ... */
};

/* Исходный файл */

function sortStrings(strings) {
  return strings.sort();
}

function createResolvedPromise() {
  return Promise.resolve();
}

/* Транспилированный файл */

import "core-js/modules/es6.array.sort";
import "core-js/modules/es6.promise";

function sortStrings(strings) {
  return strings.sort();
}

function createResolvedPromise() {
  return Promise.resolve();
}

В примере выше babel/preset-env добавил полифил к функции sort. В JavaScript нельзя узнать, объект какого типа будет передан в функцию — будет это массив или объект класса с функцией sort, но babel/preset-env выбирает худший для себя сценарий и вставляет полифил.

Ситуации, когда babel/preset-env ошибается, случаются постоянно. Чтобы убирать ненужные полифилы, время от времени проверяйте, какие из них вы импортируете, и удаляйте лишние с помощью опции exclude:

/* .babelrc.js */

module.exports = {
  presets: [
    ['@babel/preset-env', {
      useBuiltIns: 'usage',
      // Используйте эту опцию, чтобы узнать, какие полифилы вы используете
      debug: true,
      // Добавляйте в исключения ненужные полифилы
      exclude: ['es6.regexp.to-string', 'es6.number.constructor'],
      /* ... */
    }]
  ],
  /* ... */
};

Модуль regenerator-runtime я не рассматриваю, так как использую fast-async (и всем советую).

Создаем Modern Build


Приступим к настройке Modern Build.

Убедимся, что у нас в проекте есть файл browserslist, который описывает все необходимые браузеры:

/* .browserslistrc */

> 0.5%
IE 10

Добавим переменную окружения BROWSERS_ENV во время сборки, которая может принимать значения fallback (для Fallback Build) и modern (для Modern Build):

/* package.json */

{
  "scripts": {
    /* ... */
    "build": "NODE_ENV=production webpack /.../",
    "build:fallback": "BROWSERS_ENV=fallback npm run build",
    "build:modern": "BROWSERS_ENV=modern npm run build"
  },
  /* ... */
}

Теперь изменим конфигурацию babel/preset-env. Для указания поддерживаемых браузеров в пресете есть опция targets. У нее существует специальное сокращение — esmodules. При его использовании babel/preset-env автоматически подставит браузеры, поддерживающие ES6 modules.

/* .babelrc.js */

const isModern = process.env.BROWSERS_ENV === 'modern';

module.exports = {
  presets: [
    ['@babel/preset-env', {
      useBuiltIns: 'usage',
      // Для Modern Build выбираем браузеры с поддержкой ES6 modules,
      // а для Fallback Build берем список браузеров из .browsersrc
      targets: isModern ? { esmodules: true } : undefined,
      /* ... */
    }]
  ],
  /* ... */
  ],
};

Babel/preset-env сделает дальше всю работу за нас: выберет только нужные полифилы и трансформации.

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

Связываем Modern и Fallback Build


Последний шаг — это объединение Modern и Fallback Build'ов в одно целое.

Я планирую создать такую структуру проекта:

// Директория с собранными файлами
dist/ 
  // Общий html-файл
  index.html
  // Директория с Modern Build'ом
  modern/
    ...
  // Директория с Fallback Build'ом
  fallback/
    ...

В index.html будут ссылки на нужные javascript-файлы из обеих сборок:

/* index.html */

<html>
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->
    <script type="module" src="/modern/js/app.540601d23b6d03413d5b.js"></script>
    <script nomodule src="/fallback/js/app.4d03e1af64f68111703e.js"></script>
  </body>
</html>

Этот шаг можно разбить на три части:

  1. Сборка Modern и Fallback Build в разные директории.
  2. Получение информации о путях до необходимых javascript-файлов.
  3. Создание index.html со ссылками на все javascript-файлы.

Приступаем!

Сборка Modern и Fallback Build в разные директории


Для начала сделаем самый простой шаг — соберем Modern и Fallback Build в разные директории внутри директории dist.

Просто указать нужную директорию для output.path нельзя, так как нам необходимо, чтобы webpack имел пути до файлов относительно директории dist (index.html находится в этой директории, и все остальные зависимости будут выкачиваться относительно него).

Создадим специальную функцию для генерации путей файлов:

/* getFilePath.js */
/* Файл содержит функцию, которая поможет создавать пути для файлов */

const path = require('path');

const isModern = process.env.BROWSERS_ENV === 'modern';
const prefix = isModern ? 'modern' : 'fallback';

module.exports = relativePath => (
  path.join(prefix, relativePath)
);

/* webpack.prod.config.js */

const getFilePath = require('path/to/getFilePath');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  output: {
    path: 'dist',
    filename: getFilePath('js/[name].[contenthash].js'),
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: getFilePath('css/[name].[contenthash].css'),
    }),
    /* ... */
  ],
  /* ... */
}

Проект стал собираться в разные директории для Modern и Fallback Build'а.

Получение информации о путях до необходимых javascript-файлов


Чтобы получить информацию о собранных файлах, подключим webpack-manifest-plugin. В конце сборки он добавит файл manifest.json с данными о путях до файлов:

/* webpack.prod.config.js */

const getFilePath = require('path/to/getFilePath');
const WebpackManifestPlugin = require('webpack-manifest-plugin');

module.exports = {
  mode: 'production',
  plugins: [
    new WebpackManifestPlugin({
      fileName: getFilePath('manifest.json'),
    }),
    /* ... */
  ],
  /* ... */
}

Теперь у нас есть информация о собранных файлах:

/* manifest.json */

{
  "app.js": "/fallback/js/app.4d03e1af64f68111703e.js",
  /* ... */
}

Создание index.html со ссылками на все javascript-файлы


Дело осталось за малым — добавить index.html и вставить в него пути до нужных файлов.

Для генерации html-файла я буду использовать html-webpack-plugin во время Modern Build'а. Пути до modern-файлов html-webpack-plugin вставит сам, а пути до fallback-файлов я получу из созданного на предыдущем шаге файла и вставлю их в HTML с помощью небольшого webpack-плагина:

/* webpack.prod.config.js */

const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModernBuildPlugin = require('path/to/ModernBuildPlugin');

module.exports = {
  mode: 'production',
  plugins: [
    ...(isModern ? [
      // Добавим html-страницу в Modern Build
      new HtmlWebpackPlugin({
        filename: 'index.html',
      }),
      new ModernBuildPlugin(),
    ] : []),
    /* ... */
  ],
  /* ... */
}

/* ModernBuildPlugin.js */

// Safari 10.1 не поддерживает атрибут nomodule.
// Эта переменная содержит фикс для Safari в виде строки.
// Найти фикс можно тут:
// https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
const safariFix = '!function(){var e=document,t=e.createE/* ...И еще много кода... */';

class ModernBuildPlugin {
  apply(compiler) {
    const pluginName = 'modern-build-plugin';

    // Получаем информацию о Fallback Build
    const fallbackManifest = require('path/to/dist/fallback/manifest.json');

    compiler.hooks.compilation.tap(pluginName, (compilation) => {
      // Подписываемся на хук html-webpack-plugin,
      // в котором можно менять данные HTML
      compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync(pluginName, (data, cb) => {
        // Добавляем type="module" для modern-файлов
        data.body.forEach((tag) => {
          if (tag.tagName === 'script' && tag.attributes) {
            tag.attributes.type = 'module';
          }
        });

        // Вставляем фикс для Safari
        data.body.push({
          tagName: 'script',
          closeTag: true,
          innerHTML: safariFix,
        });

        // Вставляем fallback-файлы с атрибутом nomodule
        const legacyAsset = {
          tagName: 'script',
          closeTag: true,
          attributes: {
            src: fallbackManifest['app.js'],
            nomodule: true,
            defer: true,
          },
        };
        data.body.push(legacyAsset);

        cb();
      });
    });
  }
}

module.exports = ModernBuildPlugin;

Обновим package.json:

/* package.json */

{
  "scripts": {
    /* ... */
    "build:full": "npm run build:fallback && npm run build:modern"
  },
  /* ... */
}

С помощью команды npm run build:full мы создадим один html-файл с Modern и Fallback Build. Любой браузер теперь получит тот JavaScript, который он в состоянии выполнить.

Добавляем Modern Build в свое приложение


Чтобы проверить на чем-то реальном свое решение, я подвез его в один из своих проектов. Настройка конфигурации заняла у меня менее часа, а размер JavaScript-файлов уменьшился на 11%. Отличный результат при простой реализации.

Спасибо, что прочитали статью до конца!

Использованные материалы


Комментарии (6)


  1. dagen
    17.12.2018 14:41

    Только надо указать, что этот хак для Сафари нужен для одной-единственной версии: Safari 10.1, которая осталась всего у 2 пользователей из тысячи. Далеко не все поддерживают такие редкие браузеры.

    И не упомянуто, что все версии Edge (и в т.ч. IE11, а IE10 не тестил, разумеется), независимо от того, поддерживают ли они script type=module, всё равно загружают legacy-бандл, хоть и не исполняют его (как и Safari@10.1, но костыль для него есть в статье). В таком случае больше подойдёт подход из html-webpack-multi-build-plugin. Плагин этот на уровне PoC, да и полностью его тащить не надо, так как тогда и для новых браузеров не будет работать запрос js-файла ещё на стадии разбора html, а только после исполнения инлайн-скрипта. Лучше использовать его частично:

    <script type="module" src="modern_bundle.js"></script>
    <script nomodule>
        var script = document.createElement('script');
        script.type = 'text/javascript';
        script.src = 'legacy_bundle.js';
        document.body.appendChild(script);
    </script>

    Тогда страдать из-за загрузки двух бандлов будут только маргиналы, сидящие на IE11, Edge до 16, Safari до 10.1. Ну и пользователи Хромом и Фаерфоксов, прикипевшие к древним версиям.


    1. kalyukdo
      17.12.2018 16:50

      я может гдето отстал в технологиях, но всегда же можно сделать

      <script>
          var version = navigator ....;
          var script = document.createElement('script');
          script.type = 'text/javascript';
          script.src = version + '_bundle.js';
          document.body.appendChild(script);
      </script>


      1. biziwalker
        17.12.2018 17:28
        +1

        Да, это на основе User-Agent можно делать. Еще можно сделать небольшой серверный скрипт, который смотрит пришедший запрос и ищет по User-Agent и browserlist наиболее подходящий бандл, который уже и отдает. Примерно так работает polyfill.io


      1. dagen
        18.12.2018 15:38
        +1

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

        Ну и в целом userAgent для обсуждаемой проблемы — довольно нишевая и опасная вещь из-за зоопарка браузеров и устройств, хорошо и быстро поддерживается только пока вы делаете под локальный рынок устройств/браузеров, который вы знаете. И пока мы можем использовать встроенные в браузеры проверки на поддержку конкретной функциональности, лучше использовать эти проверки.


  1. speller
    18.12.2018 04:11

    У меня в результате работы вебпака всегда импорты оказываются заменены на обёртки __webpack_require__ и иже с ними. Зачем этот огород со script type=modules тогда — чтобы просто отсечь старые браузеры, для которых не надо тянуть полифилы?


  1. leshaogonkov
    18.12.2018 23:14

    Добавим переменную окружения BROWSERS_ENV

    Немного наброса: раз уж используется вебпак, мне кажется лучше передавать переменные через env

    Устанавливать переменную окружения отдаёт немного магией.