TypeScript: модульная система

Использование «пространств имен» в TypeScript для инкапсуляции ваших данных

В этом уроке мы узнаем о предшественнике модуля ECMAScript, реализованном исключительно на TypeScript. Это так называемые пространства имен, и они довольно интересны.

В предыдущем уроке мы узнаем о стандартной модульной системе, используемой TypeScript, которая также совпадает со стандартной модульной системой JavaScript, стандартизированной ECMAScript.

Когда параметр компилятора module (или --module flag) установлен в CommonJS, компилятор TypeScript преобразует операторы модуля ECMAScript, такие как import и export, в эквивалентные операторы require и exports для Node. В противном случае он остается нетронутым, чтобы он мог работать в браузерах, поддерживающих собственные модули ECMAScript.

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

Если вы хотите, чтобы скомпилированная программа JavaScript работала на Node, вы можете установить module compiler-option равным CommonJS. Если вы хотите запустить программу в среде браузера, вы можете использовать значение ES2015 или ES2020. Однако в настоящий момент невозможно добиться изоморфного JavaScript, который можно было бы запускать как в Node, так и в браузере, используя одну из этих модульных систем.

Если стандартная модульная система не требуется или не может быть реализована, но мы все же хотим добавить в наш проект модульность, можно использовать пространства имен. Пространства имен - это функция TypeScript, которая компилируется в чистый JavaScript без операторов require или import в выходном коде.

Поскольку они не используют платформенно-зависимую модульную систему и компилируются в ванильный JavaScript, к которому мы привыкли с каменного века, они называются внутренними модулями. Давайте погрузимся в это.

Что такое пространства имен?

Возможно, вы знакомы с пространствами имен в таких языках программирования, как C++ или Java. Пространство имен похоже на область, из которой ничего не может выйти. Ключевое слово namespace в TypeScript создает такую ​​область.

// a.ts
var _version = '1.0.0';
function getVersion() {
    return _version;
}
console.log( _version ); // 1.0.0
console.log( getVersion() ); // 1.0.0

В приведенном выше примере переменная _version доступна всем, что, вероятно, должно было быть доступно только в функции getVersion. Кроме того, мы не хотим, чтобы функция getVersion находилась в глобальной области видимости, поскольку функция с таким же именем могла быть добавлена ​​сторонней библиотекой в ​​глобальную область видимости. Нам нужно добавить инкапсуляцию к этим значениям.

// a.ts
namespace MyLibA {
    const _version = '1.0.0';
    
    function getVersion() {
        return _version;
    }
}
console.log( _version ); // ❌ ERROR
console.log( getVersion() ); // ❌ ERROR

В приведенной выше модификации мы заключили код логики приложения в пространство имен MyLibA. Блок namespace { ... } похож на тюрьму для кода внутри. Он не может выйти наружу, а это значит, что он не может загрязнять глобальный масштаб. Следовательно, никто извне не может получить доступ к ценностям внутри.

$ tsc a.ts
a.ts:9:14 - error TS2304: Cannot find name '_version'.
    console.log( _version ); // 1.0.0
                 ~~~~~~~~
a.ts:10:14 - error TS2304: Cannot find name 'getVersion'
    console.log( getVersion() ); // 1.0.0
                 ~~~~~~~~~~

Если мы попытаемся скомпилировать программу, компилятор TypeScript не допустит этого, поскольку значения _version и getVersion не определены в глобальной области видимости. Чтобы получить к ним доступ, нам нужно получить к ним доступ из пространства имен.

namespace MyLibA {
    const _version = '1.0.0';
export function getVersion() {
        return _version;
    }
}
console.log( MyLibA._version ); // ❌ ERROR
console.log( MyLibA.getVersion() ); // 1.0.0

В приведенном выше примере мы добавили ключевое слово export перед функцией getVersion, чтобы сделать его общедоступным из пространства имен. Однако значение _version не экспортируется, поэтому оно не будет доступно в пространстве имен.

Чтобы получить доступ к значению, экспортированному из пространства имен, мы используем выражение <ns>.<value>. MyLibA.getVersion возвращает функцию getVersion, поскольку она была экспортирована из пространства имен MyLibA, но MyLibA._version не будет доступна, поскольку не была экспортирована.

Синтаксис для экспорта значений из пространства имен так же прост, как поставить export перед объявлением, будь то объявление переменной let, var или const или объявление class, function или даже enum, как показано ниже.

namespace <name> {
  const private = 1;
  function privateFunc() { ... };
export const public = 2;
  export function publicFunc() { ... };
}

Итак, время для окончательного раскрытия. Как выглядят пространства имен в скомпилированном коде javascript? Ответ должен быть очевиден. Поскольку мы получаем доступ к общедоступным значениям пространства имен с помощью выражения <ns>.<value>, ns, скорее всего, должен быть объектом.

// a.js
var MyLibA;
(function (MyLibA) {
    var _version = '1.0.0';
function getVersion() {
        return _version;
    }
MyLibA.getVersion = getVersion;
})(MyLibA || (MyLibA = {}));
console.log(MyLibA.getVersion()); // 1.0.0

Когда мы компилируем программу a.ts, мы получаем результат, указанный выше. Каждое пространство имен в программе TypeScript создает объявление пустой переменной (с тем же именем, что и пространство имен) и IIFE в скомпилированном JavaScript.

IIFE содержит код, написанный внутри пространства имен, поэтому значения не загрязняют глобальную область видимости, поскольку они привязаны к функции. Операторы export внутри пространства имен преобразуются в операторы присвоения свойств, как показано ниже.

MyLibA.getVersion = getVersion;

Это делает getVersion значение внутри IIFE доступным для MyLibA глобального объекта, и, следовательно, любой может получить к нему доступ. В отличие от этого, значение _version недоступно за пределами IIFE.

Экспорт типов и пространств имен

Как и модуль, вы также можете экспортировать типы из пространства имен.

// a.ts
namespace MyLibA {
    export interface Person {
        name: string;
        age: number;
    }
export function getPerson( name: string, age: number ): Person {
        return { name, age };
    }
}
const ross: MyLibA.Person = MyLibA.getPerson( 'Ross', 30 );

В приведенном выше примере мы экспортируем интерфейс Person из пространства имен MyLibA, поэтому мы можем использовать MyLibA.Person в выражении аннотации типа. Однако, поскольку MyLibA.Person является типом, его не будет в скомпилированном коде JavaScript.

// a.js
var MyLibA;
(function (MyLibA) {
    function getPerson(name, age) {
        return { name: name, age: age };
    }
MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));
var ross = MyLibA.getPerson('Ross', 30);

Поскольку пространство имен также является значением, вы также можете экспортировать пространство имен из пространства имен. Они называются вложенными пространствами имен.

// a.ts
namespace MyLibA {
    export namespace Types {
        export interface Person {
            name: string;
            age: number;
        }
    }
export namespace Functions {
        export function getPerson( name: string, age: number ):
        Types.Person {
            return { name, age };
        }
    }
}
const ross: MyLibA.Types.Person = MyLibA.Functions.getPerson( 'Ross Geller', 30 );

В приведенном выше примере пространство имен MyLibA экспортирует два пространства имен, а именно. Types и Functions. Пространства имен имеют лексическую область видимости, поэтому функция getPerson может обращаться к Types.Person из внешней области.

// a.js
var MyLibA;
(function (MyLibA) {
var Functions;
    (function (Functions) {
        function getPerson(name, age) {
            return { name: name, age: age };
        }
        Functions.getPerson = getPerson;
    })(Functions = MyLibA.Functions || (MyLibA.Functions = {}));
})(MyLibA || (MyLibA = {}));
var ross = MyLibA.Functions.getPerson('Ross Geller', 30);

Сглаживание

В предыдущем примере мы видим, что вложенное пространство имен может довольно быстро стать беспорядочным и сложным в использовании. Синтаксис MyLibA.Functions.getPerson довольно громоздкий. Чтобы сократить это, мы можем ссылаться на него с помощью переменной.

// a.ts
namespace MyLibA {
    export namespace Types {
        export interface Person {
            name: string;
            age: number;
        }
    }
export namespace Functions {
        export function getPerson( name: string, age: number ):
        Types.Person {
            return { name, age };
        }
    }
}
var Person = MyLibA.Types.Person; // ❌ ERROR
var API = MyLibA.Functions;
const ross: Person = API.getPerson( 'Ross Geller', 30 );

В приведенном выше примере мы сохранили MyLibA.Functions в константу API, которая короткая, симпатичная и простая в использовании. Однако это не работает для Person, поскольку Person объявлен как переменная, а MyLibA.Types.Person - это тип. Вы можете использовать type Person = MyLibA.Types.Person для выполнения этой работы.

Но TypeScript предоставляет более простой синтаксис для создания псевдонимов для пространств имен, который хорошо работает как с экспортируемыми типами, так и с значениями. Вместо var <alias> = нам нужно использовать import <alias> = выражение.

// a.ts
namespace MyLibA {
    export namespace Types {
        export interface Person {
            name: string;
            age: number;
        }
    }
export namespace Functions {
        export function getPerson( name: string, age: number ):
        Types.Person {
            return { name, age };
        }
    }
}
import Person = MyLibA.Types.Person;
import API = MyLibA.Functions;
const ross: Person = API.getPerson( 'Ross Geller', 30 );

В приведенном выше примере мы только что изменили объявления var <alias> на выражение import <alias>. Это не следует сравнивать с заявлением ES6 import. Это просто синтаксический сахар для создания псевдонима для пространств имен.

// a.js
var MyLibA;
(function (MyLibA) {
var Functions;
    (function (Functions) {
        function getPerson(name, age) {
            return { name: name, age: age };
        }
        Functions.getPerson = getPerson;
    })(Functions = MyLibA.Functions || (MyLibA.Functions = {}));
})(MyLibA || (MyLibA = {}));
var API = MyLibA.Functions;
var ross = API.getPerson('Ross Geller', 30);

Как видно из вышеприведенного вывода, псевдоним экспорта пространства имен с использованием import создает переменную, которая ссылается на экспортируемое значение. Если псевдоним ссылается на тип, он просто игнорируется в скомпилированном выводе.

Импорт пространств имен

Чтобы отделить логику приложения от логики многократного использования, мы обычно создаем разные файлы и помещаем их в отдельные каталоги. В приведенном выше примере мы помещаем объявления пространств имен в отдельный файл и импортируем их с помощью оператора import. Поскольку пространства имен являются обычными значениями, мы можем их импортировать.

// a.ts
import { MyLibA } from './b';
const ross: MyLibA.Person = MyLibA.getPerson( 'Ross Geller', 30 );
--------------------------------------------------------------------
// b.ts
export namespace MyLibA {
    export interface Person {
        name: string;
        age: number;
    }
export function getPerson( name: string, age: number ): Person {
        return { name, age };
    }
}

Проблема с этим снова заключается в том, что мы становимся зависимыми от платформы, поскольку для скомпилированного JavaScript потребуется модульная система во время выполнения, как показано в выходных данных ниже для CommonJS модульной системы.

// a.js
var b_1 = require( "./b" );
var ross = b_1.MyLibA.getPerson('Ross Geller', 30);
--------------------------------------------------------------------
// b.js
var MyLibA;
(function (MyLibA) {
    function getPerson(name, age) {
        return { name: name, age: age };
    }
    MyLibA.getPerson = getPerson;
})(MyLibA = exports.MyLibA || (exports.MyLibA = {}));

Поскольку файл b.ts уже импортирован внутри a.ts, вы можете просто использовать команду tsc --module CommonJS a.ts для компиляции этого проекта, и компилятор TypeScript автоматически включит b.ts в процесс компиляции.

Модуляризация

TypeScript предоставляет директивы с тройной косой чертой, которые представляют собой не что иное, как комментарии JavaScript, которые помогают компилятору TypeScript находить другие файлы TypeScript и включать их в процесс компиляции.

/// <reference path="./b.ts" />

Вы можете сравнить их с директивами препроцессора, используемыми в C и C++ языках, например #include "stdio.h". Они должны появиться в верхней части файла, чтобы получить особое значение. Поскольку эти директивы являются просто комментариями, их работа существует только во время компиляции.

Атрибут path этой директивы reference указывает на другой файл TypeScript для создания зависимости. Это похоже на импорт b.ts с использованием оператора import, но без упоминания элементов импорта.

Когда ссылка на другой файл TypeScript дается с использованием директивы reference, TypeScript автоматически включает эти файлы в процесс компиляции, как и оператор import. Все глобальные значения этого файла становятся доступными в файле, в котором на него имеется ссылка.

// a.ts
/// <reference path="./b.ts"/>
const ross: MyLibA.Person = MyLibA.getPerson( 'Ross Geller', 30 );
--------------------------------------------------------------------
// b.ts
namespace MyLibA {
    export interface Person {
        name: string;
        age: number;
    }
export function getPerson( name: string, age: number ): Person {
        return { name, age };
    }
}

В приведенном выше примере мы указали b.ts внутри a.ts с помощью директивы reference. Используя это, все значения внутри b.ts, которые находятся в глобальной области видимости, будут доступны внутри a.ts. Кроме того, нам не нужно добавлять b.ts в процессе компиляции, поэтому команда tsc a.ts выполнит эту работу.

// a.js
var ross = MyLibA.getPerson('Ross Geller', 30);
--------------------------------------------------------------------
// b.js
var MyLibA;
(function (MyLibA) {
    function getPerson(name, age) {
        return { name: name, age: age };
    }
    MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));

Мы не можем запустить этот проект на Node, поскольку это два отдельных файла, и у нас нет require() операторов в скомпилированном коде, которые Node может использовать для загрузки зависимых файлов. Сначала нам нужно объединить их в один пакет и запустить с помощью команды $ node bundle.js.

В среде браузера нам нужно сначала загрузить b.js, а затем a.js, поскольку a.js зависит от b.js для MyLibA инициализации объекта. Итак, инструкция <script> должна выглядеть так.

<script src="./b.js" />
<script src="./a.js" />

Однако вы можете использовать флаг --outFile с командой tsc для создания пакета (например tsc --outFile bundle.js a.ts). Компилятор typeScript автоматически определит порядок приоритета кода в скомпилированном JavaScript на основе порядка reference директив. Поэтому директивы reference абсолютно необходимы для внутренних модулей.

// bundle.js
// (from b.ts)
var MyLibA;
(function (MyLibA) {
    function getPerson(name, age) {
        return { name: name, age: age };
    }
MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));
// (from a.ts)
var ross = MyLibA.getPerson('Ross Geller', 30);

Расширение пространств имен

На уроке интерфейсы мы увидели, что когда несколько интерфейсов с одинаковым именем объявляются в одном модуле (файле), TypeScript объединяет их в одно объявление, объединяя их свойства вместе.

Вы можете расширить предопределенное пространство имен, обратившись к файлу, который содержит исходное пространство имен (с помощью директивы reference), и повторно объявив пространство имен с новыми значениями.

// a.ts
/// <reference path="./b.ts"/>
const john: MyLibA.Person = MyLibA.defaultPerson;
const ross: MyLibA.Person = MyLibA.getPerson( 'Ross Geller', 30 );
console.log( john ); // {name: 'John Doe', age: 21}
console.log( ross ); // {name: 'Ross Geller', age: 30}
--------------------------------------------------------------------
// b.ts
/// <reference path="./c.ts" />
namespace MyLibA {
    export const defaultPerson: Person = getPerson( 'John Doe', 21 );
}
--------------------------------------------------------------------
// c.ts
namespace MyLibA {
    export interface Person {
        name: string;
        age: number;
    }
export function getPerson( name: string, age: number ): Person {
        return { name, age };
    }
}

В приведенном выше примере, поскольку b.ts ссылается на c.ts, он имеет доступ к пространству имен MyLibA и добавляет defaultPerson общедоступное значение в это пространство имен. Если вы заметили, пространство имен MyLibA в b.ts имеет доступ ко всем общедоступным значениям (только экспортированным) того же пространства имен, которое определено в c.ts.

a.ts ссылается на b.ts, и для него пространство имен MyLibA имеет элементы Person, getPerson и defaultPerson. Когда мы объединяем этот проект с помощью команды tsc --outFile bundle.js a.ts, мы получаем следующий результат.

// bundle.js
// (from c.ts)
var MyLibA;
(function (MyLibA) {
    function getPerson(name, age) {
        return { name: name, age: age };
    }
    MyLibA.getPerson = getPerson;
})(MyLibA || (MyLibA = {}));
// (from b.ts)
var MyLibA;
(function (MyLibA) {
    MyLibA.defaultPerson = MyLibA.getPerson('John Doe', 21);
})(MyLibA || (MyLibA = {}));
// (from a.ts)
var john = MyLibA.defaultPerson;
var ross = MyLibA.getPerson('Ross Geller', 30);

Расширение пространства имен имеет гораздо больший смысл, когда мы видим скомпилированный код JavaScript. Как видите, средний раздел принадлежит файлу b.ts, который добавляет свойство defaultPerson к объекту MyLibA.

Теперь вопрос на миллион долларов, где мы должны использовать пространства имен? Я предлагаю по возможности избегать этого. Теперь у нас есть стандарт для модулей на JavaScript. Когда-нибудь Node.js будет полностью поддерживать его, но пока вы можете установить --module на CommonJS, это так просто.

Пространства имен предшествовали модулям JavaScript, поэтому не стоит вкладывать деньги во что-то, что скоро устареет. Однако пространства имен хорошо подходят для приложений кроссбраузерного JavaScript, в которых модульная система ECMAScript недоступна в старых браузерах, но у нас есть Webpack и другие инструменты связывания для сделать модуль ECMAScript кроссплатформенным и обратно совместимым.

TypeScript не позволяет объединять модули ECMAScript или CommonJS, а это означает, что у вас не может быть единого файла пакета для проекта TypeScript, в котором используются эти системы модулей. Если наличие одного файла пакета критически важно, вы можете отказаться от использования пространств имен. Опять же, Webpack или другие инструменты объединения могут помочь вам создать пакет, не жертвуя ничем.