Архитектура фронтенд-приложений на React. (Нам не нужен FSD)
Всем привет, меня зовут Павел Рожков, я lead фронтенда в компании Doubletapp. Мы занимаемся заказной разработкой, и в нашей работе над React-проектами важную роль играет наш архитектурный гайдлайн, который мы постоянно совершенствуем. Это свод договоренностей о том, каким образом будет организован код в нашем проекте.
Гайдлайн помогает нам:
Безболезненно менять состав команд на проектах между собой. Каждый может заменить коллегу или усилить команду, минуя этап долгого онбординга.
Сократить время разработки. У нас часто не возникает вопроса, как здесь сделать лучше, куда что положить, как организовать. Мы подумали об этом заранее.
Поддерживать старые проекты, т.к. они написаны по тем же принципам.
Поднять качество кода: работать на проекте становится удобнее и можно сосредоточиться на важных вещах.
Онбордить новых членов команды благодаря готовой документации.
Содержание:
Почему бы нам просто не взять FSD?
Я часто общался с людьми в комьюнити по поводу архитектуры их проектов, и многие из них описывают свои гайдлайны как некий гибрид FSD и того, что каждой команде показалось удобно внедрить в их конкретном случае. И это правильно, ведь архитектура — это инструмент, который должен выполнять задачи бизнеса, а задачи бывают разные. Подробнее эту мысль раскрывает Глеб Михеев в своем докладе «Рефлексия как метод проектирования архитектуры». .
Также FSD имеет довольной высокий порог вхождения. Одной из причин этого является то, что FSD предлагает новую парадигму мышления, когда ты должен строить свой проект во многом вокруг Сущностей (Entities), которые иногда даже могут не содержать в себе визуальное представление (UI). Мы же предлагаем строить приложения более прямолинейно, как мы все привыкли, вокруг Интерфейсов.
Шаблон проекта с архитектурой
Ниже мы рассмотрим подробнее нашу методологию, но со временем она может претерпевать изменения. Поэтому мы подготовилипубличный репозиторий, который представляет собой шаблон для создания приложения на ее основе, а также содержит все необходимые настройки проекта и актуальный для нас стек. Мы будем поддерживать его в актуальном состоянии, и вы можете использовать его для своих проектов.
Структура основного кода приложения (обычно это каталог /src)
Api
App
Assets
Components
Pages
Wrapper
Layouts
Widgets
Dummies
UI
Constants
Hooks
Models
Stores
Styles
Utils
Ниже мы поговорим подробнее про каждую категорию.
/Components
Наверное один из ключевых вопросов в архитектуре React-проектов — по какому принципу мы делим наш интерфейс на компоненты. Мы предлагаем следующий подход.
Мы имеем 6 типов компонентов:
Pages
Widgets
Dummies
UI
Wrappers (HOCs)
Layouts
Создавая новую страницу (в Pages), мы думаем, какие из ее элементов могут быть потенциально или по факту переиспользованы в нашем приложении, и создаем эти компоненты в соответствующих их типу директориях.
Остальная верстка и элементы, которые необходимы только конкретному компоненту, хранятся непосредственно в нем или в его директории. Это правило действует для всех типов компонентов.
Теперь давайте разберем подробнее, как мы определяем тип компонента:
Pages
Как можно догадаться из названия, это компоненты, содержащие в себе код, отображающий всю страницу. Этот компоненты, которые мы передаем в наш роутер. Может содержать в себе бизнес-логику, если это необходимо.
Layouts
Этот парень отвечает за шаблонные расположения элементов в нашем интерфейсе. Самый популярный пример — компонент лэйаута страницы, который содержит в себе header, footer и через children prop принимает в себя верстку, которую необходимо отобразить. Layouts также могут принимать в себя компоненты через слоты, в виде props, и располагать их, как нам необходимо.
Wrappers (HOCs)
Вспомогательные компоненты, которые используются для расширения или изменения функциональности других элементов. Пример — wrapper, который добавляет своим детям анимации при перемещении.
Widgets
Автономный, самостоятельный компонент, заключающий в себе какую-то законченную часть функциональности. Содержит в себе всю бизнес-логику, которая ему необходима. Примеры: header, форма авторизации, список товаров, баннер.
Dummies
Комплексный компонент отображения. Необходимые ему данные принимает через props. Не содержит в себе бизнес-логики, только логику отображения, например переключение видимости блоков в рамках компонента. Самый популярный пример — карточка товара. Мы передаем через props все характеристики товара, а также callback для кнопки «добавить в корзину». Тем самым получаем переиспользуемый компонент, к которому мы можем привязать любую необходимую бизнес-логику, которая может быть разной для одного и того же отображения.
UI
Базовые компоненты, из которых строится интерфейс: кнопки, инпуты, заголовки, лоадеры, тултипы и т.д.). Может содержать в себе логику отображения, иметь локальный стейт.
Правила импортов:
Наши компоненты делятся на 2 типа: имеющие иерархию и нейтральные.
Нейтральные
Layouts
Wrappers
Нет ограничений на импорты.
Иерархические (с верхнего слоя к нижнему)
Pages
Widgets
Dummies
UI
Здесь каждый компонент может быть импортирован только в любой вышестоящий. Например UI не может содержать в себе Widget. При этом Widget может как содержать в себе любые компоненты нижнего уровня, так и не содержать их вообще, а иметь только свою собственную верстку с логикой. Также допускаются импорты в рамках одного уровня.
Структура папки отдельного компонента:
Каждый компонент хранится в отдельной папке с названием в PascalCase (например MyComponent) и имеет следующую структуру:
MyComponent.tsx
Основной файл компонента, название должно дублировать название папки, в которой он лежит. Если компонент принимает props, то описание типа Props мы храним в этом файле, над объявлением компонента. Также может содержать описание других типов, если необходимо.index.ts
Файл для сокращения пути import«а компонента. Также можно использовать для единой точки входа экспортируемых материалов из этого каталога.Styles.module.scss
Файл стилей, относящихся к компоненту.types.ts (опционально)
Здесь хранятся описания типов для нашего компонента. Иногда нам необходимо иметь описание типов, которые должны использоваться в другой части приложения. Их мы тоже храним здесь. Пример — тип, описывающий HTML-форму, у которой набор полей на фронтенде не совпадает с моделью бэкенда.constants.ts (опционально)
Любые статические данные компонента.useMyComponent.ts (для компонентов с бизнес логикой)
Всю бизнес-логику компонента мы выносим в хук рядом с компонентом. Это помогает разделять зоны ответственности в коде, и читать его становится легче. Также, если теоретически нам понадобится использовать эту бизнес-логику с другим отображением, мы сможем безболезненно имплементить такой функционал, ведь у нас он уже разделен.Components (опционально)
Если мы хотим вынести часть кода в отдельный компонент, и при этом мы понимаем, что использоваться он будет только своим родителем, мы создаем его в этой директории. Структура этих компонентов такая же. Вложенность может быть любая.
Вы можете расширять этот список всеми необходимыми этому компоненту материалами. Главное, чтобы соблюдалось правило использования этих материалов только этой частью приложения.
/Models
Здесь мы храним описание типов, общих для нашего приложения. Это все описания серверных запросов и ответов, а также клиентские модели, которые не замкнуты на одном конкретном интерфейсе.
Почему все модели в одном месте?
Часто в разных частях приложения нам могут понадобиться модели разных категорий. Хранение их в одном месте позволяет нам легко использовать их в интерфейсах, где нам необходимо, а также видеть всю «карту» уже созданных и доступных типов, что снижает риск дублирования кода при разработке нового функционала.
Мы создаем папку под каждую категорию моделей:
Common
Auth
Users
И т.д.
Каждая папка содержит:
— api.ts
— client.ts
Модели из обеих этих категорий могут быть использованы в нашем приложении там, где они необходимы.
Файл api.ts
Содержит в себе представления запросов и ответов сервера.
В названиях моделей все запросы и ответы сервера помечаются суффиксами Request и Response соответственно.
Пример:
interface User {
id: string;
name: string;
bio: string;
avatar: ImageDTO;
}
export interface GetUsersRequest {
limit: number;
offset: number;
}
export interface GetUsersResponse {
count: number;
items: User[];
}
Также иногда нам требуется создать модель какой-то сущности бэкенда, которую мы используем для описания эндпоинтов из разных категорий. Например File, который может приходить нам в ответе множества разных эндпоинтов. Такую модель мы кладем в папку Common и даем ей суффикс DTO, чтобы избежать потенциального пересечения имен с моделью клиента и понимать, что тип относится к серверу.
export interface FileDTO {
id: string
fileUrl?: string
fileName?: string
fileSize?: number
}
Файл client.ts
Содержит общие модели, которые относятся только к фронтенду. Мы кладем их сюда, когда понимаем, что они потенциально могут быть использованы в нескольких частях приложения.
Пример:
export interface SelectOption {
value: string
label: string
}
NOTE: когда модель бэкенда и фронта не совпадает по стилю написания (например у нас camelCase, а у бекенда на snake_case), мы используем интерсепторы axios, чтобы приводить данные к одному виду, и условно считаем, что мы работаем только в camelCase.
В ином случае мы предлагаем под каждый запрос писать функции сериализаторы для перевода данных в нужный формат. Но как показала наша практика, это не очень удобно.
export const http = axios.create({
baseURL: BASE_URL,
headers: {
'X-API-KEY': BASE_API_KEY,
'Content-Type': 'application/json'
}
})
const responseInterceptors = {
onSuccess: (response: AxiosResponse) => {
if (response.data && response.headers['content-type'] === 'application/json') {
response.data = camelizeKeys(response.data)
}
return response.data ? response.data : response
},
onError: (error: Error) => Promise.reject(error)
}
const requestInterceptors = {
onSuccess: (config: InternalAxiosRequestConfig) => {
config.params = decamelizeKeys(config.params)
if (config.data && config.headers['Content-Type'] === 'application/json') {
config.data = decamelizeKeys(config.data)
}
return config
},
onError: (error: Error) => Promise.reject(error)
}
http.interceptors.request.use(requestInterceptors.onSuccess, requestInterceptors.onError)
http.interceptors.response.use(responseInterceptors.onSuccess, responseInterceptors.onError)
/Api
Директория содержит функции для работы с сервером для всего приложения. По аналогии с models разделение файлов идет по сущностям, с которыми работаем.
auth.ts
users.ts
products.ts
и т.д.
Пример:
// auth.ts
import { http } from 'config/axios/http'
import { privateHttp } from 'config/axios/privateHttp'
import {
VerifyContactRequest,
VerifyContactResponse,
VerifyCodeRequest,
VerifyCodeResponse,
UpdateTokensResponse,
UpdateTokensRequest
} from 'models/auth/api'
import { SuccessResponse } from 'models/common/api'
export const updateTokens = (data: UpdateTokensRequest) =>
http.post('/auth/update-tokens', data)
export const verifyContact = (data: VerifyContactRequest) =>
http.post('/auth/verify/contact', data)
export const verifyCode = (data: VerifyCodeRequest) =>
http.post('/auth/verify/contact/code', data)
export const logout = () => privateHttp.post('/auth/logout')
/App
Инициализирующий слой приложения. Здесь хранится все необходимое для его запуска.
providers
styles
types
hooks
…
App.ts
Инициализация происходит в компоненте App.tsx (/app/App.tsx
). Здесь импортируются глобальные стили и шрифты приложения, провайдеры, роутер, декларации типов (d.ts), хуки и т.д.
Пример:
import { DEFAULT_ARIA_LOCALE } from 'constants/variables'
import { RouterProvider } from '@tanstack/react-router'
import { I18nProvider } from 'react-aria'
import { router } from './constants/router'
import { withAppProviders } from './providers/appProvider'
import './styles/global.scss'
function App() {
return (
)
}
export default withAppProviders(App)
/Assets
Здесь храним все медиафайлы проекта (картинки, иконки, аудио, видео)
Содержит список каталогов, разбитых по категориям:
icons
Images
audio
video
Внутри каталогов можно также организовывать файлы по категориям, например /icons/arrows/regular-arrow.svg
/Constants
Все необходимые приложению константы. Например:
// permissions.ts
export const rules: Rules = {
[UserRole.VISITOR]: {
[Permissions.READ_PRIVATE_PAGES]: false
},
[UserRole.TUTOR]: {
[Permissions.READ_PRIVATE_PAGES]: true,
[Permissions.EDIT_CHATROOM]: (adminId, user) => adminId === user?.uuid,
[Permissions.READ_SETTINGS_DOCUMENTS]: true,
[Permissions.READ_SETTINGS_MEMBERSHIP]: false,
[Permissions.CREATE_KIDS_ONLY_CHATROOM]: false,
}
}
/Hooks
Общие для приложения хуки. Пример:
// useWindowSize.ts
import { useLayoutEffect, useState } from 'react'
const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 })
const handleSize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
})
}
useLayoutEffect(() => {
handleSize()
window.addEventListener('resize', handleSize)
return () => window.removeEventListener('resize', handleSize)
}, [])
return windowSize
}
export default useWindowSize
/Stores
Сюда мы кладем хранилища наших стейт-менеджеров. Мы используем zustand.
alertStore.ts
userStore.ts
uiStore.ts
…
/Styles
Общие стили для всего приложения.
layout
mixins
variables
…
/Utils
Всевозможные вспомогательные функции, такие как debounce, compose, работа с localStorage и тд.
Файлы называем по категориям функций, к которым они относятся.
common.ts
storageManager.ts
validators.ts
…
Заключение
Все эти правила уже долгое время помогают нам разрабатывать проекты быстро и поддерживать их без боли. Такой подход отлично показывает себя как на небольших приложениях (~100–1000 часов frontend-разработки), так и на проектах долгих (~1000 и более часов frontend-разработки)
По этим же принципам можно расширять данную структуру под нужды конкретного приложения или менять ее, если в этом есть необходимость. Главное — сохранять общий подход.
Попробуйте это на своем проекте, и, возможно, вам тоже это подойдет.
И, главное, помните: проектирование приложений — это искусство)