Сидел я как-то вечером с кружкой чая и вдруг, ни с того не с сего, получил уведомление на почту о комментарии на статью следующего содержания

Дело в том, что описывать компоненты пользовательского интерфейса действительно проще в функциональном стиле. Особо выделился в этом деле React, после введения хуков код компонентов стал значительно более читаемым и удобным к модернизации

Дополнительно, отличительной особенностью этой библиотеки является большой выбор проработанных компонентов UI от OpenSource и корпораций. MUI от Google, BaseWeb от Uber, Fluent UI (Fabric) от Microsoft, Chakra UI от коммьюнити и другие

Сделать компоненты лучше крайне трудозатратно и сложно к поиску кадров. Думаю, имеет смысл рассказать о том, как сочетать декомпозицию состояние приложение в стиле ООП и современные практики построения представлений веб-приложений

Проблема устаревших JavaScript фреймворков

Backbone, Marionette и другие фреймвокри размазывают тонким слоем представление по методам класса и крайне сложны к статической проверке типов. Как следствие, Model пропускался вовсе, View сделан весьма сомнительно из-за сложностей реализации компонентного подхода, а Controller сделан слишком замысловато через встроенные в стандартную библиотеку классы, которые не всегда начисто ложатся под запросы бизнеса

Однако, если оставить View в функциональном стиле с применением готовых компонентов, написать Model на TypeScript, а бизнес логику писать на ООП с инъекцией зависимостей, можно сэкономить деньги путем применения проверенных годами практик, которые знают самые дешевые кадры на рынке - студенты

Dependency Injection

Для организации многоуровневой архитектуры необходимо разделить сервисы приложения на базовые и сервисы бизнес-логики. Наиболее чистым будет код, который делает это через инъекцию зависимостей. Например, можно применить InversifyJS

import { injectable, inject } from "inversify";
import { makeObservable } from "mobx";
import { action, observable } from "mobx";

import ApiService from "../../base/ApiService";
import RouterService from "../../base/RouterService";

import TYPES from "../../types";

@injectable()
export class SettingsPageService {

  @observable
  @inject(TYPES.apiService)
  readonly apiService!: ApiService;

  @observable
  @inject(TYPES.routerService)
  readonly routerService!: RouterService;

  constructor() {
    makeObservable(this);
  };

  ...

Однако, так как декораторы не утверждены как стандарт JavaScript и синтаксис сложен к пониманию, я бы рекомендовал написать свой Dependency Service и использовать именно его для инъекции сервисов через функции

// src/helpers/serviceManager.ts

export const serviceManager = new class {

    _creators = new Map<string | Symbol, () => unknown>();
    _instances = new Map<string | Symbol, unknown>();

    registerInstance = <T = unknown>(key: string, inst: T) => {
        this._instances.set(key, inst);
    };

    registerCreator = <T = unknown>(key: string | Symbol, ctor: () => T) => {
        this._creators.set(key, ctor);
    };

    inject = <T = unknown>(key: string | symbol): T => {
        if (this._instances.has(key)) {
            const instance = this._instances.get(key);
            return instance as T;
        } else if (this._creators.has(key)) {
            const instance = this._creators.get(key)!();
            this._instances.set(key, instance);
            return instance as T;
        } else {
            console.warn('serviceManager unknown service', key);
            return null as never;
        }
    };

    clear = () => {
        this._creators.clear();
        this._instances.clear();
    };

};

const { registerCreator: provide, inject } = serviceManager;
export { provide, inject };

export default serviceManager;

Файл serviceManager.ts будет экспортировать функции provide и inject, которые позволят зарегистрировать сервис и осуществить инъекцию зависимости. Далее накидать слоеную архитектуру не составит ни малейшей сложности

Нам следует создать базовые сервисы, я приведу пример нескольких. Их следует положить в папку src/lib/base

  1. ApiService - обертку под HTTP запросы к серверу, осуществляющую обработку сессии и ошибок

  2. ErrorService - сервис обработки исключений

  3. RouterService - сервис навигации по страницам приложения

  4. SessionService - сервис для сохранения сессии пользователя

Для понятности, я оставлю скриншот древа файлов из данного примера

Начнем с ApiService. Для примера, я накидал класс, реализующий методы get, post, put, patch, remove, реализующие соответствующие HTTP запросы к серверу

// src/lib/base/ApiService.ts

import { makeAutoObservable } from "mobx";
import { inject } from "../../helpers/serviceManager";

import SessionService from "./SessionService";
import ErrorService, { OfflineError } from "./ErrorService";

import { API_ORIGIN, API_TOKEN } from "../../config";

import TYPES from "../types";

type JSON = Record<string, unknown>;

export class ApiService {

  readonly sessionService = inject<SessionService>(TYPES.sessionService);
  readonly errorService = inject<ErrorService>(TYPES.errorService);

  constructor() {
    makeAutoObservable(this);
  };

  private handleSearchParams = <D = JSON>(url: URL, params?: D) => {
    if (params) {
      for (const [key, value] of Object.entries(params)) {
        if (typeof value === 'object') {
          url.searchParams.set(key, JSON.stringify(value));
        } else if (typeof value === 'number') {
          url.searchParams.set(key, value.toString());
        } else if (typeof value === 'string') {
          url.searchParams.set(key, value.toString());
        } else {
          throw new Error(`Unknown param type ${key}`);
        }
      }
    }
  };
  
  private handleJSON = <T = JSON>(data: string): T => {
    try {
      return JSON.parse(data) as T;
    } catch {
      return {} as T;
    }
  };
  
  private request = <T = JSON, D = JSON>(
    method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
    url: URL,
    data?: D,
  ) => new Promise<T>(async (res, rej) => {
    try {
      const request = await fetch(url.toString(), {
        method,
        headers: {
          ...(this.sessionService.sessionId && ({
            [API_TOKEN]: this.sessionService.sessionId,
          })),
          'Content-type': 'application/json',
        },
        ...(data && {
          body: JSON.stringify(data),
        }),
      });
      const text = await request.text();
      const json = this.handleJSON<T>(text);
      this.errorService.processStatusCode(request.status);
      if ('error' in json) {
        rej(json);
      } else {
        res(json);
      }
    } catch (e) {
      if (!window.navigator.onLine) {
        e = new OfflineError();
      }
      this.errorService.handleError(e as Error);
      rej(e);
    }
  });

  public get = <T = JSON, D = JSON>(url: URL | string, data?: D): Promise<T> => {
    const targetUrl = typeof url === 'string' ? new URL(url, API_ORIGIN) : url;
    this.handleSearchParams<D>(targetUrl, data);
    return this.request<T>('GET', targetUrl);
  };


  public remove = <T = JSON, D = JSON>(url: URL | string, data?: D): Promise<T> => {
    const targetUrl = typeof url === 'string' ? new URL(url, API_ORIGIN) : url;
    this.handleSearchParams<D>(targetUrl, data);
    return this.request<T, D>('DELETE', targetUrl);
  };


  public post = <T = JSON, D = JSON>(url: URL | string, data?: D): Promise<T> => {
    if (typeof url === 'string') {
      return this.request<T, D>('POST', new URL(url, API_ORIGIN), data);
    }
    return this.request<T, D>('POST', url, data);
  };

  
  public put = <T = JSON, D = JSON>(url: URL | string, data?: D): Promise<T> => {
    if (typeof url === 'string') {
      return this.request<T, D>('PUT', new URL(url, API_ORIGIN), data);
    }
    return this.request<T, D>('PUT', url, data);
  };

 
  public patch = <T = JSON, D = JSON>(url: URL | string, data?: D): Promise<T> => {
    if (typeof url === 'string') {
      return this.request<T, D>('PATCH', new URL(url, API_ORIGIN), data);
    }
    return this.request<T, D>('PATCH', url, data);
  };

};

export default ApiService;

Ошибки из ApiService следует обработать. Так же, нужно оставить возможность прикладному программисту вызвать функцию die(), если что-либо идет не по плану. В этом нам поможет ErrorService

// src/lib/base/ErrorService.ts

import { makeAutoObservable } from "mobx";

import { Subject } from "rxjs";

class BaseError { }

const createError = (type: string): typeof BaseError =>
    class extends BaseError {
        type = ''
        constructor() {
            super();
            this.type = type;
        }
    };

export const UnauthorizedError = createError('unauthorized-error');
export const ForbiddenError = createError('forbidden-error');
export const InternalError = createError('internal-error');
export const OfflineError = createError('offline-error');

const UNAUTHORIZED = 401;
const FORBIDDEN = 403;
const INTERNAL = 500;
const GATEWAY = 504;


export class ErrorService {

    permissionsSubject = new Subject<void>();
    offlineSubject = new Subject<void>();
    dieSubject = new Subject<void>();

    constructor() {
        makeAutoObservable(this);
    };

    processStatusCode = (code: number) => {
        if (code === UNAUTHORIZED) {
            throw new UnauthorizedError();
        } else if (code === FORBIDDEN) {
            throw new ForbiddenError();
        } else if (code === INTERNAL) {
            throw new InternalError();
        } else if (code === GATEWAY) {
            throw new InternalError();
        }
    };

    handleError = (e: Error) => {
        console.log('errorService handleError', e);
        if (e instanceof ForbiddenError) {
            this.logout();
        } else if (e instanceof InternalError) {
            this.die();
        } else if (e instanceof UnauthorizedError) {
            this.logout();
        } else if (e instanceof OfflineError) {
            this.offline();
        } else {
            this.die();
        }
    };

    die = () => {
        this.dieSubject.next();
    };

    offline = () => {
        this.offlineSubject.next();
    };

    logout = async () => {
        this.permissionsSubject.next();
    };

};

export default ErrorService;

ErrorService эмитит события через permissionsSubject, offlineSubject, dieSubject. Мы подпишемся на них, чтобы выбросить пользователя на соответствующий роут и, по необходимости, удалить сессию

// src/lib/base/RouterService.ts

import { makeAutoObservable } from "mobx";

import { 
  Action,
  Blocker,
  BrowserHistory,
  Listener,
  Location,
  State,
  To,
  createMemoryHistory
} from "history";

const browserHistory = createMemoryHistory();

export class RouterService implements BrowserHistory {

  previousPath = '/';

  location: Location = browserHistory.location;
  action: Action = browserHistory.action;

  constructor() {
    makeAutoObservable(this)
  }

  updateState() {
    const { location, action } = browserHistory;
    this.previousPath = this.location?.pathname || '/';
    this.location = location;
    this.action = action;
  }

  createHref(to: To) {
    const result = browserHistory.createHref(to);
    this.updateState();
    return result;
  }

  push(to: To, state?: State) {
    const result = browserHistory.push(to, state);
    this.updateState();
    return result;
  }

  replace(to: To, state?: State) {
    const result = browserHistory.replace(to, state);
    this.updateState();
    return result;
  }

  go(delta: number) {
    const result = browserHistory.go(delta);
    this.updateState();
    return result;
  }

  back() {
    const result = browserHistory.back();
    this.updateState();
    return result;
  }

  forward() {
    const result = browserHistory.forward();
    this.updateState();
    return result;
  }

  listen(listener: Listener) {
    const result = browserHistory.listen(listener);
    this.updateState();
    return result;
  }

  block(blocker: Blocker) {
    const result = browserHistory.block(blocker);
    this.updateState();
    return result;
  }
};

export default RouterService;

RouterService представляет собой обертку над history для обеспечения реактивности. Примечательно, что применение provide/inject позволит построить граф автоматически и нам не нужно думать о порядке объявления сервисов

// src/helpers/sessionService.ts

import { makeAutoObservable } from "mobx";

import createLsManager from "../../utils/createLsManager";

const storageManager = createLsManager('SESSION_ID');

export class SessionService {

  sessionId = storageManager.getValue()

  constructor() {
    makeAutoObservable(this);
  };

  dispose = () => {
    storageManager.setValue('');
    this.sessionId = '';
  };

  setSessionId = (sessionId: string, keep = true) => {
    if (keep) {
      storageManager.setValue(sessionId);
    }
    this.sessionId = sessionId;
  };

};

export default SessionService;

SessionService позволяет сохранить JWT токен, флаг keep позволяет запомнить его в localStorage для сохранения сессии при перезагрузке страницы

Соединяем сервисы

Вам потребуется создать три файла: types.ts - для строковых псевдонимов сервисов, config.ts - для регистрации фабрик и ioc.ts - синглтон для доступа к инстанциям сервисов. Код следующий

// src/lib/types.ts

const baseServices = {
    routerService: Symbol.for('routerService'),
    sessionService: Symbol.for('sessionService'),
    errorService: Symbol.for('errorService'),
    apiService: Symbol.for('apiService'),
};

const viewServices = {
    personService: Symbol.for('personService'),
};

export const TYPES = {
    ...baseServices,
    ...viewServices,
};

export default TYPES;

Файл config.ts не имеет экспортов и исполняется один раз в ioc.ts для сопоставления фабрик с строковыми псевдонимами сервисов

// src/lib.config.ts

import { provide } from '../helpers/serviceManager'

import RouterService from "./base/RouterService";
import SessionService from "./base/SessionService";
import ErrorService from "./base/ErrorService";
import ApiService from "./base/ApiService";

import MockService from './view/PersonService';

import TYPES from "./types";

provide(TYPES.routerService, () => new RouterService());
provide(TYPES.sessionService, () => new SessionService());
provide(TYPES.errorService, () => new ErrorService());
provide(TYPES.apiService, () => new ApiService());

provide(TYPES.personService, () => new PersonService());

Файл ioc.ts связывает зависящие друг от друга сервисы через события для предотвращения циклической зависимости. Этот механизм потребуется, если вы захотите написать AuthService (в моем случае обертка над клиентом auth0.com), так как он будет зависеть от SessionService, а SessionService от AuthService

// src/lib/ioc.ts

import { inject } from '../helpers/serviceManager';

import RouterService from "./base/RouterService";
import SessionService from "./base/SessionService";
import ErrorService from "./base/ErrorService";
import ApiService from "./base/ApiService";

import PersonService from './view/PersonService';

import { DENIED_PAGE } from "../config";
import { ERROR_PAGE } from "../config";
import { OFFLINE_PAGE } from "../config";

import "./config"

import TYPES from "./types";

const systemServices = {
    routerService: inject<RouterService>(TYPES.routerService),
    sessionService: inject<SessionService>(TYPES.sessionService),
    errorService: inject<ErrorService>(TYPES.errorService),
    apiService: inject<ApiService>(TYPES.apiService),
};

const appServices = {
    personService: inject<PersonService>(TYPES.personService),
};

export const ioc = {
    ...systemServices,
    ...appServices,
};

ioc.errorService.permissionsSubject.subscribe(() => {
    ioc.routerService.push(DENIED_PAGE);
});

ioc.errorService.offlineSubject.subscribe(() => {
    ioc.routerService.push(OFFLINE_PAGE);
});

ioc.errorService.dieSubject.subscribe(() => {
    ioc.routerService.push(ERROR_PAGE);
});

ioc.errorService.permissionsSubject.subscribe(() => {
    ioc.sessionService.setSessionId("", true);
});

window.addEventListener('unhandledrejection', () => ioc.errorService.die());
window.addEventListener('error', () => ioc.errorService.die());

(window as any).ioc = ioc;

export default ioc;

Синглтон ioc включает типизацию TypeScript, что позволит использовать статическую проверку типов по приложению. Дополнительно, ioc дублируется в глобальный объект window, что позволит программисту со стороны написать расширение для приложения без доступа к исходному коду

Сервис представления

Далее рассмотрим первый сервис с бизнес-логикой. В качестве академического примера это будет PersonService, сервис, осуществляющий вывод списка пользователей из CRUD с возможностью изменения элемента списка

// src/lib/view/PersonService.ts

import { makeAutoObservable } from "mobx";

import {
  ListHandlerPagination,
  ListHandlerSortModel,
} from "../../model";

import IPerson from "../../model/IPerson";

import ApiService from "../base/ApiService";
import RouterService from "../base/RouterService";

import TYPES from "../types";

export class PersonService {
  
  readonly routerService = inject<RouterService>(TYPES.routerService);
  readonly apiService = inject<ApiService>(TYPES.apiService);

  constructor() {
    makeAutoObservable(this);
  }

  async list(
    filters: Record<string, unknown>,
    pagination: ListHandlerPagination,
    sort: ListHandlerSortModel
  ) {  
    let rows = await this.apiService.get<IPerson[]>(`crud`, {
      filters,
      sort,
    });
    const { length: total } = rows;
    rows = rows.slice(
      pagination.offset,
      pagination.limit + pagination.offset
    );
    return {
      rows,
      total,
    };
  };

  one(id: string): Promise<IPerson | null> {
    if (id === 'create') {
      return Promise.resolve(null);
    } else {
      return this.apiService.get<IPerson>(`persons/${id}`);
    }
  };

  save(person: IPerson) {
    return this.apiService.put(`crud/${person.id}`, person);
  }

  async create(rawPerson: IPerson) {
    const person = this.apiService.post<IPerson>(`crud`, rawPerson);
    this.routerService.push(`/persons/${person.id}`);
    return person;
  }

  remove(person: IPerson) {
    return this.apiService.remove(`crud/${person.id}`);
  };

};

export default PersonService;

Метод list позволяет осуществить пагинацию со стороны пользовательского интерфейса. Метод save сохраняет свежесозданного пользователя без id-шника и переадресует ui на новую страницу для последующего сохранения изменений. Метод one загружает данные пользователя по id-шнику

Если в процессе загрузки данных произойдет ошибка, ApiService обратится в ErrorService и приложение её успешно обработает. В случае с моим проектом используется стратегия безопасного падения и перезапуска

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

Почему вам стоит подумать о шаблонизаторе

По хорошему, лучше исключить пагинацию и валидацию ввода из сервиса с бизнес-логикой, на мой взгляд, этот код должен лежать на стороне компонента. Именно тут вам пригодится шаблонизатор форм с конфигами)

// src/pages/PersonList.tsx

import { useRef } from 'react';

import {
  List,
  FieldType,
  IColumn,
  IListApi,
  ColumnType,
} from '../components/ListEngine';

import {
  IField,
} from '../components/OneEngine';

import IPerson from '../model/IPerson';

import ioc from '../lib/ioc';

const filters: IField[] = [
  {
    type: FieldType.Text,
    name: 'firstName',
    title: 'First name',
  },
  {
    type: FieldType.Text,
    name: 'lastName',
    title: 'Last name',
  }
];

const columns: IColumn[] = [
  {
    type: ColumnType.Text,
    field: 'id',
    headerName: 'ID',
    width: 'max(calc(100vw - 650px), 200px)',
  },
  {
    type: ColumnType.Text,
    field: 'firstName',
    headerName: 'First name',
    width: '200px',
  },
  {
    type: ColumnType.Text,
    field: 'lastName',
    headerName: 'Last name',
    width: '200px',
  },
  {
    type: ColumnType.Action,
    headerName: 'Actions',
    sortable: false,
    width: '150px',
  },
];

export const PersonList = () => {

  const apiRef = useRef<IListApi>(null);

  const handleRemove = async (person: IPerson) => {
    await ioc.personService.remove(person);
    await apiRef.current?.reload();
  };

  const handleClick = (person: IPerson) => {
    ioc.routerService.push(`/persons/${person.id}`);
  };

  const handleCreate = () => {
    ioc.routerService.push(`/persons/create`);
  };

  return (
    <List
      ref={apiRef}
      filters={filters}
      columns={columns}
      handler={ioc.personService.list}
      onCreate={handleCreate}
      onRemove={handleRemove}
      onClick={handleClick}
    />
  );
};

export default PersonList;

Данные в списочную форму и форму элемента списка загружаются через пропсу handler у шаблонизатора и методы list, one. Это обеспечивает бесшовную интеграцию бизнес логики в компоненты пользовательского интерфейса. Из плюсов, такой подход радикально снижает количество копипасты и делает оформление приложения в едином фирменном стиле

// src/pages/PersonOne.tsx

import { useState } from 'react';

import {
  One,
  IField,
  Breadcrumbs,
  FieldType,
} from '../components/OneEngine';

import IPerson from '../model/IPerson';

import ioc from '../lib/ioc';

const fields: IField[] = [
  {
    name: 'firstName',
    type: FieldType.Text,
    title: 'First name',
    description: 'Required',
  },
  {
    name: 'lastName',
    type: FieldType.Text,
    title: 'Last name',
    description: 'Required',
  },
];

interface IPersonOneProps {
  id: string;
}

export const PersonOne = ({
  id,
}: IPersonOneProps) => {

  const [data, setData] = useState<IPerson | null>(null);

  const handleSave = async () => {
    if (id === 'create') {
      await ioc.personService.create(data);
    } else {
      await ioc.personService.save(data);
    }
  };

  const handleChange = (data: IPerson) => {
    setData(data);
  };

  return (
    <>
      <Breadcrumbs
        disabled={!data}
        onSave={handleSave}
      />
      <One
        fields={fields}
        handler={() => ioc.personService.one(id)}
        change={handleChange}
      />
    </>
  );
};

export default PersonOne;

Спасибо за внимание)

Не думал, что вы дочитали до этого места.

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


  1. tripolskypetr Автор
    08.06.2022 00:48

    Овсянка, сер! Это для любителей, которые хотят сделать для React сервисы как в Angular2)


  1. kai3341
    08.06.2022 00:43
    +2

    Что это было, Бэрримор? Я одного не понимаю -- какую проблему решает этот код?


    1. tripolskypetr Автор
      08.06.2022 00:48

      Овсянка, сер! Это для любителей, которые хотят сделать для React сервисы как в Angular2)


      1. kai3341
        08.06.2022 01:41

        Я не вижу профита от всей этой горы кода. Становится ли код проще для сопровождения? Не наблюдаю. Что по производительности? Что по размеру бандла?


        1. tripolskypetr Автор
          08.06.2022 11:15

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

          Через ioc достучаться до стейта приложения можно откуда угодно. В том числе из дебаггера, отладка проще

          Сопровождение проще, так как не нужно описывать провайдеры (контексты) или boilerplate. После написания базовых сервисов, скорость разработки выше

          Размер бандла изменится не сильно, тот же самый код всё равно придется где-то расписать, например, в action-creators, middlewares и reducers. Однако, с классами проще избежать копипасты, на проектах с уже плохим кодом размер бандла, если откатить время назад и взят Mobx+DI, мог бы быть меньше.


          1. kai3341
            08.06.2022 14:37
            +1

            Спасибо. Буду повторно читать. Статью об i18nano я перечитывал кучу раз, пока не понял, что все ответы на мои вопросы есть в примерах кода