Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Feature-sliced design (FSD). обов'язкова архітектура фронтенду для знання». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Feature-Sliced Design (FSD)** — архітектурна конвенція для фронтенду, яка групує код по доменних фічах і задає суворі однонаправлені залежності між шарами. ``` src/ app/ # маршрутизація, провайдери pages/ # компоненти сторінок features/ # дії користувача (addToCart, login) entities/ # бізнес-домен (User, Product, Order) shared/ # утиліти, UI kit ``` **Головне правило:** кожен шар імпортує тільки з нижчих. Одна фіча, один каталог, один напрямок.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Feature-Sliced Design (FSD)** — архітектурна конвенція для фронтенд-додатків, яка організовує код у чіткі горизонтальні шари (app, pages, features, entities, shared), де кожен шар імпортує тільки з нижчих, а всі файли одного домену живуть разом в одному slice. ## Теорія ### TL;DR - Уяви структуру компанії: `app` — CEO, `pages` — відділи, `features` — команди, `entities` — бізнес-об'єкти, `shared` — канцелярія. - Головна відмінність від класичного підходу: код групується по фічі/домену, а не по типу файлів. Ніякого гігантського `components/`. - Правило залежностей: `shared` → `entities` → `features` → `pages` → `app`. Тільки знизу вгору. - Підходить для команди від 5 розробників або при частих змінах бізнес-вимог. Для прототипів до 1000 рядків — зайве ускладнення. - Жодної магії компілятора. Конвенція підтримується ESLint-плагіном. ### Швидкий приклад ``` // Класична структура "по типах" (погано масштабується): src/ components/ # CartButton, UserCard, ProductList — все в купі pages/ cart.tsx # імпортує з components/, utils/, api/ utils/ api/ // FSD-структура (все зібрано за доменом): src/ app/ # маршрутизація, провайдери pages/cart/ ui/CartButton.tsx # UI кошика тут model/cartApi.ts # API кошика тут entities/Product/ ui/ProductCard.tsx model/product.ts # чистий доменний тип shared/ui/Button.tsx # тільки справді загальне ``` Нова фіча — один новий каталог. Не п'ять. ### Головна ідея Класична архітектура групує по типу файлу: всі кнопки в `components/`, всі запити в `api/`, всі утиліти в `utils/`. При 2000 рядках це нормально. При 30000 — додавання фічі «кошик» означає редагування 5+ каталогів, де файли розкидані по всьому проекту. FSD перевертає цю логіку. Кнопка кошика живе разом з логікою, API-запитами і тестами кошика. Один каталог, одна відповідальність. Новий розробник знаходить все про замовлення в `entities/Order/` і не гадає, де ховається API-виклик. ### Правила залежностей Кожен шар знаходиться на конкретному рівні ієрархії: ``` app (вгорі — оркеструє все) ↑ pages ↑ features ↑ entities ↑ shared (внизу — загальні утиліти) ``` Slice зі шару `features/` може імпортувати з `entities/` або `shared/`. Але не з `pages/` чи `app/`. Компонент `shared/ui/Button.tsx` нічого не знає про бізнес-домен. Ці правила захищають від циклічних залежностей — варто `shared/` почати імпортувати з `features/`, і вся структура руйнується. Slices всередині одного шару також не можуть імпортувати один одного. `features/cart/` не може імпортувати з `features/auth/`. Якщо їм потрібно щось спільне — це щось належить до `entities/` або `shared/`. ### Коли використовувати - Команда від 5 розробників на одній кодовій базі: FSD задає чіткі межі без зайвих нарад. - Бізнес-додатки (e-commerce, SaaS-дашборди): шар `entities/` прямо відображає доменну модель. - Монорепо зі спільними бібліотеками: шар `shared/` запобігає дублюванню між пакетами. - Прототип до 1000 рядків: пропускай. Накладні витрати перевищують користь. - Міграція з legacy-проекту: починай з `pages/` (природні межі по маршрутах), потім витягуй `entities/` зі своїх `utils/`. ### Порівняння: FSD vs. класична структура | Аспект | Класична (по типах) | FSD (по фічах) | |---|---|---| | Приклад папки | `components/CartButton/`, `api/cart.ts` | `pages/cart/ui/CartButton.tsx` | | Додати фічу | Змінити 5+ каталогів | 1 каталог | | Залежності | Будь-що імпортує будь-що | Тільки зверху вниз | | 100k+ рядків | Навігація стає болючою | Slices залишаються ізольованими | | Тестування | Потрібні cross-folder моки | Тести всередині slice | | Рекомендується для | Додатки до 5k рядків | Продакшн-додатки з командами | ### Як підтримується FSD Жоден фреймворк не робить це за тебе. FSD — чиста конвенція, і конвенції розвалюються без інструментів. Стандартне рішення — пакет `eslint-plugin-feature-sliced`, який перевіряє порушення шарів. Якщо файл зі `features/auth/` намагається імпортувати з `app/routing` — лінтер відразу сигналізує. Vite і Webpack резолвять імпорти звичайним чином. Але завдяки co-location tree-shaking працює ефективніше: локальні залежності дають менші чанки. Команди повідомляють про зменшення бандлу на 20-30% після міграції, переважно завдяки кращому видаленню мертвого коду. Особисто бачив у великих кодових базах: найбільший виграш — не розмір бандлу. А онбординг. Новий розробник, який розуміє назви шарів, знаходить будь-який файл за 30 секунд. ### Типові помилки **Все скидати в `shared/`** ```tsx // Неправильно: специфічний для кошика компонент в shared // src/shared/ui/CartButton.tsx // Правильно: перемістити туди, де він належить // src/features/cart/ui/CartButton.tsx ``` `shared/` — для справді загального коду: компоненти дизайн-системи, утиліти, типові хелпери. Коли команди кладуть туди доменно-специфічні компоненти, `shared/` перетворюється на смітник з 500+ файлів. На r/reactjs описують 40% роздування бандлу саме від цього патерну. **Крос-імпорти всередині одного шару** ```tsx // Неправильно: features імпортують features // src/features/cart/model/cart.ts import { useAuth } from 'src/features/auth'; // ризик циклічних залежностей // Правильно: виносимо спільну доменну логіку в entities // src/entities/User/model/authApi.ts ``` **Імпорт вгору по ієрархії шарів** ```tsx // Неправильно: feature дотягується до шару app import { router } from 'src/app/routing'; // порушення правила шарів // Правильно: передаємо навігацію через callback export const loginSuccess = (onSuccess: () => void) => { // логіка аутентифікації onSuccess(); }; // Шар pages викликає: loginSuccess(() => router.navigate('/dashboard')) ``` **Відсутність public API barrel-файлів** ```ts // Неправильно: споживачі знають внутрішні шляхи import { Button } from 'src/shared/ui/Button/Button'; // Правильно: barrel-файл керує публічним API // src/shared/ui/index.ts export { Button } from './Button'; // Споживач: import { Button } from 'src/shared/ui' ``` Без index-файлів перейменування всередині slice ламає всіх споживачів по всьому проекту. ### Де використовується - **React + Next.js**: Saleor `react-storefront` використовує FSD-патерни для `pages/product/` та `entities/Order/`. - **Vite + React**: бойлерплейт `fsd-vite` має понад 10k зірок на GitHub. - **Стейт-менеджмент**: Zustand-стор живе в `features/cart/model/`, глобальний стан користувача — в `entities/User/model/`. - **Nx монорепо**: Angular і React проекти використовують структуру шарів FSD на межах Nx-бібліотек. - **Vs. Atomic Design**: Atomic Design орієнтований на UI (атоми, молекули, організми). FSD орієнтований на домен. Вони вирішують різні проблеми і можуть співіснувати: Atomic Design керує тим, що потрапляє в `shared/ui/`. ### Питання на співбесіді **Q:** Покажи структуру папок для todo-додатку. **A:** `app/` — роутер і провайдери. `pages/todo-list/` — основна сторінка. `features/add-todo/` — форма і логіка додавання. `entities/Todo/` — тип Todo і можливо UI-картка. `shared/ui/` — Input і Button. **Q:** Що робити, якщо компонент потрібен у двох фічах? **A:** Якщо бізнес-логіка різна — дублюй. Якщо це чистий доменний компонент (як ProductCard) — виноси в `entities/Product/ui/`. Дублювання дешевше за передчасну абстракцію. **Q:** Як FSD працює з глобальним станом? **A:** Локальний стан slice (Zustand-стор, Redux slice) живе в `features/cart/model/`. Сесія користувача та інший справді глобальний стан — в `entities/User/model/` або `app/`. **Q:** Чи можна поєднувати FSD з атомарним дизайном? **A:** Так. Atomic Design може керувати тим, що потрапляє в `shared/ui/`. FSD визначає структуру всього проекту. Вони працюють на різних рівнях і не конфліктують. **Q:** Як мігрувати legacy-проект на FSD? **A:** Починай з `pages/` — маршрути вже є природними межами. Потім визначай доменні об'єкти і переносиш їх в `entities/`. Нарешті виноси фіче-специфічний код з `components/` і `utils/`. ESLint-плагін — страховка під час міграції. Команди повідомляють про скорочення складності шляхів імпорту на 30% і часу онбордингу вдвічі після двотижневої міграції. ## Приклади ### Базовий: фіча «додати до кошика» Реальний e-commerce приклад на React і Zustand: ```tsx // src/features/cart/model/cartSlice.ts import { create } from 'zustand'; import { Product } from 'src/entities/Product/model/types'; // дозволено: feature → entity interface CartState { items: Product[]; add: (item: Product) => void; } export const useCart = create<CartState>(set => ({ items: [], add: item => set(state => ({ items: [...state.items, item] })) })); // src/features/cart/ui/AddToCartButton.tsx import { useCart } from '../model/cartSlice'; // локальний імпорт всередині slice export const AddToCartButton = ({ product }: { product: Product }) => { const add = useCart(state => state.add); return <button onClick={() => add(product)}>Додати до кошика</button>; }; ``` Кнопка і стан живуть в одному slice. Тести цієї фічі йдуть в `src/features/cart/`. Рефакторинг логіки кошика не зачіпає нічого за межами цього каталогу. ### Середній рівень: сторінка компонує entities і features ```tsx // src/pages/user-orders/index.tsx import { UserOrderList } from './ui/OrderList'; // локально для сторінки import { fetchUserOrders } from './model/ordersApi'; // локально для сторінки import { Order } from 'src/entities/Order'; // дозволено: page → entity export const UserOrdersPage = () => { const [orders, setOrders] = useState<Order[]>([]); useEffect(() => { fetchUserOrders().then(setOrders); }, []); return <UserOrderList orders={orders} />; }; ``` Сторінка імпортує з `entities/` (нижчий шар — все добре) і зі своїх локальних файлів. Жодних імпортів з інших сторінок чи фіч. ### Просунутий рівень: виявлення і виправлення порушення шарів Такий патерн не пройде ESLint-перевірку і створює довгострокові проблеми із зв'язністю: ```tsx // НЕПРАВИЛЬНО: feature дотягується до шару app // src/features/auth/model/auth.ts import { router } from 'src/app/routing'; // ESLint-порушення: імпорт з вищого шару export const login = async (credentials: Credentials) => { await authApi.login(credentials); router.navigate('/dashboard'); // жорстке зв'язування з шаром app }; // ПРАВИЛЬНО: інвертуємо контроль через callback // src/features/auth/lib/loginHandler.ts export const login = async ( credentials: Credentials, onSuccess: () => void ) => { await authApi.login(credentials); onSuccess(); }; // src/pages/login/index.tsx (шар pages відповідає за навігацію) import { login } from 'src/features/auth'; const handleLogin = (credentials: Credentials) => { login(credentials, () => router.navigate('/dashboard')); }; ``` Виправлений варіант тримає `features/auth/` незалежним від того, як реалізована навігація. Функцію `login()` можна використовувати на мобільному, на іншій сторінці або в тестах без будь-яких змін у фічі.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.