Модульна архітектура для розширеного React-застосунку



У React зазвичай використовують context’ для інкапсуляції бізнес-логіки. При цьому оновлення будь-якої властивості в одному з контекстів спричиняє оновлення всього дерева компонентів. Від цього страждає продуктивність застосунків. А якщо застосунок із великою кількістю складових, одна з важливих задач — налагодити впровадження нових функцій, витрачаючи мінімум зусиль і часу.

Завдання можна вирішити за допомогою архітектури, що робить систему гнучкою при будь-яких масштабах застосунків. Такий варіант запропонував Антон Пінкевич, Front-end Team Lead в Universe, продуктовій компанії з екосистеми Genesis. У матеріалі на DOU він розповів про новий архітектурний патерн, що він розробив та запровадив на edtech-платформі Universe. Публікуємо стислий переказ найважливішого зі статті. Материал буде корисним усім, хто пише на React та хоче покращити свої продукти.

> Модуль (Module)

> Сервіси (Services)

> Сховища (Stores)

> Адаптери/Шлюзи (Adapters/Gateways)

> Впровадження залежностей (Dependency Injection)

> Use cases

> Моделі (Models)

> Утиліти (Utils)

> Обробка помилок/винятків (Exceptions handling)



Створений фахівцями з Universe архітектурний патерн зручно використовувати із будь-яким фреймворком для React: create-react-app, Next.js та іншими. Чим він корисний? По-перше, він дає змогу розгорнути залежності застосунку, при цьому бізнес-логіка відповідатиме за презентацію, а не навпаки. По-друге, він розділяє застосунок на незалежні модулі — таким чином більшість написаного коду може використовуватись повторно шляхом змішування модулів. Кожен модуль поділений на уніковані компоненти, тому є можливість писати тести лише для бізнес-логіки. Основна перевага модульності в тому, що вона дає можливість почати з малого і додавати нові складові поступово. З чого складається архітектура?



Модуль (Module)


Модуль — це основний блок, на якому будується система. Це незалежна одиниця, що містить певну інкапсульовану поведінку, але і може існувати без візуальної презентації. Це дозволяє нам розгорнути стандартні залежності застосунку та зробити так, щоби View не керував усім застосунком, а переорієнтовувався в залежності від необхідної поведінки.



За замовчуванням, логіка знаходиться біля кожного модуля. Не всі модулі містять візуальну презентацію. Anonymous та Authenticated виконують логіку, але не відображаються в інтерфейсі. Тому застосунок залежить не від візуального шару, а від логічного.

Базовий модуль складається з двох файлів: Index, Interactor. Якщо потрібна візуалізація — додаємо Router. У випадку, якщо візуалізація складна, додаємо один або декілька View.


Стрілками позначені залежності між компонентами модулю.


Interactor містить бізнес-логіку. Бажано, щоби він був ізольований від зовнішнього простору та отримував необхідні залежності через пропси.


type Payload = {

authenticationService: IAuthenticationService

authenticationStore: IAuthenticationStore

router: IRouter

}

interface IUserSignupByEmailInteractor {

redirectToSignin: () => void

passwordRecoveryUrl: string

children: {

signupByEmail: boolean

signupByGoogle: boolean

}

}

const useSignupPageInteractor = ({ authenticationService, router, authenticationStore }: Payload): IUserSignupPageInteractor => {

// logic implementation here

return {

redirectToSignin: () => router.redirect('/signin'),

passwordRecoveryUrl: '/password-recovery',

children: {

signupByEmail: true,

signupByGoogle: canUserSignupByGoogle,

}

}

}


Таким чином у нас є необхідне сховище та сервіс із пропсів. Спочатку позначаємо, які дочірні модулі може рендерити даний, а потім виконуємо перевірки та повертаємо бульові значення для кожного з дочірніх модулів. Рендер React-компонентів буде відбуватися в роутері.


Router – це файл, який пов'язує бізнес-логіку та візуалізацію. Він отримує всі необхідні залежності через пропси та містить лише логіку перевірок if else, аби визначити чи потрібно рендерити той чи інший модуль.



interface IProps {

signupByEmail: React.ReactNode

signupByGoogle: React.ReactNode

interactor: IUserSignupByEmailInteractor

}

const SignupPageRouter: React.FC = ({ signupByEmail, signupByGoogle, interactor }) => (

<>

{interactor.children.signupByEmail && signupByEmail}

{interactor.children.signupByGoogle && signupByGoogle}

</>

)


View — файл, задача якого полягає в тому, щоби групувати різні dumb components.



interface IProps {

link: string

}

const ForgotPassword: React.FC = ({ link }) => (


А Index буде збирати необхідні залежності для Interactor і Router. У ньому ми викликаємо усі useContext, завантажуємо необхідні сервіси та сховища.



const SignupByEmail = () => {

con†st { authenticationService } = useServices()

const { router } = useUtils()

const { authenticationStore } = useStores()

const interactor = useSignupByEmail({ authenticationService, router, authenticationStore })

return (

}

signupByGoogle={}

interactor={interactor}

/>

)

}


Index и Interactor — базові файли для побудови модуля. Інші – за необхідністю. Доки нам не потрібно розширювати систему, можна зберігати стан всередині модулів через useState. Для загальних даних можна створити базовий createContext.



Сервіси (Services)


Сервіс — це простий механізм формату «запит-відповідь». В окремі сервіси ми виносимо логіку отримання даних із зовнішніх джерел, якщо нам треба використовувати її в різних модулях. Сервіс може зберігати внутрішній стан, але лише той, який йому потрібен для виконання запитів. Які сервіси існують?


  • Authentication — перевіряє вхідні дані користувача, дозволяє зареєструватися тощо.

  • Analytics — збирає та надсилає аналітику.

  • Payment — обробляє платежі.

Застосунок з підключеними сервісами виглядає так:




Сховища (Stores)


В поданій архітектурі кожен модуль може зберігати дані всередині, але якщо треба отримати загальний стан, то ці дані краще виносити в сховища. Наприклад:

  • Authentication — зберігає дані аутентифікації: token, refreshToken тощо.

  • User — зберігає дані про користувача: ім’я, email та інші.

Такий вигляд має застосунок зі сховищами:




Адаптери/Шлюзи (Adapters/Gateways)


Коли система розширюється, сервіси та сховища можуть використовувати адаптери. Наприклад, analytics service може використовувати лише Google Analytics для відправлення даних, проте пізніше можуть додатися Facebook Pixel, Amplitude, Mixpanel тощо. Не треба щоразу писати новий сервіс, достатньо лише передати необхідний адаптер у вже написаний. Так у сервісі з’являється нова залежність. Інтерфейс цього адаптера описується в сервісі, а реалізується вже зовнішніми адаптерами. Наприклад:


export interface IAnalyticsAdapter {

sendEvent: (eventName: string, eventData: unknown) => Promise

}

export class AnalyticsService {

private adapter: IAnalyticsAdapter

constructor(adapter: IAnalyticsAdapter) {

this.adapter = adapter

}

// ... implementation

}


Далі робимо необхідні адаптери:


class AnalyticsAdapter implements IAnalyticsAdapter {

private adapters: IAnalyticsAdapter[] = []

constructor(adapters: IAnalyticsAdapter[]) {

this.adapters = adapters

}

sendEvent: IAnalyticsAdapter['sendEvent'] = (eventName, eventData) => {

this.adapters.forEach((adapter) => adapter.sendEvent(eventName, eventData))

}

}

class GoogleAnalyticsAdapter implements IAnalyticsAdapter {

sendEvent: IAnalyticsAdapter['sendEvent'] = (eventName, eventData) => {

// google analytics implementation

}

}

class FacebookPixelAdapter implements IAnalyticsAdapter {

sendEvent: IAnalyticsAdapter['sendEvent'] = (eventName, eventData) => {

// facebook pixel implementation

}

}

export const analyticsAdapter = new AnalyticsAdapter([

new GoogleAnalyticsAdapter(),

new FacebookPixelAdapter(),

])


Та застосовуємо у сервісі:


constanalyticsService = newAnalyticsService(analyticsAdapter)


Наступним чином виглядають адаптери в застосунку:




Впровадження залежностей (Dependency Injection)


Аби пов'язати сервіси та сховища з модулями, використовуємо контексти React.


interface IServices {

authenticationService: IAuthenticationService

analyticsService: IAnalyticsService

}

const ServicesContext = createContext(null)

export const useServices = (): IServices => useContext(ServicesContext)

export const ServicesProvider: React.FC = ({ children }) => {

// initialize services

return (

{children}

)

}


В цій архітектурі сервіси та сховища – це класи, які не оновлюються при зміні стану. В такому випадку ми уникаємо будь-яких зайвих оновлень.


Застосунок із впровадженням залежностей:




Use cases


Коли кількість модулів зростає, починає з'являтися загальна логіка. Її можна виносити в Use cases. Вони можуть використовувати і сервіси, і сховища через контексти. Також вони за замовчуванням не можуть використовуватися поза модулями, тому сервіси та сховища будуть завжди доступні.


Вигляд застосунку із use cases:




Моделі (Models)


Якщо ми маємо об’єкти із зовнішніх джерел, які потрібно використовувати в декількох модулях, їх можна виділити в окрему сутність «Модель». Це дасть нам змогу валідувати дані, застосовувати до них загальну логіку та зберігати їх в одному місці.

Застосунок із моделями:




Утиліти (Utils)


Це всі файли, які допомагають в розробці, та яким не потрібно мати доступ до зовнішніх джерел. Їх можна використовувати як в сервісах, так і в модулях. До прикладу, утиліти це:

  • Token parsers.

  • Date formatters.

  • Device type detection.



Обробка помилок/винятків (Exceptions handling)


Всі помилки мають оброблятися винятково в модулях, тому що логіка обробки має знаходитись лише в одному місці. Для типування використовуємо тип Result, який повертається під час виклику методів у сервісах.

export type Result<R, E extends Error> = R | E

Наприклад:


class MyError1 extends Error {}

class MyError2 extends Error {}

// example-service.ts

class ExampleService {

example = (): Result<string, MyError1 | MyError2> => {

// implementation

}

}

// example-module/interactor.ts

const useExampleInteractor: React.FC = ({ exampleService }) => {

useEffect(() => {

exampleService.example()

.then((result) => {

// typeof result = string | MyError1 | MyError 2

if (result instanceof MyError1) {}

// typeof result = string | MyError2

if (result instanceof MyError2) {}

// typeof result = string

// do whatever you want with the pure result type

})

}, [])

return {}

}


Модулі — проміжна ланка між отриманням даних та їх зберіганням. В свою чергу use cases та утиліти використовують для спрощення цієї взаємодії. Розділення за зонами дає змогу зробити так, щоби кожна зона системи була відповідальна лише за одну задачу. Такий підхід до розробки дозволяє гнучко розширювати проєкт, залишаючи логіку інкапсульованою та зрозумілою.



Підписуйся на нашу розсилку

та отримуй корисні матеріали першим!

Надаючи вашу електронну адресу, ви погоджуєтесь з нашою Політикою приватності.

Дякуємо, що підписалися.

image-from-rawpixel-id-5996033-png.png