Мне кажется, настало время поделится подходом для написания ReactJS App, я не претендую на уникальность.

Первый абзац можно пропустить. Я занимаюсь web разработкой уже давно, но последние четыре года я плотно сижу на ReactJS и меня все устраивает, в моей жизни был redux, но примерно два года назад я познакомился с MobX, буквально пару месяцев назад я попытался вернуться на redux, но я не смог, было ощущение что я что-то делаю лишнее, может вообще что то не верное, на эту тему переведено уже много байт на серверах, статья не о крутости одного перед другим, это всего лишь попытка поделится своими наработками, может кому-то реально зайдет этот подход, и так к сути.

Задачи которые мы будем решать:

  • подключение di для компонентов
  • серверный рендеринг с асинхронной загрузкой данных

Структуру проекта можно посмотреть на Гитхабе. Поэтому я пропущу то, как написать примитивное приложение и в статье будут только основные моменты

Введем такие понятия как: модель данных, сервис, стор.

Заведем простую модель

TodoModel.ts
import { observable, action } from 'mobx';
export class TodoModel {
  @observable public id: number;
  @observable public text: string = '';
  @observable public isCompleted: boolean = false;

  @action
  public set = (key: 'text' | 'isCompleted', value: any): void => {
    this[key] = value;
  };
}


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

Теперь нам нужно научится работать с этой моделью, заведем сервис:

TodoService.ts
import { Service, Inject } from 'typedi';
import { plainToClass, classToClass } from 'class-transformer';
import { DataStorage } from '../storage/DataStorage';
import { action } from 'mobx';
import { TodoModel } from '../models/TodoModel';

const responseMock = {
  items: [
    {
      id: 1,
      isCompleted: false,
      text: 'Item 1'
    },
    {
      id: 2,
      isCompleted: true,
      text: 'Item 2'
    }
  ]
};

@Service('TodoService')
export class TodoService {
  @Inject('DataStorage')
  public dataStorage: DataStorage;

  @action
  public load = async () => {
    await new Promise(resolve => setTimeout(resolve, 300));
    this.dataStorage.todos = plainToClass(TodoModel, responseMock.items);
  };

  @action
  public save(todo: TodoModel): void {
    if (todo.id) {
      const idx = this.dataStorage.todos.findIndex(item => todo.id === item.id);
      this.dataStorage.todos[idx] = classToClass(todo);
    } else {
      const todos = this.dataStorage.todos.slice();
      todo.id = Math.floor(Math.random() * Math.floor(100000));
      todos.push(todo);
      this.dataStorage.todos = todos;
    }
    this.clearTodo();
  }

  @action
  public edit(todo: TodoModel): void {
    this.dataStorage.todo = classToClass(todo);
  }

  @action
  public clearTodo(): void {
    this.dataStorage.todo = new TodoModel();
  }
}


В нашем сервисе есть ссылка на

DataStorage.ts
import { Service } from 'typedi';
import { observable } from 'mobx';
import { TodoModel } from '../models/TodoModel';

@Service('DataStorage')
export class DataStorage {
  @observable public todos: TodoModel[] = [];

  @observable public todo: TodoModel = new TodoModel();
}


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

У нас уже почти все готово, осталось это все подключить к нашему приложению, для этого немного подтюним injector от mobx-react:

DI
import { inject } from 'mobx-react';

export function DI(...classNames: string[]) {
  return (target: any) => {
    return inject((props: any) => {
      const data: any = {};
      classNames.forEach(className => {
        const name = className.charAt(0).toLowerCase() + className.slice(1);
        data[name] = props.container.get(className);
      });
      data.container = props.container;
      return data;
    })(target);
  };
}



и заведем контейнер для нашего DI

browser.tsx
import 'reflect-metadata';
import * as React from 'react';
import { hydrate } from 'react-dom';
import { renderRoutes } from 'react-router-config';
import { Provider } from 'mobx-react';
import { BrowserRouter } from 'react-router-dom';
import { Container } from 'typedi';
import '../application';
import { routes } from '../application/route';

hydrate(
  <Provider container={Container}>
    <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
  </Provider>,
  document.getElementById('root')
);


Для браузера у нас всегда один контейнер, а вот для серверного рендера нужно смотреть, лучше для каждого запроса организовать свой контейнер:

server.tsx
import * as express from 'express';
import * as React from 'react';
import { Container } from 'typedi';

import '../application';
// @ts-ignore
import * as mustacheExpress from 'mustache-express';
import * as path from 'path';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router';
import { Provider } from 'mobx-react';
import * as uuid from 'uuid';
import { renderRoutes, matchRoutes } from 'react-router-config';
import { routes } from '../application/route';

const app = express();
const ROOT_PATH = process.env.ROOT_PATH;

const currentPath = path.join(ROOT_PATH, 'dist', 'server');
const publicPath = path.join(ROOT_PATH, 'dist', 'public');

app.engine('html', mustacheExpress());
app.set('view engine', 'html');
app.set('views', currentPath + '/views');

app.use(express.static(publicPath));

app.get('/favicon.ico', (req, res) => res.status(500).end());

app.get('*', async (request, response) => {
  const context: any = {};
  const id = uuid.v4();
  const container = Container.of(id);

  const branch = matchRoutes(routes, request.url);

  const promises = branch.map(({ route, match }: any) => {
    return route.component && route.component.loadData ? route.component.loadData(container, match) : Promise.resolve(null);
  });

  await Promise.all(promises);

  const markup = renderToString(
    <Provider container={container}>
      <StaticRouter location={request.url} context={context}>
        {renderRoutes(routes)}
      </StaticRouter>
    </Provider>
  );

  Container.remove(id);
  if (context.url) {
    return response.redirect(
      context.location.pathname + context.location.search
    );
  }

  return response.render('index', { markup });
});

app.listen(2016, () => {
  // tslint:disable-next-line
  console.info("application started at 2016 port");
});



Серверный рендер, это на самом деле тонкая штука, с одной стороны охото пропустить через него все, но у него всего одна бизнес задача, отдать контент ботам, таким образом лучше вообще поставить проверку на что то подобное «авторизировался ли пользователь хоть раз на сайте», и скипать серверный рендер с созданием контейнеров на сервере.

Ну и теперь к нашим компонентам:

MainRoute.tsx
import * as React from 'react';
import { TodoService } from '../service/TodoService';
import { observer } from 'mobx-react';
import { DI } from '../annotation/DI';
import { DataStorage } from '../storage/DataStorage';
import { Todo } from '../component/todo';
import { Form } from '../component/form/Form';
import { ContainerInstance } from 'typedi';

interface IProps {
  todoService?: TodoService;
  dataStorage?: DataStorage;
}

@DI('TodoService', 'DataStorage')
@observer
export class MainRoute extends React.Component<IProps> {
  public static async loadData(container: ContainerInstance) {
    const todoService: TodoService = container.get('TodoService');
    await todoService.load();
  }

  public componentDidMount() {
    this.props.todoService.load();
  }

  public render() {
    return (
      <div>
        <Form />
        <ul>
          {this.props.dataStorage.items.map(item => (
            <li key={item.id} ><Todo model={item} /></li>
          ))}
        </ul>
      </div>
    );
  }
}



Тут получается все очень логично и красиво, наша вьюха «render» для отрисовки берет данные из нашего стора, хуки компонента говорят в какой момент времени нам стоит загрузить данные.

Todo.tsx
import * as React from 'react';
import { TodoModel } from '../../models/TodoModel';
import { TodoService } from '../../service/TodoService';
import { DI } from '../../annotation/DI';
import { observer } from 'mobx-react';

interface IProps {
  model: TodoModel;
  todoService?: TodoService;
}

@DI('TodoService')
@observer
export class Todo extends React.Component<IProps> {
  public render() {
    const { model, todoService } = this.props;
    return (
      <>
        <input
          type='checkbox'
          checked={model.isCompleted}
          onChange={e => model.set('isCompleted', e.target.checked)}
        />
        <h4>{model.text}</h4>
        <button type='button' onClick={() => todoService.edit(model)}>Edit</button>
      </>
    );
  }
}



Form.tsx

import * as React from 'react';
import { observer } from 'mobx-react';
import { DI } from '../../annotation/DI';
import { TodoService } from '../../service';
import { DataStorage } from '../../storage';
import { TextField } from '../text-field';

interface IProps {
  todoService?: TodoService;
  dataStorage?: DataStorage;
}
@DI('TodoService', 'DataStorage')
@observer
export class Form extends React.Component<IProps> {
  public handleSave = (e: any) => {
    e.preventDefault();
    this.props.todoService.save(this.props.dataStorage.todo);
  };

  public handleClear = () => {
    this.props.todoService.clearTodo();
  };
  public render() {
    const { dataStorage } = this.props;

    return (
      <form onSubmit={this.handleSave}>
        <TextField name='text' model={dataStorage.todo} />
        <button>{dataStorage.todo.id ? 'Save' : 'Create'}</button>
        <button type='button' onClick={this.handleClear}>
          Clear
        </button>
      </form>
    );
  }
}


На мой взгляд работать с формами намного удобнее через модели/dtoшки, можно использовать обычные нативные формы, и обновлять модель данных и все кто ее слушают будут обновляться моментально.

Вот как-то так я использую эту связку библиотек: react, class-transformer, mobx, typedi

Такой подход мы сейчас используем в проде, это очень большие проекты, с едиными общими компонентами и сервисами

Если этот подход будет интересен, расскажу как в этом же ключе мы делаем валидацию моделей перед отправкой на сервер, как обрабатываем серверные ошибки и как мы синхронизируем наше состояние между табами браузера.

На самом деле все очень бонально: «class-validator», «localStorage + window.addEventListener('storage')»

Спасибо что дочитали :-)

Пример

Комментарии (8)


  1. gnaeus
    01.02.2019 10:39

    Можете еще посмотреть мою реализацию, заточенную спеицально под React: react-ioc. Как бонусы: иерархические контейнеры через React Context и вес в 1 КБ.


    1. kalyukdo Автор
      01.02.2019 11:01

      Действительно интересные наработки, спасибо!


    1. fljot
      01.02.2019 16:44

      А в чём мотивация делать IoC именно под React? Ведь это абстрактная штука, не связанная с реактом.
      Вот например InversifyJS это просто хороший IoC container, который можно заюзать и с реактом, и без реакта (с чем захочешь, под что напишешь адаптер).


      1. gnaeus
        01.02.2019 18:49

        А зачем в Angular свои иерархические провайдеры? Я писал статью про это не так давно.


        Все дело в деталях. Если реализовать DI поверх Inversify, причем иерархический по дереву компонентов, а не вручную созданных child контейнеров, то получим большой overhead, как по размеру бандла, так и по производительности в runtime.


        Ну и еще иерархичность DI позволяет code splitting в отличие от.


  1. mayorovp
    01.02.2019 11:50

    Замечания.


    1. Лучше бы никогда такие вот методы set, как в классе TodoModel, не делать:


      declare let x: TodoModel
      s.set('isCompleted', 'ещё нет') // типа безопасность

    2. У метода TodoService.load декоратор action ни на что не влияет! Для асинхронных методов следует использовать flow, а не action.


    3. Почему в методе save вы используете classToClass при сохранении существующего элемента, но не используете его при сохранении нового элемента?


    4. Вот в этой строчке — onChange={e => model.set('isCompleted', e.target.checked)} — что мешало написать по-нормальному?


      onChange={e => model.isCompleted = e.target.checked}


    1. kalyukdo Автор
      01.02.2019 12:02

      1) Безопасность? или валидация, если валидация, то это немного про другое
      2) согласен
      3) бага
      4) если честно, то раньше такой трюк не работал, нужен был обязательно action для изменения, сейчас это работает, спасибо, положил в копилку


      1. mayorovp
        01.02.2019 12:10
        +1

        Безопасность? или валидация, если валидация, то это немного про другое

        Нет, это именно проблема типобезопасности. Если у вас написано что isCompleted — булев, то вы не должны помещать в него что-то кроме булевых значений. А set позволяет поместить туда что угодно.


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

        Никогда не нужен был action для изменения, если только вы сами не включили режим enforceActions.


        Но даже в этом случае вы можете написать вот так:


        onChange={action(e => model.isCompleted = e.target.checked)}


        1. kalyukdo Автор
          01.02.2019 12:14

          onChange={action(e => model.isCompleted = e.target.checked)

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