javascript

Нативные EcmaScript модули: новые возможности и отличия от webpack

  • четверг, 8 июня 2017 г. в 03:14:38
https://habrahabr.ru/company/tuturu/blog/329918/
  • Разработка веб-сайтов
  • Программирование
  • Браузеры
  • JavaScript
  • Блог компании Туту.ру


image


В предыдущей статье Нативные ECMAScript модули — первый обзор я рассказал историю JavaScript модулей и текущее состояние дел реализации нативных EcmaScript модулей.


Сейчас доступны две реализации, которые мы попробуем сравнить с бандлерами модулей.


Основные мысли:


  1. выполнять скрипт или загружать внешний файл и выполнять как модуль, используя <script type="module">;
  2. .js расширение не может быть опущено в директиве import (нужно указывать полный путь);
  3. скоуп модулей не должен быть глобальным и this не должен ссылаться ни на что;
  4. нативные модули в strict mode по умолчанию (не нужно больше использовать директиву use strict);
  5. модули по умолчанию работают как deferred скрипты (такое же как у <script type="text/javascript" defer />).

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


Путь модуля


Мы уже знаем, что нужно писать расширение .js, когда используем директиву import "FILE.js".


Но есть и другие правила, которые применяются в директиве import.


Давайте разберем ошибку, которая будет появляться, если вы попробуете загрузить несуществующий скрипт:


import 'non-existing.js';

image


Круто, но что насчет пробелов?


Как в классических скриптах, любое количество пробелов в начале или в конце пути удаляются в <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-ом или:
  • он должен начинаться с “/”, “./”, или “../”.

Раз зашла речь про абсолютные URL, давайте проверим, как мы сможем их использовать.


Абсолютные URL и CORS (Cross-Origin Resource Sharing)


Еще одно отличие от бандлов — это возможность загружать файлы с других доменов (например, загрузка модулей с 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 запросы. Это можно легко определить, посмотрев в заголовки ответа:


image


Мы можем видеть 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:


image


который интерпретируется браузером как отказ от любого другого источника (https://plnkr.co в нашем случае) для доступа к ресурсу, поэтому демо перестает работать со следующей ошибкой:


image


Есть некоторые другие ограничения, которые применяются к нативным модулям, классическим скриптам и ресурсам. Например, вы не сможете импортировать 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

image


Итого:


  • вы можете использовать абсолютные 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 консоли).


image


Мы уже знаем, что нативные модули ведут себя как 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');

В этом случае у нас возникает ошибка, которую видно в консоле:


image


Поэтому наш reject callback выполнится. Вы также увидите сообщение об ошибке, если вы пытаетесь использовать import \ export в других модулях (демо):


image


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


Итого:


  • используйте события 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;

Вот обычная структура нашего приложения (которое мы будем портировать):


image


Я преднамеренно расположил 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 файлов попадают в браузер:


image


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


Теперь вы знаете, что можно перейти с Webpack-а на нативные модули и даже знаете о существовании lodash-es.


Итого:


  • собранные модули можно переписать на нативные ES модули, плюс популярные библиотеки уже начали предоставлять совместимые версии;
  • с ES модулями предпочтительнее использовать HTTPS/2.

Использование ES modules с fallback-ом


Давайте используем все наши знания, чтобы создать полезный скрипт и применить его, например, в нашем lodash демо.


Мы будем проверять, если браузер поддерживает ES модули (используя checkJsModulesSupport()) и, в зависимости от этого, решать что подключать пользователю. Если модули поддерживаются, мы будем загружать файл main-native.js для них. В противном случае, мы будем подключать Webpack-ом собранный JS файл (используя insertJS()).


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


Что-то вроде этого:


image


И вот код, который заставит все это работать, используя предыдущие примеры, обсуждаемые ранее:


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 фронтенд разработчиком. Нативные модули очень сильно развиваются и я за этим слежу. Все современные браузеры уже поддерживают их. На текущий момент, у нас есть почти полная возможность использовать эту часть спецификации языка прямо сейчас. Будущее наступает :)