В процессе обсуждения статьи "Почему я «мучаюсь» с JS" у меня сложилось понимание, что связка export
/ import
в JS является базой для указания зависимостей между элементами кода (классами и функциями). А так как современные приложения вышли за рамки однофайловых и давно уже строятся из блоков, то выстраивание зависимостей между элементами кода имеет весомое значение. Настолько весомое, что в знаменитой аббревиатуре SOLID этому посвящена отдельная буква — D (Dependency inversion — инверсия зависимостей, не путать с Dependency injection — внедрение зависимостей).
Размышляя над тем, как связываются зависимые элементы кода в JS через export
/ import
, я пришёл к выводу, что не все зависимости в коде es6
-модулей SOLID'ных приложений можно описать инструкциями import
. Излагаю свои соображения, чтобы коллеги могли указать, где я ошибаюсь, или подтвердить мои выкладки.
Ограничение: все размышления относятся к nodejs
-приложениям и es6
-модулям.
export-import
В nodejs
-приложениях самым крупным блоком является npm
-пакет, а самым малым - отдельный экспорт es6
-модуля:
export { name1, name2, …, nameN };
В рамках одного npm
-пакета зависимости между es6
-модулями этого пакета указываются через импорты, на основании относительной адресации:
import ModuleLoader from './ModuleLoader.mjs';
Для использования в мультипакетном режиме npm
-пакеты экспортируют свой код через инструкцию main дескриптора пакета package.json
:
{
"main": "src/Shared/Container.mjs"
}
Указание зависимости из "соседнего" пакета, экспортируемой через main
:
import Container from '@teqfw/di';
Также возможно указание зависимости из "соседнего" пакета напрямую, без привязки к main
:
import Container from '@teqfw/di/src/Shared/Container.mjs';
Таким образом, "джентльменским соглашением" между разработчиками разных npm
-пакетов является использование экспорта "точки входа" в пакет, указанной в main
. Это даёт понимание пользователям пакета, что из содержимого пакета его разработчик счёл публичным интерфейсом (и будет стараться изменять по минимуму), а что — "внутренностями" пакета. Тем не менее, JS предоставляет возможность использовать экспорт из любого es6
-модуля любого npm
-пакета напрямую.
Механизм export
/ import
является базовым для указания зависимостей между элементами кода в nodejs
-приложениях и привязан к файловой системе, содержащей файлы es6
-модулей. Механизм конкретен и не допускает подмены одного файла другим ни при каких условиях.
Инверсия зависимостей
Принцип инверсии зависимостей предполагает использование абстракций вместо конкретики. В программировании принято абстракции описывать как интерфейсы. Например, в TS:
# file './src/Person.ts'
interface Person {
firstName: string;
lastName: string;
}
Использование абстракции (интерфейса Person
) в декларации функции greeter
как раз и демонстрирует принцип инверсии зависимости:
# file './src/Greeter.ts'
import {Person} from './Person.js';
export function greeter(person: Person) {
return "Hello, " + person.firstName + " " + person.lastName;
}
Но проблема в том, что интерфейсы не существуют даже в ES2021, не говоря о более ранних версиях. При компиляции TS-кода в 'ESNext' для интерфейса ./src/Person.ts
имеем практически пустой файл ./build/Person.js
:
export {};
А импорт из ./build/Greeter.js
пропадает:
export function greeter(person) {
return "Hello, " + person.firstName + " " + person.lastName;
}
Что вполне логично и соответствует "бритве Оккама" — не плоди сущностей сверх необходимого. И хотя в исходном TS-коде мы имели зависимость от абстракции и import
, указывающий на эту абстракцию, в результирующем JS-коде осталась только зависимость, без import
'а.
Внедрение зависимостей
Основная идея внедрения зависимостей - "объект пассивен и не предпринимает вообще никаких шагов для выяснения зависимостей, а предоставляет для этого сеттеры и/или принимает своим конструктором аргументы, посредством которых внедряются зависимости".
Т.е., чтобы JS-код соответствовал принципам внедрения зависимостей, создание зависимостей и их внедрение должно выполняться внешней по отношению к JS-коду сущностью — контейнером. JS-код должен предоставить механизм внедрения зависимостей (сеттеры, конструктор, аргументы функции), а контейнер каким-то образом сам должен сообразить (например, на основе конфигурации), какие зависимости нужны в каком случае, создать нужные объекты и внедрить их, используя сеттеры, конструктор или входные аргументы.
В сочетании с принципом инверсии зависимостей и текущими особенностями JavaScript (отсутствием интерфейсов) я прихожу к выводу, что при декларации классов/функций, зависящих от абстракций, в es6
-модулях не должны использоваться import
'ы, т.к. они привязаны к конкретным имплементациям (файлам — es6
-модулям). Поэтому в приложениях, в которых соблюдается принцип инверсии зависимостей, допустимы es6
-модули в которых полностью отсутствуют импорты, несмотря на то, что в них есть зависимости от других es6
-модулей. Что-то типа:
export class DiCompatible {
constructor(dep1, dep2, dep3) {...}
...
}
В таком случае внедрением зависимостей занимается DI-контейнер - именно он, в соответствии со своими настройками, должен разрешать (resolve'ить) запрошенные зависимости, загружать нужные модули и импортировать соответствующие объекты кода (классы/функции). А наличие в коде es6
-модулей импортов конкретных имплементаций "убивают" принцип инверсии зависимостей.