Довелось мне как-то после нескольких проектов на React поработать над приложением под Angular 2. Прямо скажем, не впечатлило. Но один момент запомнился — управление логикой и состоянием приложения с помощью Dependency Injection. И я задался вопросом, удобно ли управлять состоянием в React используя DDD, многослойную архитектуру, и внедрение зависимостей?
Если интересно, как это сделать, а главное, зачем — добро пожаловать под кат!
Если быть честным, то даже на бекэнде DI редко где используется на полную катушку. Разве что в реально больших приложениях. А в маленьких и средних, даже при наличии DI, у каждого интерфейса обычно только одна реализация. Но внедрение зависимостей все равно имеет свои преимущества:
- Код лучше структурирован, а интерфейсы выступают в качестве явных контрактов.
- Упрощается создание заглушек в юнит-тестах.
Но современные библиотеки тестирования для JS, такие как Jest, позволяют писать моки просто на основе модульной системы ES6. Так что здесь от DI мы особого профита не получим.
Остается второй момент — управление областью видимости и временем жизни объектов. На сервере время жизни обычно привязывается ко всему приложению (Singleton), или к запросу. А на клиенте основной единицей кода является компонент. К нему мы и будем привязываться.
Если нам необходимо использовать состояние на уровне приложения, проще всего завести переменную на уровне модуля ES6 и импортировать ее там, где надо. А если состояние нужно только внутри компонента — мы просто поместим его в this.state. Для всего остального есть Context. Но Context — слишком низкоуровневый:
- Мы не можем использовать контекст вне дерева компонентов React. Например, в слое бизнес-логики.
- Мы не можем использовать более одного контекста в
Class.contextType. Чтобы определить зависимость от нескольких разных сервисов, нам придется построить "пирамиду ужаса" на новый лад:

Новый Hook useContext() слегка исправляет ситуацию для функциональных компонентов. Но мы никак не избавимся от множества <Context.Provider>. Пока не превратим наш контекст в Service Locator, а его родительский компонент в Composition Root. Но тут уже и до DI недалеко, поэтому приступим!
Эту часть можно пропустить и перейти сразу к описанию архитектуры
Реализация механизма DI
Для начала нам понадобится React Context:
export const InjectorContext= React.createContext(null);
Поскольку React использует конструктор компонента для своих нужд, мы будем использовать Property Injection. Для этого определим декоратор @inject, который:
- задает свойство
Class.contextType, - получает тип зависимости,
- находит объект
Injectorи разрешает зависимость.
import "reflect-metadata"; export function inject(target, key) { // задаем static cotextType target.constructor.contextType = InjectorContext; // получаем тип зависимости const type = Reflect.getMetadata("design:type", target, key); // определяем property Object.defineProperty(target, key, { configurable: true, enumerable: true, get() { // получаем Injector из иерархии компонентов и разрешаем зависимость const instance = getInstance(getInjector(this), type); Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); return instance; }, // settet для присваивания в обход Dependency Injection set(instance) { Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); } }); }
Теперь мы можем задавать зависимости между произвольными классами:
import { inject } from "react-ioc"; class FooService {} class BarService { @inject foo: FooService; } class MyComponent extends React.Component { @inject foo: FooService; @inject bar: BarService; }
Для тех, кто не приемлет декораторы, определим функцию inject() с такой сигнатурой:
type Constructor<T> = new (...args: any[]) => T; function inject<T>(target: Object, type: Constructor<T> | Function): T;
export function inject(target, keyOrType) { if (isFunction(keyOrType)) { return getInstance(getInjector(target), keyOrType); } // ... }
Это позволит определять зависимости в явном виде:
class FooService {} class BarService { foo = inject(this, FooService); } class MyComponent extends React.Component { foo = inject(this, FooService); bar = inject(this, BarService); // указываем явно static contextType = InjectorContext; }
А что же насчет функциональных компонентов? Для них мы можем реализовать Hook useInstance()
import { useRef, useContext } from "react"; export function useInstance(type) { const ref = useRef(null); const injector = useContext(InjectorContext); return ref.current || (ref.current = getInstance(injector, type)); }
import { useInstance } from "react-ioc"; const MyComponent = props => { const foo = useInstance(FooService); const bar = useInstance(BarService); return <div />; }
Теперь определим, как может выглядеть наш Injector, как его найти, и как разрешить зависимости. Инжектор должен содержать ссылку на родителя, кэш объектов для уже разрешенных зависимостей и словарь правил для еще не разрешенных.
type Binding = (injector: Injector) => Object; export abstract class Injector extends React.Component { // ссылка на вышестоящий Injector _parent?: Injector; // настройки разрешения зависимостей _bindingMap: Map<Function, Binding>; // кэш для уже созданных экземпляров _instanceMap: Map<Function, Object>; }
Для React-компонентов Injector доступен через поле this.context, а для классов-зависимостей мы можем временно поместить Injector в глобальную переменную. Чтобы ускорить поиск инжектора для каждого класса, будем кэшировать ссылку на Injector в скрытом поле.
export const INJECTOR = typeof Symbol === "function" ? Symbol() : "__injector__"; let currentInjector = null; export function getInjector(target) { let injector = target[INJECTOR]; if (injector) { return injector; } injector = currentInjector || target.context; if (injector instanceof Injector) { target[INJECTOR] = injector; return injector; } return null; }
Чтобы найти конкретное правило привязки, нам нужно пробежаться вверх по дереву инжекторов с помощью функции getInstance()
export function getInstance(injector, type) { while (injector) { let instance = injector._instanceMap.get(type); if (instance !== undefined) { return instance; } const binding = injector._bindingMap.get(type); if (binding) { const prevInjector = currentInjector; currentInjector = injector; try { instance = binding(injector); } finally { currentInjector = prevInjector; } injector._instanceMap.set(type, instance); return instance; } injector = injector._parent; } return undefined; }
Перейдем, наконец, к регистрации зависимостей. Для этого нам понадобится HOC provider(), который принимает массив привязок зависимостей к их реализациям, и регистрирует новый Injector через InjectorContext.Provider
export const provider = (...definitions) => Wrapped => { const bindingMap = new Map(); addBindings(bindingMap, definitions); return class Provider extends Injector { _parent = this.context; _bindingMap = bindingMap; _instanceMap = new Map(); render() { return ( <InjectorContext.Provider value={this}> <Wrapped {...this.props} /> </InjectorContext.Provider> ); } static contextType = InjectorContext; static register(...definitions) { addBindings(bindingMap, definitions); } }; };
А также, набор функций привязок, которые реализуют различные стратегии создания экземпляров зависимостей.
export const toClass = constructor => asBinding(injector => { const instance = new constructor(); if (!instance[INJECTOR]) { instance[INJECTOR] = injector; } return instance; }); export const toFactory = (depsOrFactory, factory) => asBinding( factory ? injector => factory(...depsOrFactory.map(type => getInstance(injector, type))) : depsOrFactory ); export const toExisting = type => asBinding(injector => getInstance(injector, type)); export const toValue = value => asBinding(() => value); const IS_BINDING = typeof Symbol === "function" ? Symbol() : "__binding__"; function asBinding(binding) { binding[IS_BINDING] = true; return binding; } export function addBindings(bindingMap, definitions) { definitions.forEach(definition => { let token, binding; if (Array.isArray(definition)) { [token, binding = token] = definition; } else { token = binding = definition; } bindingMap.set(token, binding[IS_BINDING] ? binding : toClass(binding)); }); }
Теперь мы сможем зарегистрировать привязки зависимостей на уровне произвольного компонента в виде набора пар [<Интерфейс>, <Реализация>].
import { provider, toClass, toValue, toFactory, toExisting } from "react-ioc"; @provider( // привязка к классу [FirstService, toClass(FirstServiceImpl)], // привязка к статическому значению [SecondService, toValue(new SecondServiceImpl())], // привязка к фабрике [ThirdService, toFactory( [FirstService, SecondService], (first, second) => ThirdServiceFactory.create(first, second) )], // привязка к уже зарегистрированному типу [FourthService, toExisting(FirstService)] ) class MyComponent extends React.Component { // ... }
Или в сокращенной форме для классов:
@provider( // [FirstService, toClass(FirstService)] FirstService, // [SecondService, toClass(SecondServiceImpl)] [SecondService, SecondServiceImpl] ) class MyComponent extends React.Component { // ... }
Поскольку время жизни сервиса определяется компонентом-провайдером, в котором он зарегистрирован, для каждого сервиса мы можем определить метод очистки .dispose(). В нем мы можем отписаться от каких-то событий, закрыть сокеты и т.д. При удалении провайдера из DOM, он вызовет .dispose() на всех созданных им сервисах.
export const provider = (...definitions) => Wrapped => { // ... return class Provider extends Injector { // ... componentWillUnmount() { this._instanceMap.forEach(instance => { if (isObject(instance) && isFunction(instance.dispose)) { instance.dispose(); } }); } // ... }; };
Для разделения кода и ленивой загрузки нам может понадобиться инвертировать способ регистрации сервисов в провайдерах. С этим нам поможет декоратор @registerIn()
export const registrationQueue = []; export const registerIn = (getProvider, binding) => constructor => { registrationQueue.push(() => { getProvider().register(binding ? [constructor, binding] : constructor); }); return constructor; };
export function getInstance(injector, type) { if (registrationQueue.length > 0) { registrationQueue.forEach(registration => { registration(); }); registrationQueue.length = 0; } while (injector) { // ... }
import { registerIn } from "react-ioc"; import { HomePage } from "../components/HomePage"; @registerIn(() => HomePage) class MyLazyLoadedService {}

Вот так, за 150 строк и 1 KB кода, можно реализовать практически полноценный иерархический DI-контейнер.
Архитектура приложения
Наконец, перейдем к главному — как организовать архитектуру приложения. Здесь возможны три варианта в зависимости от размера приложения, сложности предметной области и нашей лени.
1. The Ugly
У нас же Virtual DOM, а значит он должен быть быстрым. По крайней мере под этим соусом React подавался на заре карьеры. Поэтому просто запомним ссылку на корневой компонент (например, с помощью декоратора @observer). И будем вызывать на нем .forceUpdate() после каждого действия, затрагивающего общие сервисы (например, с помощью декоратора @action)
export function observer(Wrapped) { return class Observer extends React.Component { componentDidMount() { observerRef = this; } componentWillUnmount() { observerRef = null; } render() { return <Wrapped {...this.props} />; } } } let observerRef = null;
export function action(_target, _key, descriptor) { const method = descriptor.value; descriptor.value = function() { let result; runningCount++; try { result = method.apply(this, arguments); } finally { runningCount--; } if (runningCount === 0 && observerRef) { observerRef.forceUpdate(); } return result; }; } let runningCount = 0;
class UserService { @action doSomething() {} } class MyComponent extends React.Component { @inject userService: UserService; } @provider(UserService) @observer class App extends React.Component {}
Это даже будет работать. Но… Вы сами понимаете :-)
2. The Bad
Нас не устраивает рендеринг всего на каждый чих. Но мы все еще хотим использовать почти обычные объекты и массивы для хранения состояния. Давайте возьмем MobX!
Заводим несколько хранилищ данных со стандартными действиями:
import { observable, action } from "mobx"; export class UserStore { byId = observable.map<number, User>(); @action add(user: User) { this.byId.set(user.id, user); } // ... } export class PostStore { // ... }
Бизнес-логику, I/O и прочее выносим в слой сервисов:
import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { @inject userStore userStore; @action updateUserInfo(userInfo: Partial<User>) { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); } }
И распределяем их по компонентам:
import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(UserStore, PostStore) class App extends React.Component {} @provider(AccountService) @observer class AccountPage extends React.Component{} @observer class UserForm extends React.Component { @inject accountService: AccountService; }
import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { userStore = inject(this, UserStore); updateUserInfo = action((userInfo: Partial<User>) => { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); }); }
import { observer } from "mobx-react-lite"; import { provider, useInstance } from "react-ioc"; const App = provider(UserStore, PostStore)(props => { // ... }); const AccountPage = provider(AccountService)(observer(props => { // ... })); const UserFrom = observer(props => { const accountService = useInstance(AccountService); // ... });
Получается классическая трехуровневая архитектура.
3. The Good
Иногда предметная область становится настолько сложной, что с ней уже неудобно работать используя простые объекты (или анемичную модель в терминах DDD). Особенно это заметно, когда данные имеют реляционную структуру с множеством связей. В таких случаях нам приходит на помощь библиотека MobX State Tree, позволяющая применить принципы Domain-Driven Design в архитектуре фронтенд-приложения.
Проектирование модели начинается с описания типов:
// models/Post.ts import { types as t, Instance } from "mobx-state-tree"; export const Post = t .model("Post", { id: t.identifier, title: t.string, body: t.string, date: t.Date, rating: t.number, author: t.reference(User), comments: t.array(t.reference(Comment)) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; }, addComment(comment: Comment) { self.comments.push(comment); } })); export type Post = Instance<typeof Post>;
import { types as t, Instance } from "mobx-state-tree"; export const User = t.model("User", { id: t.identifier, name: t.string }); export type User = Instance<typeof User>;
import { types as t, Instance } from "mobx-state-tree"; import { User } from "./User"; export const Comment = t .model("Comment", { id: t.identifier, text: t.string, date: t.Date, rating: t.number, author: t.reference(User) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; } })); export type Comment = Instance<typeof Comment>;
И типа хранилища данных:
// models/index.ts import { types as t } from "mobx-state-tree"; export { User, Post, Comment }; export default t.model({ users: t.map(User), posts: t.map(Post), comments: t.map(Comment) });
Типы-сущности содержат в себе состояние модели предметной области и основные операции с ней. Более сложные сценарии, включая I/O, реализуются в слое сервисов.
import { Instance, unprotect } from "mobx-state-tree"; import Models from "../models"; export class DataContext { static create() { const models = Models.create(); unprotect(models); return models; } } export interface DataContext extends Instance<typeof Models> {}
import { observable } from "mobx"; import { User } from "../models"; export class AuthService { @observable currentUser: User; }
import { inject } from "react-ioc"; import { action } from "mobx"; import { Post } from "../models"; export class PostService { @inject dataContext: DataContext; @inject authService: AuthService; async publishPost(postInfo: Partial<Post>) { const response = await fetch("/posts", { method: "POST", body: JSON.stringify(postInfo) }); const { id } = await response.json(); this.savePost(id, postInfo); } @action savePost(id: string, postInfo: Partial<Post>) { const post = Post.create({ id, rating: 0, date: new Date(), author: this.authService.currentUser.id, comments: [], ...postInfo }); this.dataContext.posts.put(post); } }
Главной особенностью MobX State Tree является эффективная работа со снапшотами данных. В любой момент времени мы можем получить сериализванное состояние любой сущности, коллекции или даже всего состояния приложения с помощью функции getSnapshot(). И точно так же мы можем применить снапшот к любой части модели используя applySnapshot(). Это позволяет нам в несколько строчек кода инициализировать состояние с сервера, загружать из LocalStorage или даже взаимодействовать с ним через Redux DevTools.
Поскольку мы используем нормализованную реляционную модель, для загрузки данных нам понадобится библиотека normalizr. Она позволяет переводить древовидный JSON в плоские таблицы объектов, сгруппированных по id, согласно схеме данных. Как раз в тот формат, что нужен MobX State Tree в качестве снапшота.
Для этого определим схемы объектов, загружаемых с сервера:
import { schema } from "normalizr"; const UserSchema = new schema.Entity("users"); const CommentSchema = new schema.Entity("comments", { author: UserSchema }); const PostSchema = new schema.Entity("posts", { // определяем только поля-связи // примитивные поля копируются без изменений author: UserSchema, comments: [CommentSchema] }); export { UserSchema, PostSchema, CommentSchema };
И загрузим данные в хранилище:
import { inject } from "react-ioc"; import { normalize } from "normalizr"; import { applySnapshot } from "mobx-state-tree"; export class PostService { @inject dataContext: DataContext; // ... async loadPosts() { const response = await fetch("/posts.json"); const posts = await response.json(); const { entities } = normalize(posts, [PostSchema]); applySnapshot(this.dataContext, entities); } // ... }
[ { "id": 123, "title": "Иерархическое внедрение зависимостей в React", "body": "Довелось мне как-то после нескольких проектов на React...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" }, "comments": [{ "id": 1234, "text": "Hmmm...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" } }] }, { "id": 234, "title": "Lorem ipsum", "body": "Lorem ipsum dolor sit amet...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 23, "name": "Marcus Tullius Cicero" }, "comments": [] } ]
Наконец, зарегистрируем сервисы в соответствующих компонентах:
import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(AuthService, PostService, [ DataContext, toFactory(DataContext.create) ]) class App extends React.Component { @inject postService: PostService; componentDidMount() { this.postService.loadPosts(); } }
Получается все та же трехслойная архитектура, но с возможностью сохранения состояния и рантайм-проверкой типов данных (в DEV-режиме). Последнее позволяет быть уверенным, что если не возникло исключения, то состояние хранилища данных соответствует спецификации.

