В предыдущей статье Нативные ECMAScript модули — первый обзор я рассказал историю JavaScript модулей и текущее состояние дел реализации нативных EcmaScript модулей.
Сейчас доступны две реализации, которые мы попробуем сравнить с бандлерами модулей.
Основные мысли:
- выполнять скрипт или загружать внешний файл и выполнять как модуль, используя // WORKS import utils from " http://ift.tt/2seSIP7 ";</code></pre>
Вы можете найти больше примеров, прочитав часть спецификации HTML resolve a module specifier. Вот примеры валидных спецификаторов оттуда:
- http://ift.tt/2nKd2BI;
- http:example.com\pears.mjs (станет http://ift.tt/2njPZ59, анализирует без базового URL-адреса);
- //example.com/bananas;
- ./strawberries.js.cgi;
- ../lychees;
- /limes.jsx;
- data:text/javascript,export default ‘grapes’;
- blob:http://ift.tt/2nKyTJe.
Итого про путь модуля:
- он может начинаться и заканчиваться пробелами;
- он должен быть абсолютным URL-ом или:
- он должен начинаться с “/”, “./”, или “../”.
Раз зашла речь про абсолютные URL, давайте проверим, как мы сможем их использовать.
Абсолютные URL и CORS (Cross-Origin Resource Sharing)
Еще одно отличие от бандлов — это возможность загружать файлы с других доменов (например, загрузка модулей с CDN).
Давайте создадим демо, где мы загрузим модуль main-bundled.js, который в свою очередь имортирует и использует http://ift.tt/2seILRI c другого домена.
// http://ift.tt/2qVkpbS // DOES allow CORS (Cross Origin Resource Sharing) import utils from "http://ift.tt/2seSIP7"; utils.alert(` JavaScript modules work in this browser: http://ift.tt/1XvCAyP `); // http://ift.tt/2qVpxwJ export default { alert: (msg) => { alert(msg); } };
Демо будет работать точно так же, как если вы загрузили скрипты со своего домена. Хорошо, что есть поддержка абсолютных URL и работает она точно так же, как у классических скриптов, которые могут быть загружены из любого источника.
Конечно, такие запросы следуют CORS правилам. Например, в предыдущем примере мы загружали скрипт из http://ift.tt/2seSIP7, что позволяло делать CORS запросы. Это можно легко определить, посмотрев в заголовки ответа:
Мы можем видеть access-control-allow-origin:
*заголовок.
Этот заголовок Access-Control-Allow-Origin: |
*определяет URI, который может получить доступ к ресурсу. Специальный символ*позволяет любому запросу получить доступ к ресурсу, поэтому наше демо работает.Но давайте поменяем main-bundled.js, будем загружать utils.js из другого места (демо)
// http://ift.tt/2qVkpbS // DOESN'T allow CORS (Cross Origin Resource Sharing) import utils from "http://ift.tt/2seXhZO"; utils.alert(` JavaScript modules work in this browser: http://ift.tt/1XvCAyP `);
И демо перестает работать. Несмотря на это, вы можете открыть
http://ift.tt/2qVJh3b
в вашем браузере и убедиться, что его содержание совпадает с
http://ift.tt/2seILRI.
Отличие заключается в том, что второй utils.js не дает доступ к ресурсу на уровне заголовка
access-control-allow-origin:
который интерпретируется браузером как отказ от любого другого источника (https://plnkr.co в нашем случае) для доступа к ресурсу, поэтому демо перестает работать со следующей ошибкой:
Есть некоторые другие ограничения, которые применяются к нативным модулям, классическим скриптам и ресурсам. Например, вы не сможете импортировать HTTP модуль в ваш HTTPS сайт (Mixed Content, демо)
// http://ift.tt/2qVkpbS // HTTP insecure import under the app served via HTTPS import utils from "http://ift.tt/2seLN8p
Итого:
- вы можете использовать абсолютные URL для scripts type=”module” и для директив import;
- CORS правила применяются для модулей, загруженных из других источников;
- mixed content (HTTP / HTTPS) правило применяются также и для модулей.
Атрибуты script
Как в классических скриптах, есть много атрибутов, которые можно использовать в script type=”module”.
- Атрибут
typeиспользуется для установки типа"module". srcмы используем, чтобы загрузить файл с определенным URI.deferне нужен для скриптов типа “module”, так как это поведение по умолчанию- Если вы используете
asyncатрибут, модуль будет выполнен сразу же как только будет доступен, без defer поведения по умолчанию, когда скрипты выполняются по порядку после анализа документа, но перед событием DOMContentLoaded. - integrity по-прежнему может быть использован, чтобы убедиться, что выбранные файлы (например, из cdn) не были подменены на что-то другое.
- атрибут crossorigin дает возможность контролировать обмен данными, которые отправляются с помощью CORS запросов.
- nonce — это генерируемый случайным образом хеш, который добавляется в заголовок на сервер и добавляется в тег script.
Итого:
- в основном все атрибуты можно использовать с нативными модулями (за исключением integrity)
Как определить, что скрипт загружается или не может быть выполнен из-за ошибки
Как только я начал использовать 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: // 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 в других модулях (демо):
Теперь у нас есть возможность подключать скрипты и быть уверенными, что скрипты могут/не могут загрузиться.
Итого:
- используйте события onload и onerror у script элемента, чтобы обнаружить, может ли модуль быть успешно выполнен или не может загрузиться;
- import \ export не может быть использован в классических скриптах.
Особенности нативных модулей
Нативные модули — singleton
Согласно спецификации, неважно, сколько раз вы будете импортировать один и тот же модуль. Все модули представляют собой синглтон. Пример:
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';
Порядок выполнения кода ниже (демо):
- module1
- module2
- module3
- code1
- code2
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
Итого:
- модули — singletons;
- модули “поднимаются” (hoisted);
- импорт и экспорт не могут выполняться внутри блоков;
- импорты статические (нельзя управлять загрузкой модулей динамически).
Как определить, что есть поддержка модулей
Браузеры начали добавлять ES модули, и нам нужен способ, чтобы обнаружить, что браузер их поддерживает. Первые мысли о том, как можно определить поддержку модулей:
const modulesSupported = typeof exports !== undefined; const modulesSupported2 = typeof import !== undefined;
Данный вариант не работает, так как импорт/экспорт предназначены для использования только для функциональных модулей. Эти примеры выполняются с ошибкой “Syntax errors”. Еще хуже, что импорт/экспорт не должен загружаться как классический скрипт. Поэтому нам нужен другой способ.
Определение поддержки ES модулей в браузерах
У нас есть возможность определить загрузку обычных скриптов, слушая события
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 на нативные ES модули
Пора переписать некоторые 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.jsES модуль и должен быть загружен браузером вместе с зависимостями.
Мы уже знаем, что мы не можем не писать расширение файла у нативных модулей, поэтому в первую очередь мы должны добавить их.
// 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.
Итого:
- собранные модули можно переписать на нативные ES модули, плюс популярные библиотеки уже начали предоставлять совместимые версии;
- с ES модулями предпочтительнее использовать HTTPS/2.
Использование ES modules с fallback-ом
Давайте используем все наши знания, чтобы создать полезный скрипт и применить его, например, в нашем 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 фронтенд разработчиком. Нативные модули очень сильно развиваются и я за этим слежу. Все современные браузеры уже поддерживают их. На текущий момент, у нас есть почти полная возможность использовать эту часть спецификации языка прямо сейчас. Будущее наступает :)
Комментарии (0)