Разбираемся в�Feature-Sliced Design
Моргунов Александр
HolyJS 2024 Autumn
План
2
Что вас ждем
3
Об авторе
4
Об авторе (что по FSD)
5
6
Кадр с моего выступления про FSD
🍿🍿🍿
7
База про FSD 🐷🍔
База про FSD
9
База про FSD
10
База про FSD
11
База про FSD
12
Основные понятия
13
Слои (layers)
14
📂 app
📂 pages
📂 widgets
📂 features
📂 entities
📂 shared
📂 processes
Слои (layers)
15
📂 app // Глобальная инициализация
📂 pages
📂 widgets
📂 features
📂 entities
📂 shared
📂 processes
Слои (layers)
16
📂 app
📂 pages // Страницы
📂 widgets
📂 features
📂 entities
📂 shared
📂 processes
Слои (layers)
17
📂 app
📂 pages
📂 widgets // Бизнес модули с UI
📂 features
📂 entities
📂 shared
📂 processes
Слои (layers)
18
📂 app
📂 pages
📂 widgets
📂 features // Пользовательские сценарии
📂 entities
📂 shared
📂 processes
Слои (layers)
19
📂 app
📂 pages
📂 widgets
📂 features
📂 entities // Бизнес сущности
📂 shared
📂 processes
Слои (layers)
20
📂 app
📂 pages
📂 widgets
📂 features
📂 entities
📂 shared // Инфра + переиспользуемое
📂 processes
Слайсы (slices)
21
📂 app
📂 pages
📂 widgets
📂 CartCapsule
📂 ProductPopularList
📂 features
📂 entities
📂 shared
Сегменты (segments) + public API
22
📂 app
📂 pages
📂 widgets
📂 CartCapsule
📂 ProductPopularList
📂 api
📂 model
📂 ui
📄 index.ts
📂 features
📂 entities
📂 shared
23
24
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/app/*": ["./1_app/*"],
"@/pages/*": ["./2_pages/*"],
"@/widgets/*": ["./3_widgets/*"],
"@/features/*": ["./4_features/*"],
"@/entities/*": ["./5_entities/*"],
"@/shared/*": ["./6_shared/*"],
},
}
База по FSD (саммари)
25
Как мы начали�разработку проекта
26
Что мы хотели на старте?
27
Что мы хотели на старте?
28
29
Сущности
30
Entity relationship model (ERM)
31
Сущности
32
Фичи
33
Фичи
34
Виджеты
35
Проблемы 🚧🛠️
Проблемы
37
1 - Кросс импорты
38
1 - Кросс импорты
39
40
41
// @entities/cart/model/types.ts
import type { ProductId } from '@entities/product'
^^^^^^^^^^^^^^^^^^
export type Cart = {
id: CartId
productIds: ProductId[]
}
42
// @entities/cart/lib/mapCart.ts
import { mapProduct } from '@entities/product'
^^^^^^^^^^^^^^^^^^
export function mapCart(dto: CartDto): Cart {
return {
// ...
products: dto.products.map((p) => mapProduct(p)),
}
}
43
1 - Кросс импорты
44
1 - Кросс импорты
45
1 - Кросс импорты
46
47
48
// @app/rootStore.ts�
@Singleton()
export class RootStore {
constructor() {
this.cart = new CartStore({
getProductById: (productId) => {
return this.product.getProductById(productId)
},
// ...
})
// ...
}
}
49
// @app/App.ts
function App() {
return (
<DepsProvider store={{productService, cartService}}>
{children}
</DepsProvider>
)
}
// @entities/cart/ui/CartItemList.ts
function CartItemList({productIds}) {
const {productService} = useDepsContext()
productService.getByIds(productIds)
}
1 - Кросс импорты
50
51
52
53
54
// @entities/product/ui/ProductCard
type Props = { actionSlot: ReactNode }
// Глупый UI компонент карточки товара
export function ProductCard(props: Props) {
return (
<div>
// Вся магия происходит тут
{props.actionSlot}
</div>
)
}
55
// @widgets/AwesomeProductSlider
{products.map((product) => (
<ProductCard
product={product}
actionSlot={
// Прокидываем снаружи через рендер проп
<AddProductToCart productId={product.id} />
}
/>
))}
56
const render = (
<AwesomeProductList
renderListSlot={() => {
<ProductList
itemSlot={() => {
<ProductCart
actionSlot={
isAuth
? isAdded
? <RemoveProductFromCart />
: <AddProductToCart />
: null
}
/>
}}
/>
}}
/>
)
57
1 - Кросс импорты
58
@x
59
@x
60
📂 entities
📂 cart
📄 ui/AddToCart.tsx // Хотим использовать в product
📄 index.ts
📂 product
📄 lib/mapProduct.ts // Хотим использовать в cart
📄 model/types.ts // Тоже
📄 index.ts
@x
61
📂 entities
📂 cart
📄 @x/product/index.ts Публичный API для слайса product
📄 ui/AddToCart.tsx
📄 index.ts
📂 product
📄 @x/cart/index.ts Публичный API для слайса cart
📄 lib/mapProduct.ts
📄 model/types.ts
📄 index.ts
@x
62
📂 entities
📂 cart
📄 @x/product/index.ts Публичный API для слайса product
📄 @x/user/index.ts Публичный API для слайса user
📄 @x/order/index.ts Публичный API для слайса order
📄 ui/AddToCart.tsx
📄 index.ts
📂 product
📄 @x/cart/index.ts Публичный API для слайса cart
📄 lib/mapProduct.ts
📄 model/types.ts
📄 index.ts
@x
63
// @entities/product/@x/cart.ts
export type { ProductDto } from '../api/types'
export type { Product, ProductId } from '../model/types'
export { mapProduct } from '../lib/mapProduct'
@x
64
// @entities/cart/model/types.ts:diff
- import type { ProductId } from '@entities/product'
+ import type { ProductId } from '@entities/product/@x/cart'
export type Cart = {
id: CartId
productIds: ProductId[]
}
@x
65
Нет кросс импортов -
нет проблем?
66
Пару слов про Redux
67
68
// @entities/product/model/actions.ts
const someAwesomeProductAction = createAsyncThunk(
'someAwesomeProductAction',
async (_, { getState }) => {
const state = getState() as AppState
const сartItems = state.cart.items
^^^^^^^^^^^^^^^^
}
)
А можно ли Redux использовать с FSD?
69
70
// @shared/lib/store/types.ts
export type AppState = any
export type AppDispatch = () => any
71
// @entities/cart/model/slice.ts
export const cartSlice = createSlice({ /* … */ })
// Явно экспортируем селекторы
export const selectCartItems = /* … */
rootReducer.inject(cartSlice)
72
// @entities/product/model/actions.ts
import { selectCartItems } from '@entities/cart'
const someAwesomeProductAction = createAsyncThunk(
'someAwesomeProductAction',
async (_, { getState }) => {
const state = getState() as AppState� // И используем чужие экшены и части стейта� // через явную передачу их в селекторы
const cartItems = selectCartItems(state)
}
)
73
// @entities/product/model/actions.ts
import { selectCartItems } from '@entities/cart/@x/product'
const someAwesomeProductAction = createAsyncThunk(
'someAwesomeProductAction',
async (_, { getState }) => {
const state = getState() as AppState� // И используем чужие экшены и части стейта� // через явную передачу их в селекторы
const cartItems = selectCartItems(state)
}
)
1 - Кросс импорты (саммари)
74
Проблемы
75
2 - Размазывание кода по слоям
76
77
78
79
📂 entities
📂 cart
📄 ui/CartItem.tsx
📄 ui/CartPrice.tsx
📂 features
📂 cart
📄 AddToCart/ui/AddToCartButton.tsx
📄 RemoveFromCart/ui/RemoveFromCartButton.tsx
📄 GoToCheckout/ui/GoToCheckoutButton.tsx
📂 widgets
📂 MiniCart
📄 ui/MiniCart.tsx
2 - Размазывание кода по слоям
80
2 - Размазывание кода по слоям
81
2 - Размазывание кода по слоям
82
83
📂 entities
📂 cart
📂 api
📂 model
📂 ui
📄 CartItem.tsx
📄 CartPrice.tsx
📄 AddToCartButton.tsx
📄 RemoveFromCartButton.tsx
📄 ToCheckoutButton.tsx
📄 MiniCart.tsx
84
📂 entities
📂 cart
📂 api
📂 model
📄 CartItemsList.ts (entity like)
📄 AddItem.ts (feature like)
📄 RemoveItem.ts (feature like)
📂 ui
📄 CartItemsList.tsx
📄 AddToCartButton.tsx
📄 RemoveFromCartButton.tsx
....
2 - Размазывание кода по слоям
85
86
87
88
2 - Размазывание кода по слоям (саммари)
89
2 - Размазывание кода по слоям (саммари)
90
2 - Размазывание кода по слоям (саммари)
91
Закрепим
92
93
94
95
📂 entities/
📂 advert/
📂 api/
📄 advertApi.ts
📄 types.ts
📂 model/
📄 advertStore.ts
📄 types.ts
📂 ui/
📄 AdvertLabel.ts
📄 AdvertModal.ts
📂 story/
А это точно сущность?
96
97
📂 entities/
📂 story/
📂 api/
📄 advertApi.ts
📄 types.ts
📂 model/
📄 advertStore.ts
📄 types.ts
📂 ui/
📄 AdvertLabel.ts
📄 AdvertModal.ts
А что такое сущность?
98
Проблемы
99
3 - Что на какой слой раскладывать?
100
3 - Что на какой слой раскладывать?
101
Как отличить тонкий клиент?
102
Как отличить тонкий клиент?
103
Как отличить тонкий клиент?
104
Весь FSD который мы рассматривали - для толстых клиентов
105
106
107
Page-sliced�методология 😮👋
108
«Page sliced» методология
109
«Page sliced» методология
110
«Page sliced» методология
111
📂 pages
📂 products
📂 model
📄 productsStore.ts // entity like
📄 addProductToCart.ts // feature like
📂 ui
📄 ProductCard.tsx // entity like
📄 AwesomeProductSlider.tsx // widget like
«Page sliced» методология
112
«Page sliced» - первые впечатления?
113
«Page sliced» - первые впечатления?
114
«Page sliced» - первые впечатления?
115
«Page sliced» - НО
116
«Page sliced» - НО
117
Упрощенная версия
118
Упрощенная версия
119
📂 app�📂 shared
📂 widgets -> modules
📂 pages
📂 home
📂 api
📂 model
📂 ui
📄 index.ts
3 - Что на какой слой (саммари)
120
Проблемы
121
122
4 - Инфраструктура
123
4 - Инфраструктура
124
Где размещать?
125
Где размещать?
126
📂 shared
📂 api
📂 model
📄 themeStore.ts
📂 lib
📄 changeTheme.ts� 📄 themeProvider.ts
📂 ui
📄 ThemeToggler.tsx
Где размещать?
127
📂 shared
📂 ????
📂 theme
📂 model
📄 themeStore.ts
📄 changeTheme.ts� 📄 themeProvider.ts
📂 ui
📄 ThemeToggler.tsx
Где размещать?
128
shared/services
129
📂 shared
📂 services
📂 accidentMode
📂 analyticsBus
📂 featureConfig
📂 featureFlags
📂 geoService
📂 ...
📂 toastManager
Проблемы
130
5 - Расширение методологии
131
Дополнительные слои?
132
Абстрактные виджеты
133
📂 widgets
📂 _AbstractProductSlider
📂 api
📂 lib
📂 ui
📂 ProductsSliderAmazingPrices
📂 ProductsSliderRecentlyPurchased
📂 ProductsSliderRecommended
📂 ProductsSliderUpsell
Виджеты внутри страниц
134
📂 pages
📂 home
📂 widgets
📂 AwesomeProductSlider
📂 SearchContainer
📂 ...
📂 ShowcaseUserBanners
📄 index.ts
Фабрики для сущностей
135
📂 entities
📂 _AbstractEntity1Factory
📂 _AbstractEntity2Factory
📂 api
📂 lib
📂 model
📂 ui
📂 Product
📂 Category
📂 User
Слой для дополнительной навигации
136
📂 page-modals
📂 cart
📂 checkout
📂 product-[id]
📂 profile
📂 profile-settings
shared/services
137
📂 shared
📂 services
📂 accidentMode
📂 analyticsBus
📂 featureConfig
📂 featureFlags
📂 geoService
📂 ...
📂 toastManager
5 - Расширение методологии
138
Кастомная модификация с доменами
139
📂 app
📂 shared
📂 domains
📂 cart
📂 entities
📂 features
📂 widgets
📂 page
📂 product
📂 reports
Монорепозиторий
140
📂 src
📂 packages
📂 lib-shared
📂 domain-cart // внутри уже деление по FSD
📂 domain-product
📂 domain-reports
5 - Расширение методологии (саммари)
141
Проблемы
142
Какая проблема�главная?
143
144
Главная проблема
145
roke-to/roketo-business-ui
146
// apps/near-dapp/src/shared/hooks/useToken.ts
import {useStore} from 'effector-react';
import {$listedTokens} from '~/entities/wallet';
import {env} from '~/shared/config/env';
export function useToken(tokenAccountId: string) {
const tokens = useStore($listedTokens);
// ...
}
roke-to/roketo-business-ui
147
// apps/near-dapp/src/entities/employee/model/employee-model.ts
import {format, parseISO} from 'date-fns';
import {createForm} from 'effector-forms';
import {t} from 'i18next';
import {isAccountExistFx} from '~/entities/account-exist-effect';
import {$currentDaoId} from '~/entities/wallet';
amorgunov/nukeapp
148
📂 src
📂 features
📂 cart
📂 add-to-cart
📂 product
📂 sort-by
📂 session
📂 login
📂 logout
📂 theme
📂 change-theme
Документация
149
Документация
150
Документация
151
Уже 🥳🥳
152
steiger@0.5.0
153
Итого
Итого
155
Итого (что запомнить)
156
Ссылки
157
158
Допы для самостоятельного�изучения
159
1 - как развязать shared
и бизнес-логику
160
Взаимодействие с другими слоями
161
162
// @shared/api/baseQueryWithReauth.ts
import { logout } from '@entities/session'
export async function baseQueryWithReauth(/* ... */) {
const result = await baseQuery(/* ... */)
if (isApiTokenBroken(/* ... */)) {
api.dispatch(logout())
}
return result
}
Взаимодействие с другими слоями
163
164
// @shared/api/baseQueryWithReauth.ts
import { apiAccessTokenIsBrokenEvent } from './events'
export async function baseQueryWithReauth(/* ... */) {
const result = await baseQuery(/* ... */)
if (isApiTokenBroken(/* ... */)) {
api.dispatch(apiAccessTokenIsBrokenEvent())
}
return result
}
165
// @entities/session/model/logout.ts
import { apiAccessTokenIsBrokenEvent } from '@shared/api'
export const logoutMiddleware = createListenerMiddleware()
logoutMiddleware.startListening({
actionCreator: apiAccessTokenIsBrokenEvent,
effect: async (_, api) => {
api.dispatch(logoutThunk())
},
})
2 - пример как обходят кросс-импорты
166
Вынести типы в shared
167
// @shared/model/types.ts
import type { ProductId, Product } from '@entities/product'
import type { CartId, Cart } from '@entities/cart'
export {
ProductId,
Product,
CartId,
Cart,
// ...
}
Задекларировать типы в shared
168
// @shared/model/app.d.ts
declare global {
declare type ProductId = import('@entities/product').ProductId
declare type Product = import('@entities/product').Product
declare type CartId = import('@entities/cart').CartId
declare type Cart = import('@entities/cart').Cart
}
export {}
Мапперы и хелперы тоже в shared
169
// @shared/api/mapProductDto.ts
import type { Product, ProductId } from '@shared/model/types'
import type { ProductDto } from './types.ts'
export function mapProductDto(dto: ProductDto): Product {
return {
id: dto.id as ProductId,
// ...
}
}
3 - дебаг меню для виджетов
170
171
172
// src/shared/ui/global.css
body.debug [data-fsd] {
// рамка для слайса
outline: 2px solid var(--fsd-color);
� // плашка с названием слайса
&::after {
content: attr(data-fsd);
pointer-events: none;
position: absolute;
background: var(--fsd-color);
}
}