Comments 17
Насколько я понимаю, с автокомплитом в вскоде/вебшторме всё будет совсем грустно. Если при импорте автокомплит хоть как-то мог связать концы с концами, то что такое logger и config в последнем примере, он даже близко понятия не имеет. Преимущества TS тут ощущаются наиболее остро.
У TS есть определение logger и config по-умолчанию? Или всё таки кто-то где-то должен будет определить эти объекты и для TS в том числе?
На тайпскрипте параметры функции будут (logger: ILogger, config: Config), и внутри функции/класса автокомплит досконально знает, что там должно быть в составе объектов.
Тут вы конечно правы. Но не умерла еще поддержка такой штуки как https://jsdoc.app/ В пайчарме, во всяком случае, работает. В вебшторме тоже должно.
В вебшторме и вскоде тоже нормально подхватывается jsdoc, но, на мой взгляд, поддерживать в нем актуальную и подробную типизацию не менее трудозатратно, чем перейти на TS.
Я задаюсь вопросом: а зачем нам вообще нужен этот конструктор при условии, что в js этот механизм уже реализован через ключевое слово export?
Вот код service.js:
import config from './config.js';
import logger from './logger.js';
let service = {config, logger};
export default service;
Код index.html:
<script type="module">
import service from './service.js';
service.logger("test");
</script>
По факту в каждый модуль Вы будете проставлять только одну зависимость от service.js.
Я что-то упустил?
В вашем коде связывание просиходит на уровне импортов, не в рантайме. Ну и если у вас есть соответствующая фабрика и соответствующий сервис с DI он становится более ПЕРЕМЕЩАЕМЫМ, не зависит от импортов и их путей вообще. Дело вкуса короче. По идее оно должно делать мир проще, по факту уничтожает навигацию по коду. Надо постоянно держать в голове идею о том, что где-то сверху есть что-то, что вонзило сюда аргументы.
Точно так. А для навигации по коду я использую JSDoc'и (как вы указали в комменте выше). В PhpStorm'е работают очень даже неплохо. В них, кстати, и интерфейсы есть, и привязка к имплементации. Можно над конкретикой JS'а надстроить свою собственную абстракщину :)
Раскрою мысль.
Каждый подключаемый модуль пройдет в начале через импорт или его аналог (например, прямое внедрение на страницу через тег script). Это часть жизненного цикла. Ибо откуда он сам по себе возьмется? А это означает, что адрес модуля, его физическое расположение любом случае нужно будет учитывать (даже хотя бы один раз).
Причем эта концепция полностью отражена в моем простом коде service.js, где все модули грузятся только всего один раз и будут доступны в неймспейсе модуля service.js.
Если какой-то из модулей будет перемещен (кроме базового, разумеется), то изменения придется внести один раз, что в коде автора, что в моем примере. И в голове ничего не надо держать.
Нужно больше рантайма? Вот новый код service.js:
import config from './config.js';
import logger from './logger.js';
let service = {config, logger};
window.service = service;
export default service;
Теперь вообще можно не использовать js-импорт в модулях. Но рано радоваться, потому что для работы линтера как бы не пришлось в коде без js-импортов каждый раз указывать jsdoc-импорт типа service.
Например, чтобы линтер точно понял с чем мы имеем дело, все равно придется прописать прямую зависимость в каждом модуле что-то вроде этого:
/** @typedef {import("./service.js").service} service */
/** @type {service} */
let service = window.service;
Каждый подключаемый модуль пройдет в начале через импорт или его аналог (например, прямое внедрение на страницу через тег script). Это часть жизненного цикла. Ибо откуда он сам по себе возьмется? А это означает, что адрес модуля, его физическое расположение любом случае нужно будет учитывать (даже хотя бы один раз).
Точно. В ES6+ модули загружаются через импорт - статический или динамический. Других вариантов нет.
Причем эта концепция полностью отражена в моем простом коде service.js, где все модули грузятся только всего один раз и будут доступны в неймспейсе модуля service.js.
Это и есть прямой контроль - когда разработчик сервиса должен знать, где находятся его зависимости, чтобы подключить их через import.
Если какой-то из модулей будет перемещен (кроме базового, разумеется), то изменения придется внести один раз
Именно. Изменения придётся внести. Если у вас зависимость (logger) используется в одном модуле (service), то придётся делать одно изменение. А если у вас таких сервисов десять тысяч, то придётся делать десять тысяч изменений. Вы всего лишь передвинули один модуль (logger), а менять пути импорта придётся в десяти тысячах скриптов, которые этот модуль используют.
как бы не пришлось в коде без js-импортов каждый раз указывать jsdoc-импорт типа service.
Именно это и приходится делать - использовать JSDoc для описания типов зависимостей.
Вот смотрите, довольно распространённая ситуация. Вы пишите сервис, который должен логировать данные в ходе выполнения своей работы. У вас есть два логгера - консольный (для разработки) и файловый (для прода). Во "взрослых" языках программирования вы можете определить интерфейс логгера. Например так:
/** @interface */
class ILogger {
error(msg) {}
info(msg) {}
}
затем использовать этот интерфейс при разработке сервиса:
class Service {
/** @type {ILogger} */
logger;
exec(opts) {
this.logger.info('Executing...');
}
}
Всё, для разработки сервиса вам не нужно знать, где собственно находятся имплементации логгера и какие (консольные, файловые, сетевый, базы данных).
Но. Чтобы это дело заработало, каким-то образом нужно внедрить зависимость logger в класс service. Обычно это делают через конструктор:
class Service {
/** @type {ILogger} */
logger;
/** @param {ILogger} logger */
constructor(logger) {
this.logger = logger;
}
}
Смотрите, вот валидный JavaScript код, который соответствует поставленной задаче (сервис с логгированием) и в котором нет ни одного импорта. Мы просто резанули задачу по месту склейки (интерфейсу) и можем распараллелить процесс разработки: один разработчик делает сервис, второй - файловый логгер, третий - логгер для перенаправления логов на сервис Sentry.
Вы правы, что без импортов ничего не закрутится. Как разработчик сервиса вы можете поднять окружение (привет TDD!), которое мокирует зависимости в соответствии с заданным интерфейсом и проверяет корректность имплементации:
import assert from 'assert';
import {describe, it} from 'mocha';
import {Service} from './Service.js';
/** @implements ILogger */
const logger = {
error(mg) {},
info(msg) {}
};
describe('Service', () => {
it('does the job', () => {
const service = new Service(logger);
service.exec({});
assert(true);
});
});
Вот здесь, в тестовом окружении, вы import'ы и используете. Вот это уже инверсия управления. Разраб сервиса ничего не знает о том, где находится код логгера и какой из логгеров (консольный или файловый) будет использоваться - это вне рамок поставленной ему задачи. Он лишь знает, что каким-то образом его сервис получит логгер при создании. Для разработки сервиса разраб мокирует зависимости, имплементируя нужное ему поведение зависимостей. Это в данном случае логгер просто делает что-то и не возвращает ничего. Зачастую зависимости возвращают какой-то результат и разработчик сервиса может запрограммировать этот результат (или набор результатов) в своей имплементации зависимости. Более того, для разных тестов можно создавать разные имплементации одних и тех же зависимостей. Зацепление кода резко снижается. В JS код цепляется импортами. Нет импортов - нет зацепления.
Подобная красота достигается вот таким типовым кодом:
export class Service {
constructor(dep1, dep2, dep3, ...) { }
}
Да, на самом деле, с JSDoc'ами, код должен выглядеть вот так вот:
export class Service {
/**
* @param {Type1} dep1
* @param {Type2} dep2
* @param {Type3} dep3
* ...
*/
constructor(dep1, dep2, dep3, ...) { }
}
А на практике вообще вот так:
export class Service {
/**
* @param {Type1} dep1
* @param {Type2} dep2
* @param {Type3} dep3
* ...
*/
constructor({
Type1: dep1,
Type2: dep2,
Type3: dep3,
...
}) { }
}
Приходится по сути дублировать JSDoc'ами типы зависимостей. Но. В замен такому геморрою мы получаем не только слабое зацепление но и ещё кое-какие DI'ные плюшки - синглтоны и транзиентные объекты, например.
И это в ванильном JavaScript'е. Один и тот же код и для браузера, и для ноды. Без какой-либо транспиляции. Ну разве не красота?!
А если у вас таких сервисов десять тысяч, то придётся делать десять тысяч изменений. Вы всего лишь передвинули один модуль (logger), а менять пути импорта придётся в десяти тысячах скриптов, которые этот модуль используют.
Я пытаюсь донести, что если не хотите указывать все зависимости в виде импортов в ваших пакетах, то можно использовать главный (базовый) модуль-контейнер (аля master of packages), в котором и будет все импортировано один раз. Собственно будет внесено исправление один раз.
Если нужно принимать решение в рантайме что импортировать в зависимости от окружения (с помощью динамического импорта), то Вы это можете сделать в модуле-контейнере, или создать вообще отдельный контейнер для решения специфических задач.
Далее по поводу проблемы имплементации логгера, а есть ли вообще проблема? Это вопрос конфигурирования ваших сервисов с учетом окружения. Это вообще другая тема.
Коллега @Pab10 ответил совершенно верно. Код с импортами привязан к конкретной имплементации:
import config from './config.js';
А если имплементация через импорт имеет свои зависимости, у которых свои импорты, то выстраивается развесистое дерево зависимостей. В общем, если вы код не рефакторите, то и ничего - можно захардкодить архитектуру на импортах. А если рефакторите, тогда желательно обеспечивать максимально низкое зацепление (coupling). IoC как раз об этом.
В принципе, подобная технология позволяет писать код вообще без импортов в скриптах (ну или почти без импортов - на внешние библиотеки придётся делать). "Внешний управляющий" (контейнер объектов) анализирует требования к зависимостям (в конструкторе или фабричной функции), подгружает исходники через динамический импорт, анализирует зависимости зависимостей и так пока не будут удовлетворены все требования. После чего создаются все объекты в иерархии.
Во "взрослых" (с точки зрения "энтерпрайзности") языках программирования (Java, C#) уже, наверное, десятилетиями используют контейнеры объектов. Очень удобная штука. Особенно для тестирования. Но там и интерфейсы есть и прочая абстракщина.
В дополнение к моему ответу выше (https://habr.com/ru/articles/748132/comments/#comment_25753998) могу сказать, что получившийся service.js, который доступен через window.service является как раз таковым контейнером.
Если мы говорим о "пакетах", то внутри папки пакета вполне допустимо и правильно использовать относительные пути.
Если же речь идет о ситуации, когда один пакет использует другой, и вообще нам нужно гарантировать, что все зависимости будут подключены, то и мой пример вполне годится для этого. Потому что при обращении к service.unknown_packet линтер укажет на необходимость подключения пакета к service.
Да, ваш пример совершенно корректен. Так можно и нужно писать код в ES6+ - через import'ы. Как я написал в статье: "Резон использовать этот принцип появляется в тот момент, когда разработчики начинают задумываться не о том, как реализовывать бизнес-функции, а о том, как организовать код так, чтобы можно было продолжать реализовывать бизнес-функции с приемлемой скоростью."
Грубо говоря, до десятков npm-пакетов и сотен или даже тысяч es-модулей можно не заморачиваться с IoC.
Зачем нужно внедрение зависимостей в JS