Нативные EcmaScript модули: новые возможности и отличия от webpack
- четверг, 8 июня 2017 г. в 03:14:38
В предыдущей статье Нативные ECMAScript модули — первый обзор я рассказал историю JavaScript модулей и текущее состояние дел реализации нативных EcmaScript модулей.
Сейчас доступны две реализации, которые мы попробуем сравнить с бандлерами модулей.
Основные мысли:
.js
расширение не может быть опущено в директиве import
(нужно указывать полный путь);this
не должен ссылаться ни на что;use strict
);<script type="text/javascript" defer />
).В статье мы познакомимся с различиями бандлов, способами взаимодействия с модулями, узнаем, как переписать webpack модули в нативные ES и другие советы и хитрости.
Мы уже знаем, что нужно писать расширение .js
, когда используем директиву import "FILE.js"
.
Но есть и другие правила, которые применяются в директиве import
.
Давайте разберем ошибку, которая будет появляться, если вы попробуете загрузить несуществующий скрипт:
import 'non-existing.js';
Круто, но что насчет пробелов?
Как в классических скриптах, любое количество пробелов в начале или в конце пути удаляются в <script src>
и import
(демо):
<!--WORKS-->
<script type="module" async src=" ./entry.js "></script>
// WORKS
import utils from " https://blog.hospodarets.com/demos/native-javascript-modules/js/utils.js ";
Вы можете найти больше примеров, прочитав часть спецификации HTML resolve a module specifier. Вот примеры валидных спецификаторов оттуда:
Итого про путь модуля:
Раз зашла речь про абсолютные URL, давайте проверим, как мы сможем их использовать.
Еще одно отличие от бандлов — это возможность загружать файлы с других доменов (например, загрузка модулей с CDN).
Давайте создадим демо, где мы загрузим модуль main-bundled.js, который в свою очередь имортирует и использует blog.hospodarets.com/…/utils.js c другого домена.
<!-- https://plnkr.co/….html -->
<script type="module" async src="./main-bundled.js"></script>
// https://plnkr.co/….main-bundled.js
// DOES allow CORS (Cross Origin Resource Sharing)
import utils from "https://blog.hospodarets.com/demos/native-javascript-modules/js/utils.js";
utils.alert(`
JavaScript modules work in this browser:
https://blog.whatwg.org/js-modules
`);
// https://blog.hospodarets.com/.../utils.js
export default {
alert: (msg) => {
alert(msg);
}
};
Демо будет работать точно так же, как если вы загрузили скрипты со своего домена. Хорошо, что есть поддержка абсолютных URL и работает она точно так же, как у классических скриптов, которые могут быть загружены из любого источника.
Конечно, такие запросы следуют CORS правилам. Например, в предыдущем примере мы загружали скрипт из https://blog.hospodarets.com/demos/native-javascript-modules/js/utils.js, что позволяло делать CORS запросы. Это можно легко определить, посмотрев в заголовки ответа:
Мы можем видеть access-control-allow-origin: *
заголовок.
Этот заголовок Access-Control-Allow-Origin: | *
определяет URI, который может получить доступ к ресурсу. Специальный символ *
позволяет любому запросу получить доступ к ресурсу, поэтому наше демо работает.
Но давайте поменяем main-bundled.js, будем загружать utils.js из другого места (демо)
// https://plnkr.co/….main-bundled.js
// DOESN'T allow CORS (Cross Origin Resource Sharing)
import utils from "https://hospodarets.com/developments/demos/native-javascript-modules/js/utils.js";
utils.alert(`
JavaScript modules work in this browser:
https://blog.whatwg.org/js-modules
`);
И демо перестает работать. Несмотря на это, вы можете открыть
hospodarets.com/…/native-javascript-modules/js/utils.js
в вашем браузере и убедиться, что его содержание совпадает с
blog.hospodarets.com/…/utils.js.
Отличие заключается в том, что второй utils.js не дает доступ к ресурсу на уровне заголовка access-control-allow-origin
:
который интерпретируется браузером как отказ от любого другого источника (https://plnkr.co в нашем случае) для доступа к ресурсу, поэтому демо перестает работать со следующей ошибкой:
Есть некоторые другие ограничения, которые применяются к нативным модулям, классическим скриптам и ресурсам. Например, вы не сможете импортировать HTTP модуль в ваш HTTPS сайт (Mixed Content, демо)
// https://plnkr.co/….main-bundled.js
// HTTP insecure import under the app served via HTTPS
import utils from "http://blog.hospodarets.com/demos/native-javascript-modules/js/utils
Итого:
Как в классических скриптах, есть много атрибутов, которые можно использовать в script type=”module”.
type
используется для установки типа "module"
.src
мы используем, чтобы загрузить файл с определенным URI.defer
не нужен для скриптов типа “module”, так как это поведение по умолчаниюasync
атрибут, модуль будет выполнен сразу же как только будет доступен, без defer поведения по умолчанию, когда скрипты выполняются по порядку после анализа документа, но перед событием DOMContentLoaded.Итого:
Как только я начал использовать ES модули, главный вопрос, который у меня возник, — как определить, что скрипт был загружен или произошла ошибка?
Согласно спецификации, если кто-либо из потомков не загрузился, загрузка скрипта останавливается с ошибкой и скрипт не выполняется. Я подготовил демо, где намеренно пропустил расширение .js
для импортированного файла, который требуется (можно заметить ошибку в devtools консоли).
Мы уже знаем, что нативные модули ведут себя как deferred скрипты по умолчанию. С другой стороны, они могут прекратить выполнение, если, например, граф скрипта не может быть выполнен/загружен.
Для этих двух случаев мы должны каким-то образом детектировать факт того, что скрипт не загрузился либо произошла ошибка.
Давайте попробуем использовать классический способ подключения скриптов, изменив немного код. Создадим метод, который будет принимать параметры и выполнять скрипт с ними:
async
атрибутом;defer
атрибутом.Метод возвращает Promise, который позволяет определить, был ли загружен скрипт или была ошибка при загрузке:
// utils.js
function insertJs({src, isModule, async, defer}) {
const script = document.createElement('script');
if(isModule){
script.type = 'module';
} else{
script.type = 'application/javascript';
}
if(async){
script.setAttribute('async', '');
}
if(defer){
script.setAttribute('defer', '');
}
document.head.appendChild(script);
return new Promise((success, error) => {
script.onload = success;
script.onerror = error;
script.src = src;// start loading the script
});
}
export {insertJs};
Пример ее использования:
import {insertJs} from './utils.js'
// The inserted node will be:
// <script type="module" src="js/module-to-be-inserted.js"></script>
const src = './module-to-be-inserted.js';
insertJs({
src,
isModule: true,
async: true
})
.then(
() => {
alert(`Script "${src}" is successfully executed`);
},
(err) => {
alert(`An error occured during the script "${src}" loading: ${err}`);
}
);
// module-to-be-inserted.js
alert('I\'m executed');
А вот демо, где успешно выполняется скрипт. В данном примере скрипт выполнится и наш success callback будет выполнен. Теперь сделаем так, чтобы в модуле была ошибка (демо):
// module-to-be-inserted.js
import 'non-existing.js';
alert('I\'m executed');
В этом случае у нас возникает ошибка, которую видно в консоле:
Поэтому наш reject callback выполнится. Вы также увидите сообщение об ошибке, если вы пытаетесь использовать import \ export в других модулях (демо):
Теперь у нас есть возможность подключать скрипты и быть уверенными, что скрипты могут/не могут загрузиться.
Итого:
Согласно спецификации, неважно, сколько раз вы будете импортировать один и тот же модуль. Все модули представляют собой синглтон. Пример:
if(window.counter){
window.counter++;
}else{
window.counter = 1;
}
alert(`increment.js- window.counter: ${window.counter}`);
const counter = window.counter;
export {counter};
Вы можете импортировать этот модуль столько раз, сколько вы хотите. Он будет выполнен только один раз, window.counter и экспортируемый counter будет равняться 1 (демо)
Как и функции в JavaScript, imports
“поднимаются” (hoisted). О таком поведении важно знать. Вы можете применять к написанию импортов те же правила, что и к объявлению переменных — писать их всегда в начале файла. Вот почему следующий код работает:
alert(`main-bundled.js- counter: ${counter}`);
import {counter} from './increment.js';
Порядок выполнения кода ниже (демо):
import './module1.js';
alert('code1');
import module2 from './module2.js';
alert('code2');
import module3 from './module3.js';
В связи с тем, что структура ES модулей статическая, они не могут быть импортированы/экспортированы внутри условных блоков. Это широко используется для оптимизации загрузки кода. Вы также не можете обернуть их в блок try{}catch(){} или что-то подобное.
Вот демо:
if(Math.random()>0.5){
import './module1.js'; // SyntaxError: Unexpected keyword 'import'
}
const import2 = (import './main2.js'); // SyntaxError
try{
import './module3.js'; // SyntaxError: Unexpected keyword 'import'
}catch(err){
console.error(err);
}
const moduleNumber = 4;
import module4 from `module${moduleNumber}`; // SyntaxError: Unexpected token
Итого:
Браузеры начали добавлять ES модули, и нам нужен способ, чтобы обнаружить, что браузер их поддерживает. Первые мысли о том, как можно определить поддержку модулей:
const modulesSupported = typeof exports !== undefined;
const modulesSupported2 = typeof import !== undefined;
Данный вариант не работает, так как импорт/экспорт предназначены для использования только для функциональных модулей. Эти примеры выполняются с ошибкой “Syntax errors”. Еще хуже, что импорт/экспорт не должен загружаться как классический скрипт. Поэтому нам нужен другой способ.
У нас есть возможность определить загрузку обычных скриптов, слушая события onload/onerror
. Мы знаем также, что если не поддерживается атрибут type
, то он будет просто игнорироваться браузером. Это значит, мы можем подключить тег script type="module"
и знать, что если он загружен, то браузер поддерживает систему модулей.
Вряд ли вам захочется создавать отдельный скрипт в проекте для такой проверки. Для этого у нас есть есть Blob() API, чтобы создать пустой скрипт и обеспечить правильный MIME тип для него. Для того чтобы получить URL представление скрипта, который мы можем присвоить атрибуту src, нужно воспользоваться методом URL.createObjectURL()
Другая проблема в том, что браузер просто игнорирует скрипты type="module"
, если браузер не поддерживает их в браузере, без какого-либо инициирующего события onload/onerror. Давайте просто откажемся от нашего Promise-а после таймаута.
И, наконец, после нашего успешного Promise-а мы должны немного прибраться: удалить скрипт из DOM и удалить ненужные URL объекты из памяти.
А теперь все это объединим в примере:
function checkJsModulesSupport() {
// create an empty ES module
const scriptAsBlob = new Blob([''], {
type: 'application/javascript'
});
const srcObjectURL = URL.createObjectURL(scriptAsBlob);
// insert the ES module and listen events on it
const script = document.createElement('script');
script.type = 'module';
document.head.appendChild(script);
// return the loading script Promise
return new Promise((resolve, reject) => {
// HELPERS
let isFulfilled = false;
function triggerResolve() {
if (isFulfilled) return;
isFulfilled = true;
resolve();
onFulfill();
}
function triggerReject() {
if (isFulfilled) return;
isFulfilled = true;
reject();
onFulfill();
}
function onFulfill() {
// cleaning
URL.revokeObjectURL(srcObjectURL);
script.parentNode.removeChild(script)
}
// EVENTS
script.onload = triggerResolve;
script.onerror = triggerReject;
setTimeout(triggerReject, 100); // reject on timeout
// start loading the script
script.src = srcObjectURL;
});
};
checkJsModulesSupport().then(
() => {
console.log('ES modules ARE supported');
},
() => {
console.log('ES modules are NOT supported');
}
);
Круто, теперь мы можем понять, поддерживает ли браузер нативные модули. Но что если мы хотим знать, в каком режиме выполняется загружаемый скрипт?
В объекте документа есть такое свойство document.currentScript, которое содержит ссылку на текущий скрипт. Поэтому можно проверить атрибут type
:
const isModuleScript = document.currentScript.type === 'module';
но currentScript не поддерживается в модулях (демо).
Мы можем уточнить, является скрипт модулем через проверку ссылки на контекст (проще говоря, зысь). Если this ссылается на глобальный объект, то будет понятно, что скрипт не нативный модуль.
const isNotModuleScript = this !== undefined;
Но надо учитывать, что этот метод может дать ложные данные, например, bound.
Пора переписать некоторые Webpack модули на нативные, сравнить синтаксис и убедиться, что всё по-прежнему работает. Давайте возьмем простой пример, который использует популярную библиотеку lodash.
Итак, мы используем алиасы и Webpack фичи для упрощения синтаксиса import
. Например, мы сделаем:
import _ from 'lodash';
Webpack будет смотреть в нашу папку node_modules
, найдет lodash
автоматически заимпортирует index.js
файл. Что, в свою очередь, требует загрузки lodash.js
, где весь код библиотеки. Кроме того, вы можете импортировать конкретные функции следующим образом:
import map from 'lodash/map';
Webpack найдет node_modules/lodash/map.js
и заимпортирует файл. Удобно и быстро, согласны? Давайте попробуем следующий пример:
// main-bundled.js
import _ from 'lodash';
console.log('lodash version:', _.VERSION); // e.g. 4.17.4
import map from 'lodash/map';
console.log(
_.map([
{ 'user': 'barney' },
{ 'user': 'fred' }
], 'user')
); // ['barney', 'fred']
Прежде всего, lodash
просто не работает с ES модулями. Если вы посмотрите на исходный код, вы увидите, что использован commonjs подход:
// lodash/map.js
var arrayMap = require('./_arrayMap');
//...
module.exports = map;
После небольших поисков выяснилось, что авторы lodash создали специальный проект для этого — lodash-es — который содержит библиотечные модули lodash в виде ES модулей.
Если мы проверим код, мы увидим, что это ES модули:
// lodash-es/map.js
import arrayMap from './_arrayMap.js';
//...
export default map;
Вот обычная структура нашего приложения (которое мы будем портировать):
Я преднамеренно расположил lodash-es
в папке dist_node_modules
вместо node_modules
. В большинстве проектов папка node_modules
за пределами GIT-a и не является частью дистрибутива кода. Вы можете найти код на Github.
Файл main-bundle.js
собирается Webpack2
в dist/app.bundle.js
, с другой стороны, js/main-native.js
ES модуль и должен быть загружен браузером вместе с зависимостями.
Мы уже знаем, что мы не можем не писать расширение файла у нативных модулей, поэтому в первую очередь мы должны добавить их.
// 1) main-native.js DOESN'T WORK
import lodash from 'lodash-es.js';
import map from 'lodash-es/map.js';
Во-вторых, URL-ы у нативных модулей должны быть абсолютными или должны начинаться с “/”, “./”, или “../”. Ох, это самое сложное. Для нашей структуры мы должны сделать следующее:
// 2) main-native.js WORKS, USES RELATIVE URLS
import _ from '../dist_node_modules/lodash-es/lodash.js';
import map from '../dist_node_modules/lodash-es/map.js';
А через некоторое время мы можем начать с более сложной структуры организации модулей. У нас может быть много относительных и очень длинных url-ов, поэтому вы можете легко заменить все файлы на следующий вариант:
// 2) main-native.js WORKS, CAN BE REUSED/COPIED IN ANY ES MODULE IN THE PROJECT
import _ from '/dist_node_modules/lodash-es/lodash.js';
import map from '/dist_node_modules/lodash-es/map.js';
Обычно корень директории указывает на местоположение index.html, поэтому тег не влияет на поведение импортируемых модулей.
Вот демо и код
console.log('----- Native JavaScript modules -----');
import _ from '/demos/native-ecmascript-modules-aliases/dist_node_modules/lodash-es/lodash.js';
console.log(`lodash version: ${_.VERSION}`); // e.g. 4.17.4
import map from '/demos/native-ecmascript-modules-aliases/dist_node_modules/lodash-es/map.js';
console.log(
map([
{'user': 'barney'},
{'user': 'fred'}
], 'user')
); // ['barney', 'fred']
В конце этой главы отмечу, что для импорта скриптов, модулей и зависимостей браузер делает запросы (как и для других ресурсов). В нашем случае браузер загружает все lodash зависимости, в результате чего около 600 файлов попадают в браузер:
Как вы можете догадаться, это очень плохая идея грузить так много файлов, особенно если у вас нет поддержки протокола HTTP/2 на сайте.
Теперь вы знаете, что можно перейти с Webpack-а на нативные модули и даже знаете о существовании lodash-es.
Итого:
Давайте используем все наши знания, чтобы создать полезный скрипт и применить его, например, в нашем lodash демо.
Мы будем проверять, если браузер поддерживает ES модули (используя checkJsModulesSupport()) и, в зависимости от этого, решать что подключать пользователю. Если модули поддерживаются, мы будем загружать файл main-native.js для них. В противном случае, мы будем подключать Webpack-ом собранный JS файл (используя insertJS()).
Чтобы пример работал для всех браузеров, давайте предоставим API, с помощью которого можно проставить скриптам атрибуты, которые будут указывать, каким способом мы хотим их загрузить.
Что-то вроде этого:
И вот код, который заставит все это работать, используя предыдущие примеры, обсуждаемые ранее:
checkJsModulesSupport().then(
() => {
// insert module script
insertJs({
src: currentScript.getAttribute('es'),
isModule: true
});
// global class
if (isAddGlobalClassSet) {
document.documentElement.classList.add(esModulesSupportedClass);
}
},
() => {
// insert classic script
insertJs({
src: currentScript.getAttribute('js')
});
// global class
if (isAddGlobalClassSet) {
document.documentElement.classList.add(esModulesNotSupportedClass);
}
}
);
Я разместил этот скрипт es-modules-utils на Github.
В настоящее время идет обсуждение возможности добавить нативные атрибуты nomodule или nosupport к скрипту, который будет обеспечивать лучшую совместимость для fallback-а (спасибо @rauschma, предложившему это).
Мы посмотрели на практике различие между ES модулями и классическими скриптами. Узнали, как определить, если модуль загрузился или произошла ошибка. Теперь мы знаем, как использовать ES модули со сторонними библиотеками.
Кроме того, у нас есть полезный скрипт es-modules-utils на Github, который может обеспечить обратную совместимость для браузеров, которые не поддерживают ES модули.
P. S. Вы также можете прочитать мою статью о возможности динамической загрузки скриптов, использующих динамический оператор import(): Native ECMAScript modules: dynamic import().
Я работаю в Авиа команде Tutu.ru фронтенд разработчиком. Нативные модули очень сильно развиваются и я за этим слежу. Все современные браузеры уже поддерживают их. На текущий момент, у нас есть почти полная возможность использовать эту часть спецификации языка прямо сейчас. Будущее наступает :)