Pull to refresh

Тонкости модульной системы ECMAScript 2015 (ES6)

Reading time 5 min
Views 116K
Уже около полугода я пишу на ES6 (который в итоге назвали ES-2015) и ES7, с использованием бабеля в качестве транслятора. Писать мне приходилось в основном серверную часть, соответственно, использование модулей было само собой разумеющимся: до ES6 — с помощью модульной системы самой ноды, а теперь — с помощью стандартизированной семантики самого языка. И мне захотелось написать статью, в которой расписать тонкости, плюсы, подводные камни и необычности новообретенной модульной системы языка: отчасти — чтобы другим было проще, отчасти — чтобы разобраться во всём окончательно самому.

Я разберу, что такое модуль, как происходит экспорт сущностей, как происходит импорт сущностей, чем система модулей ES6 отличается от системы модулей в NodeJS.

Модуль


По сути, модуль — это инструкция (statement), которая вызывается неявно — посредством создания файла и выполнения его с помощью интерпретатора ES (прямо, при «запуске» файла программистом, или косвенно, в результате импорта другим модулем). В ES6 есть чёткое соотношение: один файл — один модуль. Каждый модуль имеет отдельную область видимости (Lexical environment) — т. е. все объявления переменных, функций и классов не будут доступны за пределами модуля (файла), если не экспортированы явно. На верхнем уровне модуля (т. е. вне других инструкций и выражений) можно использовать операторы import для импорта других модулей и их экспортируемых сущностей, и export для экспорта собственных сущностей модуля.

Оператор export


Оператор export позволяет экспортировать сущности модуля, чтобы они были доступны из других модулей. У каждого модуля есть неявный объект [[Exports]], в котором хранятся ссылки на все экспортируемые сущности, а ключом является идентификатор сущности (например, имя переменной). Это очень напоминает module.exports из модульной системы NodeJS, но [[Exports]] всегда объект, и его нельзя получить напрямую. Единственный способ его изменить — использовать оператор export.

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

Экспорт объявляемой сущности


По сути, это обычное объявление переменной, функции или класса, с ключевым словом "export" перед ним.

export var variable;
export const CONSTANT = 0;
export let scopedVariable = 20;
export function func(){};
export class Foo {};

В данном случае система экспортов ES6 удобнее, чем в NodeJS, где пришлось бы сначала объявить сущность, а потом добавить её в объект module.exports.

var variable;
exports.variable = variable;

const CONSTANT = 0;
exports.CONSTANT = CONSTANT;
...

Но есть и гораздо более важное различие между этими двумя системами. В NodeJS свойству объекта exports присваивается значение выражения. В ES6 оператор export добавляет в [[Exports]] ссылку (или привязку, binding) на объявленную сущность. Это значит, что [[Exports]].<имя сущности> всегда будет возвращать текущее значение этой сущности.

export var bla = 10; // [[Exports]].bla === 10
bla = 45; // [[Exports]].bla === 45

Экспорт уже объявленной сущности


Здесь всё то же самое, только экспортируем мы сущность, которая уже была объявлена выше. Для этого применяются фигурные скобки после ключевого слова export, в которых через запятую указываются все сущности (ну, их идентификаторы — например, имя переменной), которые необходимо экспортировать.

var bar = 10;
function foo() {}

export { bar, foo };

С помощью ключевого слова "as" можно «переименовать» сущность при экспорте (точнее будет сказать, изменить ключ для [[Exports]]).

var bar = 10;
function foo() {}

export { bar as bla, foo as choo };

Для такого вида экспорта также верно, что [[Exports]] хранит у себя лишь ссылку на сущность, даже в случае «переименования».

var bar = 10;

export { bar as bla }; // [[Exports]].bla === 10

bar = 42; // [[Exports]].bla === 42

Экспорт по умолчанию


Этот вариант использования export отличается от двух описанных выше, и, на мой взгляд, он немного нелогичен. Заключается он в использовании после export ключевого слова default, после которого может идти одно из трех: выражение, объявление функции, объявление класса.

export default 42; 

export default function func() {}

export default class Foo {}

Каждый из этих трех вариантов использования добавляет в [[Exports]] свойство с ключом «default». Экспортирование по умолчанию выражения (первый пример, «export default 42;») — единственный случай при использовании export, когда значением свойства [[Exports]] становится не ссылка на какую-либо сущность, а значение выражения. В случае же экспорта по умолчанию функции (не анонимной, естественно) или класса — они будут объявлены в области видимости модуля, а [[Exports]].default будет ссылкой на эту сущность.

Оператор import


Чтобы не разрывать повествование, продолжу сразу об импорте по умолчанию.

Экспортированное по умолчанию свойство считается «главным» в этом модуле. Его импорт осуществляется с помощью оператора import следующей модификации:

import <любое имя> from '<путь к модулю>';

В этом вся польза дефолтного экспорта — при импорте можно назвать его, как угодно.

// sub.js
export default class Sub {};

// main.js
import Base from './sub.js'; // И да, иногда это может сбить столку, поэтому лучше всё же использовать имя модуля

Импорт обычных экспортируемых свойств выглядит немного иначе:

// file1.js
export let bla = 20;

// file2.js
import { bla } from './file1.js'; // нужно точно указать имя сущности

// file3.js
import { bla as scopedVariable } from './file1.js'; // но можно переименовать

Рассмотрим модуль «file2.js». Оператор import получает объект [[Exports]] импортируемого модуля ('file1.js'), находит в нём нужное свойство («bla»), а после создаёт привязку идентификатора "bla" к значению [[Exports]].bla. Т. е., точно так же, как и [[Exports]].bla, bla в модуле «file2.js» всегда будет возвращать текущее значение переменной «bla» из модуля «file1.js». Равно как и scopedVariable из модуля «file3.js».

// count.js
export let counter = 0;

export function inc() {
  ++counter;
}

// main.js
import { counter, inc } from './count.js'; 

console.log(counter); // 0
inc();
console.log(counter); // 1

Импорт всех экспортируемых свойств


import * as sub from './sub.js';

По сути, таким образом мы получаем копию [[Exports]] модуля «sub.js».

Включение модуля без импорта


Иногда бывает нужно, чтобы файл просто запустился.

import './worker';

Реэкспорт


Последняя вещь, которую я здесь рассмотрю — это повторный экспорт модулем свойства, которое он импортирует из другого модуля. Осуществляется это оператором export.

// main.js
export { something } from './another.js';


Два замечания, которые тут стоит сделать: первое — something после реэкспорта НЕ становится доступной внутри модуля main.js, для этого придётся сделать отдельный импорт (уж не знаю, почему так, видимо, чтобы сохранить дух оператора export); и второе — система ссылок работает и тут: модуль, который импортирует из «main.js» something, будет получать актуальное значение переменной something в «another.js»;

Так же можно зареэкспортить все свойства из другого модуля.

export * from './another';

Однако важно помнить, что если вы объявите в своём модуле экспорт с таким же именем, как у реэкспортного, то он затрёт реэскпортное.

// sub.js
export const bla = 3, foo = 4;

// another.js
export const bla = 5;
export * from './sub';

// main.js
import * as another from './another';
console.log(another); // { bla: 5, foo: 4 }

Это решается переименованием конфликтных свойств при реэкспорте.

И, почему-то, синтаксиса для реэкспорта дефолтных свойств у export нет, но можно сделать так:

export { default as sub } from './sub';

Несколько слов о свойствах импортов


Поддержка циклических ссылок


Собственно, вся эта пляска с биндингами вместо присвоения нужна для нормального разрешения циклических ссылок. Т. к. это не значение (которое может быть и undefined), а ссылка на то место, где когда-то что-то будет лежать — ничего не упадёт, даже если цикл.

Импорты всплывают


Импорты «всплывают» наверх модуля.
// sub.js
console.log('sub');

// main.js
console.log('main');
import './sub.js';

Если запустить main.js, то в консоль сначала выведется «sub», и только потом «main» — именно из-за всплытия импортов.

Экспорт по умолчанию — это ещё не конец


Вот такие конструкции вполне допустимы.

// jquery.js
export default function $() {}
export const AUTHOR = "Джон Резиг";

// main.js
import $, { AUTHOR } from 'jquery.js';


И вообще, по сути, default — это просто ещё один именованный экспорт.

import Base from './Base';

То же самое, что и
import { default as Base } from './Base';




Спасибо большое за прочтение статьи, надеюсь, она сэкономит вам время и вообще поможет. Буду рад услышать вопросы и помочь).
Tags:
Hubs:
+39
Comments 38
Comments Comments 38

Articles