JavaScript модули. Часть 2. Сборка модулей.

JavaScript модули. Часть 2. Сборка модулей.
mangohost

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

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

Что такое сборка модулей?

Сборка модулей - это просто процесс склеивания группы модулей (и их зависимостей) в один файл (или группу файлов) в правильном порядке.

Как и со всеми аспектами веб-разработки, все неприятности скрываются в деталях. :)

Зачем вообще собирать модули?

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

В результате, каждый из этих файлов должен быть подключён в HTML-файл в виде тега <script>, который загружается в браузер при посещении пользователем страницы. Если для каждого файла будет отдельный тег <script>, это означает, что браузер должен загружать каждый файл по отдельности, один за другим. Это плохо сказывается на времени загрузки страницы.

Для того чтобы избавиться от этой проблемы, мы собираем или "объединяем" все наши файлы в один большой (или парочку больших, в зависимости от обстоятельств), тем самым уменьшая количество запросов к серверу. Когда вы слышите , что разработчики говорят "этап сборки" или "процесс сборки", он имеют ввиду то, о чём мы только что говорили.

Ещё один распространённый подход для ускорения операций сборки, это "минификация" собираемого кода. Минификация - это процесс удаления ненужных символов из исходного кода (таких как пробелы, комментарии, символы новой строки и т.д.) в целях уменьшения общего размера содержимого, без изменения функциональных свойств кода.

Меньше данных означает, что браузеру потребуется меньше времени для загрузки и обработки файлов. Если вы когда-нибудь видели файл с префиксом min такой как underscore-min.js, то наверняка заметили, что он крошечный (и не читаемый) по сравнению с полной версией.

Менеджеры задач такие как Gulp и Grunt выполняют объединение и минимизацию файлов очевидным для разработчиков способом и гарантируют, что человеко-понятный код останется для разработчиков, в то время как оптимизированный код в собранном виде получат браузеры.

Какие способы для сборки модулей существуют?

Объединение и минимизация ваших файлов прекрасно работает если вы используете один из стандартных паттернов (обсуждали в прошлой статье). Но по факту всё что вы делаете, дак это всего навсего перезаписываете кучу нативного JavaScript кода.

Тем не менее, если вы придерживаетесь не нативных систем модулей, которые не могут интерпретироваться браузерами, например, CommonJS или AMD (или даже нативный ES6), то вам необходимо использовать специальный инструмент для преобразования модулей в упорядоченный и понятый браузеру код. Вот здесь то и вступают в игру Browserify, RequireJS, Webpack и другие "сборщики модулей" или "загрузчики модулей".

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

Давайте рассмотрим некоторые общие методы сборки.

Сборка CommonJS

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

Предположим,  что у вас есть файл main.js, который импортирует модуль для вычисления среднего значения в массиве чисел.

var myDependency = require(‘myDependency’);

var myGrades = [93, 95, 88, 0, 91];

var myAverageGrade = myDependency.average(myGrades);

В этом случае у нас есть одна зависимость (myDependency). С помощью команды представленной ниже, Browserify рекурсивно собирает все зависимые модули, начиная с main.js, в один один файл названный bundle.js.

browserify main.js -o bundle.js

Browserify парсит AST для каждого вызова require, чтобы пройти через весь граф зависимостей вашего проекта. После того как он понял, как структурированы ваши зависимости, он собирает их в правильном порядке, в один файл. А всё что вам остаётся сделать, это подключить файл bundle.js через тег <script> в ваш HTML  файл и убедится в том, что весь исходный код подгружается в одном HTTP запросе.

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

Итоговый файл готов для таких инструментов как Minify-JS, чтобы минимизировать собранный код.

Сборка AMD

Если вы используете AMD, наверняка вы захотите использовать AMD загрузчик, такой как RequireJS или Curl. Загрузчик модулей (в отличии от сборщика) динамически загружает модули, которые необходимы для запуска вашей программы.

Напомню, что одним из основных отличий AMD от CommonJS является то, что модули загружаются асинхронно.

С AMD вам технически не нужен этап сборки, на котором вы объединяете все модули в один файл. Асинхронная загрузка модулей означает то, что вы постепенно загружаете модули по мере необходимости, вместо загрузки сразу всех файлов при первом посещении страницы пользователем.

В реальности однако, большой объём запросов на каждое действие пользователя не подходит для продакшена. Большинство разработчиков, до сих пор используют инструменты для сборки и минификации кода (такие как RequireJS optimizer, например), чтобы достичь оптимальной производительности.

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

Если хотите интересную дискуссию на тему AMD vs CommonJS, можете прочитать статью в блоге Tom Dale’s :)

Webpack

До сих к нам приходят сборщики модулей, Webpack - это ещё совсем ребёнок в этой области. Он был разработан для того, чтобы стать агностиком к системе модулей, которую вы используете, позволяя использовать CommonJS, AMD или ES6 в зависимости от обстоятельств.

Вы можете удивиться, зачем нам нужен Webpack, если уже есть такие сборщики как Browserify или RequireJS, которые делают свою работу хорошо.  В Webpack есть некоторые полезные функции, например, такие как "разделение кода (code splitting)" - способ разделить код на "куски (chunks)" и загружать их при необходимости.

Для примера, если в вашем веб-приложении есть блоки кода, которые необходимо загружать только при определённых обстоятельствах, будет неэффективно собирать всё в один большой файл. В этом случае, вы можете использовать "code splitting" для извлечения кода в собранные куски, которые могут быть загружены при необходимости, во избежании неприятностей, когда большинству пользователей нужно только ядро приложения.

Разделение кода - это только одна из множества функций, которые предлагает Webpack, а в интернете полно споров о том что лучше, Webpack или Browserify. Вот несколько уравновешенных дискуссий, которые мне показались полезными в этом вопросе:

  • https://gist.github.com/substack/68f8d502be42d5cd4942
  • http://mattdesl.svbtle.com/browserify-vs-webpack
  • http://blog.namangoel.com/browserify-vs-webpack-js-drama

Модули ES6

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

Самое важное отличие между текущими JS модулями (CommonJS и AMD) и ES6 модулями заключается в том, что в ES6 модулях из коробки есть статический анализ. Это означает, что импорт выполняется ещё до компиляции. Это позволяет удалить экспорты, которые не используются другими модулями перед запуском программы. Удаление неиспользуемых экспортов позволяет значительно сэкономить пространство и уменьшить нагрузку на браузер.

Один вопрос, который возникает у всех: В чём разница между подходом описанным выше и подходом связанным с удалением лишнего когда с помощью плагинов типа UglifyJS. Ответ как всегда прост "это зависимость".

Иногда, удаление неиспользуемого кода может работать одинаково как UglifyJS так и в ES6 модулях, а иногда нет. Есть очень крутой пример Rollup’s wiki, можете посмотреть если есть желание.

Отличительной чертой ES6 модулей, является другой подход к устранению неиспользуемого кода, который называется "встряхивание дерева (tree shaking)".  Встряхивание дерева - это по сути процедура обратная удалению неиспользуемого кода. Она не удаляет неиспользуемый код, а включает только необходимый для работы код. Давайте рассмотрим на примере. Допустим, у вас есть файл utils.js с функциями, каждую из которым мы экспортируем используя синтаксис ES6.

export function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 }

export function filter(collection, test) {
  var filtered = [];
  each(collection, function(item) {
    if (test(item)) {
      filtered.push(item);
    }
  });
  return filtered;
}

export function map(collection, iterator) {
  var mapped = [];
  each(collection, function(value, key, collection) {
    mapped.push(iterator(value));
  });
  return mapped;
}

export function reduce(collection, iterator, accumulator) {
    var startingValueMissing = accumulator === undefined;

    each(collection, function(item) {
      if(startingValueMissing) {
        accumulator = item;
        startingValueMissing = false;
      } else {
        accumulator = iterator(accumulator, item);
      }
    });

	return accumulator;
}

Дальше, давайте предположим, что мы не знаем как именно функции из utils.js мы будем использовать, поэтому идём дальше и импортируем все модули в main.js вот так.

import * as Utils from ‘./utils.js’;

В итоге, позже мы стали использовать только одну функцию each.

import * as Utils from ‘./utils.js’;

Utils.each([1, 2, 3], function(x) { console.log(x) });

Версия нашего main.js файла после загрузки модулей подходом "tree shaken", будет выглядеть следующим образом.

function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 };

each([1, 2, 3], function(x) { console.log(x) });

Обратите внимание на то, что экспортировалась только задействованная функция each.

В то же время, если мы решим использовать функцию filter вместо функции each, и напишем что-нибудь типа:

import * as Utils from ‘./utils.js’;

Utils.filter([1, 2, 3], function(x) { return x === 2 });

Версия после "tree shaken", будет выглядеть так:

function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 };

function filter(collection, test) {
  var filtered = [];
  each(collection, function(item) {
    if (test(item)) {
      filtered.push(item);
    }
  });
  return filtered;
};

filter([1, 2, 3], function(x) { return x === 2 });

Обратите внимание на то, что в этот раз включены обе функции each и filter. Это произошло потому что filter использует each, поэтому нам необходимо экспортировать для работы модуля.

Довольно ловко, неправда ли?

Я призываю вас, чтобы вы поигрались и исследовали "tree shaken" в онлайн редакторе Rollup.js.

Разработка ES6 модулей

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

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

Разработка ES6 модулей

Вот несколько вариантов разработки/конвертирования ES6 модулей в формат привычный для браузеров. №1 на сегодня является наиболее распространённым подходом.

  1. Используйте "транспилер (компилятор типа исходный код в исходный код)" (например, Babel или Traceur)  чтобы преобразовать ваш ES6 код в ES5 код любого формата CommonJS, AMD или UMD. Затем пропустите скомпилированный код через сборщик такой как Browerify или Webpack, чтобы создать один или несколько собранных файлов.
  2. Использование Rollup.js, который похож на первый вариант, за исключением того, что накладывает мощь ES6 модулей для статического анализа вашего ES6 кода и обработки зависимостей перед сборкой. Он использует "tree shaking" чтобы включить только необходимый минимум в вашу сборку. В целом, основное преимущество перед Browserify или Webpack, когда вы используете ES6  и заключается в том, что "tree shaking" может сделать ваши сборки меньше. Проблема заключается в том, что Rollup предлагает несколько форматов для ваших пакетов, включая ES6, CommonJS, AMD, UMD или LIFE. LIFE и UMD сборки будут работать в вашем браузере, но если вы выберете AMD, CommonJS или ES6, то вам нужно найти другие способы конвертировать этот код в форма понятный для браузера (например, с помощью Browserify, Webpack, RequireJS и т.д.).

Прыжки через обручи

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

Вопрос в том, когда ES6 модули будут запускаться в браузере без всяких проблем?

Ответ на этот вопрос к счастью "рано или поздно".

На текущий момент, в ECMAScript есть решение, которое называется ECMAScript 6 module loader API. Короче говоря, это программное Promise-based API, которое должно динамически загружать модули и кэшировать их так, чтобы при последующем импорте не перезагружать новую версию этого модуля.

Примерно, это будет выглядеть так:

myModule.js

export class myModule {
  constructor() {
    console.log('Hello, I am a module');
  }

  hello() {
    console.log('hello!');
  }

  goodbye() {
    console.log('goodbye!');
  }
}

main.js

System.import(‘myModule’).then(function(myModule) {
  new myModule.hello();
});

// ‘Hello!, I am a module!’

Альтернативный способ определить модуль, это указать напрямую атрибут type="module" непосредственно в теге script, вот так:

<script type="module">
  // loads the 'myModule' export from 'mymodule.js'
  import { hello } from 'mymodule';

  new Hello(); // 'Hello, I am a module!'
</script>

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

Кроме того, если вы хотите устроить тест-драйв, посмотрите SystemJS, который разработан на основе полифилла загрузчика модулей ES6. SystemJS динамически загружает модули любых форматов (ES6, AMD, CommonJS и/или глобальные скрипты) в браузер. Также он отслеживает все загруженные модули через "реестр модулей", для предотвращения повторной загрузки. Не говоря уже о том, что он автоматически компилирует ES6 модули (если установить нужный параметр) и позволяет комбинировать различные типы модулей. Довольно круто.

Будут ли нужны нам сборщики модулей, если у нас теперь есть нативные ES6 модули?

Растущая популярность ES6 модулей приводит нас к некоторым интересным последствиям:

Сделает ли HTTP/2 сборщики модулей устаревшими?

HTTP/1 позволяет нам делать только один запрос в рамках TCP соединение. Вот почему при загрузке множества ресурсов нам требуется делать несколько запросов. С HTTP/2 всё меняется. HTTP/2 полностью мультиплексный, это означает, что мы можем делать несколько запросов параллельно.

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

Сборка модулей предлагает такие преимущества, которые HTTP/2 не учитывает, например, удаление неиспользуемых экспортов для экономии места. Если вы разрабатываете сайт, где даже самый крошечный бит ставит под вопрос производительность, то сборка может дать ряд дополнительных преимуществ. Тем не менее, если ваши требования к производительности не столь критичны, можно сэкономить время и пропустить этап сборки.

В общем, всё таки мы ещё очень далеки от того, что большая часть сайтов будет обслуживать свой код через HTTP/2. Я предполагаю, что процесс сборки в ближайшее время всё-таки останется.

Устареют ли CommonJS, AMD и UMD?

После того как ES6 станет стандартом для модулей, нужны ли нам будут другие форматы?

Я сомневаюсь в этом.

Веб-разработка значительно выигрывает от единого стандартизированного подхода для импорта и экспорта модулей в JavaScript. Сколько времени нужно, чтобы ES6 стал стандартом для модулей?

Скорей всего, довольно долго)

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

Вывод

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

Как всегда пишите комментарии и не стесняйтесь задавать свои вопросы.

Счастливой сборки :)

Комментарии

9 февраля 2017 21:03
Виктор

Отличная статья!! Спасибо автору!

5 сентября 2017 13:39
burn1ng

Шикарная статья! Не поленился даже оставить комментарий:) Автор, ты навели порядок в голове со всем этим зоопарком всего за 2 небольших и интересных статьи! Очень бы хотелось услышать про Backbone от тебя! P.s. в помощь автору, я даже линкану эти 2 статьи в соц.сети:-)

8 апреля 2019 14:20
makc

Большое спасибо, крайне информативная статья.

mangohost