В этой статье хочу поделиться переводом статьи о нативных ECMAScript модулях, которые все больше и больше обсуждаются среди фронтендеров. Javascript ранее никогда не поддерживал нативно работу с модулями, и нам, фронтендерам, всегда приходилось использовать дополнительные инструменты для работы с модулями. Но вы только представьте, что в скором времени не нужно будет использовать Webpack для создания бандлов модулей. Представьте мир, в котором браузер будет собирать все за вас. Подробнее об этих перспективах я и хочу рассказать.
В 2016 году в браузеры и Nodejs было добавлено много интересных фич и полезностей из новых стандартов, в частности спецификации ECMAScript 2015. Сейчас мы сталкиваемся с ситуацией, когда поддержка среди браузеров близка к 100%:
Также фактически в стандарт введены ECMAScript модули (часто называют ES/ES6 модули). Это единственная часть спецификации, которая требовала и требует наибольшего времени для реализации, и ни один браузер пока не выпустил их в стабильной версии.
Недавно в Safari 19 Technical Preview и Edge 15 добавили реализацию модулей без использования флагов. Уже близится то время, когда мы можем отказаться от использования привычных всем бандлов и транспиляции модулей.
Чтобы лучше понять, как мир фронтенда пришел к этому, давайте начнем с истории JS модулей, а затем взглянем на текущие преимущества и реализации ES6 модулей.
Немного истории
Было много способов подключения модулей. Приведу для примера наиболее типичные из них:
1. Просто длинный код внутри script тега. Например:
<!--html-->
<script type="application/javascript">
// module1 code
// module2 code
</script>
2. Разделение логики между файлами и подключение их с помощью тегов script:
/* js */
// module1.js
// module1 code
// module2.js
// module2 code
<!--html-->
<script type="application/javascript" src="PATH/module1.js" ></script>
<script type="application/javascript" src="PATH/module2.js" ></script>
3. Модуль как функция (например: модуль функция, которая возвращает что-то; самовызывающаяся функция или функция конструктор) + Application файл/модель, которые будут точкой входа для приложения:
// polyfill-vendor.js
(function(){
// polyfills-vendor code
}());
// module1.js
function module1(params){
// module1 code
return module1;
}
// module3.js
function module3(params){
this.a = params.a;
}
module3.prototype.getA = function(){
return this.a;
};
// app.js
var APP = {};
if(isModule1Needed){
APP.module1 = module1({param1:1});
}
APP.module3 = new module3({a: 42});
<!--html-->
<script type="application/javascript" src="PATH/polyfill-vendor.js" ></script>
<script type="application/javascript" src="PATH/module1.js" ></script>
<script type="application/javascript" src="PATH/module2.js" ></script>
<script type="application/javascript" src="PATH/app.js" ></script>
Ко всему этому Frontend сообщество изобрело много разновидностей и новых способов, которые добавляли разнообразие в этот праздник анархии.
Основная идея заключается в том, чтобы обеспечить систему, которая позволит вам просто подключить одну ссылку JS файла, вот так:
<!--html-->
<script type="application/javascript" src="PATH/app.js" ></script>
Но всё свелось к тому, что разработчики выбрали сторону бандлеров — систем сборки кода. Далее предлагается рассмотреть основные реализации модулей в JavaScript.
Асинхронное определение модуля (AMD)
Такой подход широко реализуется в библиотеке RequireJS и в инструментах, таких как r.js для создания результирующего бандла. Общий синтаксис:
// polyfill-vendor.js
define(function () {
// polyfills-vendor code
});
// module1.js
define(function () {
// module1 code
return module1;
});
// module2.js
define(function (params) {
var a = params.a;
function getA(){
return a;
}
return {
getA: getA
}
});
// app.js
define(['PATH/polyfill-vendor'] , function () {
define(['PATH/module1', 'PATH/module2'] , function (module1, module2) {
var APP = {};
if(isModule1Needed){
APP.module1 = module1({param1:1});
}
APP.module2 = new module2({a: 42});
});
});
CommonJS
Это основной формат модулей в Node.js экосистеме. Одним из основных инструментов для создания бандлов для клиентских устройств является Browserify. Особенность этого стандарта — обеспечение отдельной области видимости для каждого модуля. Это позволяет избежать непреднамеренной утечки в глобальную область видимости и глобальных переменных.
Пример:
// polyfill-vendor.js
// polyfills-vendor code
// module1.js
// module1 code
module.exports= module1;
// module2.js
module.exports= function(params){
const a = params.a;
return {
getA: function(){
return a;
}
};
};
// app.js
require('PATH/polyfill-vendor');
const module1 = require('PATH/module1');
const module2 = require('PATH/module2');
const APP = {};
if(isModule1Needed){
APP.module1 = module1({param1:1});
}
APP.module2 = new module2({a: 42});
ECMAScript модули (ака ES6/ES2015/нативные JavaScript модули)
Еще один способ работы с модулями пришел к нам с ES2015. В новом стандарте появился новый синтаксис и особенности, удовлетворяющие потребностям фронтенда, таким как:
- отдельные области видимости модулей
- строгий режим по умолчанию
- циклические зависимости
- возможность легко разбить код, соблюдая спецификацию
Есть множество реализаций загрузчиков, компиляторов и подходов, которые поддерживают одну или несколько из этих систем. Например:
Инструменты
На сегодняшний день в JavaScript мы привыкли к использованию различных инструментов для объединения модулей. Если мы говорим о ECMAScript модулях, вы можете использовать один из следующих:
- Rollup
- Traceur Compiler
- Babel, в частности, плагин для преобразования ES2015 модулей в CommonJS
- Webpack 2 со своим Tree Shaking (удаление неиспользуемого кода)
- TypeScript — в качестве транспилятора
Как правило, инструмент предоставляет CLI интерфейс и возможность настроить конфигурацию для создания бандлов из ваших JS файлов. Он получает точки входа и набор файлов. Обычно такие инструменты автоматически добавляют “use strict”. Некоторые из этих инструментов также умеют транспилировать код, чтобы заставить его работать во всех окружениях, которые необходимо (старые браузеры, Node.js и т.д.).
Давайте посмотрим на упрощенной WebPack конфиг, который устанавливает точку входа и использует Babel для транспиляции JS файлов:
// webpack.config.js
const path = require('path');
module.exports = {
entry: path.resolve('src', 'webpack.entry.js'),
output: {
path: path.resolve('build'),
filename: 'main.js',
publicPath: '/'
},
module: {
loaders: {
"test": /\.js?$/,
"exclude": /node_modules/,
"loader": "babel"
}
}
};
Конфиг состоит из основных частей:
- начинаем с файла webpack.entry.js
- используем Babel лоудер для всех файлов JS (то есть, код будет транспилироваться в зависимости от пресетов/плагинов + сгенерируется бандл)
- Результат помещается в файл main.js
В этом случае, как правило, файл index.html содержит следующее:
<script src="build/main.js"></script>
И ваше приложение использует бандлы/транспилируемый код JS. Это общий подход для работы с бандлерами, давайте посмотрим, как заставить его работать в браузере без каких-либо бандлов.
Как сделать так, чтобы JavaScript модули работали в браузере
Поддержка Браузеров
На сегодняшний день каждый из современных браузеров имеет поддержку модулей ES6:
- Firefox — реализованы, доступны под флагом в Firefox 54+
- Chrome — в стадии разработки
- EDGE — реализованы, доступны под флагом в EDGE 15 Preview Build 14342+
- Webkit — реализованы, доступны по умолчанию Safari Technology Preview 21+
- Node.js — на рассмотрении, требуют дополнительного обсуждения
Где можно проверить
Как вы видели, в настоящее время можно проверить нативные JS модули в Safari Technology Preview 19+ и EDGE 15 Preview Build 14342+. Давайте скачаем и попробуем модули в действии.
ES модули доступны в Firefox
Вы можете скачать Firefox Nightly, а это означает, что скоро модули появятся в FF Developer Edition, а затем в стабильной версии браузера.
Чтобы включить ES модули:
- откройте страницу `about:config`
- нажмите “I accept the risk!”
- найдите флаг `dom.moduleScripts.enabled`
- дважды кликните для смены значения флага в значение true
И все, теперь вы у вас доступны ES модули в Firefox.
Safari Technology Preview с доступными ES модулями
Если вы используете MacOS, достаточно просто загрузить последнюю версию Safari Technology Preview (TP) с developer.apple.com. Установите и откройте его. Начиная с Safari Technology Preview версии 21+, модули ES включены по умолчанию.
Если это Safari TP 19 или 20, убедитесь, что ES6 модули включены: откройте меню «Develop» > «Experimental Features» > «ES6 Modules».
Другой вариант — скачать последнюю Webkit Nightly и играться с ним.
EDGE 15 — включаем ES модули
Вы можете скачать бесплатную виртуальную машину от Microsoft.
Просто выберите виртуальную машину (VM) «Microsoft EDGE на Win 10 Preview (15.XXXXX)» и, например, «Virtual Box» (также бесплатно) в качестве платформы.
Установите и запустите виртуальную машину, далее откройте браузер EDGE.
Зайдите на страницу about:flags и включите флаг «Включить экспериментальные функции JavaScript» (Enable experimental JavaScript features).
Вот и все, теперь у вас есть несколько сред, где вы можете играть с нативной реализацией модулей ECMAScript.
Отличия родных и собранных модулей
Давайте начнем с нативных особенностей модулей:
- Каждый модуль имеет собственную область видимости, которая не является глобальной.
- Они всегда в строгом режиме, даже когда директива «use strict» не указана.
- Модуль может импортировать другие модули с помощью import директивы.
- Модуль может экспортироваться с помощью export.
До сих пор мы не увидели особенно серьезные отличия от того, к чему мы привыкли с бандлерами. Большая разница в том, что точка входа должна быть предусмотрена в браузере. Вы должны предоставить script тег с конкретным атрибутом type=«module», например:
<script type= "module" scr= "PATH/file.js" ></script>
Это говорит браузеру, что ваш скрипт может содержать импорт других скриптов, и они должны быть соответствующим образом обработаны. Главный вопрос, который появляется здесь:
Почему интерпретатор JavaScript не может определять модули, если файл и так по сути является модулем?
Одна из причин — нативные модули в строгом режиме по умолчанию, а классические script-ы нет:
- скажем, интерпретатор анализирует файл, предполагая, что это классический сценарий в нестрогом режиме;
- потом он находит «импорт\экспорт» директивы;
- в этом случае, он должен начать с самого начала, чтобы разобрать весь код еще раз в строгом режиме.
Еще одна причина — тот же файл может быть валидным без строгого режима и невалидным с ним же. Тогда валидность зависит от того, как он интерпретируется, что и приводит к неожиданным проблемам.
Определение типа ожидаемой загрузки файла открывает множество способов для оптимизации (например, загрузка импортируемых файлов параллельно/до парсинга оставшейся части файла html). Вы можете найти некоторые примеры, используемые движками Microsoft Chakra JavaScript для модулей ES.
Node.js способ указать файл как модуль
Node.js окружение отличается от браузеров и использовать тег script type=«module» не особо подходит. В настоящее время все еще продолжается спор, каким подходящим способом сделать это.
Некоторые решения были отклонены сообществом:
- добавить «use module» к каждому файлу;
- метаданные в package.json.
Другие варианты все еще находятся на рассмотрении (спасибо @bmeck за подсказку):
- определение, если файл является ES модулем;
- новое расширение файла для ES6 Модули .mjs, которое будет использоваться в качестве запасного варианта, если предыдущая версия не сработает.
Каждый метод имеет свои плюсы и минусы, и в настоящее время до сих пор нет четкого ответа, каким путем Node.js будет идти.
Простой пример нативного модуля
Во-первых, давайте создадим простую демку (вы можете запустить его в браузерах, которые вы установили ранее, чтобы проверить модули). Так что это будет простой модуль, который импортирует другой и вызывает метод из него. Первый шаг — включить файл, используя:
<script type="module"/>
<!--index.html-->
<!DOCTYPE html>
<html>
<head>
<script type="module" src="main.js"></script>
</head>
<body>
</body>
</html>
Вот файл модуля:
// main.js
import utils from "./utils.js";
utils.alert(`
JavaScript modules work in this browser:
https://blog.whatwg.org/js-modules
`);
И, наконец, импортированные утилиты:
// utils.js
export default {
alert: (msg)=>{
alert(msg);
}
};
Как вы могли заметить, мы оставили расширение файла .js, когда используется директива import. Это еще одно отличие от поведения бандлеров — нативные модули не добавляют .js расширения по умолчанию.
Во-вторых, давайте проверим область видимости у модуля (демо):
var x = 1;
alert(x === window.x);//false
alert(this === undefined);// true
В-третьих, мы проверим, что нативные модули в строгом режиме по умолчанию. Например, строгий режим запрещает удалять простые переменные. Следующее демо показывает, что появляется сообщение об ошибке в модуле:
// module.js
var x;
delete x; // !!! syntax error
alert(`
THIS ALERT SHOULDN'T be executed,
the error is expected
as the module's scripts are in the strict mode by default
`);
// classic.js
var x;
delete x; // !!! syntax error
alert(`
THIS ALERT SHOULD be executed,
as you can delete variables outside of the strict mode
`);
Строгий режим нельзя обойти в нативных модулях.
Итого:
- .js расширение не может быть опущено;
- область видимости не является глобальной, this ни на кого не ссылается;
- нативные модули в строгом режиме по умолчанию (больше не требуется писать «use strict»).
Встроенный модуль в тег script
Как и обычные скрипты, вы можете встраивать код, вместо того, чтобы разделять их по отдельным файлам. В предыдущем демо вы можете просто вставить main.js непосредственно в тег script type=«module» что приведет к такому же поведению:
<script type="module">
import utils from "./utils.js";
utils.alert(`
JavaScript modules work in this browser:
https://blog.whatwg.org/js-modules
`);
</script>
Итого:
- script type=«module» можно использовать как для загрузки и выполнения внешнего файла, так и для выполнения встроенного кода в тег script.
Как браузер загружает и выполняет модули
Нативные модули (асинхронные) по умолчанию имеют поведение deffered скриптов. Чтобы понять это, мы можем представить каждый тег script type=«module» с атрибутом defer и без. Вот изображение из спецификации, которое объясняет поведение:
Это означает, что по умолчанию скрипты в модулях не блокируют, загружаются параллельно и выполняются, когда страница завершает парсинг html. Вы можете изменить это поведение, добавив атрибут async, тогда скрипт будет выполнен, как только он загрузится.
Главное отличие нативных модулей от обычных скриптов заключается в том, что обычные скрипты загружаются и выполняются сразу же, блокируя парсинг html. Чтобы представить это, посмотрите демо с разными вариантами атрибутов в теге script, где первым будет выполнен обычный скрипт без атрибутов defer \ async:
<!DOCTYPE html>
<html>
<head>
<script type="module" src="./script1.js"></script>
<script src="./script2.js"></script>
<script defer src="./script3.js"></script>
<script async src="./script4.js"></script>
<script type="module" async src="./script5.js"></script>
</head>
<body>
</body>
</html>
Порядок загрузки зависит от реализации браузеров, размера скриптов, количества импортируемых скриптов и т. д.
Итого:
- модули по умолчанию асинхронны и ведут себя как deffered скрипты
Мы вступаем в эпоху нативной поддержки модулей в JavaScript. JS прошел долгий путь становления, и, наконец-то, он добрался до этой точки. Наверное, это одна из самых долгожданных и востребованных фич. Никакой синтаксический сахар и новые языковые конструкции не идут в сравнение с этим новым стандартом.
Все вышесказанное дается для первого знакомства с нативными ECMAScript модулями. В следующей статье будут разобраны способы взаимодействия модулей, определение поддержки в браузерах, конкретные моменты и различия с обычными бандлами и т. д.
Если хотите узнать больше сейчас, предлагаю пройтись по ссылкам:
- Modules part JavaScript books by Dr. Axel Rauschmayer
- The proposal of `script type=”module” ` by Domenic Denicola
- HTML live standard, “Scripting” section
Честно говоря, когда я пробовал нативные модули в первый раз, и они заработали в браузере, я почувствовал то, чего не чувствовал с появлением таких языковых фич, как const/let/arrow functions и прочих новомодных фишек, когда они начали работать непосредственно в браузерах. Я надеюсь, что вы будете, как и я, рады добавлению нативного механизма работы с модулями в браузеры.
Другие статьи автора по данной теме
- Native ECMAScript modules: dynamic import()
- Native ECMAScript modules: nomodule attribute for the migration
- Native ECMAScript modules: the new features and differences from Webpack modules
От переводчика
Я Frontend разработчик в команде Авиа в Tutu.ru. Сейчас у нас в проектах используется Webpack в качестве бандлера. Есть легаси код и старые проекты с RequireJS. Нативные модули очень интересны и ждем их с нетерпением, тем более мы уже перевели все наши проекты на HTTP/2. Конечно, совсем без бандлеров обходиться мы не собираемся, так как у нас большое количество модулей во всех проектах. Но приход нативных модулей мог бы поменять воркфлоу сборки и деплоя.
Комментарии (8)
Nikelandjelo
20.04.2017 01:55+1Хоть убейте не понимаю в чём преимущество нативных модулей в браузере. Как сказал автор, бандлеры всё равно придётся использовать, потому что загружать 100 модулей на сайте всё же никто не будет (надеюсь). А вместо этого соберут пару бандлов и подгрузят их когда надо. Т.е. бандлеры остаются. Согласен, что удобно для разработки. Но тут возникает опасность, что разрабатывал в одной среде (100 нативных модулей), а в проде другая среда (пару больших бандлов).
Еще минус, что при загрузке модулей в браузере бандлеру/компилятору становится сложно удалять неиспользуемый код да и вообще делать какие либо меж-файловые оптимизации надо кодом.
Нативные модули конечно хороши как спецификация, чтобы избавится от разных реализаций модулей каждая из которых имеет свой синтаксис.VJean
20.04.2017 05:54Не браузером единым.
Допустим у меня есть приложение с поддержкой скриптов ECMAScript. Кто-то из разработчиков скриптов реализовал нечто полезное, что может использоваться другими скриптами. Теперь вместо копипаста можно просто подключить модуль. И это афигенно! Я джва года ждал поддержки модулей.raveclassic
20.04.2017 22:54+1Три раза перечитал ваше сообщение, но так и не понял, о чем вы. О браузере? Ну выше уже ответили, что смысла там в модулях мало. Про ноду? Так там commonjs-модули испокон веков. Какая копипаста?
MTonly
24.04.2017 00:56Было бы полезно иметь возможность подгружать JS-файл внутрь другого JS-файла в расширениях для браузеров, где накладных расходов на HTTP-запросы нет, а использование сборщиков не слишком удобно.
eyeofhell
Если не секрет, а как при «большое количество модулей» бандлер лучше, чем связка native modules + HTTP/2? Меньше нагрузка на веб сервер? Так он вроде статику очень быстро раздает? Вы замеряли уже цифры, или пока только здравый смысл?
nialvi
Пока не замеряли, но есть соблазн. Как минимум это было бы полезно при разработке, дебажить отдельные моудли, а не бандл. Так мы раньше поступали с RequireJS, пока он не перестал справляться с тем объемом файлов что у нас есть сейчас.
Photon79
Дык Source Maps же ж
Finom
Скорость загрузки, в большей степени, будет зависить от длины цепочки зависимостей, а не только от скорости отдачи файлов HTTP/2. Иначе придется включать прелоад всех модулей, а это требует тулзы подобной бандлеру. В этом не очень много смысла.