Compare commits
4 Commits
main
...
70f0e20646
| Author | SHA1 | Date | |
|---|---|---|---|
| 70f0e20646 | |||
| ced6a07398 | |||
| 1af27795ed | |||
| d69fca16fe |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -135,7 +135,6 @@ dist
|
|||||||
.vitepress/cache
|
.vitepress/cache
|
||||||
.vitepress/dist
|
.vitepress/dist
|
||||||
docs/.vitepress
|
docs/.vitepress
|
||||||
docs/public/
|
|
||||||
|
|
||||||
# Generated artifacts
|
# Generated artifacts
|
||||||
public/docs/
|
public/docs/
|
||||||
@@ -150,3 +149,4 @@ dist
|
|||||||
# Рабочие заметки
|
# Рабочие заметки
|
||||||
notes
|
notes
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,33 +8,16 @@ const sidebar = [
|
|||||||
{ text: 'Слои', link: '/architecture/layers' },
|
{ text: 'Слои', link: '/architecture/layers' },
|
||||||
{ text: 'Модули', link: '/architecture/modules' },
|
{ text: 'Модули', link: '/architecture/modules' },
|
||||||
{ text: 'Сегменты', link: '/architecture/segments' },
|
{ text: 'Сегменты', link: '/architecture/segments' },
|
||||||
{ text: 'Монорепозитории', link: '/architecture/monorepo' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Примеры React',
|
|
||||||
items: [
|
|
||||||
{ text: 'Создание фабрики', link: '/examples/react/factory' },
|
|
||||||
{ text: 'Композиция фабрик', link: '/examples/react/factory-composition' },
|
|
||||||
{ text: 'Композиция через Provider', link: '/examples/react/composition-provider' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
srcDir: 'docs',
|
srcDir: 'docs',
|
||||||
srcExclude: ['public/**'],
|
|
||||||
outDir: 'public/docs',
|
outDir: 'public/docs',
|
||||||
title: 'SLM Design',
|
title: 'SLM Design',
|
||||||
description: 'Правила и стандарты архитектуры проекта',
|
description: 'Правила и стандарты архитектуры проекта',
|
||||||
base: '/docs/',
|
base: '/docs/',
|
||||||
cleanUrls: true,
|
|
||||||
head: [
|
|
||||||
['meta', { name: 'llms', content: '/llms.txt' }],
|
|
||||||
['link', { rel: 'alternate llms', type: 'text/plain', href: '/llms.txt', title: 'llms.txt' }],
|
|
||||||
['link', { rel: 'alternate', type: 'text/plain', href: '/llms-full.txt', title: 'llms-full.txt' }],
|
|
||||||
['link', { rel: 'alternate', type: 'text/markdown', href: '/ARCHITECTURE.md', title: 'ARCHITECTURE.md' }],
|
|
||||||
],
|
|
||||||
|
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
sidebar,
|
sidebar,
|
||||||
|
|||||||
41
Caddyfile
41
Caddyfile
@@ -1,40 +1,11 @@
|
|||||||
:8082 {
|
:8082 {
|
||||||
root * /srv
|
root * /srv
|
||||||
|
@plainText path /llms.txt /llms-full.txt
|
||||||
# Устаревшие пути llms.txt в подпапках ведём к корневым артефактам.
|
header @plainText Content-Type "text/plain; charset=utf-8"
|
||||||
redir /docs/llms.txt /llms.txt 301
|
@markdown path /ARCHITECTURE.md
|
||||||
redir /docs/llms-full.txt /llms-full.txt 301
|
header @markdown Content-Type "text/markdown; charset=utf-8"
|
||||||
|
header @markdown Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
# Чистые URL: запросы вида `/docs/foo.html` редиректим на `/docs/foo`.
|
|
||||||
@legacyHtml {
|
|
||||||
path_regexp legacyHtml ^(/.+)\.html$
|
|
||||||
not path /index.html
|
|
||||||
}
|
|
||||||
redir @legacyHtml {re.legacyHtml.1} 301
|
|
||||||
|
|
||||||
header Link "</llms.txt>; rel=\"llms\""
|
|
||||||
|
|
||||||
@existingText {
|
|
||||||
path *.txt
|
|
||||||
file
|
|
||||||
}
|
|
||||||
header @existingText Content-Type "text/plain; charset=utf-8"
|
|
||||||
|
|
||||||
@existingMarkdown {
|
|
||||||
path *.md
|
|
||||||
file
|
|
||||||
}
|
|
||||||
header @existingMarkdown Content-Type "text/markdown; charset=utf-8"
|
|
||||||
|
|
||||||
@architecture path /ARCHITECTURE.md
|
|
||||||
header @architecture Cache-Control "no-cache, no-store, must-revalidate"
|
|
||||||
|
|
||||||
@missingText {
|
|
||||||
path *.txt *.md
|
|
||||||
not file
|
|
||||||
}
|
|
||||||
respond @missingText 404
|
|
||||||
|
|
||||||
file_server
|
file_server
|
||||||
|
header Link "</llms.txt>; rel=\"llms\""
|
||||||
try_files {path} {path}.html {path}/index.html /index.html
|
try_files {path} {path}.html {path}/index.html /index.html
|
||||||
}
|
}
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,17 +1,6 @@
|
|||||||
# SLM Design
|
# SLM Design
|
||||||
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
|
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
|
||||||
|
|
||||||
## Разделы спецификации
|
|
||||||
|
|
||||||
Спецификация SLM Design состоит из нескольких связанных разделов. Этот обзор даёт общий контекст, а детальные правила описаны дальше:
|
|
||||||
|
|
||||||
- [Слои](docs/architecture/layers.md) — уровни организации `src/`, направление зависимостей и зона ответственности каждого слоя.
|
|
||||||
- [Модули](docs/architecture/modules.md) — границы ответственности, публичный API, типы модулей и отличие модуля от компонента.
|
|
||||||
- [Сегменты](docs/architecture/segments.md) — внутренние папки модуля (`ui/`, `parts/`, `hooks/`, `types/` и другие) и правила размещения файлов.
|
|
||||||
- [Монорепозитории](docs/architecture/monorepo.md) — применение SLM в `apps/` и `packages/`, правила выноса общих слоёв и ограничения для business.
|
|
||||||
|
|
||||||
Рекомендуемый порядок чтения: обзор → слои → модули → сегменты → монорепозитории.
|
|
||||||
|
|
||||||
## Преимущества
|
## Преимущества
|
||||||
|
|
||||||
### Вертикальная организация домена
|
### Вертикальная организация домена
|
||||||
@@ -42,10 +31,6 @@ Cross-domain зависимости в бизнес-слое реализуют
|
|||||||
|
|
||||||
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: бизнес-домены по субдоменам, страницы по разделам, UI-компоненты по уровню абстракции (примитивы и композиции).
|
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: бизнес-домены по субдоменам, страницы по разделам, UI-компоненты по уровню абстракции (примитивы и композиции).
|
||||||
|
|
||||||
### Адаптация к монорепозиториям
|
|
||||||
|
|
||||||
SLM применяется внутри каждого приложения, а `packages/*` используются только для общего кода из слоёв `ui`, `infra` и `shared`. Бизнес-домены остаются внутри приложений, чтобы не размывать продуктовые границы.
|
|
||||||
|
|
||||||
## Происхождение
|
## Происхождение
|
||||||
|
|
||||||
SLM Design вырос на основе:
|
SLM Design вырос на основе:
|
||||||
|
|||||||
@@ -6,17 +6,6 @@ description: Назначение архитектуры, ключевые пр
|
|||||||
# SLM Design
|
# SLM Design
|
||||||
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
|
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
|
||||||
|
|
||||||
## Разделы спецификации
|
|
||||||
|
|
||||||
Спецификация SLM Design состоит из нескольких связанных разделов. Этот обзор даёт общий контекст, а детальные правила описаны дальше:
|
|
||||||
|
|
||||||
- [Слои](/architecture/layers) — уровни организации `src/`, направление зависимостей и зона ответственности каждого слоя.
|
|
||||||
- [Модули](/architecture/modules) — границы ответственности, публичный API, типы модулей и отличие модуля от компонента.
|
|
||||||
- [Сегменты](/architecture/segments) — внутренние папки модуля (`ui/`, `parts/`, `hooks/`, `types/` и другие) и правила размещения файлов.
|
|
||||||
- [Монорепозитории](/architecture/monorepo) — применение SLM в `apps/` и `packages/`, правила выноса общих слоёв и ограничения для business.
|
|
||||||
|
|
||||||
Рекомендуемый порядок чтения: обзор → слои → модули → сегменты → монорепозитории.
|
|
||||||
|
|
||||||
## Преимущества
|
## Преимущества
|
||||||
|
|
||||||
### Вертикальная организация домена
|
### Вертикальная организация домена
|
||||||
@@ -47,10 +36,6 @@ Cross-domain зависимости в бизнес-слое реализуют
|
|||||||
|
|
||||||
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: бизнес-домены по субдоменам, страницы по разделам, UI-компоненты по уровню абстракции (примитивы и композиции).
|
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: бизнес-домены по субдоменам, страницы по разделам, UI-компоненты по уровню абстракции (примитивы и композиции).
|
||||||
|
|
||||||
### Адаптация к монорепозиториям
|
|
||||||
|
|
||||||
SLM применяется внутри каждого приложения, а `packages/*` используются только для общего кода из слоёв `ui`, `infra` и `shared`. Бизнес-домены остаются внутри приложений, чтобы не размывать продуктовые границы.
|
|
||||||
|
|
||||||
## Происхождение
|
## Происхождение
|
||||||
|
|
||||||
SLM Design вырос на основе:
|
SLM Design вырос на основе:
|
||||||
|
|||||||
@@ -194,11 +194,87 @@ Business-модуль всегда экспортирует фабрику. Фа
|
|||||||
|
|
||||||
Компоновка фабрик происходит на уровне модуля-потребителя: screen, layout, widget или любой другой модуль группы «Композиция».
|
Компоновка фабрик происходит на уровне модуля-потребителя: screen, layout, widget или любой другой модуль группы «Композиция».
|
||||||
|
|
||||||
### Примеры
|
### Структура business-модуля
|
||||||
|
|
||||||
Пример реализации фабрики в React см. в [Создание фабрики](/examples/react/factory).
|
```text
|
||||||
|
business/customer/
|
||||||
|
├── customer.factory.ts
|
||||||
|
├── index.ts
|
||||||
|
└── types/
|
||||||
|
├── customer.type.ts
|
||||||
|
├── customer-api.type.ts
|
||||||
|
├── customer-deps.type.ts
|
||||||
|
└── customer-factory.type.ts
|
||||||
|
```
|
||||||
|
|
||||||
Пример композиции фабрик в React screen-модуле см. в [Композиция фабрик](/examples/react/factory-composition).
|
### Типы
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/customer/types/customer-api.type.ts
|
||||||
|
export type CustomerApi = {
|
||||||
|
useCustomer: () => Customer
|
||||||
|
CustomerCard: (props: CustomerCardProps) => ReactNode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/order/types/order-deps.type.ts
|
||||||
|
export type OrderDeps = {
|
||||||
|
customer: Pick<CustomerApi, 'useCustomer'>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/order/types/order-factory.type.ts
|
||||||
|
export type OrderFactory = (deps: OrderDeps) => OrderApi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фабрика без зависимостей
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/customer/customer.factory.ts
|
||||||
|
import type { CustomerFactory } from './types/customer-factory.type'
|
||||||
|
|
||||||
|
export const customerFactory: CustomerFactory = () => {
|
||||||
|
return {
|
||||||
|
useCustomer,
|
||||||
|
CustomerCard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фабрика с зависимостями
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/order/order.factory.ts
|
||||||
|
import type { OrderFactory } from './types/order-factory.type'
|
||||||
|
|
||||||
|
export const orderFactory: OrderFactory = (deps) => {
|
||||||
|
return {
|
||||||
|
useOrder,
|
||||||
|
OrderCard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Композиция на уровне screen
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// screens/home/home.screen.tsx
|
||||||
|
import { customerFactory } from '@/business/customer'
|
||||||
|
import { orderFactory } from '@/business/order'
|
||||||
|
|
||||||
|
const customer = customerFactory()
|
||||||
|
const order = orderFactory({ customer })
|
||||||
|
|
||||||
|
const { useOrder, OrderCard } = order
|
||||||
|
|
||||||
|
export const HomeScreen = () => {
|
||||||
|
const currentOrder = useOrder()
|
||||||
|
|
||||||
|
return <OrderCard order={currentOrder} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Жизненный цикл
|
## Жизненный цикл
|
||||||
|
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
---
|
|
||||||
title: Монорепозитории
|
|
||||||
description: Правила применения SLM Design для frontend-проектов, находящихся в монорепозитории
|
|
||||||
---
|
|
||||||
|
|
||||||
# Монорепозитории
|
|
||||||
|
|
||||||
Раздел описывает, как применять SLM Design, когда фронтенд-проекты находятся в одном монорепозитории. В нём показано, что остаётся внутри приложений, что можно выносить в `packages/` и какие ограничения действуют для общих пакетов.
|
|
||||||
|
|
||||||
## Определение
|
|
||||||
|
|
||||||
**Монорепозиторий — внешний уровень организации нескольких фронтенд-приложений и общих пакетов. SLM применяется внутри каждого приложения, а frontend-пакеты, относящиеся к SLM, содержат переиспользуемый код, вынесенный из слоёв `ui`, `infra` и `shared`.**
|
|
||||||
|
|
||||||
## Базовая структура
|
|
||||||
|
|
||||||
Каждое приложение внутри `apps/` сохраняет собственную SLM-структуру в `src/`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
repo/
|
|
||||||
├── apps/
|
|
||||||
│ ├── web/
|
|
||||||
│ │ └── src/
|
|
||||||
│ │ ├── app/
|
|
||||||
│ │ ├── layouts/
|
|
||||||
│ │ ├── screens/
|
|
||||||
│ │ ├── widgets/
|
|
||||||
│ │ ├── business/
|
|
||||||
│ │ ├── infra/
|
|
||||||
│ │ ├── ui/
|
|
||||||
│ │ └── shared/
|
|
||||||
│ └── admin/
|
|
||||||
│ └── src/
|
|
||||||
│ └── ...
|
|
||||||
└── packages/
|
|
||||||
├── ui/
|
|
||||||
│ ├── button/ # самостоятельный пакет UI-модуля
|
|
||||||
│ ├── input/ # самостоятельный пакет UI-модуля
|
|
||||||
│ └── modal/ # самостоятельный пакет UI-модуля
|
|
||||||
├── infra/
|
|
||||||
│ ├── theme/ # самостоятельный пакет infra-модуля
|
|
||||||
│ ├── backend-api/ # самостоятельный пакет infra-модуля
|
|
||||||
│ └── logger/ # самостоятельный пакет infra-модуля
|
|
||||||
└── shared/ # единый shared-пакет
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── lib/ # переиспользуемые утилиты
|
|
||||||
├── helpers/ # переиспользуемые helpers
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
`apps/{app}/src` — граница SLM-приложения. `packages/*` находятся выше SLM и не добавляют новые архитектурные слои.
|
|
||||||
|
|
||||||
## Группировка frontend-пакетов
|
|
||||||
|
|
||||||
Frontend-пакеты, вынесенные из SLM-приложений, рекомендуется группировать по источнику кода: `ui`, `infra`, `shared`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/ui/* # пакеты UI-модулей
|
|
||||||
packages/infra/* # пакеты infra-модулей
|
|
||||||
packages/shared # единый shared-пакет
|
|
||||||
```
|
|
||||||
|
|
||||||
Эта группировка повторяет названия SLM-слоёв для навигации, но сама не является слоистой архитектурой внутри `packages/`. Монорепозиторий может содержать другие пакеты: tooling, конфиги, SDK, схемы, e2e и другие технические пакеты вне SLM.
|
|
||||||
|
|
||||||
## Пакет и модуль
|
|
||||||
|
|
||||||
Пакет не равен SLM-модулю: модуль — архитектурная единица внутри слоя приложения, package — единица монорепозитория для переиспользования, владения, сборки и публикации.
|
|
||||||
|
|
||||||
В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей. В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей. `packages/shared` устроен иначе: это единый пакет для переиспользуемых утилит, helpers и другого фундаментального кода без привязки к конкретному приложению.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/ui/button/
|
|
||||||
packages/ui/modal/
|
|
||||||
packages/infra/theme/
|
|
||||||
packages/infra/backend-api/
|
|
||||||
packages/shared/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что остаётся в приложении
|
|
||||||
|
|
||||||
Слои `app`, `layouts`, `screens`, `widgets` и `business` остаются внутри конкретного приложения.
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/web/src/app/
|
|
||||||
apps/web/src/layouts/
|
|
||||||
apps/web/src/screens/
|
|
||||||
apps/web/src/widgets/
|
|
||||||
apps/web/src/business/
|
|
||||||
```
|
|
||||||
|
|
||||||
`app`, `layouts` и `screens` привязаны к роутингу, каркасу и страницам конкретного приложения. `widgets` не выносятся в пакеты, потому что это слой композиции интерфейса приложения.
|
|
||||||
|
|
||||||
`business` не выносится в `packages/*`. Домены остаются рядом со сценариями приложения, чтобы не превращать монорепозиторий в общий бизнес-слой.
|
|
||||||
|
|
||||||
## Что можно выносить
|
|
||||||
|
|
||||||
В пакеты выносится только код из `ui`, `infra` и `shared`, который потенциально будет использоваться в двух и более фронтенд-приложениях монорепозитория.
|
|
||||||
|
|
||||||
| Группа | Что выносить | Пример |
|
|
||||||
|--------|--------------|--------|
|
|
||||||
| `packages/ui/*` | Самостоятельные UI-модули без бизнес-логики | `packages/ui/button` |
|
|
||||||
| `packages/infra/*` | Самостоятельные технические сервисы | `packages/infra/backend-api` |
|
|
||||||
| `packages/shared` | Общие утилиты, helpers и фундаментальный код | `packages/shared` |
|
|
||||||
|
|
||||||
Пакет можно создавать сразу, если модуль имеет общую природу и ожидается его переиспользование между приложениями. App-specific код остаётся внутри приложения.
|
|
||||||
|
|
||||||
## UI-пакеты
|
|
||||||
|
|
||||||
В `packages/ui/*` размещаются переиспользуемые UI-модули.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/ui/button/
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── button.tsx
|
|
||||||
├── styles/
|
|
||||||
├── types/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
UI-пакет не содержит бизнес-логику, обращения к API, сценарные хуки приложения и композицию страниц.
|
|
||||||
|
|
||||||
## Infra-пакеты
|
|
||||||
|
|
||||||
В `packages/infra/*` размещаются переиспользуемые инфраструктурные модули.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/infra/backend-api/
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── clients/
|
|
||||||
├── config/
|
|
||||||
├── types/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Привязанные к конкретному приложению сервисы остаются в `apps/{app}/src/infra`. Например, локализация со словарями конкретного продукта остаётся в приложении; общим пакетом может быть только переиспользуемый i18n-движок.
|
|
||||||
|
|
||||||
## Shared-пакет
|
|
||||||
|
|
||||||
`packages/shared` является единым пакетом.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/shared/
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── lib/
|
|
||||||
├── helpers/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
В `packages/shared` сразу выносится общий фундаментальный код: чистые функции, helpers, утилиты, независимые константы и другой код без знания о продукте.
|
|
||||||
|
|
||||||
Проектные стили, типы приложения, продуктовые конфиги и ресурсы, завязанные на одно приложение, в общий `shared` не выносятся.
|
|
||||||
|
|
||||||
## Имена пакетов и импорты
|
|
||||||
|
|
||||||
Путь импорта задаётся `name` в `package.json`, а не расположением директории.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@repo/theme"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/infra/theme/package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { ThemeProvider } from '@repo/theme'
|
|
||||||
```
|
|
||||||
|
|
||||||
Пакеты должны импортироваться только через публичный API. Deep imports внутрь пакета запрещены.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Хорошо
|
|
||||||
import { Button } from '@repo/button'
|
|
||||||
|
|
||||||
// Плохо
|
|
||||||
import { Button } from '@repo/button/src/button'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
На уровне монорепозитория приложения зависят от пакетов, а пакеты не зависят от приложений.
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps → packages
|
|
||||||
packages -/→ apps
|
|
||||||
```
|
|
||||||
|
|
||||||
Внутри приложения продолжает действовать обычное направление зависимостей SLM.
|
|
||||||
|
|
||||||
```text
|
|
||||||
app → [ layouts | screens ] → widgets → business → infra → ui → shared
|
|
||||||
```
|
|
||||||
|
|
||||||
Пакеты не должны нарушать природу своей группы: `packages/ui/*` не импортирует `packages/infra/*`, `packages/shared` не импортирует другие группы, а `packages/infra/*` не знает о приложениях.
|
|
||||||
|
|
||||||
## Когда не выносить
|
|
||||||
|
|
||||||
Не выносите код в пакет, если он не может быть использован в двух и более фронтенд-приложениях, зависит от роутинга или страниц, содержит бизнес-логику, отражает продуктовую композицию конкретного интерфейса или не имеет стабильного публичного API.
|
|
||||||
|
|
||||||
Фактическое использование в одном приложении не запрещает пакет, если модуль имеет общую природу и потенциально нужен нескольким приложениям.
|
|
||||||
|
|
||||||
```text
|
|
||||||
# Плохо
|
|
||||||
apps/web/src/screens/home/parts/promo-section/
|
|
||||||
packages/ui/promo-section/
|
|
||||||
```
|
|
||||||
|
|
||||||
Если блок нужен только одной странице или отражает продуктовую композицию конкретного приложения, он остаётся локальным `parts/`-модулем.
|
|
||||||
|
|
||||||
## Конфигурационные пакеты
|
|
||||||
|
|
||||||
Конфигурационные пакеты не относятся к SLM-архитектуре.
|
|
||||||
|
|
||||||
Если в монорепозитории есть общие настройки TypeScript, ESLint, сборки или форматирования, они относятся к tooling-инфраструктуре репозитория. Такие пакеты могут находиться в `packages/`, но их структура зависит от выбранного инструментария и не участвует в правилах слоёв внутри `src/`.
|
|
||||||
|
|
||||||
## Правила
|
|
||||||
|
|
||||||
- SLM применяется внутри каждого `apps/{app}/src`.
|
|
||||||
- Frontend-пакеты, вынесенные из SLM-приложений, группируются в `packages/ui`, `packages/infra`, `packages/shared`.
|
|
||||||
- Группы `packages/ui`, `packages/infra`, `packages/shared` не являются SLM-слоями.
|
|
||||||
- В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей.
|
|
||||||
- В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей.
|
|
||||||
- `packages/shared` является единым пакетом для переиспользуемых утилит и helpers.
|
|
||||||
- Модуль можно размещать в пакете, если он потенциально будет использоваться в двух и более фронтенд-приложениях.
|
|
||||||
- `business`, `app`, `layouts`, `screens`, `widgets` не выносятся в пакеты.
|
|
||||||
- Проектные стили, типы приложения и продуктовые конфиги не выносятся в `packages/shared`.
|
|
||||||
- Пакеты не импортируют приложения.
|
|
||||||
- Межпакетные импорты идут только через публичный API.
|
|
||||||
- Deep imports внутрь пакетов запрещены.
|
|
||||||
- Локальная колокация важнее преждевременного выноса в `packages/*`.
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
---
|
|
||||||
title: Композиция через Provider
|
|
||||||
description: Пример композиции бизнес-фабрик screen-модуля через React Provider
|
|
||||||
---
|
|
||||||
|
|
||||||
# Композиция через Provider
|
|
||||||
|
|
||||||
Раздел показывает, как screen-модуль может получить готовую композицию бизнес-доменов через React Context, не вызывая фабрики внутри себя.
|
|
||||||
|
|
||||||
## Идея
|
|
||||||
|
|
||||||
Screen получает готовый API бизнес-доменов через React Context. Граф фабрик собирается снаружи, например в роутере, а внутренние `parts/` достают нужные домены через хук без пропс-дриллинга.
|
|
||||||
|
|
||||||
## Принципы
|
|
||||||
|
|
||||||
1. **Принадлежность.** Provider, Context и хук принадлежат конкретному screen-модулю и лежат в его сегментах.
|
|
||||||
2. **Внутренний тип.** Тип композиции не экспортируется наружу — это закрывает доступ из бизнес-модулей.
|
|
||||||
3. **Внутренний хук.** Хук доступа не экспортируется — доступен только внутри screen и его `parts/`.
|
|
||||||
4. **Публичный Provider.** Только Provider экспортируется через `index.ts`, чтобы роутер мог обернуть screen.
|
|
||||||
5. **Сборка снаружи.** Граф фабрик собирается в роутере или другом композиторе, screen фабрики не вызывает.
|
|
||||||
6. **Запрет для бизнеса.** Бизнес-модули не используют провайдеры композиции. Cross-domain зависимости передаются только через аргументы фабрики.
|
|
||||||
|
|
||||||
## Структура модуля
|
|
||||||
|
|
||||||
```text
|
|
||||||
screens/main/
|
|
||||||
├── main.screen.tsx
|
|
||||||
├── providers/
|
|
||||||
│ └── main-composition.provider.tsx
|
|
||||||
├── hooks/
|
|
||||||
│ └── use-main-composition.hook.ts
|
|
||||||
├── types/
|
|
||||||
│ └── main-composition.type.ts
|
|
||||||
├── parts/
|
|
||||||
│ └── featured-products/
|
|
||||||
│ ├── featured-products.tsx
|
|
||||||
│ └── index.ts
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Сегмент `providers/` — расширение стандартного набора SLM. Спецификация это разрешает: команда сама определяет, какие сегменты используются.
|
|
||||||
|
|
||||||
## Распределение по сегментам
|
|
||||||
|
|
||||||
| Файл | Сегмент | Назначение |
|
|
||||||
|------|---------|------------|
|
|
||||||
| `main-composition.type.ts` | `types/` | TypeScript-тип композиции |
|
|
||||||
| `main-composition.provider.tsx` | `providers/` | Context и Provider-компонент |
|
|
||||||
| `use-main-composition.hook.ts` | `hooks/` | React-хук доступа |
|
|
||||||
| `main.screen.tsx` | корень | Корневой компонент screen-модуля |
|
|
||||||
| `featured-products/` | `parts/` | Вложенный модуль со своим публичным API |
|
|
||||||
|
|
||||||
## Тип композиции
|
|
||||||
|
|
||||||
Файл: `screens/main/types/main-composition.type.ts`.
|
|
||||||
|
|
||||||
Тип описывает, какие бизнес-домены доступны на этой странице. Он не экспортируется через `index.ts`, чтобы другие модули не зависели от внутренней формы композиции screen.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CatalogApi } from '@/business/catalog'
|
|
||||||
import type { CartApi } from '@/business/cart'
|
|
||||||
|
|
||||||
export type MainComposition = {
|
|
||||||
catalog: CatalogApi
|
|
||||||
cart: CartApi
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Context и Provider
|
|
||||||
|
|
||||||
Файл: `screens/main/providers/main-composition.provider.tsx`.
|
|
||||||
|
|
||||||
Context — внутренняя деталь Provider, наружу он не экспортируется. Значение `null` по умолчанию нужно, чтобы хук мог проверить отсутствие Provider в дереве.
|
|
||||||
|
|
||||||
Provider-компонент экспортируется через `index.ts`. Роутер передаёт в `value` уже собранный граф фабрик со стабильной ссылкой.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { createContext, type ReactNode } from 'react'
|
|
||||||
import type { MainComposition } from '../types/main-composition.type'
|
|
||||||
|
|
||||||
export const MainCompositionContext = createContext<MainComposition | null>(null)
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
value: MainComposition
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MainCompositionProvider = ({ value, children }: Props) => (
|
|
||||||
<MainCompositionContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</MainCompositionContext.Provider>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Хук доступа
|
|
||||||
|
|
||||||
Файл: `screens/main/hooks/use-main-composition.hook.ts`.
|
|
||||||
|
|
||||||
Хук остаётся внутренним и не экспортируется через `index.ts` модуля. Он доступен только внутри screen и его `parts/`.
|
|
||||||
|
|
||||||
Если хук используется вне Provider, он бросает ошибку. Это даёт раннюю диагностику неправильной композиции дерева.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { useContext } from 'react'
|
|
||||||
import { MainCompositionContext } from '../providers/main-composition.provider'
|
|
||||||
|
|
||||||
export const useMainComposition = () => {
|
|
||||||
const ctx = useContext(MainCompositionContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useMainComposition must be used within MainCompositionProvider')
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Сборка графа в роутере
|
|
||||||
|
|
||||||
Файл: `app/router.tsx`.
|
|
||||||
|
|
||||||
Роутер или другой композитор собирает граф фабрик в точке использования screen. Каждый домен получает свои зависимости через аргументы фабрики.
|
|
||||||
|
|
||||||
Фабрики вызываются вне React-компонента, если не зависят от runtime-параметров. Так API доменов не пересоздаётся на каждый рендер route-компонента.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { MainScreen, MainCompositionProvider } from '@/screens/main'
|
|
||||||
import { catalogFactory } from '@/business/catalog'
|
|
||||||
import { cartFactory } from '@/business/cart'
|
|
||||||
import { authFactory } from '@/business/auth'
|
|
||||||
|
|
||||||
const auth = authFactory()
|
|
||||||
const catalog = catalogFactory()
|
|
||||||
const cart = cartFactory({ auth })
|
|
||||||
|
|
||||||
const MainRoute = () => (
|
|
||||||
<MainCompositionProvider value={{ catalog, cart }}>
|
|
||||||
<MainScreen />
|
|
||||||
</MainCompositionProvider>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Корневой компонент screen
|
|
||||||
|
|
||||||
Файл: `screens/main/main.screen.tsx`.
|
|
||||||
|
|
||||||
Screen получает нужные домены из композиции и достаёт из API готовые хуки, компоненты или функции. В JSX используются уже локальные `useCategories` и `CategoryList`, а не обращение к фабричному API через точку.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useMainComposition } from './hooks/use-main-composition.hook'
|
|
||||||
import { FeaturedProducts } from './parts/featured-products'
|
|
||||||
|
|
||||||
export const MainScreen = () => {
|
|
||||||
const { catalog } = useMainComposition()
|
|
||||||
const { useCategories, CategoryList } = catalog
|
|
||||||
const categories = useCategories()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<CategoryList categories={categories} />
|
|
||||||
<FeaturedProducts />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Вложенный part
|
|
||||||
|
|
||||||
Файл: `screens/main/parts/featured-products/featured-products.tsx`.
|
|
||||||
|
|
||||||
Вложенный модуль получает доступ к той же композиции родительского screen. Промежуточные компоненты не прокидывают домены через props.
|
|
||||||
|
|
||||||
Из API доменов достаются готовые сущности: `useFeatured`, `ProductCard` и `addItem`. Компонент работает с ними напрямую.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useMainComposition } from '../../hooks/use-main-composition.hook'
|
|
||||||
|
|
||||||
export const FeaturedProducts = () => {
|
|
||||||
const { catalog, cart } = useMainComposition()
|
|
||||||
const { useFeatured, ProductCard } = catalog
|
|
||||||
const { addItem } = cart
|
|
||||||
const products = useFeatured()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{products.map((product) => (
|
|
||||||
<ProductCard
|
|
||||||
key={product.id}
|
|
||||||
product={product}
|
|
||||||
onAdd={() => addItem(product.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Файл: `screens/main/parts/featured-products/index.ts`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export { FeaturedProducts } from './featured-products'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Публичный API screen-модуля
|
|
||||||
|
|
||||||
Файл: `screens/main/index.ts`.
|
|
||||||
|
|
||||||
Наружу экспортируются только screen и его Provider. `MainComposition`, `MainCompositionContext` и `useMainComposition` остаются деталями реализации.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export { MainScreen } from './main.screen'
|
|
||||||
export { MainCompositionProvider } from './providers/main-composition.provider'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Почему тип композиции не экспортируется
|
|
||||||
|
|
||||||
Внутренний тип закрывает доступ к форме композиции из внешних модулей. Бизнес-модуль не должен знать, какие домены собраны для конкретного screen.
|
|
||||||
|
|
||||||
Такой импорт из бизнес-модуля не должен быть возможен через публичный API screen.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { MainComposition } from '@/screens/main'
|
|
||||||
```
|
|
||||||
|
|
||||||
Когда тип остаётся внутренним, такая связь невозможна через публичный API screen-модуля.
|
|
||||||
|
|
||||||
## Почему хук не экспортируется
|
|
||||||
|
|
||||||
Если хук доступа сделать публичным, любой модуль сможет вызвать его напрямую. Внутренний хук доступен только через относительные импорты внутри screen-модуля и его `parts/`.
|
|
||||||
|
|
||||||
## Почему Provider экспортируется
|
|
||||||
|
|
||||||
Provider безопасно экспортировать: сам по себе он не даёт доступ к данным, а только принимает готовую композицию и передаёт её детям внутри React-дерева.
|
|
||||||
|
|
||||||
## Стабильность value
|
|
||||||
|
|
||||||
Фабрики создаются на уровне модуля, поэтому `catalog` и `cart` сохраняют ссылки между рендерами `MainRoute`.
|
|
||||||
|
|
||||||
Если домены зависят от runtime-параметров, граф нужно собирать в отдельном композиторе для этих параметров и передавать в Provider уже готовую композицию.
|
|
||||||
|
|
||||||
## Расширение на другие screen-модули
|
|
||||||
|
|
||||||
Паттерн повторяется для каждого screen, которому нужна композиция бизнес-доменов.
|
|
||||||
|
|
||||||
```text
|
|
||||||
screens/checkout/providers/checkout-composition.provider.tsx
|
|
||||||
screens/checkout/hooks/use-checkout-composition.hook.ts
|
|
||||||
screens/checkout/types/checkout-composition.type.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена включают имя screen-модуля. Не используйте универсальные названия вроде `useComposition` или `useScope`: по имени файла должно быть понятно, к какой странице привязан Context.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
title: Композиция фабрик
|
|
||||||
description: Пример композиции business-фабрик на уровне screen-модуля в React-проекте
|
|
||||||
---
|
|
||||||
|
|
||||||
# Композиция фабрик
|
|
||||||
|
|
||||||
Раздел показывает, как собрать API нескольких business-модулей в React screen-модуле. Пример подходит для простой композиции, когда screen сам является точкой использования доменов.
|
|
||||||
|
|
||||||
## Идея
|
|
||||||
|
|
||||||
Композиция фабрик выполняется в модуле-потребителе: screen, layout или другом модуле группы «Композиция». Business-модули не импортируют runtime-код друг друга напрямую, а cross-domain зависимости получают только через аргументы фабрик.
|
|
||||||
|
|
||||||
## Структура screen-модуля
|
|
||||||
|
|
||||||
```text
|
|
||||||
screens/home/
|
|
||||||
├── home.screen.tsx
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Сборка фабрик
|
|
||||||
|
|
||||||
Файл: `screens/home/home.screen.tsx`.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { customerFactory } from '@/business/customer'
|
|
||||||
import { orderFactory } from '@/business/order'
|
|
||||||
|
|
||||||
const customer = customerFactory()
|
|
||||||
const order = orderFactory({ customer })
|
|
||||||
|
|
||||||
const { useOrder, OrderCard } = order
|
|
||||||
|
|
||||||
export const HomeScreen = () => {
|
|
||||||
const currentOrder = useOrder()
|
|
||||||
|
|
||||||
return <OrderCard order={currentOrder} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`customerFactory` создаётся первой, потому что `orderFactory` зависит от части API домена `customer`. Модуль `order` не импортирует `customer` в runtime — зависимость передаётся снаружи.
|
|
||||||
|
|
||||||
## Публичный API screen-модуля
|
|
||||||
|
|
||||||
Файл: `screens/home/index.ts`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export { HomeScreen } from './home.screen'
|
|
||||||
```
|
|
||||||
|
|
||||||
Screen экспортирует только собственный публичный API. Собранные экземпляры business API остаются деталями реализации screen-модуля.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
---
|
|
||||||
title: Создание фабрики
|
|
||||||
description: Пример создания фабрики business-модуля в React-проекте
|
|
||||||
---
|
|
||||||
|
|
||||||
# Создание фабрики
|
|
||||||
|
|
||||||
Раздел показывает, как оформить фабрику business-модуля в React-проекте: описать публичный API, зависимости и функцию, возвращающую runtime API.
|
|
||||||
|
|
||||||
## Структура business-модуля
|
|
||||||
|
|
||||||
Фабрика лежит в корне business-модуля. Типы публичного API и зависимостей размещаются в `types/`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
business/customer/
|
|
||||||
├── customer.factory.ts
|
|
||||||
├── hooks/
|
|
||||||
├── types/
|
|
||||||
│ ├── customer.type.ts
|
|
||||||
│ ├── customer-api.type.ts
|
|
||||||
│ └── customer-factory.type.ts
|
|
||||||
├── ui/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Тип публичного API
|
|
||||||
|
|
||||||
Публичный API описывает runtime-возможности, которые модуль отдаёт потребителям: хуки, компоненты и сценарные методы.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/types/customer-api.type.ts
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { Customer } from './customer.type'
|
|
||||||
|
|
||||||
export type CustomerCardProps = {
|
|
||||||
customer: Customer
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CustomerApi = {
|
|
||||||
useCustomer: () => Customer | null
|
|
||||||
CustomerCard: (props: CustomerCardProps) => ReactNode
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/types/customer-factory.type.ts
|
|
||||||
import type { CustomerApi } from './customer-api.type'
|
|
||||||
|
|
||||||
export type CustomerFactory = () => CustomerApi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Фабрика без зависимостей
|
|
||||||
|
|
||||||
Если модулю не нужны другие домены в runtime, фабрика создаётся без аргументов.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/customer.factory.ts
|
|
||||||
import { useCustomer } from './hooks/use-customer.hook'
|
|
||||||
import { CustomerCard } from './ui/customer-card'
|
|
||||||
import type { CustomerFactory } from './types/customer-factory.type'
|
|
||||||
|
|
||||||
export const customerFactory: CustomerFactory = () => {
|
|
||||||
return {
|
|
||||||
useCustomer,
|
|
||||||
CustomerCard,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/index.ts
|
|
||||||
export { customerFactory } from './customer.factory'
|
|
||||||
|
|
||||||
export type { Customer } from './types/customer.type'
|
|
||||||
export type { CustomerApi } from './types/customer-api.type'
|
|
||||||
export type { CustomerFactory } from './types/customer-factory.type'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Фабрика с зависимостями
|
|
||||||
|
|
||||||
Если модулю нужен другой домен в runtime, зависимость передаётся аргументом фабрики. Тип зависимости описывает только нужную часть API.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/order/types/order-deps.type.ts
|
|
||||||
import type { CustomerApi } from '@/business/customer'
|
|
||||||
|
|
||||||
export type OrderDeps = {
|
|
||||||
customer: Pick<CustomerApi, 'useCustomer'>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/order/types/order-factory.type.ts
|
|
||||||
import type { OrderApi } from './order-api.type'
|
|
||||||
import type { OrderDeps } from './order-deps.type'
|
|
||||||
|
|
||||||
export type OrderFactory = (deps: OrderDeps) => OrderApi
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/order/order.factory.ts
|
|
||||||
import { createUseOrder } from './hooks/use-order.hook'
|
|
||||||
import { OrderCard } from './ui/order-card'
|
|
||||||
import type { OrderFactory } from './types/order-factory.type'
|
|
||||||
|
|
||||||
export const orderFactory: OrderFactory = (deps) => {
|
|
||||||
const useOrder = createUseOrder(deps)
|
|
||||||
|
|
||||||
return {
|
|
||||||
useOrder,
|
|
||||||
OrderCard,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -12,7 +12,6 @@ title: Документация
|
|||||||
- Если проектируете структуру `src/` — откройте [Слои](/architecture/layers).
|
- Если проектируете структуру `src/` — откройте [Слои](/architecture/layers).
|
||||||
- Если создаёте новый домен или блок интерфейса — используйте [Модули](/architecture/modules).
|
- Если создаёте новый домен или блок интерфейса — используйте [Модули](/architecture/modules).
|
||||||
- Если выбираете папку внутри модуля — смотрите [Сегменты](/architecture/segments).
|
- Если выбираете папку внутри модуля — смотрите [Сегменты](/architecture/segments).
|
||||||
- Если адаптируете SLM к монорепозиторию — откройте [Монорепозитории](/architecture/monorepo).
|
|
||||||
|
|
||||||
## Разделы
|
## Разделы
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ title: Документация
|
|||||||
| [Слои](/architecture/layers) | Нужно определить, где должен жить код и какие зависимости допустимы. |
|
| [Слои](/architecture/layers) | Нужно определить, где должен жить код и какие зависимости допустимы. |
|
||||||
| [Модули](/architecture/modules) | Нужно оформить границы модуля, публичный API или фабрику. |
|
| [Модули](/architecture/modules) | Нужно оформить границы модуля, публичный API или фабрику. |
|
||||||
| [Сегменты](/architecture/segments) | Нужно выбрать внутреннюю папку для компонента, хука, стиля, типа или конфига. |
|
| [Сегменты](/architecture/segments) | Нужно выбрать внутреннюю папку для компонента, хука, стиля, типа или конфига. |
|
||||||
| [Монорепозитории](/architecture/monorepo) | Нужно понять, что можно выносить в `packages/*` и какие слои остаются внутри приложения. |
|
|
||||||
|
|
||||||
## Для ассистентов
|
## Для ассистентов
|
||||||
|
|
||||||
|
|||||||
117
generate.ts
117
generate.ts
@@ -5,12 +5,7 @@ import { execFileSync } from "child_process";
|
|||||||
|
|
||||||
const SRC_DIR = "./docs";
|
const SRC_DIR = "./docs";
|
||||||
const PUBLIC_DIR = "./public";
|
const PUBLIC_DIR = "./public";
|
||||||
const DOCS_PUBLIC_DIR = path.join(SRC_DIR, "public");
|
|
||||||
const DOC_ROUTE_PREFIX = "/docs";
|
|
||||||
const PUBLIC_ARCHITECTURE_FILE = "ARCHITECTURE.md";
|
const PUBLIC_ARCHITECTURE_FILE = "ARCHITECTURE.md";
|
||||||
const PUBLIC_ARCHITECTURE_NOTICE = `> Локальная копия канонической спецификации SLM Design.
|
|
||||||
> Источник: https://slm-design.gromlab.ru/ARCHITECTURE.md
|
|
||||||
> Не редактировать вручную в этом проекте.`;
|
|
||||||
|
|
||||||
interface SidebarItem {
|
interface SidebarItem {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -48,59 +43,15 @@ function parseSidebar(): SidebarGroup[] {
|
|||||||
|
|
||||||
const SIDEBAR = parseSidebar();
|
const SIDEBAR = parseSidebar();
|
||||||
|
|
||||||
function linkToFileRel(link: string): string {
|
|
||||||
const rel = link.replace(/^\//, "");
|
|
||||||
if (rel === "" || rel.endsWith("/")) return `${rel}index.md`;
|
|
||||||
return `${rel}.md`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileRelToRoute(file: string): string {
|
|
||||||
const route = file.endsWith("/index.md")
|
|
||||||
? file.replace(/index\.md$/, "")
|
|
||||||
: file.replace(/\.md$/, "");
|
|
||||||
return `${DOC_ROUTE_PREFIX}/${route}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileRelToMdUrl(file: string): string {
|
|
||||||
return `${DOC_ROUTE_PREFIX}/${file}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DOC_LINK_RE = /\]\((\/(?:architecture|examples)(?:\/[^)\s#]*)?)(#[^)\s]*)?\)/g;
|
|
||||||
|
|
||||||
function docRouteToFileRel(route: string): string {
|
|
||||||
if (route.replace(/\/$/, "") === "/architecture") return "architecture/index.md";
|
|
||||||
if (route.replace(/\/$/, "") === "/examples") return "examples/index.md";
|
|
||||||
return linkToFileRel(route);
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformDocLinks(
|
|
||||||
content: string,
|
|
||||||
toHref: (route: string, hash: string) => string,
|
|
||||||
): string {
|
|
||||||
return content.replace(DOC_LINK_RE, (_match, route: string, hash = "") => {
|
|
||||||
return `](${toHref(route, hash)})`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeMarkdownPath(fromFile: string, toFile: string): string {
|
|
||||||
const relative = path.relative(path.dirname(fromFile), toFile).split(path.sep).join("/");
|
|
||||||
return relative.startsWith(".") ? relative : `./${relative}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformArchiveLinks(content: string, fromFile: string): string {
|
|
||||||
return transformDocLinks(content, (route, hash) => {
|
|
||||||
return `${formatRelativeMarkdownPath(fromFile, docRouteToFileRel(route))}${hash}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformSiteMarkdownLinks(content: string): string {
|
|
||||||
return transformDocLinks(content, (route, hash) => {
|
|
||||||
return `${fileRelToMdUrl(docRouteToFileRel(route))}${hash}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllFiles(): string[] {
|
function getAllFiles(): string[] {
|
||||||
return SIDEBAR.flatMap((g) => g.items.map((item) => linkToFileRel(item.link)));
|
return SIDEBAR.flatMap((g) =>
|
||||||
|
g.items.map((item) => {
|
||||||
|
const rel = item.link.replace(/^\//, "") + ".md";
|
||||||
|
const indexPath = rel.replace(/\.md$/, "/index.md");
|
||||||
|
const filePath = path.join(SRC_DIR, indexPath);
|
||||||
|
return fs.existsSync(filePath) ? indexPath : rel;
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripFrontmatter = (content: string) =>
|
const stripFrontmatter = (content: string) =>
|
||||||
@@ -109,18 +60,6 @@ const stripFrontmatter = (content: string) =>
|
|||||||
const stripRulesLink = (content: string) =>
|
const stripRulesLink = (content: string) =>
|
||||||
content.replace(/<!-- rules-link -->[\s\S]*?<!-- \/rules-link -->\n*/g, "");
|
content.replace(/<!-- rules-link -->[\s\S]*?<!-- \/rules-link -->\n*/g, "");
|
||||||
|
|
||||||
function transformSingleFileLinks(content: string): string {
|
|
||||||
return transformDocLinks(content, (route, hash) => {
|
|
||||||
return `${fileRelToMdUrl(docRouteToFileRel(route))}${hash}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformReadmeLinks(content: string): string {
|
|
||||||
return transformDocLinks(content, (route, hash) => {
|
|
||||||
return `docs/${docRouteToFileRel(route)}${hash}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const shiftHeadings = (content: string) => {
|
const shiftHeadings = (content: string) => {
|
||||||
const lines = content.split("\n");
|
const lines = content.split("\n");
|
||||||
let inCodeBlock = false;
|
let inCodeBlock = false;
|
||||||
@@ -145,9 +84,8 @@ const buildArchitectureMarkdown = (routePrefix: string) => {
|
|||||||
const content = stripRulesLink(stripFrontmatter(raw)).trim();
|
const content = stripRulesLink(stripFrontmatter(raw)).trim();
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
const route = routePrefix + fileRelToRoute(file).replace(DOC_ROUTE_PREFIX, "");
|
const route = routePrefix + "/" + file.replace(/\.md$/, "");
|
||||||
const shifted = file.endsWith("index.md") ? content : shiftHeadings(content);
|
const processed = file.endsWith("index.md") ? content : shiftHeadings(content);
|
||||||
const processed = transformSingleFileLinks(shifted);
|
|
||||||
parts.push(`<!-- ${route} -->\n${processed}`);
|
parts.push(`<!-- ${route} -->\n${processed}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +99,9 @@ function buildLlms() {
|
|||||||
for (const group of SIDEBAR) {
|
for (const group of SIDEBAR) {
|
||||||
parts.push(`## ${group.text}`);
|
parts.push(`## ${group.text}`);
|
||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
const fileRel = linkToFileRel(item.link);
|
const rel = item.link.replace(/^\//, "") + ".md";
|
||||||
|
const indexPath = rel.replace(/\.md$/, "/index.md");
|
||||||
|
const fileRel = fs.existsSync(path.join(SRC_DIR, indexPath)) ? indexPath : rel;
|
||||||
const filePath = path.join(SRC_DIR, fileRel);
|
const filePath = path.join(SRC_DIR, fileRel);
|
||||||
let desc = "";
|
let desc = "";
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
@@ -169,7 +109,7 @@ function buildLlms() {
|
|||||||
const fm = raw.match(/^---[\s\S]*?---\n*/m);
|
const fm = raw.match(/^---[\s\S]*?---\n*/m);
|
||||||
desc = fm ? fm[0].match(/description:\s*(.+)/)?.[1] || "" : "";
|
desc = fm ? fm[0].match(/description:\s*(.+)/)?.[1] || "" : "";
|
||||||
}
|
}
|
||||||
const route = fileRelToMdUrl(fileRel);
|
const route = "/docs" + item.link;
|
||||||
const line = desc
|
const line = desc
|
||||||
? `- [${item.text}](${route}): ${desc}`
|
? `- [${item.text}](${route}): ${desc}`
|
||||||
: `- [${item.text}](${route})`;
|
: `- [${item.text}](${route})`;
|
||||||
@@ -190,29 +130,10 @@ function buildLlmsFull() {
|
|||||||
console.log(`llms-full.txt создан: ${outPath}`);
|
console.log(`llms-full.txt создан: ${outPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyMarkdownFiles() {
|
|
||||||
fs.rmSync(DOCS_PUBLIC_DIR, { recursive: true, force: true });
|
|
||||||
|
|
||||||
let copied = 0;
|
|
||||||
for (const file of getAllFiles()) {
|
|
||||||
const src = path.join(SRC_DIR, file);
|
|
||||||
if (!fs.existsSync(src)) continue;
|
|
||||||
|
|
||||||
const content = transformSiteMarkdownLinks(fs.readFileSync(src, "utf8"));
|
|
||||||
const dest = path.join(DOCS_PUBLIC_DIR, file);
|
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
fs.writeFileSync(dest, content, "utf8");
|
|
||||||
copied++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`скопировано ${copied} .md-файлов в ${DOCS_PUBLIC_DIR}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPublicArchitecture() {
|
function buildPublicArchitecture() {
|
||||||
const outPath = path.join(PUBLIC_DIR, PUBLIC_ARCHITECTURE_FILE);
|
const outPath = path.join(PUBLIC_DIR, PUBLIC_ARCHITECTURE_FILE);
|
||||||
const content = `${PUBLIC_ARCHITECTURE_NOTICE}\n\n${buildArchitectureMarkdown("/docs")}`;
|
|
||||||
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
|
fs.mkdirSync(PUBLIC_DIR, { recursive: true });
|
||||||
fs.writeFileSync(outPath, content, "utf8");
|
fs.writeFileSync(outPath, buildArchitectureMarkdown("/docs"), "utf8");
|
||||||
console.log(`${PUBLIC_ARCHITECTURE_FILE} создан: ${outPath}`);
|
console.log(`${PUBLIC_ARCHITECTURE_FILE} создан: ${outPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +148,8 @@ function buildZip() {
|
|||||||
if (!fs.existsSync(src)) continue;
|
if (!fs.existsSync(src)) continue;
|
||||||
let content = fs.readFileSync(src, "utf8");
|
let content = fs.readFileSync(src, "utf8");
|
||||||
content = stripRulesLink(stripFrontmatter(content)).trim();
|
content = stripRulesLink(stripFrontmatter(content)).trim();
|
||||||
content = transformArchiveLinks(content, file);
|
const destName = path.basename(file);
|
||||||
const dest = path.join(tmpDir, file);
|
fs.writeFileSync(path.join(tmpDir, destName), content, "utf8");
|
||||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
||||||
fs.writeFileSync(dest, content, "utf8");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
|
const pkg = JSON.parse(fs.readFileSync("./package.json", "utf8"));
|
||||||
@@ -255,14 +174,12 @@ function buildReadme() {
|
|||||||
|
|
||||||
let content = stripFrontmatter(fs.readFileSync(indexPath, "utf8"));
|
let content = stripFrontmatter(fs.readFileSync(indexPath, "utf8"));
|
||||||
content = content.replace(/<!-- rules-link -->[\s\S]*?<!-- \/rules-link -->\n*/g, "");
|
content = content.replace(/<!-- rules-link -->[\s\S]*?<!-- \/rules-link -->\n*/g, "");
|
||||||
content = transformReadmeLinks(content);
|
|
||||||
fs.writeFileSync("./README.md", content, "utf8");
|
fs.writeFileSync("./README.md", content, "utf8");
|
||||||
console.log("README.md создан");
|
console.log("README.md создан");
|
||||||
}
|
}
|
||||||
|
|
||||||
buildLlms();
|
buildLlms();
|
||||||
buildLlmsFull();
|
buildLlmsFull();
|
||||||
copyMarkdownFiles();
|
|
||||||
buildPublicArchitecture();
|
buildPublicArchitecture();
|
||||||
buildZip();
|
buildZip();
|
||||||
buildReadme();
|
buildReadme();
|
||||||
|
|||||||
20
index.html
20
index.html
@@ -5,28 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SLM Design</title>
|
<title>SLM Design</title>
|
||||||
<meta name="description" content="Scoped Layered Module Design — модульная архитектура фронтенд-приложений" />
|
<meta name="description" content="Scoped Layered Module Design — модульная архитектура фронтенд-приложений" />
|
||||||
<meta name="llms" content="/llms.txt" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="alternate llms" type="text/plain" href="/llms.txt" title="llms.txt" />
|
|
||||||
<link rel="alternate" type="text/plain" href="/llms-full.txt" title="llms-full.txt" />
|
|
||||||
<link rel="alternate" type="text/markdown" href="/ARCHITECTURE.md" title="ARCHITECTURE.md" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">
|
<div id="root"></div>
|
||||||
<main>
|
|
||||||
<h1>SLM Design</h1>
|
|
||||||
<p>Scoped Layered Module Design — модульная архитектура фронтенд-приложений.</p>
|
|
||||||
<nav aria-label="Карта сайта и AI-артефакты">
|
|
||||||
<ul>
|
|
||||||
<li><a href="/docs/">Документация</a></li>
|
|
||||||
<li><a href="/llms.txt" rel="alternate" type="text/plain">llms.txt</a></li>
|
|
||||||
<li><a href="/llms-full.txt" rel="alternate" type="text/plain">llms-full.txt</a></li>
|
|
||||||
<li><a href="/ARCHITECTURE.md" rel="alternate" type="text/markdown">ARCHITECTURE.md</a></li>
|
|
||||||
<li><a href="/slm-design.zip" download>slm-design.zip</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
> Локальная копия канонической спецификации SLM Design.
|
<!-- /docs/architecture//index -->
|
||||||
> Источник: https://slm-design.gromlab.ru/ARCHITECTURE.md
|
|
||||||
> Не редактировать вручную в этом проекте.
|
|
||||||
|
|
||||||
<!-- /docs/architecture/ -->
|
|
||||||
# SLM Design
|
# SLM Design
|
||||||
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
|
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
|
||||||
|
|
||||||
## Разделы спецификации
|
|
||||||
|
|
||||||
Спецификация SLM Design состоит из нескольких связанных разделов. Этот обзор даёт общий контекст, а детальные правила описаны дальше:
|
|
||||||
|
|
||||||
- [Слои](/docs/architecture/layers.md) — уровни организации `src/`, направление зависимостей и зона ответственности каждого слоя.
|
|
||||||
- [Модули](/docs/architecture/modules.md) — границы ответственности, публичный API, типы модулей и отличие модуля от компонента.
|
|
||||||
- [Сегменты](/docs/architecture/segments.md) — внутренние папки модуля (`ui/`, `parts/`, `hooks/`, `types/` и другие) и правила размещения файлов.
|
|
||||||
- [Монорепозитории](/docs/architecture/monorepo.md) — применение SLM в `apps/` и `packages/`, правила выноса общих слоёв и ограничения для business.
|
|
||||||
|
|
||||||
Рекомендуемый порядок чтения: обзор → слои → модули → сегменты → монорепозитории.
|
|
||||||
|
|
||||||
## Преимущества
|
## Преимущества
|
||||||
|
|
||||||
### Вертикальная организация домена
|
### Вертикальная организация домена
|
||||||
@@ -47,10 +32,6 @@ Cross-domain зависимости в бизнес-слое реализуют
|
|||||||
|
|
||||||
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: бизнес-домены по субдоменам, страницы по разделам, UI-компоненты по уровню абстракции (примитивы и композиции).
|
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: бизнес-домены по субдоменам, страницы по разделам, UI-компоненты по уровню абстракции (примитивы и композиции).
|
||||||
|
|
||||||
### Адаптация к монорепозиториям
|
|
||||||
|
|
||||||
SLM применяется внутри каждого приложения, а `packages/*` используются только для общего кода из слоёв `ui`, `infra` и `shared`. Бизнес-домены остаются внутри приложений, чтобы не размывать продуктовые границы.
|
|
||||||
|
|
||||||
## Происхождение
|
## Происхождение
|
||||||
|
|
||||||
SLM Design вырос на основе:
|
SLM Design вырос на основе:
|
||||||
@@ -513,7 +494,7 @@ backend-api/
|
|||||||
└── index.ts # публичный API
|
└── index.ts # публичный API
|
||||||
```
|
```
|
||||||
|
|
||||||
Подробное описание сегментов — в разделе [Сегменты](/docs/architecture/segments.md).
|
Подробное описание сегментов — в разделе [Сегменты](/architecture/segments).
|
||||||
|
|
||||||
### Публичный API
|
### Публичный API
|
||||||
|
|
||||||
@@ -556,11 +537,87 @@ Business-модуль всегда экспортирует фабрику. Фа
|
|||||||
|
|
||||||
Компоновка фабрик происходит на уровне модуля-потребителя: screen, layout, widget или любой другой модуль группы «Композиция».
|
Компоновка фабрик происходит на уровне модуля-потребителя: screen, layout, widget или любой другой модуль группы «Композиция».
|
||||||
|
|
||||||
#### Примеры
|
#### Структура business-модуля
|
||||||
|
|
||||||
Пример реализации фабрики в React см. в [Создание фабрики](/docs/examples/react/factory.md).
|
```text
|
||||||
|
business/customer/
|
||||||
|
├── customer.factory.ts
|
||||||
|
├── index.ts
|
||||||
|
└── types/
|
||||||
|
├── customer.type.ts
|
||||||
|
├── customer-api.type.ts
|
||||||
|
├── customer-deps.type.ts
|
||||||
|
└── customer-factory.type.ts
|
||||||
|
```
|
||||||
|
|
||||||
Пример композиции фабрик в React screen-модуле см. в [Композиция фабрик](/docs/examples/react/factory-composition.md).
|
#### Типы
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/customer/types/customer-api.type.ts
|
||||||
|
export type CustomerApi = {
|
||||||
|
useCustomer: () => Customer
|
||||||
|
CustomerCard: (props: CustomerCardProps) => ReactNode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/order/types/order-deps.type.ts
|
||||||
|
export type OrderDeps = {
|
||||||
|
customer: Pick<CustomerApi, 'useCustomer'>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/order/types/order-factory.type.ts
|
||||||
|
export type OrderFactory = (deps: OrderDeps) => OrderApi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Фабрика без зависимостей
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/customer/customer.factory.ts
|
||||||
|
import type { CustomerFactory } from './types/customer-factory.type'
|
||||||
|
|
||||||
|
export const customerFactory: CustomerFactory = () => {
|
||||||
|
return {
|
||||||
|
useCustomer,
|
||||||
|
CustomerCard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Фабрика с зависимостями
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// business/order/order.factory.ts
|
||||||
|
import type { OrderFactory } from './types/order-factory.type'
|
||||||
|
|
||||||
|
export const orderFactory: OrderFactory = (deps) => {
|
||||||
|
return {
|
||||||
|
useOrder,
|
||||||
|
OrderCard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Композиция на уровне screen
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// screens/home/home.screen.tsx
|
||||||
|
import { customerFactory } from '@/business/customer'
|
||||||
|
import { orderFactory } from '@/business/order'
|
||||||
|
|
||||||
|
const customer = customerFactory()
|
||||||
|
const order = orderFactory({ customer })
|
||||||
|
|
||||||
|
const { useOrder, OrderCard } = order
|
||||||
|
|
||||||
|
export const HomeScreen = () => {
|
||||||
|
const currentOrder = useOrder()
|
||||||
|
|
||||||
|
return <OrderCard order={currentOrder} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Жизненный цикл
|
### Жизненный цикл
|
||||||
|
|
||||||
@@ -614,7 +671,7 @@ Business-модуль всегда экспортирует фабрику. Фа
|
|||||||
- Не получает данные самостоятельно, не выбирает источник данных и не композирует данные.
|
- Не получает данные самостоятельно, не выбирает источник данных и не композирует данные.
|
||||||
- Не содержит бизнес-логику или сценарную логику.
|
- Не содержит бизнес-логику или сценарную логику.
|
||||||
|
|
||||||
Если UI-сущности нужно что-то за пределами этих ограничений, она должна быть оформлена как модуль. Полная граница описана в разделе [Компонент](/docs/architecture/modules.md#компонент).
|
Если UI-сущности нужно что-то за пределами этих ограничений, она должна быть оформлена как модуль. Полная граница описана в разделе [Компонент](/architecture/modules#компонент).
|
||||||
|
|
||||||
Корневой файл модуля в `ui/` не размещается. Он лежит в корне модуля: `{module-name}.tsx`.
|
Корневой файл модуля в `ui/` не размещается. Он лежит в корне модуля: `{module-name}.tsx`.
|
||||||
|
|
||||||
@@ -751,641 +808,3 @@ config/
|
|||||||
├── routes.ts
|
├── routes.ts
|
||||||
└── constants.ts
|
└── constants.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- /docs/architecture/monorepo -->
|
|
||||||
## Монорепозитории
|
|
||||||
|
|
||||||
Раздел описывает, как применять SLM Design, когда фронтенд-проекты находятся в одном монорепозитории. В нём показано, что остаётся внутри приложений, что можно выносить в `packages/` и какие ограничения действуют для общих пакетов.
|
|
||||||
|
|
||||||
### Определение
|
|
||||||
|
|
||||||
**Монорепозиторий — внешний уровень организации нескольких фронтенд-приложений и общих пакетов. SLM применяется внутри каждого приложения, а frontend-пакеты, относящиеся к SLM, содержат переиспользуемый код, вынесенный из слоёв `ui`, `infra` и `shared`.**
|
|
||||||
|
|
||||||
### Базовая структура
|
|
||||||
|
|
||||||
Каждое приложение внутри `apps/` сохраняет собственную SLM-структуру в `src/`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
repo/
|
|
||||||
├── apps/
|
|
||||||
│ ├── web/
|
|
||||||
│ │ └── src/
|
|
||||||
│ │ ├── app/
|
|
||||||
│ │ ├── layouts/
|
|
||||||
│ │ ├── screens/
|
|
||||||
│ │ ├── widgets/
|
|
||||||
│ │ ├── business/
|
|
||||||
│ │ ├── infra/
|
|
||||||
│ │ ├── ui/
|
|
||||||
│ │ └── shared/
|
|
||||||
│ └── admin/
|
|
||||||
│ └── src/
|
|
||||||
│ └── ...
|
|
||||||
└── packages/
|
|
||||||
├── ui/
|
|
||||||
│ ├── button/ # самостоятельный пакет UI-модуля
|
|
||||||
│ ├── input/ # самостоятельный пакет UI-модуля
|
|
||||||
│ └── modal/ # самостоятельный пакет UI-модуля
|
|
||||||
├── infra/
|
|
||||||
│ ├── theme/ # самостоятельный пакет infra-модуля
|
|
||||||
│ ├── backend-api/ # самостоятельный пакет infra-модуля
|
|
||||||
│ └── logger/ # самостоятельный пакет infra-модуля
|
|
||||||
└── shared/ # единый shared-пакет
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── lib/ # переиспользуемые утилиты
|
|
||||||
├── helpers/ # переиспользуемые helpers
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
`apps/{app}/src` — граница SLM-приложения. `packages/*` находятся выше SLM и не добавляют новые архитектурные слои.
|
|
||||||
|
|
||||||
### Группировка frontend-пакетов
|
|
||||||
|
|
||||||
Frontend-пакеты, вынесенные из SLM-приложений, рекомендуется группировать по источнику кода: `ui`, `infra`, `shared`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/ui/* # пакеты UI-модулей
|
|
||||||
packages/infra/* # пакеты infra-модулей
|
|
||||||
packages/shared # единый shared-пакет
|
|
||||||
```
|
|
||||||
|
|
||||||
Эта группировка повторяет названия SLM-слоёв для навигации, но сама не является слоистой архитектурой внутри `packages/`. Монорепозиторий может содержать другие пакеты: tooling, конфиги, SDK, схемы, e2e и другие технические пакеты вне SLM.
|
|
||||||
|
|
||||||
### Пакет и модуль
|
|
||||||
|
|
||||||
Пакет не равен SLM-модулю: модуль — архитектурная единица внутри слоя приложения, package — единица монорепозитория для переиспользования, владения, сборки и публикации.
|
|
||||||
|
|
||||||
В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей. В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей. `packages/shared` устроен иначе: это единый пакет для переиспользуемых утилит, helpers и другого фундаментального кода без привязки к конкретному приложению.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/ui/button/
|
|
||||||
packages/ui/modal/
|
|
||||||
packages/infra/theme/
|
|
||||||
packages/infra/backend-api/
|
|
||||||
packages/shared/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Что остаётся в приложении
|
|
||||||
|
|
||||||
Слои `app`, `layouts`, `screens`, `widgets` и `business` остаются внутри конкретного приложения.
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/web/src/app/
|
|
||||||
apps/web/src/layouts/
|
|
||||||
apps/web/src/screens/
|
|
||||||
apps/web/src/widgets/
|
|
||||||
apps/web/src/business/
|
|
||||||
```
|
|
||||||
|
|
||||||
`app`, `layouts` и `screens` привязаны к роутингу, каркасу и страницам конкретного приложения. `widgets` не выносятся в пакеты, потому что это слой композиции интерфейса приложения.
|
|
||||||
|
|
||||||
`business` не выносится в `packages/*`. Домены остаются рядом со сценариями приложения, чтобы не превращать монорепозиторий в общий бизнес-слой.
|
|
||||||
|
|
||||||
### Что можно выносить
|
|
||||||
|
|
||||||
В пакеты выносится только код из `ui`, `infra` и `shared`, который потенциально будет использоваться в двух и более фронтенд-приложениях монорепозитория.
|
|
||||||
|
|
||||||
| Группа | Что выносить | Пример |
|
|
||||||
|--------|--------------|--------|
|
|
||||||
| `packages/ui/*` | Самостоятельные UI-модули без бизнес-логики | `packages/ui/button` |
|
|
||||||
| `packages/infra/*` | Самостоятельные технические сервисы | `packages/infra/backend-api` |
|
|
||||||
| `packages/shared` | Общие утилиты, helpers и фундаментальный код | `packages/shared` |
|
|
||||||
|
|
||||||
Пакет можно создавать сразу, если модуль имеет общую природу и ожидается его переиспользование между приложениями. App-specific код остаётся внутри приложения.
|
|
||||||
|
|
||||||
### UI-пакеты
|
|
||||||
|
|
||||||
В `packages/ui/*` размещаются переиспользуемые UI-модули.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/ui/button/
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── button.tsx
|
|
||||||
├── styles/
|
|
||||||
├── types/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
UI-пакет не содержит бизнес-логику, обращения к API, сценарные хуки приложения и композицию страниц.
|
|
||||||
|
|
||||||
### Infra-пакеты
|
|
||||||
|
|
||||||
В `packages/infra/*` размещаются переиспользуемые инфраструктурные модули.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/infra/backend-api/
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── clients/
|
|
||||||
├── config/
|
|
||||||
├── types/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Привязанные к конкретному приложению сервисы остаются в `apps/{app}/src/infra`. Например, локализация со словарями конкретного продукта остаётся в приложении; общим пакетом может быть только переиспользуемый i18n-движок.
|
|
||||||
|
|
||||||
### Shared-пакет
|
|
||||||
|
|
||||||
`packages/shared` является единым пакетом.
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/shared/
|
|
||||||
├── package.json
|
|
||||||
└── src/
|
|
||||||
├── lib/
|
|
||||||
├── helpers/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
В `packages/shared` сразу выносится общий фундаментальный код: чистые функции, helpers, утилиты, независимые константы и другой код без знания о продукте.
|
|
||||||
|
|
||||||
Проектные стили, типы приложения, продуктовые конфиги и ресурсы, завязанные на одно приложение, в общий `shared` не выносятся.
|
|
||||||
|
|
||||||
### Имена пакетов и импорты
|
|
||||||
|
|
||||||
Путь импорта задаётся `name` в `package.json`, а не расположением директории.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@repo/theme"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/infra/theme/package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { ThemeProvider } from '@repo/theme'
|
|
||||||
```
|
|
||||||
|
|
||||||
Пакеты должны импортироваться только через публичный API. Deep imports внутрь пакета запрещены.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Хорошо
|
|
||||||
import { Button } from '@repo/button'
|
|
||||||
|
|
||||||
// Плохо
|
|
||||||
import { Button } from '@repo/button/src/button'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Зависимости
|
|
||||||
|
|
||||||
На уровне монорепозитория приложения зависят от пакетов, а пакеты не зависят от приложений.
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps → packages
|
|
||||||
packages -/→ apps
|
|
||||||
```
|
|
||||||
|
|
||||||
Внутри приложения продолжает действовать обычное направление зависимостей SLM.
|
|
||||||
|
|
||||||
```text
|
|
||||||
app → [ layouts | screens ] → widgets → business → infra → ui → shared
|
|
||||||
```
|
|
||||||
|
|
||||||
Пакеты не должны нарушать природу своей группы: `packages/ui/*` не импортирует `packages/infra/*`, `packages/shared` не импортирует другие группы, а `packages/infra/*` не знает о приложениях.
|
|
||||||
|
|
||||||
### Когда не выносить
|
|
||||||
|
|
||||||
Не выносите код в пакет, если он не может быть использован в двух и более фронтенд-приложениях, зависит от роутинга или страниц, содержит бизнес-логику, отражает продуктовую композицию конкретного интерфейса или не имеет стабильного публичного API.
|
|
||||||
|
|
||||||
Фактическое использование в одном приложении не запрещает пакет, если модуль имеет общую природу и потенциально нужен нескольким приложениям.
|
|
||||||
|
|
||||||
```text
|
|
||||||
# Плохо
|
|
||||||
apps/web/src/screens/home/parts/promo-section/
|
|
||||||
packages/ui/promo-section/
|
|
||||||
```
|
|
||||||
|
|
||||||
Если блок нужен только одной странице или отражает продуктовую композицию конкретного приложения, он остаётся локальным `parts/`-модулем.
|
|
||||||
|
|
||||||
### Конфигурационные пакеты
|
|
||||||
|
|
||||||
Конфигурационные пакеты не относятся к SLM-архитектуре.
|
|
||||||
|
|
||||||
Если в монорепозитории есть общие настройки TypeScript, ESLint, сборки или форматирования, они относятся к tooling-инфраструктуре репозитория. Такие пакеты могут находиться в `packages/`, но их структура зависит от выбранного инструментария и не участвует в правилах слоёв внутри `src/`.
|
|
||||||
|
|
||||||
### Правила
|
|
||||||
|
|
||||||
- SLM применяется внутри каждого `apps/{app}/src`.
|
|
||||||
- Frontend-пакеты, вынесенные из SLM-приложений, группируются в `packages/ui`, `packages/infra`, `packages/shared`.
|
|
||||||
- Группы `packages/ui`, `packages/infra`, `packages/shared` не являются SLM-слоями.
|
|
||||||
- В `packages/ui/*` размещаются пакеты самостоятельных UI-модулей.
|
|
||||||
- В `packages/infra/*` размещаются пакеты самостоятельных инфраструктурных модулей.
|
|
||||||
- `packages/shared` является единым пакетом для переиспользуемых утилит и helpers.
|
|
||||||
- Модуль можно размещать в пакете, если он потенциально будет использоваться в двух и более фронтенд-приложениях.
|
|
||||||
- `business`, `app`, `layouts`, `screens`, `widgets` не выносятся в пакеты.
|
|
||||||
- Проектные стили, типы приложения и продуктовые конфиги не выносятся в `packages/shared`.
|
|
||||||
- Пакеты не импортируют приложения.
|
|
||||||
- Межпакетные импорты идут только через публичный API.
|
|
||||||
- Deep imports внутрь пакетов запрещены.
|
|
||||||
- Локальная колокация важнее преждевременного выноса в `packages/*`.
|
|
||||||
|
|
||||||
<!-- /docs/examples/react/factory -->
|
|
||||||
## Создание фабрики
|
|
||||||
|
|
||||||
Раздел показывает, как оформить фабрику business-модуля в React-проекте: описать публичный API, зависимости и функцию, возвращающую runtime API.
|
|
||||||
|
|
||||||
### Структура business-модуля
|
|
||||||
|
|
||||||
Фабрика лежит в корне business-модуля. Типы публичного API и зависимостей размещаются в `types/`.
|
|
||||||
|
|
||||||
```text
|
|
||||||
business/customer/
|
|
||||||
├── customer.factory.ts
|
|
||||||
├── hooks/
|
|
||||||
├── types/
|
|
||||||
│ ├── customer.type.ts
|
|
||||||
│ ├── customer-api.type.ts
|
|
||||||
│ └── customer-factory.type.ts
|
|
||||||
├── ui/
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Тип публичного API
|
|
||||||
|
|
||||||
Публичный API описывает runtime-возможности, которые модуль отдаёт потребителям: хуки, компоненты и сценарные методы.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/types/customer-api.type.ts
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import type { Customer } from './customer.type'
|
|
||||||
|
|
||||||
export type CustomerCardProps = {
|
|
||||||
customer: Customer
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CustomerApi = {
|
|
||||||
useCustomer: () => Customer | null
|
|
||||||
CustomerCard: (props: CustomerCardProps) => ReactNode
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/types/customer-factory.type.ts
|
|
||||||
import type { CustomerApi } from './customer-api.type'
|
|
||||||
|
|
||||||
export type CustomerFactory = () => CustomerApi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Фабрика без зависимостей
|
|
||||||
|
|
||||||
Если модулю не нужны другие домены в runtime, фабрика создаётся без аргументов.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/customer.factory.ts
|
|
||||||
import { useCustomer } from './hooks/use-customer.hook'
|
|
||||||
import { CustomerCard } from './ui/customer-card'
|
|
||||||
import type { CustomerFactory } from './types/customer-factory.type'
|
|
||||||
|
|
||||||
export const customerFactory: CustomerFactory = () => {
|
|
||||||
return {
|
|
||||||
useCustomer,
|
|
||||||
CustomerCard,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/customer/index.ts
|
|
||||||
export { customerFactory } from './customer.factory'
|
|
||||||
|
|
||||||
export type { Customer } from './types/customer.type'
|
|
||||||
export type { CustomerApi } from './types/customer-api.type'
|
|
||||||
export type { CustomerFactory } from './types/customer-factory.type'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Фабрика с зависимостями
|
|
||||||
|
|
||||||
Если модулю нужен другой домен в runtime, зависимость передаётся аргументом фабрики. Тип зависимости описывает только нужную часть API.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/order/types/order-deps.type.ts
|
|
||||||
import type { CustomerApi } from '@/business/customer'
|
|
||||||
|
|
||||||
export type OrderDeps = {
|
|
||||||
customer: Pick<CustomerApi, 'useCustomer'>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/order/types/order-factory.type.ts
|
|
||||||
import type { OrderApi } from './order-api.type'
|
|
||||||
import type { OrderDeps } from './order-deps.type'
|
|
||||||
|
|
||||||
export type OrderFactory = (deps: OrderDeps) => OrderApi
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// business/order/order.factory.ts
|
|
||||||
import { createUseOrder } from './hooks/use-order.hook'
|
|
||||||
import { OrderCard } from './ui/order-card'
|
|
||||||
import type { OrderFactory } from './types/order-factory.type'
|
|
||||||
|
|
||||||
export const orderFactory: OrderFactory = (deps) => {
|
|
||||||
const useOrder = createUseOrder(deps)
|
|
||||||
|
|
||||||
return {
|
|
||||||
useOrder,
|
|
||||||
OrderCard,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
<!-- /docs/examples/react/factory-composition -->
|
|
||||||
## Композиция фабрик
|
|
||||||
|
|
||||||
Раздел показывает, как собрать API нескольких business-модулей в React screen-модуле. Пример подходит для простой композиции, когда screen сам является точкой использования доменов.
|
|
||||||
|
|
||||||
### Идея
|
|
||||||
|
|
||||||
Композиция фабрик выполняется в модуле-потребителе: screen, layout или другом модуле группы «Композиция». Business-модули не импортируют runtime-код друг друга напрямую, а cross-domain зависимости получают только через аргументы фабрик.
|
|
||||||
|
|
||||||
### Структура screen-модуля
|
|
||||||
|
|
||||||
```text
|
|
||||||
screens/home/
|
|
||||||
├── home.screen.tsx
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сборка фабрик
|
|
||||||
|
|
||||||
Файл: `screens/home/home.screen.tsx`.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { customerFactory } from '@/business/customer'
|
|
||||||
import { orderFactory } from '@/business/order'
|
|
||||||
|
|
||||||
const customer = customerFactory()
|
|
||||||
const order = orderFactory({ customer })
|
|
||||||
|
|
||||||
const { useOrder, OrderCard } = order
|
|
||||||
|
|
||||||
export const HomeScreen = () => {
|
|
||||||
const currentOrder = useOrder()
|
|
||||||
|
|
||||||
return <OrderCard order={currentOrder} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`customerFactory` создаётся первой, потому что `orderFactory` зависит от части API домена `customer`. Модуль `order` не импортирует `customer` в runtime — зависимость передаётся снаружи.
|
|
||||||
|
|
||||||
### Публичный API screen-модуля
|
|
||||||
|
|
||||||
Файл: `screens/home/index.ts`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export { HomeScreen } from './home.screen'
|
|
||||||
```
|
|
||||||
|
|
||||||
Screen экспортирует только собственный публичный API. Собранные экземпляры business API остаются деталями реализации screen-модуля.
|
|
||||||
|
|
||||||
<!-- /docs/examples/react/composition-provider -->
|
|
||||||
## Композиция через Provider
|
|
||||||
|
|
||||||
Раздел показывает, как screen-модуль может получить готовую композицию бизнес-доменов через React Context, не вызывая фабрики внутри себя.
|
|
||||||
|
|
||||||
### Идея
|
|
||||||
|
|
||||||
Screen получает готовый API бизнес-доменов через React Context. Граф фабрик собирается снаружи, например в роутере, а внутренние `parts/` достают нужные домены через хук без пропс-дриллинга.
|
|
||||||
|
|
||||||
### Принципы
|
|
||||||
|
|
||||||
1. **Принадлежность.** Provider, Context и хук принадлежат конкретному screen-модулю и лежат в его сегментах.
|
|
||||||
2. **Внутренний тип.** Тип композиции не экспортируется наружу — это закрывает доступ из бизнес-модулей.
|
|
||||||
3. **Внутренний хук.** Хук доступа не экспортируется — доступен только внутри screen и его `parts/`.
|
|
||||||
4. **Публичный Provider.** Только Provider экспортируется через `index.ts`, чтобы роутер мог обернуть screen.
|
|
||||||
5. **Сборка снаружи.** Граф фабрик собирается в роутере или другом композиторе, screen фабрики не вызывает.
|
|
||||||
6. **Запрет для бизнеса.** Бизнес-модули не используют провайдеры композиции. Cross-domain зависимости передаются только через аргументы фабрики.
|
|
||||||
|
|
||||||
### Структура модуля
|
|
||||||
|
|
||||||
```text
|
|
||||||
screens/main/
|
|
||||||
├── main.screen.tsx
|
|
||||||
├── providers/
|
|
||||||
│ └── main-composition.provider.tsx
|
|
||||||
├── hooks/
|
|
||||||
│ └── use-main-composition.hook.ts
|
|
||||||
├── types/
|
|
||||||
│ └── main-composition.type.ts
|
|
||||||
├── parts/
|
|
||||||
│ └── featured-products/
|
|
||||||
│ ├── featured-products.tsx
|
|
||||||
│ └── index.ts
|
|
||||||
└── index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Сегмент `providers/` — расширение стандартного набора SLM. Спецификация это разрешает: команда сама определяет, какие сегменты используются.
|
|
||||||
|
|
||||||
### Распределение по сегментам
|
|
||||||
|
|
||||||
| Файл | Сегмент | Назначение |
|
|
||||||
|------|---------|------------|
|
|
||||||
| `main-composition.type.ts` | `types/` | TypeScript-тип композиции |
|
|
||||||
| `main-composition.provider.tsx` | `providers/` | Context и Provider-компонент |
|
|
||||||
| `use-main-composition.hook.ts` | `hooks/` | React-хук доступа |
|
|
||||||
| `main.screen.tsx` | корень | Корневой компонент screen-модуля |
|
|
||||||
| `featured-products/` | `parts/` | Вложенный модуль со своим публичным API |
|
|
||||||
|
|
||||||
### Тип композиции
|
|
||||||
|
|
||||||
Файл: `screens/main/types/main-composition.type.ts`.
|
|
||||||
|
|
||||||
Тип описывает, какие бизнес-домены доступны на этой странице. Он не экспортируется через `index.ts`, чтобы другие модули не зависели от внутренней формы композиции screen.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { CatalogApi } from '@/business/catalog'
|
|
||||||
import type { CartApi } from '@/business/cart'
|
|
||||||
|
|
||||||
export type MainComposition = {
|
|
||||||
catalog: CatalogApi
|
|
||||||
cart: CartApi
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context и Provider
|
|
||||||
|
|
||||||
Файл: `screens/main/providers/main-composition.provider.tsx`.
|
|
||||||
|
|
||||||
Context — внутренняя деталь Provider, наружу он не экспортируется. Значение `null` по умолчанию нужно, чтобы хук мог проверить отсутствие Provider в дереве.
|
|
||||||
|
|
||||||
Provider-компонент экспортируется через `index.ts`. Роутер передаёт в `value` уже собранный граф фабрик со стабильной ссылкой.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { createContext, type ReactNode } from 'react'
|
|
||||||
import type { MainComposition } from '../types/main-composition.type'
|
|
||||||
|
|
||||||
export const MainCompositionContext = createContext<MainComposition | null>(null)
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
value: MainComposition
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MainCompositionProvider = ({ value, children }: Props) => (
|
|
||||||
<MainCompositionContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</MainCompositionContext.Provider>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Хук доступа
|
|
||||||
|
|
||||||
Файл: `screens/main/hooks/use-main-composition.hook.ts`.
|
|
||||||
|
|
||||||
Хук остаётся внутренним и не экспортируется через `index.ts` модуля. Он доступен только внутри screen и его `parts/`.
|
|
||||||
|
|
||||||
Если хук используется вне Provider, он бросает ошибку. Это даёт раннюю диагностику неправильной композиции дерева.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { useContext } from 'react'
|
|
||||||
import { MainCompositionContext } from '../providers/main-composition.provider'
|
|
||||||
|
|
||||||
export const useMainComposition = () => {
|
|
||||||
const ctx = useContext(MainCompositionContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useMainComposition must be used within MainCompositionProvider')
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Сборка графа в роутере
|
|
||||||
|
|
||||||
Файл: `app/router.tsx`.
|
|
||||||
|
|
||||||
Роутер или другой композитор собирает граф фабрик в точке использования screen. Каждый домен получает свои зависимости через аргументы фабрики.
|
|
||||||
|
|
||||||
Фабрики вызываются вне React-компонента, если не зависят от runtime-параметров. Так API доменов не пересоздаётся на каждый рендер route-компонента.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { MainScreen, MainCompositionProvider } from '@/screens/main'
|
|
||||||
import { catalogFactory } from '@/business/catalog'
|
|
||||||
import { cartFactory } from '@/business/cart'
|
|
||||||
import { authFactory } from '@/business/auth'
|
|
||||||
|
|
||||||
const auth = authFactory()
|
|
||||||
const catalog = catalogFactory()
|
|
||||||
const cart = cartFactory({ auth })
|
|
||||||
|
|
||||||
const MainRoute = () => (
|
|
||||||
<MainCompositionProvider value={{ catalog, cart }}>
|
|
||||||
<MainScreen />
|
|
||||||
</MainCompositionProvider>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Корневой компонент screen
|
|
||||||
|
|
||||||
Файл: `screens/main/main.screen.tsx`.
|
|
||||||
|
|
||||||
Screen получает нужные домены из композиции и достаёт из API готовые хуки, компоненты или функции. В JSX используются уже локальные `useCategories` и `CategoryList`, а не обращение к фабричному API через точку.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useMainComposition } from './hooks/use-main-composition.hook'
|
|
||||||
import { FeaturedProducts } from './parts/featured-products'
|
|
||||||
|
|
||||||
export const MainScreen = () => {
|
|
||||||
const { catalog } = useMainComposition()
|
|
||||||
const { useCategories, CategoryList } = catalog
|
|
||||||
const categories = useCategories()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<CategoryList categories={categories} />
|
|
||||||
<FeaturedProducts />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Вложенный part
|
|
||||||
|
|
||||||
Файл: `screens/main/parts/featured-products/featured-products.tsx`.
|
|
||||||
|
|
||||||
Вложенный модуль получает доступ к той же композиции родительского screen. Промежуточные компоненты не прокидывают домены через props.
|
|
||||||
|
|
||||||
Из API доменов достаются готовые сущности: `useFeatured`, `ProductCard` и `addItem`. Компонент работает с ними напрямую.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useMainComposition } from '../../hooks/use-main-composition.hook'
|
|
||||||
|
|
||||||
export const FeaturedProducts = () => {
|
|
||||||
const { catalog, cart } = useMainComposition()
|
|
||||||
const { useFeatured, ProductCard } = catalog
|
|
||||||
const { addItem } = cart
|
|
||||||
const products = useFeatured()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{products.map((product) => (
|
|
||||||
<ProductCard
|
|
||||||
key={product.id}
|
|
||||||
product={product}
|
|
||||||
onAdd={() => addItem(product.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Файл: `screens/main/parts/featured-products/index.ts`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export { FeaturedProducts } from './featured-products'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Публичный API screen-модуля
|
|
||||||
|
|
||||||
Файл: `screens/main/index.ts`.
|
|
||||||
|
|
||||||
Наружу экспортируются только screen и его Provider. `MainComposition`, `MainCompositionContext` и `useMainComposition` остаются деталями реализации.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export { MainScreen } from './main.screen'
|
|
||||||
export { MainCompositionProvider } from './providers/main-composition.provider'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Почему тип композиции не экспортируется
|
|
||||||
|
|
||||||
Внутренний тип закрывает доступ к форме композиции из внешних модулей. Бизнес-модуль не должен знать, какие домены собраны для конкретного screen.
|
|
||||||
|
|
||||||
Такой импорт из бизнес-модуля не должен быть возможен через публичный API screen.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { MainComposition } from '@/screens/main'
|
|
||||||
```
|
|
||||||
|
|
||||||
Когда тип остаётся внутренним, такая связь невозможна через публичный API screen-модуля.
|
|
||||||
|
|
||||||
### Почему хук не экспортируется
|
|
||||||
|
|
||||||
Если хук доступа сделать публичным, любой модуль сможет вызвать его напрямую. Внутренний хук доступен только через относительные импорты внутри screen-модуля и его `parts/`.
|
|
||||||
|
|
||||||
### Почему Provider экспортируется
|
|
||||||
|
|
||||||
Provider безопасно экспортировать: сам по себе он не даёт доступ к данным, а только принимает готовую композицию и передаёт её детям внутри React-дерева.
|
|
||||||
|
|
||||||
### Стабильность value
|
|
||||||
|
|
||||||
Фабрики создаются на уровне модуля, поэтому `catalog` и `cart` сохраняют ссылки между рендерами `MainRoute`.
|
|
||||||
|
|
||||||
Если домены зависят от runtime-параметров, граф нужно собирать в отдельном композиторе для этих параметров и передавать в Provider уже готовую композицию.
|
|
||||||
|
|
||||||
### Расширение на другие screen-модули
|
|
||||||
|
|
||||||
Паттерн повторяется для каждого screen, которому нужна композиция бизнес-доменов.
|
|
||||||
|
|
||||||
```text
|
|
||||||
screens/checkout/providers/checkout-composition.provider.tsx
|
|
||||||
screens/checkout/hooks/use-checkout-composition.hook.ts
|
|
||||||
screens/checkout/types/checkout-composition.type.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена включают имя screen-модуля. Не используйте универсальные названия вроде `useComposition` или `useScope`: по имени файла должно быть понятно, к какой странице привязан Context.
|
|
||||||
@@ -8,19 +8,10 @@ export const homeCards = [
|
|||||||
cta: 'Открыть →',
|
cta: 'Открыть →',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Скачать',
|
title: 'ARCHITECTURE.md',
|
||||||
description: 'Локальная копия спецификации и архив документации.',
|
description: 'Полная версия архитектуры в одном файле',
|
||||||
actions: [
|
href: '/ARCHITECTURE.md',
|
||||||
{
|
cta: 'Открыть →',
|
||||||
href: '/ARCHITECTURE.md',
|
|
||||||
label: 'ARCHITECTURE.md',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/slm-design.zip',
|
|
||||||
label: 'slm-design.zip',
|
|
||||||
download: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Ассистенту',
|
title: 'Ассистенту',
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ export function HomeScreen() {
|
|||||||
<p>{card.description}</p>
|
<p>{card.description}</p>
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
{card.actions.map((action) => (
|
{card.actions.map((action) => (
|
||||||
<a
|
<a className={styles.cardAction} href={action.href} key={action.href}>
|
||||||
className={styles.cardAction}
|
|
||||||
download={'download' in action ? action.download : undefined}
|
|
||||||
href={action.href}
|
|
||||||
key={action.href}
|
|
||||||
>
|
|
||||||
{action.label}
|
{action.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user