Сидел я как-то вечером с кружкой чая и вдруг, ни с того не с сего, получил уведомление на почту о комментарии на статью следующего содержания
Дело в том, что описывать компоненты пользовательского интерфейса действительно проще в функциональном стиле. Особо выделился в этом деле 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
ApiService - обертку под HTTP запросы к серверу, осуществляющую обработку сессии и ошибок
ErrorService - сервис обработки исключений
RouterService - сервис навигации по страницам приложения
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;
Спасибо за внимание)
Не думал, что вы дочитали до этого места.