В интернете немало публикаций на тему реализации Dependency Injection (далее - DI) в React, также существует немало сторонних npm-пакетов, таких как inversify-react, react-simple-di и других. Но, по моему мнению, DI настолько просто реализуется средствами самого React, без дополнительных выкрутасов и boilerplate-кода, что никакая сторонняя библиотека во многих случаях попросту не нужна. В данной небольшой статье я постараюсь обосновать это свое мнение. Примеры кода будут приведены на TypeScript.
Итак, чего мы собственно хотим добиться. Допустим у нас есть некоторый компонент, использующий наш кастомный хук:
import { useMyHook } from ...;
const MyComponent: React.FC = () => {
...
var someResult = useMyHook();
...
}
Как мы видим, компонент MyComponent имеет прямую зависимость в виде хука useMyHook. Техника внедрения зависимостей же, в свою очередь, предполагает, что мы должны иметь возможность каким-то образом внедрить useMyHook в компонент извне, при необходимости заменив его реализацию чем-то другим. Это позволяет решить задачи уменьшения связанности, дает более широкие возможности для повторного использования и тестирования ваших компонентов, если такие задачи, конечно, стоят перед разработчиком.
Типичное решение этой задачи - использование некого DI-контейнера, хранящего зависимости - с одной стороны, мы можем сконфигурировать контейнер, положив в него все нужные зависимости, а с другой стороны - компонент, которому нужна зависимость, может извлечь ее из контейнера.
Вот как я предлагаю объявить и реализовать сам контекст:
import React from 'react';
import { foo } from ...;
import { bar } from ...;
const Container = {
foo,
bar,
};
export type DIContainerType = typeof Container;
export const DIContext = React.createContext<Partial<DIContainerType>>(Container);
Мы сделали три вещи: во-первых объявили константу, содержащую зависимости, необходимые нашему приложению. Может показаться, что мы захардкодили все наши зависимости, но это не так - эта константа нужна нам для использования в качестве значения по-умолчанию для контекста и для того, чтобы задать в TypeScript тип нашего контейнера (см. далее). Работая с любой библиотекой по реализации DI, вы скорее всего будете где-то инициализировать ваш контейнер - мы это сделали объявлением этой константы. Во-вторых, мы вывели тип нашего контейнера. И в третьих - собственно создали React Context. И вот в этих во-вторых и в-третьих мы использовали несколько маленьких хитростей:
import { useContext } from 'react';
import { DIContext, DIContainerType } from '../context/DIContext';
export const useInjection = (): DIContainerType => {
return useContext(DIContext) as DIContainerType;
};
Реализуя этот хук, мы, во-первых, инкапсулировали DIContext - теперь компонентам, использующим useInjection, не нужно ничего знать про этот контекст, а во-вторых, пошли на некоторый обман TypeScript и явно привели тип, возвращаемый useContext к DIContainerType. Если бы мы этого не сделали, то результат, возвращаемый из useInjection, имел бы тип Partial<DIContainerType> и содержащиеся в нем значения необходимо было бы проверять на undefined. В нашем приложении, учитывая то, как мы объявили наш контекст, зависимости никогда не будут undefined, это возможно только в тестах, если мы забудем передать необходимую зависимость в Context Provider. Что как по мне, большой проблемой не является. Пусть использование такого трюка и немного нечестно по отношению к TypeScript, зато очень удобно.
Ну и теперь в компоненте, мы можем получить зависимость следующим образом:
const MyComponent: React.FC = () => {
...
const { foo, bar } = useInjection();
...
}
А в тестах мы сможем подменить необходимые зависимости на их Mock:
it("...", () => {
const mockFoo = ...
const mockBar = ...
const page = mount(
<DIContext.Provider value={{foo: mockFoo, bar: mockBar}}>
<MyComponent />
</DIContext.Provider>
);
...
}
И напоследок, небольшой вариант улучшения. Если хотите инициализировать ваш контейнер не в одном месте, превращая его в большую свалку, а разнести по различным файлам (by business domain), это можно легко осуществить:
import myDomainContainer from ...
import otherDomainContainer from ...
const Container = {
...myDomainContainer,
...otherDomainContainer
};
Вот собственно и все, что я хотел показать. Как мне кажется, мне удалось реализовать механизм DI довольно просто, без необходимости использования сторонних библиотек. Возможно и в вашем проекте вполне можно обойтись без них, избежав тем самым лишней сложности - если требования, предъявленные к реализации, достаточны, а сама реализация и использование вас удовлетворяют. А может вам и вовсе не нужен DI - тут нужно исходить из того, какие преимущества вам это даст и даст ли вообще.
Итак, чего мы собственно хотим добиться. Допустим у нас есть некоторый компонент, использующий наш кастомный хук:
import { useMyHook } from ...;
const MyComponent: React.FC = () => {
...
var someResult = useMyHook();
...
}
Как мы видим, компонент MyComponent имеет прямую зависимость в виде хука useMyHook. Техника внедрения зависимостей же, в свою очередь, предполагает, что мы должны иметь возможность каким-то образом внедрить useMyHook в компонент извне, при необходимости заменив его реализацию чем-то другим. Это позволяет решить задачи уменьшения связанности, дает более широкие возможности для повторного использования и тестирования ваших компонентов, если такие задачи, конечно, стоят перед разработчиком.
Типичное решение этой задачи - использование некого DI-контейнера, хранящего зависимости - с одной стороны, мы можем сконфигурировать контейнер, положив в него все нужные зависимости, а с другой стороны - компонент, которому нужна зависимость, может извлечь ее из контейнера.
Реализация
Предъявим некоторые требования к решению:- Зависимости после внедрения должны быть типизированными (иметь строгий соответствующий им тип, не any)
- В случаях, когда нам нужно подменить зависимости (например, в тестах - заменить на Mock-и) - мы должны иметь возможность указать только интересующие нас зависимости, а не интересующие - не указывать
- Минимизация boilerplate-кода. В идеале его не должно быть вообще, в реальности - кода должно быть не сильно больше, по сравнению с неиспользованием DI
Вот как я предлагаю объявить и реализовать сам контекст:
import React from 'react';
import { foo } from ...;
import { bar } from ...;
const Container = {
foo,
bar,
};
export type DIContainerType = typeof Container;
export const DIContext = React.createContext<Partial<DIContainerType>>(Container);
Мы сделали три вещи: во-первых объявили константу, содержащую зависимости, необходимые нашему приложению. Может показаться, что мы захардкодили все наши зависимости, но это не так - эта константа нужна нам для использования в качестве значения по-умолчанию для контекста и для того, чтобы задать в TypeScript тип нашего контейнера (см. далее). Работая с любой библиотекой по реализации DI, вы скорее всего будете где-то инициализировать ваш контейнер - мы это сделали объявлением этой константы. Во-вторых, мы вывели тип нашего контейнера. И в третьих - собственно создали React Context. И вот в этих во-вторых и в-третьих мы использовали несколько маленьких хитростей:
- Тип контейнера не нужно расписывать вручную - мы выводим его используя оператор typeof. Очень удобно - в случае, если нам понадобится добавить зависимости - мы просто добавим их в Container, а вся магия TypeScript сработает автоматически.
- Мы задали тип нашему контексту не просто DIContainerType, а Partial<DIContainerType>. Это позволит нам в тестах, где мы будем оборачивать компонент в DIContext.Provider, указать только интересующие нас зависимости
- Мы передали в React.createContext значение Container в качестве параметра defaultValue. Что это нам дает? Теперь нам не надо в нашем реальном приложении создавать где-либо DIContext.Provider. Мы это будем делать только в тестах, когда нам нужно подменить зависимости. А в реальном приложении, в случае если провайдер не задан (а мы его умышленно не будем задавать) - значения будут браться из параметра defaultValue, который содержит контейнер со всеми необходимыми нашему приложению зависимостями.
import { useContext } from 'react';
import { DIContext, DIContainerType } from '../context/DIContext';
export const useInjection = (): DIContainerType => {
return useContext(DIContext) as DIContainerType;
};
Реализуя этот хук, мы, во-первых, инкапсулировали DIContext - теперь компонентам, использующим useInjection, не нужно ничего знать про этот контекст, а во-вторых, пошли на некоторый обман TypeScript и явно привели тип, возвращаемый useContext к DIContainerType. Если бы мы этого не сделали, то результат, возвращаемый из useInjection, имел бы тип Partial<DIContainerType> и содержащиеся в нем значения необходимо было бы проверять на undefined. В нашем приложении, учитывая то, как мы объявили наш контекст, зависимости никогда не будут undefined, это возможно только в тестах, если мы забудем передать необходимую зависимость в Context Provider. Что как по мне, большой проблемой не является. Пусть использование такого трюка и немного нечестно по отношению к TypeScript, зато очень удобно.
Ну и теперь в компоненте, мы можем получить зависимость следующим образом:
const MyComponent: React.FC = () => {
...
const { foo, bar } = useInjection();
...
}
А в тестах мы сможем подменить необходимые зависимости на их Mock:
it("...", () => {
const mockFoo = ...
const mockBar = ...
const page = mount(
<DIContext.Provider value={{foo: mockFoo, bar: mockBar}}>
<MyComponent />
</DIContext.Provider>
);
...
}
И напоследок, небольшой вариант улучшения. Если хотите инициализировать ваш контейнер не в одном месте, превращая его в большую свалку, а разнести по различным файлам (by business domain), это можно легко осуществить:
import myDomainContainer from ...
import otherDomainContainer from ...
const Container = {
...myDomainContainer,
...otherDomainContainer
};
Вот собственно и все, что я хотел показать. Как мне кажется, мне удалось реализовать механизм DI довольно просто, без необходимости использования сторонних библиотек. Возможно и в вашем проекте вполне можно обойтись без них, избежав тем самым лишней сложности - если требования, предъявленные к реализации, достаточны, а сама реализация и использование вас удовлетворяют. А может вам и вовсе не нужен DI - тут нужно исходить из того, какие преимущества вам это даст и даст ли вообще.
Dependency Injection в React — максимально просто
В интернете немало публикаций на тему реализации Dependency Injection (далее - DI) в React, также существует немало сторонних npm-пакетов, таких как inversify-react, react-simple-di и других. Но, по...
habr.com