Быстрый старт
В этом разделе мы рассмотрим все основные паттерны ED.
Этой статьи будет достаточно, чтобы уже получить пользу от использования этого паттерна.
Дальнейшие статьи в разделе гайды будут раскрывать отдельные аспекты работы с ED.
Какие проекты можно делать на ED
ED предназначен для работы на основных web фреймворках: vue, react, svelte, solid.
В простых модификациях он будет полезен даже в самых базовых проектах.
Единственное, для разработки библиотек ED не предназначен. Но вы всегда можете взять часть паттернов, если они вам будут полезны.
Модификации ED
Архитектура представлена в 4-х модификациях
- ED small - для маленьких проектов (до 12 человеко/месяцев разработки)
- ED medium - для средних проектов (команда до 5-6 человек фронтов)
- ED enterprise - для крупных проектов (больше одной команды)
- ED monorepo - для команды, которая работает над несколькими проектами
В текущем гайде будут описаны первые 2 модификации. 3-4 в будущем будут описаны в других статьях
Из чего состоит ED.
Для внешнего наблюдателя это просто папочки и файлики. На самом же деле это абстракции и архитектурные границы.
Подробнее об этом мы поговорим далее. Но пока вам никто не мешает об этом думать, как о папочках и файликах
Первое, с чего начинается ED, это с выделения слоёв.
Выделение основных слоёв
Как и в большей части архитектур, в ED на верхнем уровне код организован по слоям.
Всего есть 4 слоя верхнего уровня.
Директорияsrc/
Директорияapp/
- …
Директорияfeatures/
- …
Директорияservices/
- …
Директорияshared/
- …
Отличия от FSD
- В ED
shared
в отличие от fsdshared
может содержать бизнес-логику. services
очень похожи наentities
, но тут нет ограничений на семантику “объекта из бизнеса”features
это крупные куски функциональности приложения. По сути это слоиpages
widgets
features
entities
в одном.- Отдельного слоя
pages
нет. Код страниц находится вfeatures
. Если страницы содержат несколько фич, то композиция находится вapp
- Слайсы у нас называются модулями, а сегменты группами. А ещё у нас разрешены подмодули.
App
Слой точка входа. Здесь происходит запуск проекта, глобальная конфигурация, связь фич в единое приложение
Внутренняя структура app
не стандартизирована и сильно зависит от приложения.
Слой, в котором должна лежать самая часто меняющаяся логика
Примеры кода, который тут может быть:
app.tsx
корневой компонент приложенияroot-layout.tsx
общий лейаут всего приложенияroot-header.tsx
заголовок всего приложения. Может делегировать задачи компонентам изfeatures
global.css
глобальные стилиrouter.tsx
инициализацияreact-router
providers.tsx
компонент, который использует глобальные react провайдеры
export function Providers({ children }: { children: React.ReactNode }) { return ( <StoreProvider store={store}> <QueryProvider client={queryClient}>{children}</QueryProvider> </StoreProvider> );}
- В
next.js
app остаётся самим собой, только старайтесь большую часть логики уносить вfeatures
Features
Основной слой. Здесь должна находиться большая часть кода приложения.
Каждая папка слоя features
это реализация крупного независимого куска функциональности
Пример features
для планировщика задач
Директорияsrc/
Директорияapp/
- …
Директорияfeatures/
Директорияauth/
- …
Директорияtask-list/
- …
Директорияsidebar/
- …
Директорияsettings/
- …
Ограничение взаимодействия с app
Это крайне важное ограничение даёт нам следующее:
- В
app
находятся часто меняющиеся штуки. Зависимость на часто меняющийся код делает фичи ненадёжными sdp - Импорт из app почти всегда - циклическая зависимость. Циклические зависимости всегда плохо, и для компилятора, и для мозга. adp
Структура фичи
Каждая фича по своей сути это модуль!
Так как модули в ED бывают большие их структура подробно описана ниже.
Сейчас важно понимать, что в фиче может быть всё: И компоненты отображения, и логика, и инфраструктура, и страница.
Главный критерий, все эти вещи должны иметь высокую смысловую связность
Взаимодействие фич
Тут есть различие между “маленькой” модификацией и “средней”
Средняя модификация
Директорияsrc/
Директорияapp/
- …
Директорияfeatures/
Директорияauth/
- …
Директорияtask-list/ // из task-list нельзя импортировать auth
- …
Это ограничение очень важно в средних и крупных проектах, чтобы можно было рассматривать фичи как независимые блоки.
- Таким образом сильно возрастает понятность. Так как можно рассматривать фичу в отрыве от других
- Увеличивается надёжность. Так как изменение в одной фиче с меньшей вероятностью сломает другую
- Защита от циклических зависимостей.
В реальности, полностью изолированные фичи бывают редко. Поэтому для взаимодействия фич, с запретом на прямой импорт используются паттерны слабой связанности или по-другому dependency inversion (Инверсия зависимостей)
Звучит сложно, но по факту это: связь через общий стейт, слоты, рендер-пропсы, события, контекст, и dependency injection
Подробнее о DI
Это самая сложная часть ED. По этой причине в “маленькой” модификации это ограничение накладывать нецелесообразно
Маленькая модификация
В маленькой модификации прямой импорт разрешён. Но это не отменяет, что этих импортов должно быть как можно меньше!
Чем меньше связей между фичами будет, тем лучше. В идеале их вообще не должно быть
Services
Слой переиспользуемых бизнес модулей. Могут хранить не только логику, но и представление.
Как и фича, каждый сервис это самодостаточный модуль. Но в отличие от фичи он не реализует функционал приложения, а помогает фичам выполнять свою работу.
Чаще всего модули в services нужны, если есть большое количество переиспользуемой между фичами логики.
Пример services
для планировщика задач
Директорияsrc/
Директорияapp/
- …
Директорияfeatures/
Директорияauth/
- …
Директорияtask-list/
- …
Директорияmanage-settings/
- …
Директорияservices/
Директорияsession/
- …
Директорияsettings/
- …
В данном случае в session
скорее всего находится хранилище сессии, которое используется в большей части других фич.
А в settings
хранятся настройки, которые редактируются в фиче manage-settings
и используются в task-list
Ограничение взаимодействия с app
и features
Как фичи не могут импортировать app
, так и сервисы не могут импортировать features
и app
Сделано это по тем же самым причинам. Но для сервисов это ещё важнее. Они чаще всего переиспользуются в нескольких местах.
Зависимость на более часто меняющиеся фичи сделала бы сервисы неустойчивыми, и подвергла бы опасности сразу несколько других фич.
Взаимодействие сервисов
Тут нет однозначного решения. Базово я разрешаю взаимодействие сервисов друг с другом.
Так как чаще всего dependency inversion на таком уровне вызывает очень много сложностей.
Но если появляются проблемы, вводится такое же ограничение как и для фич.
Shared
Слой ядро приложения. Здесь расположены вещи, которые используются в приложении повсеместно
Старайтесь располагать в shared
только следующее:
- Глобальные бизнес типы. Типы редко меняются и не вызывают багов
- Глобальную инфраструктуру. Инстансы сторов, нотификации, интернационализацию, тему Обычно такая инфраструктура используется широко, а меняется редко
- Глобальные константы, связывающие приложение. Например, константы роутинга и результат чтения .env
Важные исключения:
По своей сути uikit
и api
не должны находиться в shared
Это крайне часто меняющиеся модули. Но все попытки унести их из shared
не удались.
Поэтому знайте, что uikit
и api
вечные источники багов. И относитесь к ним соответственно
Структура shared
жёстко не стандартизирована, но всегда похожа
Пример стандартного shared
всё ниже перечисленное опционально и может не понадобиться в вашем случае
Директорияsrc/
Директорияshared/
Директорияdomain/ // глобальные бизнес типы. Нужные для работы инфраструктуры и всего приложения
- ids.ts
- events.ts
- session.ts
- user.ts
Директорияui/ // uikit приложения
Директорияkit/
- button.tsx
Директорияtable/ // переиспользуемый компонент таблицы
- …
Директорияapi/ // обычно автоматически генерируемый api instance
- api-instance.ts // инстанс axios
- generated.ts
Директорияmodel/ // работа с глобальными данными
- routes.ts // константы для роутинга
- config.ts // получение доступа к .env
- store.ts // инстанс redux
Директорияlib/ // Глобальная инфраструктура и хелперы
Директорияnotifications/
- …
Директорияi18n/
- …
Директорияreact/
- use-mutation-observer.ts
- date.ts
domain, model, ui, lib
Весь код shared
разделён на стандартные группы:
domain
: Самые важные бизнес типы и правила. Эта группа должна быть изолированной и ни на кого не зависетьmodel
: Работа с глобальным состояниемui
: Глобальные компонентыlib
: модули глобальной инфраструктуры
Ограничение взаимодействия с app
, features
и services
shared
это слой, в котором находится корневая логика. Он не может напрямую работать ни с одним другим слоем.
При этом его могут и будут импортировать все другие слои.
Это правило крайне важно и не должно нарушаться. shared
- опасный слой. С ним всегда нужно быть на чеку)
Схема слоёв
”Маленькая” модификация
“Средняя” модификация
Структура модуля
features
и services
- в ED содержат модули. Но по вашему желанию вы можете создавать модули и в app
и shared
Также ED поддерживает концепцию sub-modules
, таким образом Модули в ED могут быть очень большими.
По сути модули в ED можно рассматривать как мини-приложения, у которых есть своя архитектура
Этапы эволюции модулей
Мы против оверхеда, поэтому у модулей есть этапы эволюции, от простого к сложному.
Всегда при создании модулей начинайте с самых простых этапов, а потом проводите рефакторинг, если это требуется.
По нашему опыту это самый эффективный подход! Излишняя архитектура хуже, чем её недостаток
Этап 1: Single file module
Да-да, самые простые модули могут состоять только из одного файла!
Вот пример фичи todo-list
, на самых первых этапах
todo-list
, на самых первых этапахimport { useState } from "react";
export function TodoListPage() { const [todos, setTodos] = useState([]); const [input, setInput] = useState("");
const addTodo = (e) => { e.preventDefault(); if (!input.trim()) return; setTodos([...todos, { id: Date.now(), text: input, done: false }]); setInput(""); };
const toggleTodo = (id) => { setTodos( todos.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo ) ); };
const deleteTodo = (id) => { setTodos(todos.filter((todo) => todo.id !== id)); };
return ( <div className="max-w-md mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">Todo</h1>
<form onSubmit={addTodo} className="flex mb-4"> <input value={input} onChange={(e) => setInput(e.target.value)} className="flex-1 border p-2 rounded-l" placeholder="Add todo..." /> <button type="submit" className="bg-blue-500 text-white px-4 rounded-r hover:bg-blue-600" > Add </button> </form>
<ul> {todos.map((todo) => ( <li key={todo.id} className="flex items-center mb-2"> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} className="mr-2" /> <span className={`flex-1 ${ todo.done ? "line-through text-gray-400" : "" }`} > {todo.text} </span> <button onClick={() => deleteTodo(todo.id)} className="text-red-500 hover:text-red-700" > × </button> </li> ))} </ul>
{todos.length > 0 && ( <div className="mt-4 text-sm text-gray-500"> {todos.filter((t) => !t.done).length} items left </div> )} </div> );}
Чаще всего модули очень быстро перерастают этот этап. Но иногда нет!
С файлом до 400 строк работать приемлемо.
При этом скорость разработки и рефакторинга такого модуля выше, чем модуля из 20 файлов по 20 строк
Не пренебрегайте этим подходом, особенно на этапе прототипирования.
Этап 2: Flat module
Вот ваш файл стал больше 400 строк, и стало неудобно. Что выделяем components/hooks/ui/model
?
Нет ещё рано!
Нет ничего хуже папки, в которой один файл.
Второй этап эволюции, это создание плоской структуры разнородных модулей
Вот пример фичи todo-list
на втором этапе
Директорияtodo-list/
- todo-list-page.tsx
- use-todo-list.tsx
- api.tsx
- use-intersection-observer.tsx
- index.ts
Здесь мы просто разделили код на функции. А потом вынесли их в отдельные файлы.
Файлы называем в соответствии с содержимым. Избегаем названия hooks
components
Это число выведено эмпирически и связано с концепцией кошелёка миллера
Public api
На этом этапе уже не понятно, какой код предназначен для внешнего использования, а какой должен оставаться внутри.
Поэтому мы добавляем public-api
.
Это index.ts
файл, в котором вы реэкспортируем (иногда со сменой названия) доступные извне элементы.
export { TodoListPage } from "./todo-list-page";
Для тех у кого next.js
Если в index файлах нет сайд эффектов, то в vite tree-shaking работает корректно
С next.js же встречались проблемы. Поэтому там часто используется другой подход с public-api.
Все приватные файлы начинаются с _, а публичные без. Таким образом не обязательно создавать только один public файл
Директорияtodo-list/
- todo-list-page.tsx
- _use-todo-list.tsx
- _api.tsx
- _use-intersection-observer.tsx
Explicit dependencies (экспериментально)
При взаимодействии модулей друг с другом, часто крайне сложно проследить все зависимости модуля от других модулей.
Поэтому все зависимости, которые используются внутри, можно реэкспортировать через специальный deps.ts
файл.
Особенно это важно для services
и features
, если разрешены кросс импорты (из фичи фичу, из сервиса сервис)
Пример:
Директорияfeatures/
Директорияsettings/
- …
Директорияauth/
- …
Директорияtodo-list/
- index.ts
- deps.ts
- todo-list-page.tsx
- use-todo-list.tsx
- create-todo-form.tsx
deps.tsx
export { useSession } from "@/features/auth";export { useSettings } from "@/features/settings";
todo-list-page.tsx
import { useSession, useSettings } from "./deps";
Sub modules
По сути, когда мы сделали такое разделение, мы получили модуль, который состоит из нескольких однофайловых модулей.
Но они не обязаны быть однофайловыми. Любой дочерний модуль может быть на любом этапе эволюции
Директорияtodo-list/
- index.ts
- todo-list-page.tsx
- use-todo-list.tsx
Директорияcreate-todo-form/
- index.ts
- create-todo-form.tsx
- use-create-todo.tsx
Опасности вложенности
Благодаря подмодулям, на втором уровне можно оставаться очень долго.
Но глубокая вложенность не так хороша, как кажется. Глубокие древовидные структуры сложны для понимания. Намного комфортнее читаются однородные списки (слой features как раз пример однородного списка)
Поэтому чаще всего для преодоления ограничения в ~6 элементов подмодулям лучше предпочитать создание групп
Этап 3: Grouped module
Как было сказано выше, если для разнородных папок комфортно < 6 элементов. То для однородных папок это количество резко возрастает (до 20 элементов. Сильно зависиот от однородности)
Поэтому мы можем разделить подмодули на группы, таким образом сильно увеличив допустимый размер модуля
Что такое группа.
Группа это объединение нескольких модулей на основании общего признака:
Примеры групп:
- components
- hooks
- services
- features
- ui
- model
- lib
- api
Кстати, все слои это тоже группы.😉
Стандартные группы
Существуют достаточно удачные группы, которые хорошо себя показали:
Группа: ui
По сути это объединение всех компонентов, реже хуков, которые отвечают целиком и полностью за отображение. И не несут в себе сложной логики
Примеры:
- todo-list-page.tsx
- todo-card.tsx
- todo-form.tsx
- use-render-arrows.tsx
Группа: model
Группа, в которой лежит основная работа с данными.
Если вы на чистом react, здесь лежат хуки, которые манипулируют данными в отрыве от отображения.
Если у вас стейт менеджер, то здесь будет лежать вся логика работы со стейт менеджером
Примеры:
- use-todo-list.ts
- todo-list.slice.ts
- todo-list-store.ts
- todo-item.ts
Группа: lib
Группа, в которой находится инфраструктурный код. Это код чаще всего предоставляет более удобные обёртки над браузерным api и библиотеками. Или просто упрощает рутинные задачи
Примеры:
- use-mutation-observer.ts
- date.ts
Группа: api
Группа для кода работы с api и типами контрактов.
Группа: domain
Если логика в model
становится очень сложной.
То код, описывающий самые важные бизнес процессы:
- Расчёт скидки
- Вычисление отпуска
- Получение прогресса
- Расчёт координат при перемещении элемента по карте
Можно вынести в виде чистых функций
в группу domain
Также в domain
находятся все типы, над которыми эти чистые функции проводят манипуляции
Примеры:
- map-node.ts
- get-intersections.ts
- compute-next-lesson.ts
Группа: view-model
В некоторых кейсах модуль содержит большое количество логики, которая обрабатывает пользовательский ввод
Обычно это происходит, если реализуется dnd
или анимации
В таком случае этот код можно вынести в отдельную группу view-model
Пример:
- use-dnd.tsx
- use-animation.tsx
Вложенные группы
Внутри группы вы можете группировать модули и по другим признакам.
Это работает точно так же, как с подмодулями. Только не забывайте, что излишняя глубина это неудобно
Пример:
Директорияui/
Директорияfields/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
- create-user-form.tsx
- update-user-form.tsx
Пример модуля с группами
Директорияtodo-list/
- index.ts
- api.ts
Директорияmodel/
- todo-item.ts
- use-create-todo.ts
- use-update-todo.ts
Директорияui/
Директорияpages/
- todo-list-page.tsx
- todo-details-page.tsx
Директорияfields/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
- create-todo-form.tsx
- todo-details-form.tsx
- todo-item.module.css
- todo-item.tsx
Группы и подмодули
Появление групп не отменяет подмодули. Иногда чем разделять код на группы, лучше разбить весь код на несколько подмодулей
Директорияtodo-list/
- index.ts
- api.ts
Директорияui/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
Директорияtodo-list/
- index.ts
- todo-list-page.tsx
- create-todo-form.tsx
- use-create-todo.ts
- todo-item.tsx
Директорияtodo-details/
- index.ts
- todo-details-page.tsx
- todo-details-form.tsx
- use-update-todo.ts
Когда переходить на следующий этап?
На этом этапе можно разрабатывать модули любого размера.
Но здесь есть важная проблема - связи между подмодулями хаотичны, и в них может быть очень сложно разобраться
Если вы столкнулись с такой проблемой, вам поможет следующий этап:
Этап 4: Module with compose
Это сложный, но при этом крайне мощный паттерн борьбы с хаотическими зависимостями.
Его ближайщий аналог это DIP принцип из SOLID.
Основная суть этого паттерна, чтобы убрать зависимости между подмодулями в model
и ui
Для этого используются инструменты слабой связанности.
(для ui
слоты и рендер-пропсы, для model
события, DI, или простая связь через параметры)
После того как мы получили набор независимых элементов,
всё это объединяется в специальных компонентах медиаторах
Для них я обычно создаю отдельную группу compose
Связи между компонентами в compose
разрешены
Пример:
Директорияtodo-list/
- index.ts
- api.ts
Директорияcompose/
- todo-list-page.tsx
- todo-details-page.tsx
- create-todo-form.tsx
- todo-details-form.tsx
Директорияdomain/
- todo-item.ts
Директорияmodel/
- use-todo-list.ts
- use-delete-todo.ts
- use-create-todo.ts
- use-update-todo.ts
Директорияui/
Директорияfields/
- file-field.tsx
- text-field.tsx
- select-field.tsx
- date-field.tsx
- todo-page-layout.tsx
- todo-item.tsx
- common-fields.tsx
- update-button.tsx
- delete-button.tsx
Тогда todo-list-page.tsx
выглядел бы как-то так
export function TodoListPage() { const todoList = useTodoList(); const deleteTodo = useDeleteTodo(todoList); const createTodo = useCreateTodo(todoList); const updateTodo = useUpdateTodo(todoList);
return ( <TodoPageLayout createForm={<CreateTodoForm createTodo={createTodo} />} todos={todoList.list.map((item) => ( <TodoItem key={item.id} item={item}> <UpdateButton onClick={updateTodo.bind(item)} /> <DeleteButton onClick={deleteTodo.bind(item)} /> </TodoItem> ))} /> );}
Где можно почитать подробнее
Документация ещё активно разрабатывается. Позже об этом паттерне появится отдельная статья. Сейчас вы можете изучить следующие материаллы:
Ограничения этапа 4
Самые главные здесь ограничения технические. Не все инструменты поддерживают слабую связанность. И не во всех ситуациях, это даёт нужную производительность
Но в большей части ситуаций, такой подход помогает сильно уменьшить сложность модуля!
Выводы по эволюции модулей
Может быть вы уже запутались во всех вариантах модулей здесь представленных.
Это нормально, подобная гибкость чуть усложняет вход.
При более глубоком рассмотрении оказывается, что это всё вариации вокруг трёх понятий: модуль
группа
и public-api
.
На самом деле, этот подход одновременно и стандартизирует подходы. И предоставляет гибкость
По мере усложнения ваших модулей, вы можете строить удобный и поддерживаемый код из кирпичиков модулей
и групп
При этом не тратить время на постоянный оверхед от неудобных ритуалов и паттернов.
Что дальше?
Пользуясь советами из этого гайда, вы уже сейчас можете начать использовать ED.
Если хотите понять, а как это всё вообще работает. Приходите читать продвинутую часть документации
Сейчас эта часть только начинает развиваться, но со временем там будет появляться всё больше статей, раскрывающих концепции и паттерны