Большинство веб-разработчиков, с которыми я общаюсь сейчас, любят писать JavaScript со всеми новейшими функциями языка — async/await, классами, стрелочными функциями и т.д. Однако, несмотря на то, что все современные браузеры могут исполнять код ES2015+ и изначально поддерживают упомянутый мной функционал, большинство разработчиков по-прежнему транспилируют свой код на ES5 и связывают его с полифиллами, чтобы удовлетворить небольшой процент пользователей, все еще работающих в старых браузерах.

Это отвратительно. В идеальном мире мы не будем развертывать ненужный код.


При работе с новыми API-интерфейсами JavaScript и DOM мы можем условно загружать полифиллы, т.к. мы можем выявить поддержку этих интерфейсов во время выполнения программы. Но с новым синтаксисом JavaScript сделать это намного сложнее, поскольку любой неизвестный синтаксис вызовет ошибку синтаксического анализа (parse error), и тогда наш код вообще не будет запущен.

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

Решим это с помощью тега script type="module".

Большинство разработчиков думают о script type="module" как о способе загрузки модулей ES (и, конечно же, это так), но script type="module" также имеет более быстрый и практичный вариант использования — загружает обычные файлы JavaScript с функциями ES2015+, зная, что браузер может справиться с ними!

Другими словами, каждый браузер, поддерживающий script type="module" также поддерживает большинство функций ES2015+, которые вы знаете и любите. Например:

  • Каждый браузер, поддерживающий script type="module", также поддерживает async/await
  • Каждый браузер, поддерживающий script type="module", также поддерживает классы.
  • Каждый браузер, поддерживающий script type="module", также поддерживает стрелочные функции.
  • Каждый браузер, поддерживающий script type="module", также поддерживает fetch, Promises, Map, Set, и многое другое!

Осталось только предоставить резервную копию для браузеров, которые не поддерживают script type="module". К счастью, если вы в настоящее время генерируете ES5-версию своего кода, вы уже сделали эту работу. Все, что вам теперь нужно — создать версию ES2015+!

В остальной части этой статьи объясняется, как реализовать эту технику, и обсуждается, как возможность развертывания кода ES2015+ изменит способ создания модулей в будущем.

Реализация


Если вы уже используете сборщик модулей (module bundler), например webpack или rollup для генерации своего кода на JavaScript, продолжайте по-прежнему это делать.

Затем, в дополнение к вашему текущему набору (bundle), вы создадите второй набор, как и первый; единственное различие будет заключается в том, что вы не будете транспилировать код в ES5, и вам не нужно будет подключать устаревшие полифиллы (legacy polyfills).

Если вы уже используете babel-preset-env (что должны), то второй шаг будет очень прост. Все, что вам нужно сделать, это изменить список браузеров только на те, которые поддерживают script type="module", и Babel автоматически не будет делать ненужные преобразования.

Иными словами, это будет вывод кода ES2015+ вместо ES5.

Например, если вы используете webpack, и вашей основной точкой входа является скрипт ./path/to/main.js, тогда конфигурация вашей текущей версии ES5 может иметь следующий вид (обратите внимание, так как это ES5, я называю набор (bundle) main-legacy):

module.exports = {
  entry: {
    'main-legacy': './path/to/main.js',
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'public'),
  },
  module: {
    rules: [{
      test: /\.js$/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: [
            ['env', {
              modules: false,
              useBuiltIns: true,
              targets: {
                browsers: [
                  '> 1%',
                  'last 2 versions',
                  'Firefox ESR',
                ],
              },
            }],
          ],
        },
      },
    }],
  },
};

Для того, чтобы сделать современную версию для ES2015+, все, что вам нужно — это создать вторую конфигурацию и настроить целевую среду только для браузеров, поддерживающих script type="module". Вот как это может выглядеть:

module.exports = {
  entry: {
    'main': './path/to/main.js',
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'public'),
  },
  module: {
    rules: [{
      test: /\.js$/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: [
            ['env', {
              modules: false,
              useBuiltIns: true,
              targets: {
                browsers: [
                  'Chrome >= 60',
                  'Safari >= 10.1',
                  'iOS >= 10.3',
                  'Firefox >= 54',
                  'Edge >= 15',
                ],
              },
            }],
          ],
        },
      },
    }],
  },
};

При запуске эти две конфигурации выведут на продакшн два JavaScript-файла:

  • main.js (ES2015+ синтаксис)
  • main-legacy.js (ES5 синтаксис)

Следующим шагом будет обновление вашего HTML для условной загрузки ES2015+ пакета (bundle) в браузерах, поддерживающих модули. Вы можете сделать это, используя script type="module" и script nomodule:

<!-- Браузеры с поддержкой модулей ES загрузят этот файл. -->
<script type="module" src="main.js"></script>

<!-- Устаревшие браузеры загрузят этот файл (поддерживающие модули -->
<!-- браузеры знают, что этот файл загружать *не* нужно). -->
<script nomodule src="main-legacy.js"></script>

Внимание! Единственная засада (gotcha) здесь — браузер Safari 10, который не поддерживает атрибут nomodule, но вы можете решить это, встроив JavaScript-сниппет в ваш HTML до использования любых тегов script nomodule. (Примечание: это было исправлено в Safari 11).

Важные моменты


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

  1. Модули загружаются как script defer. Это означает, что они не выполняются до тех пор, пока документ не будет распарсен. Если какую-то часть вашего кода нужно запустить раньше, лучше разбить этот код и загрузить его отдельно.
  2. Модули всегда запускают код в строгом режиме (strict mode), поэтому, если по какой-либо причине часть вашего кода должна быть запущена за пределами строгого режима, ее придется загружать отдельно.
  3. Модули обрабатывают объявления верхнего уровня переменных (var) и функций (function) отлично от обычных сценариев. Например, к var foo = 'bar' и function foo() {…} в скрипте можно получить доступ через window.foo, но в модуле это не будет работать. Убедитесь, что в своем коде вы не зависите от такого поведения.

Рабочий пример


Я создал webpack-esnext-boilerplate, чтобы разработчики могли увидеть реальное применение описанной здесь техники.

В этот пример я намеренно включил несколько продвинутых фич webpack, поскольку хотел показать, что описанная мною техника готова к применению и работает в реальных сценариях. К ним относятся хорошо известные передовые практики, такие как:

  • Разделение кода (Code splitting)
  • Динамический импорт (Dynamic imports, условная загрузка дополнительного кода во время выполнения программы)
  • Asset fingerprinting (для эффективного длительного кэширования)

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

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

Игра стоит свеч?


По-моему, определенно! Экономия может быть значительной. Например, ниже приведено сравнение общих размеров файлов для двух версий кода из моего блога:
Версия Размер (minified) Размер (minified + gzipped)
ES2015+ (main.js) 80K 21K
ES5 (main-legacy.js) 175K 43K

Устаревшая ES5-версия кода более чем в два раза превышает размер (даже gzipped) версии ES2015+.

Большие файлы занимают больше времени для загрузки, но они также занимают больше времени для анализа и оценки. При сравнении двух версий моего блога, время, затраченное на parse/eval, также было стабильно вдвое дольше для устаревшей ES5-версии (эти тесты выполнялись на Moto G4 с использованием webpagetest.org):
Версия Parse/eval time (по отдельности) Parse/eval time (среднее)
ES2015+ (main.js) 184ms, 164ms, 166ms 172ms
ES5 (main-legacy.js) 389ms, 351ms, 360ms 367ms

Хотя эти абсолютные размеры файлов и время parse/eval не особенно большие, поймите, что это блог, и я не загружаю много скриптов. Но для большинства сайтов это не так. Чем больше у вас скриптов, тем больше будет выигрыш, который вы получите, развернув код на ES2015+ в своем проекте.

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

Быстрый запрос данных HTTPArchive показывает, что из лучших сайтов по рейтингу Alexa, 85 181 включают в себя babel-polyfill, core-js или regenerator-runtime в своих пакетах (bundles) продакшн. Шесть месяцев назад их число было 34 588!

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

Пришло время собирать наши модули как ES2015


Главная засада (gotcha) для описанной здесь техники сейчас состоит в том, что большинство авторов модулей не публикуют ES2015+ версии исходного кода, а публикуют сразу транспилированную ES5-версию.

Теперь, когда развертывание кода на ES2015+ возможно, пришло время изменить это.

Я полностью осознаю, что такой шаг сопряжен с множеством проблем в ближайшем будущем. Сегодня большинство инструментов сборки публикуют документацию, рекомендующую конфигурацию, которая предполагает, что все модули написаны на ES5. Это означает, что если авторы модулей начнут публиковать исходный код на ES2015+ в npm, то они, вероятно, сломают некоторые сборки пользователей и просто вызовут путаницу.

Проблема заключается в том, что большинство использующих Babel разработчиков настраивают его так, чтобы код в node_modules не транспилировался. Однако, если модули опубликованы с исходным кодом ES2015+, возникает проблема. К счастью, она легко исправима. Вам просто нужно удалить исключение node_modules из конфигурации сборки:

rules: [
  {
    test: /\.js$/,
    exclude: /node_modules/, // удалите эту строку
    use: {
      loader: 'babel-loader',
      options: {
        presets: ['env']
      }
    }
  }
]

Недостаток заключается в том, что если такие инструменты как Babel должны начать транспилировать зависимости (dependencies) из node_modules, в дополнение к локальным зависимостям, это замедлит скорость сборки. К счастью, эту проблему можно отчасти решить на уровне инструментария с постоянным локальным кэшированием.

Несмотря на удары, мы, скорее всего, пройдем путь к тому, что ES2015+ станет новым стандартом публикации модулей. Я думаю, что борьба стоит своей цели. Если мы, как авторы модулей, публикуем в npm только ES5-версии нашего кода, мы навязываем пользователям раздутый (bloated) и медленный код.

Публикуя код в ES2015, мы даем разработчикам выбор, что в конечном счете приносит пользу всем.

Заключение


Хотя script type="module" предназначен для загрузки модулей ES (и их зависимостей) в браузере, его не нужно использовать только для этой цели.

script type="module" будет успешно загружать единственный файл Javascript, и это даст разработчикам столь необходимое средство для условной загрузки современного функционала в тех браузерах, которые могут его поддерживать.

Это, наряду с атрибутом nomodule, дает нам возможность использовать код ES2015+ в продакшн, и наконец-то мы можем прекратить отправку транспилированного кода в браузеры, которые в нем не нуждаются.

Написание кода ES2015 — это победа для разработчиков, а внедрение кода ES2015 — победа для пользователей.

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


  1. inoyakaigor
    25.09.2017 10:33
    -7

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


    1. targettaiga
      25.09.2017 10:56
      +5

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


  1. RubaXa
    25.09.2017 13:45

    ES5 (main-legacy.js) 175K

    У него там там babel-polyfill подключен, если чё.


  1. sharpfellow
    25.09.2017 15:12
    +1

    Как этот подход будет работать при работе с React, Vue и тд?


    1. ollazarev Автор
      25.09.2017 16:17
      +2

      Не исключено, что в скором времени этот подход будет реализован в create-react-app. Обсуждение здесь.


  1. KYuri
    25.09.2017 18:35

    Описанный в статье способ (указание в html-е двух тэгов script — одного с type=«module», второго — с атрибутом nomodule) работает, но все быстро проверенные мной браузеры (ie11, opera 12.18, chrome 61, ff 55 в обоих режимах dom.moduleScripts.enabled) загружают оба скрипта.
    По всей видимости, наиболее правильным будет использовать подход, подобный этому: определять возможности браузера и инжектить соответствующим образом сформированный тэг script.


    1. ollazarev Автор
      25.09.2017 22:00
      +1

      Проверял на Firefox и Chrome тех же версий — работает. Единственное, включил флаги поддержки ES модулей. Подробнее здесь.


      1. KYuri
        25.09.2017 22:05

        Закладку «network» в DevTool-зах смотрели?


        1. ollazarev Автор
          25.09.2017 22:08

          Да, только один файл приходит. При выключенных флагах main-legacy.js, при включенных — main.js


          1. KYuri
            25.09.2017 22:33

            Ну вот как это выглядит на ff.


            1. ollazarev Автор
              26.09.2017 15:58

              Этот пример у вас точно также работает?


              1. KYuri
                26.09.2017 17:33

                Хром грузит только один из скриптов, файрфокс — оба.
                А у вас этот пример сколько скриптов грузит?


                1. ollazarev Автор
                  26.09.2017 17:45

                  Firefox — грузит оба, исполняет один. Chrome грузит один скрипт.


                  1. KYuri
                    26.09.2017 17:52
                    +1

                    Хм, похоже, по поводу хрома я не прав: сейчас перепроверил, да, грузит один.
                    А вот остальные (ff, opera 12, ie11) — грузят оба. Исполняют да, один.


  1. alex_blank
    26.09.2017 08:50
    +3

    Пришло время собирать наши модули как ES2015

    Ох, как раз недавно столкнулись с такой бедой, когда «скрипя сердцем» решили дропнуть ES5-транспилирование в нашем проекте — и выяснили, что в популярных фронтенд фреймворках, типа Nuxt, в конфиге babel-loader тупо захардкодено исключение node_modules из сборки. И включить это назад там не так-то просто бывает. Разработчики же этого Nuxt вообще не понимают, зачем кому-то может понадобиться импортировать ES6 модуль — мол, «я ни разу не видел не пред-транспилированного модуля, а значит, этого не бывает».


    Причём ломается это все в итоге даже не в браузере пользователя — слава богу, основные браузеры уже умеют в ES6 — а на таких замечательных и непременных штуках в тулчейнах девелоперов, как Uglify или Google Closure Compiler. Которые в 2017 году до сих пор не умеют даже в классы, какое там async/await...


    К счастью, нашу либу в основном юзают из Node, поэтому это оказалось не так страшно. Но и там нашлись свои приколы: поле engine в package.json, судя по всему, не работает так, как многие думают. С его помощью вы не сможете запретить устанавливать ваш пакет в ранние версии Node (где ещё не появился, скажем, async/await). Раньше в NPM был флаг engineStrict, но теперь его убрали. И, убрав поддержку ES5 в своем проекте, мы столкнулись с тем, что многие юзеры до сих пор сидят на доисторических версиях Node и совсем не понимают, почему после npm update у них всё вдруг перестало работать...


    В целом многие JS девелоперы вообще не понимают что такое Babel и как его настраивать. Вот например тут человек целый день провозился с настройкой вебпака, в итоге обматерил меня и мой проект, и написал свой собственный аналог :)


    Транспилирование библиотек должно умереть, конечно же — т.к. ES модули позволяют импортировать только ту часть кода, которая реально используется (tree shaking). В ином случае, чтобы достичь подобного, приходится разбивать большие модули на миллион маленьких микромодулей (в пределе — до уровня отдельных функций), что крайне неудобно в плане поддержки...


    1. justboris
      26.09.2017 10:21
      +1

      А Babili (babel-minify) пробовали? Единственный минификатор с поддежкой es6 кода, что я знаю.


      1. alex_blank
        27.09.2017 05:55

        Да, я советовал тому челу его попробовать, но он сказал что тот валится с out-of-memory-error, т.к. он запускает свой проект на amazon tiny instance, а там памяти как на калькуляторе...


      1. alex_blank
        27.09.2017 05:56

        Ещё есть бранч в uglify, называется uglify-es, тот тоже якобы может ES6, но я сам не пробовал..


    1. justboris
      26.09.2017 10:32
      +1

      И про поддержку доисторических Node.js. Уже давно было заявлено, что версии ниже Node 4 больше не поддерживаются. То есть они не получают вообще никаких обновлений, в том числе критических. Поддерживать их в своих библиотеках смысла нет, их уже не поддерживают множество популярных npm-модулей, например: https://github.com/request/request/issues/2772#issuecomment-330879495


      А Node.js 4 много чего из ES6 понимает. Я использую ESlint с плагином node/no-unsupported-features, который понимает секцию engines из package.json и предупреждает об использовании неподдерживаемых ES6-фич


  1. PaulMaly
    29.09.2017 08:31

    Интересное решение, спасибо! Обязательно попробуем. Сейчас в изоморфных (универсальных) аппах юзаем инъекцию скриптов после чека возможностей браузера. Работает норм именно потому что с сервера изначально приходит готовый html и есть время в фоне подгрузить скрипты. Для чистых SPA не очень-то хорошо, ваш способ определено лучше, если реально работает.