В интернете немало публикаций на тему реализации 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-контейнера, хранящего зависимости - с одной стороны, мы можем сконфигурировать контейнер, положив в него все нужные зависимости, а с другой стороны - компонент, которому нужна зависимость, может извлечь ее из контейнера.
Реализация
Предъявим некоторые требования к решению:
Зависимости после внедрения должны быть типизированными (иметь строгий соответствующий им тип, не any)
В случаях, когда нам нужно подменить зависимости (например, в тестах - заменить на Mock-и) - мы должны иметь возможность указать только интересующие нас зависимости, а не интересующие - не указывать
Минимизация boilerplate-кода. В идеале его не должно быть вообще, в реальности - кода должно быть не сильно больше, по сравнению с неиспользованием DI
Итак, средствами React внедрить зависимость в компонент можно как минимум двумя очевидными способами: через props и с использованием React Context. Первый способ лично мне не сильно по душе - очень легко скатиться в props hell. А вот второй - как будто бы для этого и придуман. Основная идея - наши зависимости будут лежать в контексте, а компонент будет извлекать их из контекста. В случаях, когда нам нужно подменить зависимости (например - в тестах) - мы обернем компонент в соответствующий Context Provider. Но есть несколько небольших хитростей, на которые мы пойдем ради достижения предъявленных выше требований.
Вот как я предлагаю объявить и реализовать сам контекст:
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, который содержит контейнер со всеми необходимыми нашему приложению зависимостями.
Почти все готово. Как уже сказано выше, нам даже не нужно оборачивать наши компоненты в DIContext.Provider - что, по моему мнению, довольно удобно. Все что нам нужно теперь - это внедрить зависимость в нужный компонент. Для этого мы можем напрямую вызвать useContext в нашем компоненте, но я предлагаю реализовать простой хук-обертку, т.к. здесь тоже будет одна хитрость.
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 - тут нужно исходить из того, какие преимущества вам это даст и даст ли вообще.