Довелось мне как-то после нескольких проектов на 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-режиме). Последнее позволяет быть уверенным, что если не возникло исключения, то состояние хранилища данных соответствует спецификации.
