В этой статье хочу поделиться переводом статьи о нативных 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:
Adding JavaScript modules to the web platform
`);
И, наконец, импортированные утилиты:
// 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:
Adding JavaScript modules to the web platform
`);
</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. Конечно, совсем без бандлеров обходиться мы не собираемся, так как у нас большое количество модулей во всех проектах. Но приход нативных модулей мог бы поменять воркфлоу сборки и деплоя.
Автор: nialvi