Pull to refresh

Comments 17

Насколько я понимаю, с автокомплитом в вскоде/вебшторме всё будет совсем грустно. Если при импорте автокомплит хоть как-то мог связать концы с концами, то что такое logger и config в последнем примере, он даже близко понятия не имеет. Преимущества TS тут ощущаются наиболее остро.

У TS есть определение logger и config по-умолчанию? Или всё таки кто-то где-то должен будет определить эти объекты и для TS в том числе?

На тайпскрипте параметры функции будут (logger: ILogger, config: Config), и внутри функции/класса автокомплит досконально знает, что там должно быть в составе объектов.

Вы лишь подтвердили, что кто-то где-то определил типы ILogger & Config. А то, что автокомплит знает то, а не знает это - всего лишь вопрос программирования автокомплита. В Notepad++ никто не знает ничего.

Тут вы конечно правы. Но не умерла еще поддержка такой штуки как https://jsdoc.app/ В пайчарме, во всяком случае, работает. В вебшторме тоже должно.

В вебшторме и вскоде тоже нормально подхватывается jsdoc, но, на мой взгляд, поддерживать в нем актуальную и подробную типизацию не менее трудозатратно, чем перейти на TS.

У JSDoc'а есть перед TS одно немаловажное преимущество - его не надо транспилировать.

И что ? На дворе 23 год, TS уже стандарт в разработке front приложений.

Я задаюсь вопросом: а зачем нам вообще нужен этот конструктор при условии, что в 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.

Sign up to leave a comment.

Articles