На самом деле привычный всем механизм импорта\экспорта таит в себе множество курьезов, способных нарушить ожидаемый ход выполнения программы. Помимо детального рассмотрения каждого из них, текущая глава также расскажет о способах их разрешения.
Предыстория возникновения import type и export type¶
Представьте сценарий, по которому существуют два модуля, включающих экспорт класса. Один из этих классов использует другой в аннотации типа своего единственного параметра конструктора, что требует от модуля, в котором он реализован, импорта другого модуля. Подвох заключается в том, что несмотря на использование класса в качестве типа, модуль, в котором он определен вместе с его содержимым, все равно будет скомпилирован в конечную сборку.
// @filename: ./FirstLevel.tsimport{SecondLevel}from'./SecondLevel';exportclassFirstLevel{/** * класс SecondLevel используется * только как тип */constructor(secondLevel:SecondLevel){}}
// @info: скомпилированный проект// @filename: ./SecondLevel.jsexportclassSecondLevel{}// @filename: ./FirstLevel.js/** * Несмотря на то, что от класса SecondLevel не осталось и следа, * модуль *, в котором он определен, все равно включен в сборку. */import'./SecondLevel';// <-- *exportclassFirstLevel{/** * класс SecondLevel используется * только как тип */constructor(secondLevel){}}// @filename: ./index.jsexport{FirstLevel}from'./FirstLevel';
Поскольку при использовании допустимых JavaScript конструкций исключительно в качестве типа, было бы разумно ожидать, что конечная сборка не будет обременена модулями, в которых они определены. Кроме того, конструкции, присущие только TypeScript, не попадают в конечную сборку, в отличие от модулей, в которых они определены. Если в нашем примере поменять тип конструкции SecondLevel с класса на интерфейс, то модуль ./FirstLevel.js все равно будет содержать импорт модуля ./SecondLevel.js, содержащего экспорт пустого объекта export {};. Не лишним будет обратить внимание, что в случае с интерфейсом определяющий его модуль мог содержать и другие конструкции. И если бы среди этих конструкций оказались допустимые с точки зрения JavaScript, то они, на основании изложенного ранее, попали бы в конечную сборку. Даже если бы вообще не использовались.
Это поведение привело к тому, что в TypeScript появился механизм импорта и экспорта только типа. Этот механизм позволяет устранить рассмотренные случаи, тем не менее имеет несколько нюансов, которые будут подробно изложены далее.
// @filename: ./index.jsimporttype{ClassType}from'./types';// Ok -> импорт только типаexporttype{ClassType};// Ok -> экспорт только типа
123
// @filename: ./index.jsexporttype{ClassType}from'./types';// Ok -> ре-экспорт только типа
Единственное отличие импорта и экспорта только типа от обычных одноименных инструкций состоит в невозможности импортировать в одной форме обычный импорт\экспорт и по умолчанию.
// @filename: ./index.ts// пример с обычным импортомimportDefaultClassType,{ClassType}from'./types';// Ok -> обычный импорт
123456789
// @filename: ./index.ts// неправильный пример с импортом только типаimporttypeDefaultClassType,{ClassType}from'./types';// Error -> импорт только типа/** * [0] A type-only import can specify a default import or named bindings, but not both. */
Как можно почерпнуть из текста ошибки, решение заключается в создании отдельных форм импорта.
123456
// @filename: ./index.ts// правильный пример с импортом только типаimporttypeDefaultClassTypefrom'./types';// Ok -> импорт только типа по умолчаниюimporttype{ClassType}from'./types';// Ok -> импорт только типа
Прежде всего перед рассматриваемым механизмом стоит задача недопущения использования импортированных или экспортированных только как тип конструкций и значений в роли, отличной от обычного типа. Другими словами, допустимые JavaScript конструкции и значения нельзя использовать в привычных для них выражениях. Нельзя создавать экземпляры классов, вызывать функции и использовать значения, ассоциированные с переменными.
importtype{o,fe,fd,ClassType,IInterfaceType,}from'./types';// Ok/** * * - '{{NAME}}' cannot be used as a value because it was imported using 'import type'. */letperson=o.person;// Error -> *fe();// Error -> *fd();// Error -> *newClassType();// Error -> *
Несложно догадаться, что значения и функции, импортированные или экспортированные только как типы, необходимо использовать в совокупности с другим механизмом, называемым запрос типа.
1 2 3 4 5 6 7 8 9101112131415161718
importtype{o,fe,fd,ClassType,IInterfaceType,}from'./types';/** * v2, v3 и v4 используют механизм * запроса типа */letv0:IInterfaceType;// Ok -> let v0: IInterfaceTypeletv1:ClassType;// Ok -> let v1: ClassTypeletv2:typeoffd;// Ok -> let v2: () => voidletv3:typeoffe;// Ok -> let v3: () => voidletv4:typeofo;// Ok -> let v4: {person: string;}
Будет нелишним уточнить, что классы, экспортированные как уточнённые, не могут участвовать в механизме наследования.
123
// @filename: Base.tsexportclassBase{}
12345678
// @filename: index.tsimporttype{Base}from'./Base';/** * Error -> 'Base' cannot be used as a value because it was imported using 'import type'. */classDerivedextendsBase{}
Другая задача, решаемая с помощью данного способа, заключается в управлении включением модулей в конечную сборку при помощи вспомогательного флага --importsNotUsedAsValues, значение которого может принадлежать к одному из трех вариантов. Но прежде чем познакомиться с каждым из них, необходимо поглубже погрузиться в природу возникновения необходимости в данном механизме.
Большинство разработчиков, пользуясь механизмом импорта\экспорта в повседневной работе, даже не подозревают, что с ним связано немало различных трудностей, возникающих из-за механизмов, призванных оптимизировать код. Но для начала рассмотрим несколько простых вводных примеров.
Представьте ситуацию, при которой один модуль импортирует необходимый ему тип, представленный интерфейсом.
Поскольку интерфейс является конструкцией, присущей исключительно TypeScript, то неудивительно, что после компиляции от неё и модуля, в котором она определена, не останется и следа.
12345
// после компиляции @file action.jsfunctionaction(person){// ...}
Теперь представьте, что один модуль импортирует конструкцию, представленную классом, который задействован в логике уже знакомой нам функции action().
// после компиляции @file action.jsimport{Person}from'./Person';functionaction(person){newPerson(person);}
В этом случае класс Person был включён в скомпилированный файл, поскольку необходим для правильного выполнения программы.
А теперь представьте ситуацию, когда класс Person задействован в том же модуле action.ts, но исключительно в качестве типа. Другими словами, он не задействован в логике работы модуля.
Подумайте, что должна включать в себя итоговая сборка? Если вы выбрали вариант идентичный первому, то вы совершенно правы! Поскольку класс Person используется в качестве типа, то нет смысла включать его в результирующий файл.
12345
// после компиляции @file action.jsfunctionaction(person){//...}
Подобное поведение кажется логичным и возможно благодаря механизму, называемому import elision. Этот механизм определяет, что конструкции, которые теоретически могут быть включены в скомпилированный модуль, требуются ему исключительно в качестве типа. И как уже можно было догадаться, именно с этим механизмом и связаны моменты, мешающие оптимизации кода.
Рассмотрим пример, состоящий из двух модулей. Первый модуль экспортирует объявленные в нем интерфейс и функцию, использующую этот интерфейс в аннотации типа своего единственного параметра. Второй модуль лишь ре-экспортирует интерфейс и функцию из первого модуля.
// @filename re-export.tsimport{IActionParams,action}from'./types';/** * Error -> Re-exporting a type when the '--isolatedModules' flag is provided requires using 'export type' */export{IActionParams,action};
Поскольку компиляторы как TypeScript, так и Babel неспособны определить, является ли конструкция IActionParams допустимой для JavaScript в контексте файла, существует вероятность возникновения ошибки. Простыми словами, механизмы обоих компиляторов не знают, нужно ли удалять следы, связанные с IActionParams из скомпилированного JavaScript кода или нет. Именно поэтому существует флаг --isolatedModules, активация которого заставляет компилятор предупреждать об опасности данной ситуации.
Механизм уточнения способен разрешить возникающие перед import-elision трудности ре-экспорта модулей, предотвращению которых способствует активация флага --isolatedModules.
Рассмотренный выше случай можно разрешить с помощью явного уточнения формы импорта\экспорта.
123456789
// @filename: re-export.tsimport{IActionParams,action}from'./module';/** * Явно указываем, что IActionParams это тип. */exporttype{IActionParams};export{action};
Специально введенный и ранее упомянутый флаг --importsNotUsedAsValues ожидает одно из трех возможных на данный момент значений - remove, preserve или error.
Значение remove реализует поведение по умолчанию и которое обсуждалось на протяжении всей главы.
Значение preserve способно разрешить проблему, возникающую при экспорте так называемых сайд-эффектов.
123456789
// @filename: module-with-side-effects.tsfunctionincrementVisitCounterLocalStorage(){// увеличиваем счетчик посещаемости в localStorage}exportinterfaceIDataFromModuleWithSideEffects{}incrementVisitCounterLocalStorage();// ожидается, что вызов произойдет в момент подключения модуля
Несмотря на то, что модуль module-with-side-effects.ts задействован в коде, его содержимое не будет включено в скомпилированную программу, поскольку компилятор исключает импорты конструкций, не участвующих в её логике. Таким образом, функция incrementVisitCounterLocalStorage() никогда не будет вызвана, а значит программа не будет работать корректно!
1234
// @filename: index.js// после компиляцииletdata={};
Решение этой проблемы заключается в повторном указании импорта всего модуля. Но не всем такое решение кажется очевидным.
1234
import{IDataFromModuleWithSideEffects}from'./module-with-side-effects';import'./module-with-side-effects';// импорт всего модуляletdata:IDataFromModuleWithSideEffects={};
Теперь программа выполнится так как и ожидалось. То есть модуль module-with-side-effects.ts включен в её состав.
123456
// @filename: index.js// после компиляцииimport'./module-with-side-effects.js';letdata={};
Кроме того, сама ide укажет на возможность уточнения импорта только типов, что в свою очередь должно подтолкнуть на размышление об удалении импорта при компиляции.
1
import{IDataFromModuleWithSideEffects}from'./module-with-side-effects';// This import may be converted to a type-only import.ts(1372)
Также флаг preserve в отсутствие уточнения поможет избавиться от повторного указания импорта. Простыми словами, значение preserve указывает компилятору импортировать все модули полностью.
123456789
// @filename: module-with-side-effects.tsfunctionincrementVisitCounterLocalStorage(){// увеличиваем счетчик посещаемости в localStorage}exportinterfaceIDataFromModuleWithSideEffects{}incrementVisitCounterLocalStorage();
// @filename: index.ts// Без уточненияimport{IDataFromModuleWithSideEffects}from'./module-with-side-effects';import{IDataFromModuleWithoutSideEffects}from'./module-without-side-effects';letdataFromModuleWithSideEffects:IDataFromModuleWithSideEffects={};letdataFromModuleWithoutSideEffects:IDataFromModuleWithoutSideEffects={};
Несмотря на то, что импортировались исключительно конструкции-типы, как и предполагалось, модули были импортированы целиком.
1234567
// после компиляции @file index.jsimport'./module-with-side-effects';import'./module-without-side-effects';letdataFromModuleWithSideEffects={};letdataFromModuleWithoutSideEffects={};
В случае уточнения поведение при компиляции останется прежним. То есть импорты в скомпилированный файл включены не будут.
12345678
// @filename: index.ts// С уточнениемimporttype{IDataFromModuleWithSideEffects}from'./module-with-side-effects';importtype{IDataFromModuleWithoutSideEffects}from'./module-without-side-effects';letdataFromModuleWithSideEffects:IDataFromModuleWithSideEffects={};letdataFromModuleWithoutSideEffects:IDataFromModuleWithoutSideEffects={};
Импорты модулей будут отсутствовать.
12345
// @filename: index.js// после компиляцииletdataFromModuleWithSideEffects={};letdataFromModuleWithoutSideEffects={};
Если флаг --importsNotUsedAsValues имеет значение error, то при импортировании типов без явного уточнения будет считаться ошибочным поведением.
1 2 3 4 5 6 7 8 9101112
// @filename: index.ts/** * * [0][1] Error > This import is never used as a value and must use 'import type' because the 'importsNotUsedAsValues' is set to 'error'.ts(1371) */import{IDataFromModuleWithSideEffects}from'./module-with-side-effects';import{IDataFromModuleWithoutSideEffects}from'./module-without-side-effects';letdataFromModuleWithSideEffects:IDataFromModuleWithSideEffects={};letdataFromModuleWithoutSideEffects:IDataFromModuleWithoutSideEffects={};
Скомпилированный код после устранения ошибок, то есть после уточнения, включать в себя импорты не будет.
В заключение стоит заметить, что, теоретически, уточнение класса, используемого только в качестве типа, способно ускорить компиляцию, поскольку это избавляет компилятор от ненужных проверок на вовлечение его в логику работы модуля. Кроме того, уточнение формы импорта\экспорта это ещё один способ сделать код более информативным.