The Clear Architecture на примере TypeScript и React

от автора

Добрый день, уважаемые читатели. В этой статье мы поговорим об архитектуре программного обеспечения в веб-разработке. Довольно долгое время я и мои коллеги используем вариацию The Clean Architecture для построения архитектуры в своих проектах Frontend проектах. Изначально я взял ее на вооружение с переходом на TypeScript, так как не нашел других подходящих общепринятых архитектурных подходов в мире разработки на React (а пришел я из Android-разработки, где давным-давно, еще до Kotlin, наделала шумихи статья от Fernando Cejas, на которую я до сих пор иногда ссылаюсь).

В данной статье я хочу рассказать вам о нашем опыте применения The Clean Architecture в React-приложениях с использованием TypeScript. Зачем я это рассказываю? — Иногда мне приходится разъяснять и обосновывать ее использование разработчикам, которые еще не знакомы с таким подходом. Поэтому здесь я сделаю детальный разбор с наглядными пояснениями на которое я смогу ссылаться в будущем.

Содержание

  1. Введение
  2. Теоретическая часть
    • Для чего вообще нужна архитектура?
    • Оригинальное определение The Clean Architecture
    • The Clean Architecture для Frontend
  3. Практическая часть
    • Описание веб-приложения авторизации
    • Структура исходного кода
    • UML диаграмма проекта
    • Разбор кода
  4. Заключение
  5. Ресурсы и источники

1. Введение

Архитектура — это, прежде всего, глобальная вещь. Ее понимание необходимо не в разрезе конкретного языка программирования. Вам необходимо понимание ключевых идей в целом, чтобы значит за счет чего достигаются преимущества от использования той или иной архитектуры. Принцип тот же, что и с паттернами проектирования или SOLID — они придуманы не для конкретного языка, а для целых методологий программирования (как, например, ООП).

Разобраться в архитектуре проще всего, когда видишь всю картину целиком. Поэтому в данной статье я расскажу не только о том, как должно быть “в теории” — а и приведу конкретный пример проекта. Сначала разберемся с теоретической частью применения The Clean Architecture во frontend’e, а потом рассмотрим веб-приложение с UML диаграммой и описанием каждого класса.

Важное уточнение: The Clean Architecture не устанавливает строгих правил организации приложений, она дает только рекомендации. У каждой платформы и языка будут свои нюансы. В данной статье преподноситься подход, который я использовал со своими коллегами и использую сейчас — он не является панацеей.

Также хотелось бы отметить, что использование подобных архитектурных подходов может быть избыточно для маленьких проектов. Основная задача любой архитектуры — сделать код понятным, поддерживаемым и тестируемым. Но если ваше приложение быстрее написать на JS без архитектур, тестирования и прочего — это вполне нормально.

2. Теоретическая часть

2.1. Для чего вообще нужна архитектура?

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

Более детально о том, что бывает, если не закладывать архитектуру для больших приложений — Вы можете прочитать, например, в книге The Clean Architecture Боба Мартина. Для краткого объяснения, я приведу следующий график из этой книги:

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

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

Еще раз о соотношении времени разработки

Как раз сегодня читал статью на Хабре и встретил следующую цитату (с которой полностью согласен):

«На первые 90 процентов кода уходит 10 процентов времени, потраченного на разработку. На оставшиеся 10 процентов кода уходит оставшиеся 90 процентов»

— Том Каргилл, Bell Labs

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

А теперь мой “идеальный” вариант, какой мы (разработчики, PM’ы, заказчики) хотели бы видеть в наших проектах:

На графике наглядно показано, что скорость роста количества строк не меняется в зависимости от версии. Стоимость строки кода (в зависимости от версии) увеличивается, но незначительно с учетом того, что речь идет о миллионах строк. К сожалению, такой вариант маловероятен, если мы говорим о большой Enterprise системе, так как продукт расширяется, сложность системы увеличивается, разработчики меняются, поэтому затраты на разработку неизбежно будут расти.

Однако я могу Вас и обрадовать — мы говорим о Frontend приложениях! Давайте смотреть правде в глаза — как правило, подобные приложения не вырастают до миллионов строк, иначе браузеры бы банально долго загружали такие приложения. В крайнем случае, они разбиваются на разные продукты, а основная логика лежит на backend стороне. Поэтому мы в какой-то мере можем стремиться к приведенной выше тенденции роста стоимости кода (с разной успешностью, в зависимости от размера приложения). Если наш проект даже на 50% дешевле сопровождается, чем мог бы без хорошей архитектуры — это уже экономия времени разработчиков и средств заказчика.

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

  • дешевле сопровождения кода (следовательно, меньше временных и финансовых затрат);
  • упрощение тестируемости кода (следовательно, потребуется меньше тестировщиков и ниже потери из-за пропущенных “багов на проде”);
  • ускорение внедрения новых разработчиков в проект.

Думаю, на вопрос “а зачем это нужно?!”, я ответил. Далее переходим к технической части вопроса.

2.2. Оригинальное определение

Я не буду углубляться в детальное описание The Clean Architecture, так как эта тема раскрыта во многих статья, а только коротко сформулирую суть вопроса.

В оригинальной статье Боба Мартина 2012-го года показана следующая диаграмма:

Ключевая идея данной диаграммы заключается в том, что приложение делиться на слои (слоев может быть любое количество). Внутренние слои не знают о внешних, зависимости обращены в центр. Чем дальше слой от центра, тем больше он знает о “небизесовых” деталях приложения (например, что за фреймворк используется и сколько кнопок на экране).

  • Entities. В центре у нас находятся Entities (сущности). В них заключена бизнес-логика приложения и здесь нет зависимостей от платформы. Entities описывают только бизнес-логику приложения. Например, возьмем класс Cart (корзина) — мы можем добавить товар в корзину, удалить его и т.д. Ничего о таких вещах, как React, базы данных, кнопках — данный класс не знает.

    Говоря о независимости от платформы имеется ввиду, что здесь не применяются специфические библиотеки как React\Angular\Express\Nest.js\DI и т.д. Если, например, возникнет необходимость, мы сможем взять цельную сущность из Web-приложения на React’e — и вставить в код для NodeJS без изменений.

  • Use cases. Во втором слое диаграммы расположены Use Cases (они же — сценарии использования, они же — Interactors). Сценарии использования описывают, как взаимодействовать с сущностями в контексте нашего приложение. Например, если сущность знает только о том, что в нее можно добавить заказ — сценарий использования знает, что из сущности можно взять этот заказ и отправить в репозиторий (см. далее).
  • Gateways, Presenters, etc. В данном контексте (Gateways = Repositories, Presenters = View Models) — слои системы, которые отвечают за связь между бизнес-правилами приложения и платформенно зависимыми частями системы. Например, репозитории предоставляют интерфейсы, которые будут реализовывать классы для доступа к API или хранилищам, а View Model интерфейс будет служить для связи React-компонентов с вызовами бизнес-логики.

    Уточнение: в нашем случае Use Cases и Repositories, как правило, будут находиться в ином порядке, так как большая часть работы frontend приложений заключается в получении и отправке данных через API.

  • External interfaces. Платформенно зависимый слой. Здесь находятся прямые обращения к API, компоненты React’а и т.д. Именно этот слой труднее всего поддается тестированию и абстрагированию (кнопочка в React’e — есть кнопочка React’e ).

2.3. Определение в контексте frontend’a

А теперь перейдем к нашей frontend области. В контексте Frontend’a, диаграмму выше можно представить вот так:

  • Entities. Бизнес сущности такие же, как и в оригинальном варианте архитектуры. Обратите внимание, что сущности умеют хранить состояние и часто используются для этой цели. Например, сущность “корзина” может хранить в себе заказы текущей сессии, чтобы предоставлять методы работы с ними (получение общей цены, суммарного количества товаров и т.д.).
  • Repository interfaces. Интерфейсы для доступа к API, БД, хранилищам и т. д. Может показаться странным, что интерфейсы для доступа к данным находятся “выше” сценариев использования. Однако, как показывает практика, сценарии использования знают о репозиториях и активно используют их. А вот репозитории ничего не знают о сценариях использования, но знают о сущностях. Это пример инверсии зависимостей из SOLID’a (возможность определения интерфейса во внутреннем слое, сделав реализацию во внешнем). Использование интерфейсов добавляет абстракцию (например, никто не знает, делает ли репозиторий запросы к API или берет данные из кеша).
  • Use Cases. Аналогично оригинальной диаграмме. Объекты, которые реализуют бизнес-логику в контексте нашего приложения (т. е. понимают, что делать с сущностями — отправлять, загружать, фильтровать, объединять).
  • View Models и View Interfaces.

    ViewModel — это замена Presenters из оригинальной диаграммы. В своих проектах я применяю архитектуру MVVP вместо MVP\MVC\MV*. Если описывать кратко, разница с MVP лишь в одном: Presenter знает о View и вызывает ее методы, а ViewModel не знает о View, имея только один метод уведомления об изменениях. View просто “мониторит” состояние View Model. MVVP имеет однонаправленную зависимость (View → ViewModel), а MVP — двунаправленную (View ︎ Presenter). Меньше зависимостей — проще тестировать.

    View Interfaces — в нашем случае, один базовый класс для всех View, через который View Model уведомляет конкретные реализации View об изменениях. Содержит метод по типу onViewModelChanged(): void. Еще один пример инверсии зависимостей.

  • 5. External interfaces. Аналогично оригинальной диаграмме, в этом слое находятся платформенно зависимые реализации. В случае приложения ниже — это компоненты React’a и реализация интерфейсов для доступа к API. Однако также здесь может быть любой другой фреймворк (AngularJS, React Native) и любое другое хранилище (IndexDB, local storage и т.д.). The Clean Architecutre позволяет изолировать применение конкретных фреймворков, библиотек и технологий, тем самым давая возможность в какой-то мере заменять их.

Если представить диаграмму выше в виде трехслойного приложения, она приобретает следующий вид:

Красные стрелки — поток течения данных (но не зависимостей, диаграмма зависимостей отображена на круговой диаграмме выше). Изображение в виде прямоугольной диаграммы позволяет лучше понять, как движется поток данных внутри приложения. Идею описания в виде такой диаграммы я увидел в ЭТОЙ статье.

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

3. Пример приложения

3.1. Описание веб-приложения авторизации

Чтобы применение архитектуры было более наглядным и понятным, я создал веб-приложение, построенное ее основе. Исходный код приложения Вы можете посмотреть в репозитории GitHub. Приложение выглядит так:

Приложение представляет собой простое окно авторизации. Для усложнения самого приложения (чтобы архитектура была уместна), делаем следующие вводные:

  1. Поля не должны быть пустыми (валидация).
  2. Введенная почта должна иметь корректный формат (валидация).
  3. Данные доступа должны пройти валидацию на сервере (заглушка API) и получить ключ валидации.
  4. Для авторизации методу API нужно предоставить данные валидации и ключ валидации.
  5. После авторизации ключ доступа должен быть сохранен внутри приложения (слой сущностей).
  6. При выходе ключ авторизации должен стираться из памяти.

3.2. Структура исходного кода

В нашем примере структура папки src выглядит следующим образом:

  • data — содержит классы для работы с данными. Эта директория является крайним кругом на круговой диаграмме, так как содержит классы для реализации интерфейсов репозиториев. Следовательно, эти классы знают об API и платформенно зависимых вещах (local storage, cookie и т.д.).
  • domain — классы бизнес логики. Здесь находятся Entities, Use Cases и Repository Interfaces. В подкаталоге entities есть разделение на две директории: models и structures. Разница между этими директориями в том, что models — это сущности с логикой, а structures — простые структуры данных (по типу POJO в Java). Это разделение сделано для удобства, так как в models мы кладем классы, с которыми мы (разработчики) непосредственно и часто работаем, а в structures — объекты, которые возвращает сервер в виде JSON-объекта (json2ts, «привет») и мы их используем для передачи между слоями.
  • presentation — содержит View Models, View Interfaces и View (фреймворковские компоненты), а также util — для различных валидаций, утилит и т.п.

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

3.3. URM диаграмма проекта

Исходники для увеличения — GitHub.

Разделение классов по слоям наглядно показано прямоугольниками. Обратите внимание, что зависимости направлены в сторону слоя Domain (в соответствии с диаграммой).

3.4. Разбор кода

Entities layer

В данном разделе мы пройдемся по всем классам с описанием их логики работы. Начнем с самого дальнего круга — Entities, так как на его основе базируются остальные классы.

AuthListener.tsx

// Используем для обновления слушателей // в классе AuthHolder export default interface AuthListener {   onAuthChanged(): void; }

AuthHolder.tsx

import AuthListener from './AuthListener';  // Данный класс хранит состояние авторизации (п. 3.1.5). Для того, чтобы // обновлять presentation слой, мы используем паттерн Observer // со слушателями AuthListener export default class AuthHolder {   private authListeners: AuthListener[];   private isAuthorized: boolean;   private authToken: string;    public constructor() {     this.isAuthorized = false;     this.authListeners = [];     this.authToken = '';   }    public onSignedIn(authToken: string): void {     this.isAuthorized = true;     this.authToken = authToken;     this.notifyListeners();   }    public onSignedOut(): void {     this.isAuthorized = false;     this.authToken = '';     this.notifyListeners();   }    public isUserAuthorized(): boolean {     return this.isAuthorized;   }    /**    * @throws {Error} if user is not authorized    */   public getAuthToken(): string {     if (!this.isAuthorized) {       throw new Error('User is not authorized');     }      return this.authToken;   }    public addAuthListener(authListener: AuthListener): void {     this.authListeners.push(authListener);   }    public removeAuthListener(authListener: AuthListener): void {     this.authListeners.splice(this.authListeners.indexOf(authListener), 1);   }    private notifyListeners(): void {     this.authListeners.forEach((listener) => listener.onAuthChanged());   } }

AuthorizationResult.tsx

// Простая структура данных для передачи между слоями export default interface AuthorizationResult {   authorizationToken: string; }

ValidationResult.tsx

// Еще одна структура данных для передачи между слоями export default interface ValidationResult {   validationKey: string; }

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

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

Repository interfaces

AuthRepository.tsx

import ValidationResult from '../../entity/auth/stuctures/ValidationResult'; import AuthorizationResult from '../../entity/auth/stuctures/AuthorizationResult';  // Здесь мы объявляем интерфейс, который потом реализует класс для доступа к API export default interface AuthRepository {   /**    * @throws {Error} if validation has not passed    */   validateCredentials(email: string, password: string): Promise<ValidationResult>;    /**    * @throws {Error} if credentials have not passed    */   login(email: string, password: string, validationKey: string): Promise<AuthorizationResult>; }

Use Cases

LoginUseCase.tsx

import AuthRepository from '../../repository/auth/AuthRepository'; import AuthHolder from '../../entity/auth/models/AuthHolder';  export default class LoginUseCase {   private authRepository: AuthRepository;   private authHolder: AuthHolder;    public constructor(authRepository: AuthRepository, authHolder: AuthHolder) {     this.authRepository = authRepository;     this.authHolder = authHolder;   }    /**    * @throws {Error} if credentials are not valid or have not passed    */   public async loginUser(email: string, password: string): Promise<void> {     const validationResult = await this.authRepository.validateCredentials(email, password);     const authResult = await this.authRepository.login(       email,       password,       validationResult.validationKey,     );      this.authHolder.onSignedIn(authResult.authorizationToken);   } }

В данном случае Use Case имеет только один метод. Обычно сценарии использования имеют только один публичный метод, в котором реализована сложная логика для одного действия. В данном случае – необходимо сначала провести валидацию, а потом отправить данные валидации в API метод авторизации.

Однако также часто используется подход, когда несколько сценариев объединяются в один, если имеют общую логику.

Внимательно следите, чтобы сценарии использования не содержали логику, которая должна находится в сущностях. Слишком большое количество методов или хранение состояния в Use Case часто служит индикатором того, что код должен находиться в другом слое.

Repository implenetation

AuthFakeApi.tsx

import AuthRepository from '../../domain/repository/auth/AuthRepository'; import ValidationResult from '../../domain/entity/auth/stuctures/ValidationResult'; import AuthorizationResult from '../../domain/entity/auth/stuctures/AuthorizationResult';  // Класс, имитирующий доступ к API export default class AuthFakeApi implements AuthRepository {   /**    * @throws {Error} if validation has not passed    */   validateCredentials(email: string, password: string): Promise<ValidationResult> {     return new Promise((resolve, reject) => {       // Создаем правило, которое должен был бы поддерживать сервер       if (password.length < 5) {         reject(new Error('Password length should be more than 5 characters'));         return;       }        resolve({         validationKey: 'A34dZ7',       });     });   }    /**    * @throws {Error} if credentials have not passed    */   login(email: string, password: string, validationKey: string): Promise<AuthorizationResult> {     return new Promise((resolve, reject) => {       // Имитируем проверку ключа валидации       if (validationKey === 'A34dZ7') {         // Создаем пример подходящего аккаунта с логином user@email.com и паролем password         if (email === 'user@email.com' && password === 'password') {           resolve({             authorizationToken: 'Bearer ASKJdsfjdijosd93wiesf93isef',           });         }       } else {         reject(new Error('Validation key is not correct. Please try later'));         return;       }        reject(new Error('Email or password is not correct'));     });   } } 

В данном классе мы сделали имитацию доступа к API. Мы возвращаем Promise, который вернул бы настоящий fetch-запрос. Если мы захотим заменить реализацию на реальный API — просто изменим класс AuthFakeApi на AuthApi в файле App.tsx или инструменте внедрения зависимостей, если такой используется.

Обратите внимание, что мы аннотируем методы описанием ошибок, чтобы другие программисты понимали потребность обработки ошибок. К сожалению, TypeScript в данный момент не имеет инструкций по типу throws в Java, поэтому мы используем простую аннотацию.

util (presentation слой)

В данную директорию мы кладем классы, которые осуществляют логику “превентивной” валидации данных, а также другие классы для работы с UI слоем.

FormValidator.tsx

export default class FormValidator {   static isValidEmail(email: string): boolean {     const emailRegex = /^\S+@\S+\.\S+$/;     return emailRegex.test(email);   } }

View interfaces

BaseView.tsx

Класс, которые позволяет View Model уведомлять View об изменениях. Реализуется всеми View компонентами.

export default interface BaseView {   onViewModelChanged(): void; }

View Models

BaseViewModel.tsx

Класс, который предоставляет базовые методы для связи View Model и View. Реализуется всеми View Models.

import BaseView from '../view/BaseView';  export default interface BaseViewModel {   attachView(baseView: BaseView): void;   detachView(): void; }

AuthViewModel.tsx

import BaseViewModel from '../BaseViewModel';  // Интерфейс ViewModel, который будет доступен View. Здесь // объявлены все публичные поля, которые будет использовать View export default interface AuthViewModel extends BaseViewModel {   emailQuery: string;   passwordQuery: string;   isSignInButtonVisible: boolean;   isSignOutButtonVisible: boolean;    isShowError: boolean;   errorMessage: string;    authStatus: string;   isAuthStatusPositive: boolean;    onEmailQueryChanged(loginQuery: string): void;   onPasswordQueryChanged(passwordQuery: string): void;   onClickSignIn(): void;   onClickSignOut(): void; }

AuthViewModelImpl.tsx

 import AuthViewModel from './AuthViewModel'; import BaseView from '../../view/BaseView'; import LoginUseCase from '../../../domain/interactors/auth/LoginUseCase'; import AuthHolder from '../../../domain/entity/auth/models/AuthHolder'; import AuthListener from '../../../domain/entity/auth/models/AuthListener'; import FormValidator from '../../util/FormValidator';  export default class AuthViewModelImpl implements AuthViewModel, AuthListener {   public emailQuery: string;   public passwordQuery: string;   public isSignInButtonVisible: boolean;   public isSignOutButtonVisible: boolean;    public isShowError: boolean;   public errorMessage: string;    public authStatus: string;   public isAuthStatusPositive: boolean;    private baseView?: BaseView;   private loginUseCase: LoginUseCase;   private authHolder: AuthHolder;    public constructor(loginUseCase: LoginUseCase, authHolder: AuthHolder) {     this.emailQuery = '';     this.passwordQuery = '';     this.isSignInButtonVisible = true;     this.isSignOutButtonVisible = false;      this.isShowError = false;     this.errorMessage = '';      this.authStatus = 'is not authorized';     this.isAuthStatusPositive = false;      this.loginUseCase = loginUseCase;     this.authHolder = authHolder;      // Делаем наш класс слушателем событий авторизации     this.authHolder.addAuthListener(this);   }    public attachView = (baseView: BaseView): void => {     this.baseView = baseView;   };    public detachView = (): void => {     this.baseView = undefined;   };    // Данный метод является методом интерфейса AuthListener   public onAuthChanged = (): void => {     // Изменяем данные модели, чтобы View     // отобразила изменения при входе и выходе     if (this.authHolder.isUserAuthorized()) {       this.isSignInButtonVisible = false;       this.isSignOutButtonVisible = true;       this.authStatus = 'authorized';       this.isAuthStatusPositive = true;     } else {       this.isSignInButtonVisible = true;       this.isSignOutButtonVisible = false;       this.authStatus = 'is not autorized';       this.isAuthStatusPositive = false;     }      this.notifyViewAboutChanges();   };    public onEmailQueryChanged = (loginQuery: string): void => {     this.emailQuery = loginQuery;     this.notifyViewAboutChanges();   };    public onPasswordQueryChanged = (passwordQuery: string): void => {     this.passwordQuery = passwordQuery;     this.notifyViewAboutChanges();   };    public onClickSignIn = async (): Promise<void> => {     if (!this.validateLoginForm()) {       this.notifyViewAboutChanges();       return;     }      try {       await this.loginUseCase.loginUser(this.emailQuery, this.passwordQuery);       this.isShowError = false;       this.errorMessage = '';     } catch (e) {       this.errorMessage = e.message;       this.isShowError = true;     }      this.notifyViewAboutChanges();   };    public onClickSignOut = (): void => {     // Удаляем данные авторизации без посредника в виде сценария использования     this.authHolder.onSignedOut();   };    private validateLoginForm = (): boolean => {     if (!this.emailQuery) {       this.isShowError = true;       this.errorMessage = 'Email cannot be empty';       return false;     }     // Убираем ошибку, если раньше ставили для этого условия     if (this.errorMessage === 'Email cannot be empty') {       this.isShowError = false;       this.errorMessage = '';     }      if (!FormValidator.isValidEmail(this.emailQuery)) {       this.isShowError = true;       this.errorMessage = 'Email format is not valid';       return false;     }     if (this.errorMessage === 'Email format is not valid') {       this.isShowError = false;       this.errorMessage = '';     }      if (!this.passwordQuery) {       this.isShowError = true;       this.errorMessage = 'Password cannot be empty';       return false;     }     if (this.errorMessage === 'Password cannot be empty') {       this.isShowError = false;       this.errorMessage = '';     }      return true;   }    private notifyViewAboutChanges = (): void => {     if (this.baseView) {       this.baseView.onViewModelChanged();     }   }; } 

Обратите внимание на метод onClickSignOut — в нем мы напрямую обращаемся к классу AuthHolder. Это один из тех случаев, когда посредник в виде сценария использования был бы лишним, потому что логика метода довольно тривиальна. Аналогично можно обращаться напрямую к интерфейсу репозиториев.

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

UI (views)

AuthComponent.tsx

 import React from 'react'; import './auth-component.css'; import BaseView from '../BaseView'; import AuthViewModel from '../../view-model/auth/AuthViewModel';  export interface AuthComponentProps {   authViewModel: AuthViewModel; }  export interface AuthComponentState {   emailQuery: string;   passwordQuery: string;   isSignInButtonVisible: boolean;   isSignOutButtonVisible: boolean;    isShowError: boolean;   errorMessage: string;    authStatus: string;   isAuthStatusPositive: boolean; }  export default class AuthComponent    extends React.Component<AuthComponentProps, AuthComponentState>   implements BaseView {   private authViewModel: AuthViewModel;    public constructor(props: AuthComponentProps) {     super(props);      const { authViewModel } = this.props;     this.authViewModel = authViewModel;      this.state = {       emailQuery: authViewModel.emailQuery,       passwordQuery: authViewModel.passwordQuery,       isSignInButtonVisible: authViewModel.isSignInButtonVisible,       isSignOutButtonVisible: authViewModel.isSignOutButtonVisible,        isShowError: authViewModel.isShowError,       errorMessage: authViewModel.errorMessage,        authStatus: authViewModel.authStatus,       isAuthStatusPositive: authViewModel.isAuthStatusPositive,     };   }    public componentDidMount(): void {     this.authViewModel.attachView(this);   }    public componentWillUnmount(): void {     this.authViewModel.detachView();   }    // При каждом обновлении ViewModel, мы обновляем    // state нашего компонента   public onViewModelChanged(): void {     this.setState({       emailQuery: this.authViewModel.emailQuery,       passwordQuery: this.authViewModel.passwordQuery,       isSignInButtonVisible: this.authViewModel.isSignInButtonVisible,       isSignOutButtonVisible: this.authViewModel.isSignOutButtonVisible,        isShowError: this.authViewModel.isShowError,       errorMessage: this.authViewModel.errorMessage,        authStatus: this.authViewModel.authStatus,       isAuthStatusPositive: this.authViewModel.isAuthStatusPositive,     });   }    public render(): JSX.Element {     const {       emailQuery,       passwordQuery,       isSignInButtonVisible,       isSignOutButtonVisible,        isShowError,       errorMessage,        authStatus,       isAuthStatusPositive,     } = this.state;      return (       <div className="row flex-grow-1 d-flex justify-content-center align-items-center">         <div className="auth-container col bg-white border rounded-lg py-4 px-5">           <div className="row mt-2 mb-4">             Status:              <span className={`${isAuthStatusPositive ? 'text-success' : 'text-danger'}`}>               {authStatus}             </span>           </div>            <div className="row mt-2">             <input               type="text"               placeholder="user@email.com"               onChange={(e: React.FormEvent<HTMLInputElement>): void => {                 this.authViewModel.onEmailQueryChanged(e.currentTarget.value);               }}               value={emailQuery}               className="form-control"             />           </div>           <div className="row mt-2">             <input               type="password"               placeholder="password"               onChange={(e: React.FormEvent<HTMLInputElement>): void => {                 this.authViewModel.onPasswordQueryChanged(e.currentTarget.value);               }}               value={passwordQuery}               className="form-control"             />           </div>            {isShowError && (             <div className="row my-3 text-danger justify-content-center">{errorMessage}</div>           )}            {isSignInButtonVisible && (             <div className="row mt-4">               <button                 type="button"                 className="col btn btn-primary"                 onClick={(): void => this.authViewModel.onClickSignIn()}               >                 Sign in               </button>             </div>           )}            {isSignOutButtonVisible && (             <div className="row mt-4">               <button                 type="button"                 className="col btn btn-primary"                 onClick={(): void => this.authViewModel.onClickSignOut()}               >                 Sign out               </button>             </div>           )}         </div>       </div>     );   } } 

Данный компонент является зависимым от фреймворка и, следовательно, находиться в самом крайнем слое диаграммы.

AuthComponent при монтировании (componentDidMount) прикрепляется к AuthViewModel и открепляется при исчезновении (componentWillUnmount). При каждом изменении ViewModel, AuthComponent обновляет свое состояние для дальнейшего обновления разметки.

Обратите внимание на условный рендеринг в зависимости от состояния:

 {isSignOutButtonVisible && (   <div className="row mt-4">     <button       type="button"       className="col btn btn-primary"       onClick={(): void => this.authViewModel.onClickSignOut()}     >       Sign out     </button>   </div> )} 

А также на обращение к методам ViewModel для передачи значений:

onClick={(): void => this.authViewModel.onClickSignOut()}

Entry point

Для входа в приложение, мы используем файлы index.tsx и App.tsx.

index.tsx

 import React from 'react'; import ReactDOM from 'react-dom'; import 'bootstrap/dist/css/bootstrap.css';  import App from './App'; import * as serviceWorker from './serviceWorker';  ReactDOM.render(   <React.StrictMode>     <App />   </React.StrictMode>,   document.getElementById('root'), );  serviceWorker.unregister(); 

App.tsx

 import React from 'react'; import './app.css'; import AuthComponent from './presentation/view/auth/AuthComponent'; import AuthViewModelImpl from './presentation/view-model/auth/AuthViewModelImpl'; import AuthFakeApi from './data/auth/AuthFakeApi'; import LoginUseCase from './domain/interactors/auth/LoginUseCase'; import AuthHolder from './domain/entity/auth/models/AuthHolder';  function App(): JSX.Element {   // data layer   const authRepository = new AuthFakeApi();   // domain layer   const authHolder = new AuthHolder();   const loginUseCase = new LoginUseCase(authRepository, authHolder);   // view layer   const authViewModel = new AuthViewModelImpl(loginUseCase, authHolder);    return (     <div className="app-container d-flex container-fluid">       <AuthComponent authViewModel={authViewModel} />     </div>   ); }  export default App; 

Именно в файле App.tsx происходит инициализация всех зависимостей. В данном приложении мы не используем инструменты внедрения зависимостей, чтобы излишне не усложнять код.

Если нам потребуется изменить какую-то зависимость, мы будем заменять ее в этом файле. Например, вместо строки:

const authRepository = new AuthFakeApi();

Напишем:

const authRepository = new AuthApi();

Также обратите внимание, что мы используем только интерфейсы, а не конкретные реализации (все основывается на абстракции). При объявлении переменных, мы подразумеваем следующее:

const authRepository: AuthRepository = new AuthFakeApi();

Это позволяет скрывать детали реализации (чтобы потом заменять ее без изменения интерфейса).

4. Заключение

Надеюсь, в ходе чтения статьи у вас сложилось понимание, как можно применять The Clean Architecture в React (и не только проектах), и наш опыт поможет сделать ваши приложения более качественными.

В данной статье были описаны теоретические и практические основы использования The Clean Architecture в frontend проектах. Как говорилось ранее, The Clean Architecture дает только рекомендации о том, как строить Вашу архитектуру.

Выше был приведен пример простого приложения, которое использует данную архитектуру. Учтите, что по мере роста приложения, архитектура может меняться, поэтому приведенный выше код — не является панацеей (как говорилось вначале), в этой статье лишь передача части нашего опыта.

5. Ресурсы

Исходный код — github.com/RostislavDugin/clean-architecture-react-typescript

UML диаграмма — github.com/RostislavDugin/clean-architecture-react-typescript/tree/master/information

ссылка на оригинал статьи https://habr.com/ru/post/499078/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *