Глобальная область видимости (aka namespace в TypeScript) — уже давно не круто. Можно долго перечислять преимущества модулей (ES6 модулей, в частности), но лично для меня решающим стала возможность использовать SystemJS для динамической загрузки исходников и Rollup, для сборки бандла.
Однако, первое, с чем пришлось столкнуться при внедрении ES6-модулей- безумное количество import выражений, с безумным количеством точек внутри:
import { FieldGroup } from "../../../Common/Components/FieldGroup/FieldGroup";
Откуда щупальца растут?
ES6 спецификация по этому поводу ничего особо не говорит, отмахиваясь фразой, что пути к модулям "loader specific". Ну то есть, если вы используете SystemJS, то формат путей определяет SystemJS, если Webpack, то Webpack. Работа над спецификацией загрузчика идет, но, как говорит главная страница репозитория watwg:
This spec is currently undergoing potentially-large redesigns (see #147 and #149) and is not ready for implementations.
Согласие между загрузчиками пока только в том, что путь начинающийся с "./" означает, что нужно искать в той же директории, где находится текущий модуль. Двойные точки "../", соответственно, позволяют подняться на уровень выше, и посмотреть в родительской директории. При этом, даже в самом простом проекте очень легко получить пути, содержащие 3-4 двойных точки "../../../", что ужасно во всех смыслах.
Поскольку спецификации нет, то сейчас каждый решает проблему кто как может. Обычно для этих целей настраивают некоторую корневую папку и все пути указывают относительно нее. Например, babel сообщество придумали себе плагин, а webpack поддерживает настройку resolve.root.
import { BasicEvent } from '~/Common/Utils/Events/BasicEvent'
Однако, даже если вы настроите корневую папку, то это все равно не избавит вас от огромной шапки import-выражений в начале каждого файла. Правила хорошего тона говорят о том, чтобы разбивать код на как можно более маленькие модули, что и является источником проблемы (зануды сейчас скажут, что нужно лучше декомпозировать код, но реальный мир всегда не такой, как хотелось бы).
Что особенно печально, каждый раз, импортируя некоторый модуль, вы создаете жесткую привязку к расположению этого модуля в файловой системе. Поэтому 1) вам, как минимум, нужно точно помнить, где находится каждый модуль 2) если вы захотите сделать рефакторинг (например, переименовать файл), то вас ожидает много боли.
Ну и последняя боль, с которой придется столкнуться при использовании TypeScript в VisualStudio — там не работает подсветка синтаксиса, а также линтинг JSX для импортированных символов. Например:
import { FieldGroup } from "../../Components/FieldGroup/FieldGroup";
import { BasicEvent } from "../../Common/Utils/Events/BasicEvent'
...
var event = new BasicEvent(); // BasicEvent в VisualStudio не подсвечивается как класс
...
render() {
// JSX для FieldGroup в VisualStudio не линтится (параметры компонента не проверяются),
// и intellicese не работает, т.к. FieldGroup импортированный символ
return <FieldGroup name="blabla" />;
}
В Microsoft, по-видимому, проблему решать не спешат (issue 1, issue 2).
Магические пакеты всех спасут
Решение проблемы состоит в том, чтобы отказаться от идеи отдельных модулей, беспорядочно связанных между собой, и начать использовать, хм, что-то вроде "пакетов модулей". Я не уверен, публиковалось ли уже где-то такое решение в данном контексте (UPD: gogolor подсказал, что у Angular в доках это называется barrel), однако сама идея не нова. Например, в C# у нас также есть отдельные файлы с кодом, но при этом данные файлы собираются в "сборки" (dll), которые уже явно объявляют ссылки на другие сборки.
Представим, что у нас есть следующая структура проекта (скриншот из реального проекта некоторой админ-панели):
Для того, чтобы из файла AssignmentTemplatSettings.tsx дотянуться до BasicEvent.ts, пришлось бы написать что-то вроде:
import { BasicEvent } from '../../Common/Utils/Events/BasicEvent';
Это ужасно по всем тем причинам, что я описывал выше. Однако, если вы посмотрите на структуру проекта, то легко заметить, что все модули естественным образом распределены по папкам. Чем сложнее проект, тем более разветвленной становится структура папок. Это стремление к упорядочиванию живет в каждом разработчике, и скорее всего, в вашем проекте наблюдается нечто подобное.
Хорошая новость заключается в том, что ES6-модули позволяют конвертировать такую структуру папок в структуру "пакетов", очень напоминающих dll в десктоп мире. Можно сделать каждую папку пакетом (например, Common/Utils/Events будут вложенными пакетами), можно ограничиться более крупными единицами (только Common/Utils). Для каждого пакета модулей будет четко указано, от каких пакетов он зависит и что "выставляет наружу". Все эти зависимости будут собраны в одной точке, так что модули пакета не будут ничего знать о расположении модулей других пакетов. При этом количество точек ("../../") в относительных путях будет не больше, чем вложенность папок внутри одного пакета, а количество import-выражений сократится вплоть до одного.
Реализация
Для того, чтобы конвертировать папку в пакет, достаточно добавить в нее два файла — imports и exports. В первом файле мы импортируем и делаем ре-экспорт всего того, что необходимо модулям данного пакета. Во второй файл помещается экспорт всего того, что пакет делает доступным для импорта в другие пакеты.
Реализуем экспорт
Попробуем сделать пакет из папки Events. Пусть наружу он выставляет два класса — BasicEvent и SimpleEvent. Тогда, файл @EventsExports.ts будет выглядеть следующим образом:
export * from "./BasicEvent";
export * from "./SimpleEvent";
Собака "@" в имени файла гарантирует, что он не потеряется среди других файлов пакета и будет всегда самом верху. От других пакетов нам здесь ничего не понадобится, поэтому файла imports здесь пока не делаем. Далее конвертируем родительские папки Utils и Common в пакеты. Например, @UtilsExports.ts будет содержать:
import * as Events from "./Events/@EventsExports";
import * as ModalWindow from "./ModalWindow/@ModalWindowExports";
import * as Other from "./Other/@OtherExports";
import * as RequestManager from "./RequestManager/@RequestManagerExports";
import * as ServiceUtils from "./ServiceUtils/@ServiceUtilsExports";
export { Events, ModalWindow, RequestManager, ServiceUtils };
Здесь не указаны модули CachingLoader и другие, которые находились непосредственно в папке Utils. Это ограничение данного подхода, пакеты, которые экспортируют другие пакеты, не могут содержать своих модулей. Поэтому пришлось переместить все эти файлы в дочерний пакет Other. Содержимое imports-файла будет рассмотрено позже.
Аналогично делаем @CommonExports.ts:
import * as Components from "./Components/@ComponentsExports";
import * as Extensibility from "./Extensibility/@ExtensibilityExports";
import * as Models from "./Models/@ModelsExports";
import * as Services from "./Services/@ServicesExports";
import * as Utils from "./Utils/@UtilsExports";
export { Components, Extensibility, Models, Services, Utils };
Реализуем импорт
Теперь перейдем к пакету Tabs. Очевидно, что ему потребуется много классов из пакета Common. Соответственно, его файл @TabsImports.ts будет выглядеть следующим образом:
import * as Common from "../Common/@CommonExports";
export { Common };
Теперь в модуле AssignmentTemplatesSettings.tsx этого пакета достаточно написать следующее:
import { Common } from "../@TabsImports";
// Что-то вроде using для удобства обращения к вложенному пакету
var Events = Common.Utils.Events;
// Используем класс BasicEvent из модуля Common/Utils/Events/BasicEvent.ts
var basicEvent = new Events.BasicEvent();
Как видно, вместо указания полного пути к файлу BasicEvent, мы просто указываем, в каком пакете он располагается. Что особенно приятно, так это то, что при написании Events.BasicEvent подсветка синтаксиса и линтинг JSX в VisualStudio прекрасно работают!
Если пакету Tabs нужен только пакет Events, то можно переписать TabsImports.ts следующим образом:
import * as Common from "../Common/@CommonExports";
var Events = Common.Utils.Events;
export { Events };
Либо так:
import * as Events from "../Common/Utils/@EventsExports";
export { Events };
В последнем случае мы опять-таки привязываемся к пути, однако эта привязка идет на уровне пакета, поэтому при рефакторинге боли будет намного меньше, чем когда привязка стоит в каждом модуле. Сократив же количество импортируемого кода, мы ограничили количество файлов, которые загрузчик должен подготовить, прежде чем выполнить код нашего модуля (например, это актуально, если вы разбиваете бандл на несколько частей, подгружаемых лениво).
Внутренние связи модулей в пакете
Связь модулей внутри пакета уже не такая страшная проблема, т.к. все они находятся рядом. Однако, по ряду причин, может понадобиться использовать такой же механизм для импорта модулей текущего пакета. Использовать exports-файл не получится, т.к. он по определению должен включать не все содержимое пакета. Однако, можно использовать его для создания третьего служебного файла internals:
export * from "./@EventsExports"; // Ре-экспортируем публичные члены
export * from "./SomeInternalEventImpl"; // Экспортируем внутренние элементы
export * from "./SomeAnotherInternalEventImpl
Соответственно, после этого мы можем использовать этот файл везде внутри пакета:
import * as Events from "./@EventsInternals";
let eventImpl = new Events.SomeInternalEventImpl();
Циклические зависимости разрешены спецификацией, поэтому с импортом internals проблем возникнуть не должно. По крайней мере, SystemJS корректно обрабатывает такие ситуации.
Результаты
- Мы избавились от ужасных точек "../../../" в пути импорта, при этом не прибегая к абсолютным путям, сохранив гибкость относительных.
- Мы избавились от необходимости импортировать каждый используемый модуль по отдельности, создавая огромную import-шапку в каждом файле. Вместо этого один раз импортируем нужные пакеты из imports-файла своего пакета.
- Мы вернули подсветку синтаксиса и линтинг JSX в VisualStudio.
- Поскольку мы используем переменные (пакеты), а не имена файлов при импорте, то рефакторинг в TypeScript окружении становится элементарным. Переименование пакета происходит автоматически, работает find-all-references и т.д.
- Зависимости между модулями упорядочены и сконцентрированы в специальных файлах, которых не очень много. Даже без TypeScript рефакторить такой код намного проще.
- При желании любой пакет легко будет выделить в отдельный проект, поскольку он в некоторой степени обособлен и все его зависимости явно прописаны. Разделение на пакеты естественным образом понуждает разработчика лучше структурировать приложение.
- Для часто используемых пакетов можно настроить alias в вашем загрузчике и импортировать его просто по имени, без указания пути.
Из недостатков — появились дополнительные imports/exports файлы, которые нужно постоянно актуализировать. В принципе, составление таких файлов можно автоматизировать не очень сложной gulp-задачей, главное придумать конвенцию, как различать экспортируемые и внутренние модули пакета. Ну и еще как недостаток — при обращении к импортированным символам необходимо добавлять имя пакета (Events.BasicEvent вместо BasicEvnet). Но, я считаю, с этим можно смириться, учитывая, что мы получаем взамен.
UPD: justboris обратил внимание, что exports-файл может быть удобно называть index.ts, т.к. многие сборщики и IDE считают его файлом "по умолчанию" в директории.
UPD: dzigoro отметил, что WebStorm поддерживает автоматическое добавление import-деклараций, а также их обновление при рефакторинге.