Многоуровневая Архитектура Или Ооп В Современном Приложении React/Mobx

Однажды вечером я сидел с кружкой чая и вдруг ни с того ни с сего получил уведомление на электронную почту.

о комментариях к статье следующее содержание

Многоуровневая архитектура или ООП в современном приложении React/Mobx

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

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

Кроме того, отличительной особенностью этой библиотеки является большой выбор проработанных UI-компонентов от OpenSource и корпораций.

МУИ из Google, Базовый веб от Убер, Свободный пользовательский интерфейс (Fabric) от Майкрософт, Интерфейс чакры от сообщества и других Улучшать комплектующие — чрезвычайно трудозатратное занятие и сложно найти персонал.

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



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

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

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

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

Внедрение зависимости

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

Самый чистый код будет тот, который делает это посредством внедрения зависимостей.

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



// 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 будет экспортировать функции предоставлять И вводить , что позволит вам зарегистрировать службу и выполнить внедрение зависимостей.

Далее, создание многоуровневой архитектуры не составит ни малейшего труда.

Нам следует создать базовые сервисы, я приведу в пример несколько.

Их следует положить в папку источник/библиотека/база

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

  2. ErrorService — служба обработки исключений
  3. RouterService — сервис навигации по страницам приложений
  4. SessionService — сервис сохранения сессии пользователя
Для наглядности оставлю скриншот дерева файлов из этого примера

Многоуровневая архитектура или ООП в современном приложении React/Mobx

Начнем с ApiService. Например, я создал класс, реализующий методы get, post, put, patch, delete, реализующий соответствующие 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 генерирует события через разрешенияТема , оффлайнТема , умеретьТема .

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



// 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 — это оболочка над история для обеспечения реактивности.

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

// 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, чтобы сохранить сеанс при перезагрузке страницы.



Подключение услуг

Вам нужно будет создать три файла: типы.

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;

Синглтон МОК включает типизацию TypeScript, которая позволит выполнять статическую проверку типов во всем приложении.

Кроме того, МОК дублируется в глобальный объект окна, что позволит стороннему программисту написать расширение для приложения без доступа к исходному коду

Презентационный сервис

Далее давайте рассмотрим первый сервис с бизнес-логикой.

В качестве академического примера это было бы ЧеловекСервис — сервис, отображающий список пользователей из 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;

Метод список позволяет осуществлять нумерацию страниц из пользовательского интерфейса.

Метод сохранять сохраняет вновь созданного пользователя без идентификатора и перенаправляет пользовательский интерфейс на новую страницу для последующего сохранения изменений.

Метод один загружает данные пользователя по идентификатору Если в процессе загрузки данных произойдет ошибка, 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;

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

Это обеспечивает плавную интеграцию бизнес-логики в компоненты пользовательского интерфейса.

Плюс такого подхода — радикально сокращает количество копипаста и делает дизайн приложения выдержанным в едином фирменном стиле.



// 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;



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

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

Теги: #JavaScript #OOP #react.js #typescript #react #внедрение зависимостей #mobx #di #IoC #cleancode

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.