Как решал проблему кросс-импортов в Feature-Sliced Design?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Как решал проблему кросс-импортов в Feature-Sliced Design
Feature-Sliced Design (FSD) — архитектурный паттерн организации кода по фичам с чёткой иерархией слоёв. Кросс-импорты — это когда фича импортирует другую фичу напрямую, нарушая архитектуру. Правило: импорты идут ВВЕРХ по иерархии, не в стороны.
Проблема кросс-импортов
Правильное направление импортов в FSD:
pages
|
v
widgets
|
v
features (НЕ горизонтально между собой!)
|
v
entities
|
v
shared
ПРОБЛЕМА - нарушение слоёв:
// src/features/profile/ui/ProfileCard.tsx
import { useAuthStore } from '@/features/auth/model'; // ❌ BAD!
// features -> features (горизонтально) запрещено!
ПРАВИЛЬНО - через entities:
// src/features/profile/ui/ProfileCard.tsx
import { useUserStore } from '@/entities/user/model'; // ✅ OK
// Общие данные живут в entities
Решение 1: Вынести общую логику в entities
Основной способ — создать entities слой для общих сущностей (User, Question, Comment).
// src/entities/user/
// ├── ui/
// │ └── UserCard.tsx
// ├── model/
// │ ├── userStore.ts (состояние пользователя)
// │ ├── userModel.ts (бизнес-логика)
// │ └── types.ts (типы User)
// ├── api/
// │ └── userApi.ts (API запросы)
// └── index.ts (public API)
// src/features/auth/model/authStore.ts
import { useUserStore } from '@/entities/user/model'; // ✅ Правильно
export function login(email: string, password: string) {
// Логика логина
// Сохраняем User в entities/user store
}
// src/features/profile/ui/ProfileCard.tsx
import { useUserStore } from '@/entities/user/model'; // ✅ Правильно
import { UserCard } from '@/entities/user/ui'; // ✅ Правильно
export function ProfileCard() {
const user = useUserStore(s => s.user);
return <UserCard user={user} />;
}
// Обе фичи используют общий источник истины - entities/user
Решение 2: Shared слой для cross-feature кода
Общие хуки, утилиты, типы которые используют ВСЕ фичи.
// src/shared/
// ├── hooks/
// │ ├── useNotification.ts
// │ ├── useMediaQuery.ts
// │ └── useDebounce.ts
// ├── utils/
// │ ├── formatDate.ts
// │ ├── cn.ts
// │ └── api.ts
// ├── stores/
// │ ├── notificationStore.ts
// │ └── themeStore.ts
// └── types/
// └── common.ts
// src/features/auth/ui/LoginForm.tsx
import { useNotification } from '@/shared/hooks'; // ✅ OK (shared)
import { cn } from '@/shared/utils'; // ✅ OK (shared)
import { useAuthStore } from '../model/authStore'; // ✅ OK (своя фича)
// src/features/profile/ui/ProfileCard.tsx
import { useNotification } from '@/shared/hooks'; // ✅ OK (shared)
import { useProfileStore } from '../model/profileStore'; // ✅ OK (своя фича)
// Обе фичи используют shared, но НЕ импортируют друг друга
Решение 3: Public API через index.ts
Каждая фича имеет явный public API (index.ts), импортируем только из него.
// src/features/auth/index.ts - public API
export { LoginForm } from './ui/LoginForm';
export { RegisterForm } from './ui/RegisterForm';
export { useAuthStore } from './model/authStore';
export type { LoginPayload, RegisterPayload } from './model/types';
// src/features/auth/model/authStore.ts - приватное
// (импортировать НЕ нужно, используем index.ts)
// src/pages/login/page.tsx
// ❌ Неправильно - прямой импорт
import { useAuthStore } from '@/features/auth/model/authStore';
// ✅ Правильно - через index
import { useAuthStore, LoginForm } from '@/features/auth';
Решение 4: ESLint правила для контроля
Автоматическая проверка что импорты идут в правильном направлении.
// .eslintrc.json
{
"plugins": ["boundaries"],
"rules": {
"boundaries/element-types": [
"error",
{
"default": "disallow",
"rules": [
{
"from": ["shared"],
"allow": [] // shared ничего не импортирует
},
{
"from": ["entities"],
"allow": ["shared"] // entities -> shared
},
{
"from": ["features"],
"allow": ["shared", "entities"] // features -> shared, entities
},
{
"from": ["widgets"],
"allow": ["shared", "entities", "features"]
},
{
"from": ["pages"],
"allow": ["shared", "entities", "features", "widgets"]
}
]
}
]
}
}
Решение 5: Event Bus для асинхронного взаимодействия
Если двум фичам нужно синхронизировать — используй event bus.
// src/shared/event-bus/eventBus.ts
type EventHandler<T> = (payload: T) => void;
class EventBus {
private listeners = new Map<string, EventHandler<any>[]>();
on<T>(event: string, handler: EventHandler<T>) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(handler);
}
emit<T>(event: string, payload: T) {
this.listeners.get(event)?.forEach(h => h(payload));
}
}
export const eventBus = new EventBus();
// src/features/auth/model/authStore.ts
import { eventBus } from '@/shared/event-bus';
export function logout() {
// Логика логаута
userStore.clear();
// Уведомляем другие фичи о логауте
eventBus.emit('auth:logout', {});
}
// src/features/profile/model/profileStore.ts
import { eventBus } from '@/shared/event-bus';
export function initProfileStore() {
// Подписываемся на событие
eventBus.on('auth:logout', () => {
// Очищаем профиль при логауте
clearProfile();
});
}
// ✅ Правильно: фичи не импортируют друг друга
// ✅ Взаимодействие через event bus (незаметное)
Решение 6: Context для фичи-специфичной логики
Если логика нужна только внутри одной фичи — используй Context.
// src/features/interview/
// ├── InterviewContext.tsx
// ├── InterviewProvider.tsx
// └── ui/
// ├── InterviewScreen.tsx
// └── QuestionDisplay.tsx
// src/features/interview/InterviewContext.tsx
type InterviewState = {
currentQuestion: Question;
answers: Record<string, string>;
isCompleted: boolean;
};
const InterviewContext = createContext<InterviewState | null>(null);
export function useInterview() {
const ctx = useContext(InterviewContext);
if (!ctx) throw new Error('useInterview outside InterviewProvider');
return ctx;
}
// src/features/interview/InterviewProvider.tsx
export function InterviewProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(interviewReducer, initialState);
return (
<InterviewContext.Provider value={state}>
{children}
</InterviewContext.Provider>
);
}
// src/features/interview/ui/InterviewScreen.tsx
export function InterviewScreen() {
return (
<InterviewProvider>
<QuestionDisplay />
<AnswerForm />
</InterviewProvider>
);
}
// src/features/interview/ui/QuestionDisplay.tsx
export function QuestionDisplay() {
const { currentQuestion } = useInterview(); // Используется только внутри фичи
return <div>{currentQuestion.text}</div>;
}
Решение 7: Model layer pattern
Вынеси чистую бизнес-логику в отдельный слой.
// src/entities/question/model/questionModel.ts - чистая логика
export function validateQuestion(q: Question): string[] {
const errors: string[] = [];
if (!q.text) errors.push('Question text required');
if (q.options.length < 2) errors.push('Need at least 2 options');
if (!q.correctOption) errors.push('Set correct option');
return errors;
}
export function shuffleOptions(options: string[]): string[] {
return [...options].sort(() => Math.random() - 0.5);
}
// src/entities/question/model/types.ts
export type Question = {
id: string;
text: string;
options: string[];
correctOption: number;
difficulty: 'easy' | 'medium' | 'hard';
};
// src/features/question-editor/ui/QuestionForm.tsx
import { validateQuestion } from '@/entities/question/model';
import type { Question } from '@/entities/question/model/types';
export function QuestionForm() {
const [question, setQuestion] = useState<Question>();
const errors = validateQuestion(question); // Чистая логика
return (
<form>
{errors.map(e => <div key={e}>{e}</div>)}
{/* form fields */}
</form>
);
}
Правильная структура проекта
src/
├── shared/ (← нулевой слой, все зависят от него)
│ ├── hooks/ (useNotification, useMediaQuery, etc)
│ ├── utils/ (cn, api, dates, etc)
│ ├── stores/ (notificationStore, themeStore)
│ ├── types/ (common types)
│ ├── event-bus/ (event bus для фич)
│ └── ui/ (базовые компоненты: Button, Input)
│
├── entities/ (← сущности, чистая логика)
│ ├── user/
│ │ ├── ui/ (UserCard, UserAvatar)
│ │ ├── model/ (userStore, types, userModel)
│ │ ├── api/ (userApi.ts)
│ │ └── index.ts
│ ├── question/
│ ├── comment/
│ └── interview/
│
├── features/ (← бизнес-фичи)
│ ├── auth/
│ │ ├── ui/ (LoginForm, RegisterForm)
│ │ ├── model/ (authStore)
│ │ ├── api/ (authApi)
│ │ └── index.ts
│ ├── profile/
│ ├── questions-list/
│ ├── question-editor/
│ ├── interview/
│ └── comments/
│
├── widgets/ (← композиция фич)
│ ├── sidebar/
│ ├── navbar/
│ ├── header/
│ └── footer/
│
├── pages/ (← страницы/маршруты)
│ ├── login/
│ ├── profile/
│ ├── questions/
│ ├── interview/
│ └── 404/
│
└── app/ (← инициализация
├── layout.tsx
└── providers.tsx
Чек-лист избежания кросс-импортов
- Вынести общую логику в
entities/слой - Использовать
shared/для универсальных утилит - Явный public API через
index.tsв каждой фичи - Event Bus для асинхронного взаимодействия фич
- Context для фичи-специфичной логики
- Model layer для чистой бизнес-логики
- ESLint rules проверяют направление импортов
- Code review проверяет архитектуру
Итог: Кросс-импорты решаются правильной архитектурой: entities для сущностей, shared для утилит, event bus для синхронизации, public API (index.ts) для явного интерфейса. ESLint автоматизирует контроль.