← Назад к вопросам

Как решал проблему кросс-импортов в Feature-Sliced Design?

2.3 Middle🔥 171 комментариев
#JavaScript Core

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как решал проблему кросс-импортов в 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

Чек-лист избежания кросс-импортов

  1. Вынести общую логику в entities/ слой
  2. Использовать shared/ для универсальных утилит
  3. Явный public API через index.ts в каждой фичи
  4. Event Bus для асинхронного взаимодействия фич
  5. Context для фичи-специфичной логики
  6. Model layer для чистой бизнес-логики
  7. ESLint rules проверяют направление импортов
  8. Code review проверяет архитектуру

Итог: Кросс-импорты решаются правильной архитектурой: entities для сущностей, shared для утилит, event bus для синхронизации, public API (index.ts) для явного интерфейса. ESLint автоматизирует контроль.