Когда я задумался о внедрении зависимостей в TypeScript, то первое, что мне посоветовали — inversify. Я посмотрел эту и другие библиотеки, реализующие паттерн Service Locator, и даже сделал свою собственную — typedin.


Но когда я работал над версией typedin 2.0, то в конце концов понял, что вообще никакой библиотеки не нужно. В TypeScript есть все необходимое.




Sevice Locator это антипаттерн


Уже давно известно, что Service Locator это антипаттерн. В первую очередь потому, что он создает неявные зависимости. Если вы просто передаете service сontainer в класс, и в коде класса произвольным образом получаете сервисы, то единственный способ узнать зависимости такого класса — изучить его код.


// Пример из inversify
var ninja = kernel.get<INinja>("INinja");

Конечно, можно чуточку улучшить это обстоятельство, если внедрять зависимости через свойства. Например, вот так это делается в typedin (для inversify тоже есть декоторы):


 class SomeComponent {
     @inject logService: ILogService;
 }

Объявлением такого свойства мы якобы объявляем в интерфейсе класса его зависимость. Но это все еще плохо — мы можем спокойно создать экземпляр класса, не передав ему нужные зависимости, и получить ошибку времени исполнения. IDE нам не подсказывает, как правильно использовать класс.



Вместо этого мы должны сами изучить документацию и все выяснить. Но, допустим, мы преодолели все трудности и написали правильный код. Однако если при добавлении новых фич кто-то добавит еще один сервис в класс, то компилятор никак нас об этом не предупредит. Наш код просто упадет рантайм из-за того, что мы не передаем нужный сервис.


По всем этим причинам самым лучшим способом внедрения зависимостей является constructor injection совместно с composition root.


 class SomeComponent {
     constrcutor(private logService: ILogService) {
     }
 }

Сложности с constructor injection


Внедрение зависимостей через конструктор лишено всех перечисленных выше недостатков. Мы явно объявляем зависимости, так что пользователь просто не сможет создать экземпляр класса, не передав нужные сервисы. При этом компилятор полностью контролирует наш код и сообщит об ошибке сразу. Однако этот подход довольно неудобен в "сыром" виде. Каждый раз при создании экземпляра класса нам нужно передавать в него все необходимые сервисы.


 var some = new SomeComponent(logService)

А если у нас дерево компонентов, то код передачи зависимостей нужно писать во всей цепочке.


 class SomeWrapperComponent {
     constructor(private logService: ILogService) {
        var some = new SomeComponent(logService)
     }
 }

При изменении списка сервисов в SomeComponent придется менять код SomeWrapperComponent и далее всех, кто его использует. Особенно это печально, когда количество сервисов становится сколько-нибудь значительным.


Тем не менее, как показал нам Angular, благодаря декораторам в TypeScript можно автоматически внедрять зависимости, перечисленные в параметрах конструктора.


// Пример внедрения зависимостей через конструктор в Angular
@Injectable()
export class HeroService {     
  constructor(private logger: Logger) {  }
}

То есть, с одной стороны, мы явно объявляем зависимости в параметрах конструктора, а с другой — не пишем кучу бойлерплейта по передаче сервисов в каждый компонент. Сервисы автоматически находятся в дереве компонентов или родительском модуле.


Однако такой подход проблематично реализовать в React. Аналогом конструктора для React-компонентов являются props. То есть, constructor injection в React должен выглядеть примерно так:


render() {
    return <SomeComponent logService={this.logService} />
}

К сожалению, props — это всего лишь интерфейс, и никакие декораторы не позволят нам сделать автоматическую инъекцию зависимостей, как в Angular.


export interface SomeComponentProps {
    logger: Logger
}
export class SomeComponent extends React.Component<SomeComponentProps, {}> {
}

Это проблема не только React. Во многих фреймворках мы не контролируем создание компонентов через конструктор. Например, в том же Vue. На самом деле, в Angular тоже никто не создает компоненты через конструктор, так что там тоже это все актуально.


Нативная инъекция зависимостей средствами TypeScript


Я долго думал, как бы все это совместить, работая над typedin v2.0. Хотелось сохранить явный характер передачи зависимостей, как в constructor injection, но при этом сократить количество бойлерплейта и сделать это совместимым с React.


Постепенно у меня начал появляться прототип такого решения. Я шаг за шагом улучшал код, выкидывал все лишнее до тех пор, пока в один прекрасный момент от библиотеки typedin не осталось совсем ничего. Оказалось, что все, что нужно, уже есть в TypeScript, так что, можно сказать, данная статья — это и есть typedin v2.0.


Итак, все, что нам нужно сделать — добавить одно объявление типа $Logger рядом с объявлением сервиса.


export class Logger {
    log(msg: string) { console.info(msg); }
}
export type $Logger = { logger: Logger; };

Добавим еще один сервис, чтобы было интереснее:


export class LocalStorage {
    setItem(key: string, value: string) { localStorage.setItem(key, value); } 
    getItem(key: string) { return localStorage.getItem(key); } 
}
export type $LocalStorage = { localStorage: LocalStorage }

Объявляем наш компонент, которому требуются зависимости Logger и LocalStorage.


export interface SomeComponentProps {
    services: $Logger & $LocalStorage;
}
export class SomeComponent extends React.Component<SomeComponentProps, {}> {
    constructor(props) {
        super(props);

        // Обращаемся к зависимостям
        let habrGreeting = props.services.localStorage.getItem("Habrahabr");
        props.services.logger.log("Native TypeScript DI! " + habrGreeting);
    )
}

Давайте еще объявим другой сервис, который также нуждается во внедрении зависимостей.


export class HeroService {     
  constructor(private services: $Logger) {
    services.logger.log("Constructor injection is awesome!");
  }
}

Осталось собрать все это вместе. В каком-то месте приложения, мы инициализируем все наши сервисы, согласно паттерну composition root:


let logger = new Logger();
export var services = {
    logger: logger,
    localStorage: new LocalStorage(),
    heroService: new HeroService({ logger }) // Обратите внимание!
};

Теперь можно просто передать этот объект в наш компонент:


render() {
    return <SomeComponent services={services} />
}

Вот и все! Настоящий чистый универсальный constructor injection без бойлерплейта!


Как все это работает


Я обожаю TypeScript за этот оператор & применительно типам. Именно благодаря нему все это выглядит так просто и изящно. При объявлении сервиса Logger мы дополнительно объявили тип $Logger. Если Вас смущает конструкция type, альтернативый вариант такой:


export interface $Logger {
    logger: Logger;
}

Буквально, мы объявляем интерфейс некоторого контейнера, содержащего сервис Logger в переменной logger. И так делает каждый сервис — $LocalStorage, $HeroService. В компоненте нам нужно несколько сервисов, поэтому мы просто объединяем два интерфейса:


services: $Logger & $LocalStorage;

Данная конструкция равносильна примерно следующему:


interface SomeComponentDependecies extends $Logger, $LocalStorage {
     logger: Logger;
     localStorage: LocalStorage;
}
services: SomeComponentDependecies;

То есть мы говорим, что компоненту SomeComponent нужно передать контейнер, содержащий сервисы Logger и LocalStorage. И это все! Каким образом компоненту передадут соответствующий контейнер, откуда он возьмется и как будет создан — это уже не так важно. Можно импортировать какой-то глобальный объект services, созданный в одном месте в composition root. Можно передавать этот объект через цепочку родительских компонентов. Можно создавать его динамически по требованию. Все зависит от условий конкретного приложения.


Заключение


InversifyJS содержит порядка 100кб кода и документацию из порядка 40 разделов, не самых простых для понимания. Тем не менее, ее пакет npm загружают около 100 тысяч раз месяц, пишут для нее множество плагинов и расширений. Из этого можно сделать два вывода:


  1. Внедрение зависимостей набирает популярность в фронтенд-мире
  2. Фронтенд-сообщество еще не успело осознать, что Service Locator — это антипаттерн

То есть как обычно, бездумно выхватываем идеи из других технологий и языков. На самом же деле, инверсия зависимостей это просто передача параметра, и для этого не нужно никаких библиотек. Вы уверены, что все эти фабрики, провайдеры, биндинги, хандлеры, циклические зависимости и тому подобное стоит ресурсов и того усложенения кода, которое они дают?

Комментарии (32)


  1. mayorovp
    12.03.2018 11:18

    А как в вашей схеме передавать сервисы вложенным компонентам?


    Вот у вас есть код метода render: return <SomeComponent services={services} />
    Как будет выглядеть конструктор этого компонента? Да и на объявление класса тоже глянуть хочется.


    1. PFight77 Автор
      12.03.2018 11:24

      Объявление класса после строчек "Объявляем наш компонент, которому требуются зависимости Logger и LocalStorage." в статье.


      Вложенным компонентам сервисы можно передавать двумя способами:


      1. Правильно — передавать services родительского компонента. При этом, родительский компонент должен объявить свой интерфейс services так, чтобы включать все сервисы, необходимые ему и вложенным компонентам.
      2. Использовать какой-то обходной путь, и получать необходимые сервисы из вне.

      В этом суть constructor injection — все зависимости спускаются вниз по дереву простым и очевидным образом.


      1. mayorovp
        12.03.2018 11:27

        Меня интересуют не общие слова, а именно объявления типов, которые предлагается вами делать для того чтобы передавать services родительского компонента дочерним.


        1. PFight77 Автор
          12.03.2018 11:39

          Допустим, дочернему компоненту 1 нужен сервис Logger:


          services: $Logger; // props дочернего компонента 1

          дочернему компоненту 2 сервис LocalStorage:


          services: $LocalStorage; // props дочернего компонента 2

          Родительский компонент должен получить оба сервиса, чтобы передать их в дочерние компоненты. Соответственно, он объявляет свой services следующим образом:


          services: $Logger & $LocalStorage; // props родительского компонента

          Соответственно, этот объект services он сможет передать обоим дочерним компонентам, т.к. тип $Logger & $LocalStorage совместим и с $Logger и c $LocalStorage.


          1. mayorovp
            12.03.2018 11:40

            То есть как я и думал: проблема передачи зависимостей "по цепочке" никуда не делать, просто ушла на уровень типов.


            PS пока спрашивал вас, сам нашел решение:


            export interface AnotherComponentProps {
                services: $Logger & SomeComponentProps['services'];
            }


            1. PFight77 Автор
              12.03.2018 12:00

              Не знал что так можно, спасибо. Отличное решение.


        1. babylon
          12.03.2018 11:43
          -1

          Это проблема не только React. Во многих фреймворках мы не контролируем создание компонентов через конструктор. Например, в том же Vue. На самом деле, в Angular тоже никто не создает компоненты через конструктор, так что там тоже это все актуально.

          Это проблема не React. Это проблема вашего отношения к данному подходу. И да пропсы это интерфейс. И он более правильный чем подход предложенный в Ангуляр
          Замените constructor injection на container injection. Контейнер может быть не только родительским, а любым владельцем. Таким образом вы реализуете не только и не столько наследование сколько композицию, которая намного гибче. Траверсинг по дереву вниз от этого нисколько не меняется. Та жа "очевидность" :)))


          1. mayorovp
            12.03.2018 11:47

            Вы издеваетесь?


            Меня интересуют не общие слова, а именно объявления типов, которые предлагается вами делать для того чтобы передавать services родительского компонента дочерним.

            Пост как бы про конкретное решение относительно организации кода и организации зависимостей. И мне в этом конкретном решении была неясна одна деталь. Почему в ответ на вопрос про эту деталь я получаю кучу теории (которую и сам неплохо знаю)?


  1. justboris
    12.03.2018 11:52

    А что делать с многоуровневым деревом компонента?


    type $Logger = {
        logger: Logger;
    }
    
    // компоненту Child нужен логгер, тут все ок
    interface ChildProps {
        services: $Logger
    }
    const Child: React.SFC<ChildProps> = ({services}) => {
        services.logger.log("test");
        return <span />
    };
    
    // Parent логгер не использует, но декларировать его обязан, так как его использует Child
    interface ParentProps {
        services: $Logger
    }
    
    const Parent: React.SFC<ParentProps> = ({services}) => {
        return <Child services={services} />
    };

    Получается, что на любое изменение зависимостей в глубине дерева, декларация должна всплывать наверх. С тем же успехом можно было везде явным образом передавать отдельный сервис logger через props <Parent logger={logger}> со всеми вытекающими последствиями.


    1. PFight77 Автор
      12.03.2018 11:59

      Да, в этом идея constructor injection. Передавать все сервисы по одному будет более накладно — суть моего подхода в том, чтобы свести эти накладные издержки синтаксиса к минимуму. Впрочем, там выше mayorovp предложил отличный вариант:


      export interface AnotherComponentProps {
          services: $Logger & SomeComponentProps['services'];
      }


  1. staticlab
    12.03.2018 12:14

    А что, если зависимости компонентов пробрасывать не через props, а через контекст?


    1. PFight77 Автор
      12.03.2018 12:19

      Тогда мы опять вернемся к Service Locator. Проблема с контекстом в том, что он не представлен в публичном интерфейсе компонента. То есть, пользователь может создать компонент, не передав ему необходимые сервисы, и узнает об ошибке только во время выполнения. При этом, выяснить какие компоненту нужны сервисы можно только через документацию, изучение его кода или методом проб и ошибок.


  1. oxidmod
    12.03.2018 13:04

    А в мире JS нет вменяемого DI, который сам порешает все зависимости?


    1. staticlab
      12.03.2018 13:33
      +1

      Тут немного другая проблема. В Реакте конструктор класса компонента вызывается неявно библиотекой с передачей ему объекта props. Соответственно, сделать инъекцию через дополнительные параметры конструктора не получится.


      1. mayorovp
        12.03.2018 13:43

        Такая проблема присуща UI вообще, а не только реакту...


        Зато в JS есть замыкания. Можно сделать фабрику классов компонентов, и внедрять зависимости в нее.


        export interface SomeComponentProps { /* ... */ };
        export type SomeComponent = React.Component<SomeComponentProps, {}>;
        export function SomeComponentFactory(
              logger: Logger,
              localStorage: LocalStorage) 
        {
            return class SomeComponentImpl extends SomeComponent {
                // ...
            }
        }
        
        export interface AnotherComponentProps { /* ... */ };
        export type AnotherComponent = React.Component<AnotherComponentProps, {}>
        export function AnotherComponentFactory(SomeComponent: SomeComponent) {
            return class AnotherComponent extends AnotherComponent {
                // ...
        
                render() {
                    return <SomeComponent />
                }
            }
        }

        Но решение автора все равно красивее.


  1. DexterHD
    12.03.2018 13:11

    Всегда казалось что проблема «шаблонного кода» при использовании классического DI через конструктор или свойства класса, это вообще последняя проблема, которую необходимо решать уже после того как проект написал, архитектура поставлена, прототип выпущен и приложение работает. На практике такого бойлерплейта как правило выходит не более сотни строк суммарно на достаточно большой проект состоящий из десятков тысяч строк кода. Конечно если подходить к архитектуре ответственно и следовать хотя бы SRP и feature composition в структуре пакетов. Но видимо чего то я не понимаю или же это специфика какой то конкретной области где зависимостей сотни и тысячи раз приходится писать «бойлерплейт» из пары тройки строк кода.


  1. DarthVictor
    12.03.2018 13:34

    Я конечно не специалист, но Context в React разве не для этого сделан? Раз уж его стандартизировали.


    1. staticlab
      12.03.2018 13:40

      Compile-time type checking не будет.


    1. PFight77 Автор
      12.03.2018 14:57

      Чуть выше уже ответил staticlab на аналогичный вопрос.


  1. redyuf
    12.03.2018 14:34

    У фронтенда есть некоторые особенности перед бэкендом, из-за которых с таким трудом тут внедряется классический DI:
    1. Большая сложность композиции, например, композиция из 10 компонент (если считать их зависимостями) вполне норм, а на бэке (если по SOLID), больше 3х зависимостей считается не очень хорошо.
    2. Интенсивный рефакторинг: чем ближе к ui, тем чаще вносятся изменения, поэтому дополнительный обслуживающий код (типы, регистрация в composition root) замедляет разработку в больше степени, нежели на бэкенде.
    3. Сильная иерархичность условного MVC и необходимость наличия состояния в сервисах (хоть и странно звучит), из-за которой возникает потребность более гибко управлять скоупами зависимостей и временем их жизни. Например, как в ангуларовском hierarchical-dependency-injection.
    4. Необходимость использования типов для объявления зависимостей в паре с несовершенными средствами интроспекции js/ts (нельзя ассоциировать интерфейс со значением в run-time)

    Если просто копировать решения из бэкенда, как это сделано в inversify (калька с C#/Ninject), то результат будет не очень хороший. Как лучше, пока никто не знает, все экспериментируют.

    За отсутствием типизации и вывода типов в React.context, идея хорошая, но все-таки кажется, что бойлерплейта еще много.

    1. Все интерфейсы дочерних компонент приходится объединять в интерфейсе родительского. При рефакторинге родителя, вместе с чилдами надо двигать и их интерфейсы.

    class A extends React.Component<{
      services: Services<A> & Services<B> & Services<C>}>
    }> { ... }

    2. Необходимо примешивать services к каждому компоненту
    class A extends React.Component<{
    services: Services<A> & Services<B> & Services<C>}>
      render() {
       <B services={services}/><C services={services}/>
      }
    }>

    3. Все зависимости по-умолчанию жесткие. Добавили в сторонней библиотеке зависимость компоненту, а в 10 приложухах, использующих ее, теперь надо пойти в composition root и зарегить эту зависимость. Что-бы сделать зависимость мягкой, надо прикладывать усилия:
    class B extends React.Component<{prop: string} & $Logger>
      services: $Logger = {...this.props.services, logger: this.props.services.logger || new Logger(this.props.services)}
      render() {
        // ...
      }
    }>


    Вы в реальном более-менее сложном приложении использовали такой подход?


    1. PFight77 Автор
      12.03.2018 14:55

      Первые 2 пункта в сторону constructor injection как такового. Если их решить, то получится Service Locator…


      Все зависимости по-умолчанию жесткие.

      У нас есть хитрая система, которая позволяет писать так:


      services: $Logger = App.Instance;

      Об этом будет статья чуть позже.


      Как быть с зависимостями сервисов в compostion root?

      Вот пример из статьи:


      let logger = new Logger();
      export var services = {
          logger: logger,
          localStorage: new LocalStorage(),
          heroService: new HeroService({ logger }) // Обратите внимание!
      };


      1. redyuf
        12.03.2018 16:47

        Первые 2 пункта в сторону constructor injection как такового.
        А какая разница? В случае вашего подхода такие же проблемы, только вместо сигнатуры конструктора сигнатура services.

        Если их решить, то получится Service Locator…
        Не обязательно. Можно разными способами попытаться уменьшить бойлерплейт. Например, генерацией метаданных из сигнатур конструкторов и выстраиванием на их основе DI.

        services: $Logger = App.Instance;
        А как это решит проблему, если синглтон App.Instance один на все компоненты? Все-равно в нем надо регистрировать зависимость.

        Вот пример из статьи:
        Я не нашел у вас автоматизации внедрения зависимостей. Каждую новую зависимость надо инжектить вручную. Для компонет-страниц повторяется аналогичная ситуация. В общем случае будет уже не так все просто:
        const logger = new Logger()
        const localStorage = new LocalStorage()
        const fetcher = new Fetcher({logger, localStorage, baseUrl: '/api'})
        const localizations = new Localizations({fetcher})
        
        const services = { logger, localStorage, fetcher, localizations }
        
        class TodosPage extends Component<{
          services: $Fetcher & $Localizations & $LocalStorage & {
            todoRepsitory?: TodoRepsitory; 
            todoFiltered?: TodoFiltered
        }}> {
        
          todoRepsitory = this.props.services.todoRepository || new TodoRepository(this.props.services)
        
          services = {
            ... this.props.services,
            todoRepository: this.todoRepository,
            todoFiltered: this.props.services.todoFiltered || new TodoFiltered({...this.props.services, todoRepository: this.todoRepository})
          }
          render() {
            const {props, services} = this
            return <ul>{services.todoFiltered.todos().map(todo => ...)}</ul>
          }
        }
        
        class App extends Component<Services<Todos> & $Location> {
          render() {
            switch (this.props.services.location.get('page')) {
              case 'todos': return <TodosPage services={this.props.services}/>
            }
          }
        }
        


        1. PFight77 Автор
          12.03.2018 17:10

          Хм, как Вам такой вариант:


          interface MyComponentProps {
              services: $Logger;
          }
          
          class MyComponent extends React.Component<MyComponentProps, {}> {
              defaultServices = {
                  logger: new Logger();
              };
              services: $Logger;
              constructor(props) {
                  this.services = applyDefaultServices(props.services, this.defaultServices);
              }
          }
          
          function applyDefaultServices(services, defaultServices) {
              for (let prop in defaultServices) {
                  if (!services[prop]) {
                      services[prop] = defaultServices[prop];
                  }
              }
          }

          Выглядит вполне прилично. Еще там выше предлагали вариант, как можно объявлять services родительского компонента через services: $Logger & SomeChildComponentProps['services'].


          генерацией метаданных из сигнатур конструкторов и выстраиванием на их основе DI

          Можете подробнее? Пока я вижу в Ваших словах все тот же Service Locator. Если мы не передаем явно каждый раз зависимости, то значит компонент можно создать не передав ему нужные зависимости. В этом вся соль constructor injection — он не позволяет такой ситуации возникнуть на уровне компилятора.


          1. redyuf
            12.03.2018 22:48

            Хелпер может только немного уменьшить бойлерплейт. В вашем примере, запись в services[prop] создаст утечку зависимостей, необходимых для компонента. Они будут жить и после его смерти, лучше уж клонировать services и передавать его чилдам.

            Кстати как бороться с неуникальностью ключей в services?

            Можете подробнее?
            Экспериментируя с DI, я отказался от компонент классов. Описывая контекст во втором аргументе функций и генерируя через бабел из них метаданные, можно добиться примерно такого:
            function TodosView(props, context: {todosRepository: TodosRespository}) {
              return <div>{todosRepository.todos.map( todo => <Todo todo={todo}/> )}</div>
            }
            TodosView.deps = [{todosRepository: TodosRespository}]
            

            Реализация сложнее конечно чем у вас, но шаблонного кода в приложении меньше и c flow совместимо. Зависимости компонента живут вместе с ним. Класс — уникальный ключ.


            1. PFight77 Автор
              12.03.2018 23:08

              Да, клонировать там будет правильнее.


              Кстати как бороться с неуникальностью ключей в services?

              Ключ в этой системе должен быть уникальным. Допустим, в Service Locator сервисы получаются по имени интерфейса — соответственно, имя интерфейса тоже должно быть уникальным. Согласен, что есть потенциальная проблема.


              Экспериментируя с DI

              А как потом использовать TodosView? Как передать ему другую реализацию сервиса? И где он создает для себя свои зависимости (где создается TodosRespository)?


              1. redyuf
                13.03.2018 22:19

                Допустим, в Service Locator сервисы получаются по имени интерфейса
                Лучше путь в импортах + имя, только вычислять его в js, где может быть алиасинг и относительные пути, сложно. Нужны более жесткие соглашения по импортам тогда. Ссылаться через ambiant decorator можно так:
                import _ from 'some'
                import type {A} from 'some-lib/interfaces'
                
                const map = new Map([
                 [(_: A), new MyA()]
                ])
                
                // transpiled to:
                import type {A} from 'some-lib/interfaces'
                
                const map = new Map([
                 ['some-lib/interfaces/A', new MyA()]
                ])
                

                Согласен, что есть потенциальная проблема.
                В одном проекте норм, но чем больше масштаб, тем чаще могут возникнуть коллизии.
                А как потом использовать TodosView?
                Можно оборачивать в фабрику. Можно заменить React.createElement на свою реализацию с сервис локатором внутри, как я сделал.
                Как передать ему другую реализацию сервиса?
                Если только верхний компонент надо замочить, то напрямую, через фабрику. Если во всем поддереве зависимостей, то через конфигурацию di. Что вполне норм, т.к. в средах, где есть DI, объекты вручную обычно не создают.
                const ClonedTodosView = clone(TodosView, [
                  [TodosRepository, MyTodosRepository]
                ])
                вот пример, демо
                И где он создает для себя свои зависимости (где создается TodosRespository)?
                В инстансе первого отображаемого компонента, который задекларировал зависимость. Родительские инстансы наследуются. Пока лучшей стратегии я не смог придумать.


                1. PFight77 Автор
                  14.03.2018 07:25

                  Лучше путь в импортах + имя

                  В typedin у меня еще лучше — ключем выступает ссылка на конструктор класса. Само собой, с ограничением, что нельзя использовать интерфейсы.


                1. vintage
                  14.03.2018 09:42
                  +1

                  В инстансе первого отображаемого компонента, который задекларировал зависимость. Родительские инстансы наследуются.

                  Я вижу 3 странных кейса в такой логике:


                  1. Живут два компонента со своими зависимостями (например, у каждой свой роутер), вдруг их владельцу она тоже понадобилась (например, параметр из роутера нужен для редиректа). И внезапно все 3 компонента начинают работать с общей зависимостью. Довольно неожиданный побочный эффект, не соответствующий нашим намерениям.
                  2. У нас есть два компонента, которые могут работать самостоятельно (карточка задачи и список задач). Если их просто отрендерить рядом, то у каждого из них будет свой набор зависимостей (модель, кеш данных и тп). В результате у нас получится параллельная загрузка одних и тех же данных несколько раз. Довольно странное поведение по умолчанию. Этот косяк надо ещё обнаружить, а обнаружив — влепить странный код с указанием зависимости в общем владельце, где мы её декларируем, но не используем.
                  3. Как быть, когда во владельце нужна одна зависимость, в одном вложенном — та же, а в другом вложенном — уже другая? Пример — область сколлинга, которая провайдит позицию скролла и любой вложенный компонент может её получить. Если вложить два скролла друг в друга, то вложенные во внутренний скролл компоненты внезапно начнут получать позиции внешнего скролла.


                  1. redyuf
                    14.03.2018 15:23

                    В каждом подходе есть свои плюсы и минусы. Есть тенденция (в том же ангуларе) модели тоже делать частью зависимостей. Отсюда возникает проблема — как создавать инстансы с соотвествующим жизненным циклом. Как красиво отделять общее от частного, причем общее может быть для всего приложения и для группы компонент и а частное — для одного. Можно выделить 2 стратегии:

                    1. Все по-умолчанию общее, если для зависимостей в поддереве не сказано обратное (общепринятый подход).

                    class Some extends Component {
                      getChildContext() {
                        return { color: 'test' }
                      }
                    }

                    Это хорошо работает, когда все зависимости — синглтоны или в них нет стейта. А частное возможно только внутри вью-моделей (React.Component или mol_view). На частное DI и контексты не работают, инициализировать модели надо в компонентах, прокидывать их части надо через пропсы в реакте или через make-конструкторы в mol_view. Компонент функцией уже быть не может в этом случае. Если используется какой-нить mobx или атомы, то надо подумать об упаковке значений, перед передачей их дочерним компонентам, ради исключения паразитного автотрекинга.

                    2. Я попытался развить идею, когда все по-умолчанию частное, если для поддерева не сказано обратное. Тогда не важно компонент в виде класса или функции. Система сама догадывается — вот это общая часть, вот эта общая для этой группы компонент, а эта — частная для такого-то компонента. Расчет на то, что более низкоуровневые зависимости обычно вызываются в корневых компонентах, либо регистрируются в корневом DI контейнере (router, fetcher, localStorage) т.к. в них надо передать окружение. Такую компромиссную стратегию я выбрал, т.к. не хотел перегружать DI конструкциями для управления этим добром, как в ангуларе (Self, SkipSelf, Host, provides). Вы правы в том, что тут некоторые вещи не очевидны, т.к. нет четкой грани между свой-чужой.

                    Живут два компонента со своими зависимостями (например, у каждой свой роутер), вдруг их владельцу она тоже понадобилась (например, параметр из роутера нужен для редиректа). И внезапно все 3 компонента начинают работать с общей зависимостью
                    Можно объявить их на уровне корневого компонента, можно унаследовать роутер во владельце и сказать Owner(router: OwnerRouter), тогда у него всегда будет свой экзепляр.
                    У нас есть два компонента, которые могут работать самостоятельно (карточка задачи и список задач). Если их просто отрендерить рядом, то у каждого из них будет свой набор зависимостей (модель, кеш данных и тп). В результате у нас получится параллельная загрузка одних и тех же данных несколько раз. Довольно странное поведение по умолчанию. Этот косяк надо ещё обнаружить, а обнаружив — влепить странный код с указанием зависимости в общем владельце, где мы её декларируем, но не используем.
                    Деоптимизация да, но по идее поведение не сломается, т.к. выше никто о модели не знает. В случае контекстов выбор не сильно лучше — либо мы декларируем модель TaskList в контексте корневого компоненте без использования, либо создаем в компоненте и пробрабрысаваем в пропсы, не используя мощь контекстов. Тут также можно сделать, в корневом заинжектить и в пропсы TaskView и TaskListView пробросить.
                    Как быть, когда во владельце нужна одна зависимость, в одном вложенном — та же, а в другом вложенном — уже другая? Пример — область сколлинга, которая провайдит позицию скролла и любой вложенный компонент может её получить. Если вложить два скролла друг в друга, то вложенные во внутренний скролл компоненты внезапно начнут получать позиции внешнего скролла.
                    Вроде нет принципиальной разницы с контекстами. Ситуация такая: A(scroll1) -> B(scroll1) -> C(scroll2). Как я в комменте выше писал, есть cloneComponent, который может переопределить зависимость для всего поддерева. В случае контекстов, также надо писать в B код, который заменит scroll1 на scroll2 для поддерева.


              1. redyuf
                13.03.2018 22:21
                -1

                Вообще есть холивор между сторонниками whitebox (зависимости публичны, большинство зависимостей жесткие, при тестировании заменяются заглушками) и blackbox (зависимости могут быть приватные, зависимости мягкие и мокается только ввод-вывод: fetch, база и т.д.) подходов в тестировании. Статья на эту тему.

                Стремление сделать зависимости компонента публичными, максимально через типы выразить предусловия всего, что нужно и не нужно для тестирования, как раз происходит из whitebox-подхода, когда все мокается. Переопределяемость детали для поддерева зависимостей нужна, но вот публичность деталей поддерева компонент — вопрос открытый.

                Следуя whitebox, если быть честным до конца, то мы в наш компонент должны инжектить и React.createElement и все дочерние компоненты, предварительно описав их интерфейсы в props. Это было бы очень трудозатратно.

                Для blackbox и мягких зависимостей, проблемы, излагаемые Симаном в приведенной вами статье, отходят на второй план. В случае тестирования компонент, ИМХО, это как раз удобнее. Публичность вообще всех деталей тут не нужна.

                Ambiant context, как в примере винтажа вполне сгодится. Обратите внимание, this.$ там это не центральный реестр (часто под нечто на основе центрального реестра подразумевают service locator).

                Повторюсь, на фронтенде типизация и переопределяемость для поддерева компонент важна, а вот публичность не особенно.


                1. PFight77 Автор
                  14.03.2018 07:24
                  +1

                  Вы однобоко рассматриваете внедрение зависимостей только с точки зрения удобства тестирования. Есть еще повторное использование кода, причем возможно не автором кода. В этом юзкейсе только явные зависимости в интерфейсе и ничего другого быть не может.


                  Вот допустим, разместили вы на гитхабе компонент, и хотите сделать там инверсию зависимостей. Использование чего-то вроде inversify тут было бы странным, а вот описанный мной подход очень подходит.


                  1. redyuf
                    14.03.2018 10:58

                    Я ставил под вопрос не саму инверсию, а публичность, излишнюю полноту контракта, жесткость зависимостей. Повторное использование кода достигается инверсией и без этого, с меньшим бойлерплейтом.

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