Pull to refresh

Comments 32

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


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

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


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


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

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

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

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


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

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


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

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


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

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

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


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


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

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

UFO just landed and posted this here

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


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

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

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


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}> со всеми вытекающими последствиями.

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


export interface AnotherComponentProps {
    services: $Logger & SomeComponentProps['services'];
}
UFO just landed and posted this here

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

UFO just landed and posted this here
UFO just landed and posted this here

Такая проблема присуща 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 />
        }
    }
}

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

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

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

У фронтенда есть некоторые особенности перед бэкендом, из-за которых с таким трудом тут внедряется классический 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() {
    // ...
  }
}>

4. Как быть с зависимостями сервисов в compostion root? Вручную им их передавать? Ведь нельзя же сделать
const services = { logger: new Logger(services) }


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

Первые 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 }) // Обратите внимание!
};
Первые 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}/>
    }
  }
}

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


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 — он не позволяет такой ситуации возникнуть на уровне компилятора.

Хелпер может только немного уменьшить бойлерплейт. В вашем примере, запись в 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 совместимо. Зависимости компонента живут вместе с ним. Класс — уникальный ключ.

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


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

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


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

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

Допустим, в 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)?
В инстансе первого отображаемого компонента, который задекларировал зависимость. Родительские инстансы наследуются. Пока лучшей стратегии я не смог придумать.
Лучше путь в импортах + имя

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

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

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


  1. Живут два компонента со своими зависимостями (например, у каждой свой роутер), вдруг их владельцу она тоже понадобилась (например, параметр из роутера нужен для редиректа). И внезапно все 3 компонента начинают работать с общей зависимостью. Довольно неожиданный побочный эффект, не соответствующий нашим намерениям.
  2. У нас есть два компонента, которые могут работать самостоятельно (карточка задачи и список задач). Если их просто отрендерить рядом, то у каждого из них будет свой набор зависимостей (модель, кеш данных и тп). В результате у нас получится параллельная загрузка одних и тех же данных несколько раз. Довольно странное поведение по умолчанию. Этот косяк надо ещё обнаружить, а обнаружив — влепить странный код с указанием зависимости в общем владельце, где мы её декларируем, но не используем.
  3. Как быть, когда во владельце нужна одна зависимость, в одном вложенном — та же, а в другом вложенном — уже другая? Пример — область сколлинга, которая провайдит позицию скролла и любой вложенный компонент может её получить. Если вложить два скролла друг в друга, то вложенные во внутренний скролл компоненты внезапно начнут получать позиции внешнего скролла.
В каждом подходе есть свои плюсы и минусы. Есть тенденция (в том же ангуларе) модели тоже делать частью зависимостей. Отсюда возникает проблема — как создавать инстансы с соотвествующим жизненным циклом. Как красиво отделять общее от частного, причем общее может быть для всего приложения и для группы компонент и а частное — для одного. Можно выделить 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 для поддерева.
Вообще есть холивор между сторонниками whitebox (зависимости публичны, большинство зависимостей жесткие, при тестировании заменяются заглушками) и blackbox (зависимости могут быть приватные, зависимости мягкие и мокается только ввод-вывод: fetch, база и т.д.) подходов в тестировании. Статья на эту тему.

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

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

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

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

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

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


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

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

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