7 минут
Внедряем FSD в Nuxt 3: ручная конфигурация против nuxt-fsd
В прошлом посте мы разобрали, почему Feature-Sliced Design конфликтует с конвенциями Nuxt 3 и как эти конфликты разрешаются конфигурацией. Остался главный практический вопрос: чем охранять архитектуру? Правила, которые не проверяются автоматически, умирают за квартал — какой-нибудь спешный хотфикс импортнёт стор напрямую из внутренностей слайса, за ним второй, и через полгода от FSD останутся только названия папок.
Инструмента два: ручная конфигурация + линтер Steiger и Nuxt-модуль nuxt-fsd. Я сравнил их по десяти критериям на реальном production-проекте на Nuxt 3 (Apollo/GraphQL, Pinia, Storybook, сотня с лишним компонентов). Спойлер: выиграла ручная конфигурация, но не всухую — и проигравшие критерии не менее интересны, чем выигранные.
Подход A: вручную + Steiger
Философия — «FSD своими руками»: никакого стороннего модуля, только конфигурация и статический анализ. Именно этот сценарий описывает официальный гайд FSD.
Конфигурационный патч
Весь патч nuxt.config.ts — меньше 20 строк:
export default defineNuxtConfig({
// 1. Алиас: '@/' указывает внутрь src/, а не в корень
alias: {
'@': './src',
},
// 2. Файловый роутинг и layouts переезжают внутрь src/app/
dir: {
pages: './src/app/routes',
layouts: './src/app/layouts',
},
// 3. Авто-импорт компонентов — только домен-независимый UI-кит
components: {
dirs: [{ path: '~/src/shared/ui' }],
},
// 4. Авто-импорт composables/utils отключён — обязательное
// условие: линтер видит только явные импорты
imports: {
autoImport: false,
dirs: [],
},
})
Существующий код продолжает работать: никаких переименований компонентов, никаких массовых правок шаблонов.
Steiger: что ловит, что нет
Steiger — официальный линтер команды FSD (той же, что публикует спецификацию). Анализирует файловую структуру и статические import-выражения в AST, без привязки к фреймворку:
yarn add -D steiger @feature-sliced/steiger-plugin
// steiger.config.ts
import { defineConfig } from 'steiger'
import fsd from '@feature-sliced/steiger-plugin'
export default defineConfig([
...fsd.configs.recommended,
{ ignores: ['./node_modules', './.nuxt'] },
])
Запуск: npx steiger ./src разово или --watch в разработке. Из коробки — 20 правил в четырёх группах: импорты (запрет импорта из слоя выше, кросс-импорты между слайсами, обход публичного API), именование (тайпо в именах слоёв, неконсистентные имена), структура (слайсы без сегментов, избыточное дробление), ограничения слоёв (deprecated-слой processes, UI в app).
Важно понимать границу возможностей. Steiger гарантирует структурную корректность — но не смысловую:
| Ловит автоматически | Остаётся на ревью |
|---|---|
| Импорт из более высокого слоя | Правильность выбора entities vs features |
Обход index.ts (public API sidestep) | Семантика имени слайса |
| Кросс-импорты слайсов одного слоя | Преждевременные абстракции в shared/ |
| Отсутствие публичного API у слайса | Бизнес-логика, попавшая в ui-сегмент |
Два практических ограничения. Первое: ESLint-интеграции нет — Steiger это отдельный CLI, его нарушения не подсвечиваются в IDE как ESLint-диагностики; нужен либо watch-режим, либо CI-job. Второе: свои правила добавить нельзя — всё за пределами двадцати встроенных остаётся на code review.
Отсюда главная слабость подхода: архитектурные нарушения не ломают локальную сборку. Разработчик может собрать проект, запушить и увидеть проблему только на красном CI-job lint:fsd. Правило, которое проверяется «где-то потом», дисциплинирует слабее, чем ошибка компиляции.
Подход B: модуль nuxt-fsd
Противоположная философия: одна строка modules: ['nuxt-fsd'] — и модуль сам решает конфликты. Что он делает:
- Авто-алиасы слоёв:
@features/exchangeвместо@/features/exchange— каждый слой становится путём импорта. - Переопределение
pages/иlayouts/— то же, что ручнойdir-патч, но неявно, внутри модуля. - Авто-импорт компонентов с префиксом слоя и слайса:
shared/ui/button.vue→<SharedButton />,features/exchange/ui/order-card.vue→<FeaturesExchangeOrderCard />. - Build-time запрет кросс-импортов: опция
preventCrossImports(включена по умолчанию) — нарушение правил слоёв ломаетnuxt buildтак же, как ошибка TypeScript.
Последний пункт — суперсила подхода: мгновенная локальная блокировка без зависимости от дисциплины запуска линтера. Но дальше начинаются нюансы.
Цена вопроса
Bus factor. На момент исследования: один мейнтейнер, 7 звёзд на GitHub, единственный релиз v1.0.0 (декабрь 2024) и 17+ месяцев без единого коммита. Ноль открытых issues при таких цифрах — признак малоизвестности, а не безупречности. Если следующий мажор Nuxt изменит module API — модуль сломается, и чинить его, возможно, будет некому. Для инструмента, который становится ядром архитектурной стратегии (а не вспомогательной утилитой), это серьёзный риск: форк технически прост (MIT), но означает, что команда берёт сопровождение Nuxt-модуля на себя.
Дыра в собственной защите. README модуля честно признаёт: «Auto-imports do not respect cross-import protection rules». То есть preventCrossImports запрещает статические import между слоями — но компоненты, зарегистрированные через авто-импорт, проверке не подлежат. Можно использовать <FeaturesExchangeOrderCard /> внутри shared/ui/, и модуль этого не заметит. Главный механизм защиты не покрывает главную фичу модуля.
Принудительное переименование. Формат <Layer><Slice><Name> не конфигурируется. Если в проекте компоненты называются <OrderCard />, после подключения модуля они обязаны стать <FeaturesExchangeOrderCard /> — при сотне с лишним компонентов это массовый рефакторинг шаблонов с высоким риском merge-конфликтов. И это же — стоимость отката: убрал модуль — глобальная регистрация исчезла — все шаблоны сломались.
Мелочи, которые не мелочи: добавление нового слайса перезапускает dev-сервер целиком (мейнтейнер признаёт это недоработкой), а совместимость со Storybook в README не упомянута вовсе — Storybook бандлится отдельно от Nuxt и может не увидеть ни глобальную регистрацию компонентов, ни Vite-плагин защиты.
Сравнение по десяти критериям
| Критерий | A: Manual + Steiger | B: nuxt-fsd | Вердикт |
|---|---|---|---|
| Build-time enforcement | только CI-job | ломает nuxt build напрямую | B |
| Стоимость миграции | патч ≤20 строк | + переименование 100+ компонентов | A |
| Bus factor | команда FSD, активные релизы | 1 мейнтейнер, 17 мес. тишины | A |
| Nuxt 4 readiness | стандартные конфиг-ключи | заявлена, релизом не подтверждена | ничья |
| DX импортов | @/features/exchange | @features/exchange | B |
| Совместимость со стеком | ничего не меняет в сборке | Storybook под вопросом | A |
| Инкрементальность | слайс за слайсом, watch-режим | «большой PR» при включении | A |
| Onboarding | явные конфиги | самодокументирующие префиксы, но скрытая магия | ничья |
| Lock-in / откат | удалить пакет и 3 строки | + вернуть все имена компонентов | A |
| Прозрачность | всё в двух конфигах | 4 механизма за одной строкой | A |
Счёт: A — 6, B — 2, ничьи — 2. Но интереснее не счёт, а асимметрия: критерии, где выигрывает A, лежат в зоне долгосрочных рисков (bus factor, lock-in, совместимость, прозрачность); критерии B — в зоне ежедневного DX (мгновенная блокировка сборки, короткие импорты). Это разные валюты, и обменный курс между ними у каждой команды свой.
Причём оба выигрыша B устранимы внутри A: короткие алиасы по слоям добавляются шестью строками конфига ('@features': './src/features', …), а build-time блокировку частично заменяет дисциплина обязательного CI-gate. Обратное неверно: bus factor и принудительное переименование изнутри B не лечатся.
Пилотная миграция на бумаге
Прежде чем принимать решение на весь проект, полезно расписать миграцию одного слайса — по шагам, с оценкой. Наш пилот для слайса exchange уложился в восемь атомарных коммитов:
- Конфиг: alias,
dir.pages,dir.layouts, скелетsrc/— файлы не трогаем, сборка не ломается. - Перенос страниц:
git mv pages/ src/app/routes/— чистое перемещение без рефакторинга. shared/: apollo-client, сгенерированные GraphQL-типы, error-logger — и точечная замена импортов.- Слайс: стор →
features/exchange/model/, GraphQL-операции →api/, middleware-гейт →config/+ регистрация черезaddRouteMiddleware. - FSD-слой pages: содержимое страницы уезжает в
pages/exchange-order/ui/, в маршрутной обёртке остаётся один тег. - Компоненты: карточка ордера →
features/exchange/ui/, виджет главного экрана →widgets/, импорт — строго через публичный API. - Steiger в watch-режиме: устранение нарушений (обходы
index.ts, кросс-импорты). - Тесты + верификация ключей i18n.
Оценка: 2,5–3,5 человеко-дня на пилот — достаточно дёшево, чтобы проверить подход без риска для остального проекта, который продолжает жить в старой структуре (спасибо инкрементальности подхода A).
Из рисков пилота два стоят упоминания, потому что они не ловятся ни сборкой, ни типами:
- Потеря
<i18n>SFC-блока при переносе страницы:t('statusLabel')молча вернёт строку'statusLabel'вместо перевода. Митигация — grep-проверка ключей после переноса как явный пункт чеклиста. - Устаревший путь codegen: если GraphQL-типы переехали в
shared/api/types/, а конфиг кодогенерации не обновлён — следующий прогон запишет файл по старому пути и молча сломает импорты. Митигация:rmстарого файла → прогон кодогенерации → проверка, что файл появился на новом месте.
Вердикт
Для большинства проектов — подход A: ручная конфигурация + Steiger. Аргументы, которые перевесили:
- Steiger поддерживает та же команда, что развивает саму спецификацию FSD, — консистентность методологии и инструмента гарантирована;
- миграция инкрементальна: один слайс мигрирует, остальной проект живёт как жил;
- откат почти бесплатен — структура файлов остаётся, уходит только линтер;
- всё поведение явно записано в двух конфигах, никакой магии за строкой
modules: [...].
Подход B стоит рассмотреть, если: проект новый (нет сотни компонентов под переименование), команда готова принять формат <Layer><Slice><Name> с первого дня, и критично, чтобы нарушение границ ломало сборку локально, а не на CI. Но и тогда стоит перепроверить живость модуля: 17 месяцев без коммитов — это ставка на код, который никто не сопровождает.
И последнее. Какой бы инструмент вы ни выбрали, помните из первого поста: линтер проверяет структуру, а не смысл. Куда отнести бизнес-концепцию — entities или features, где граница слайса, что достойно shared/ — эти решения остаются за командой. Инструмент лишь гарантирует, что однажды принятое решение не разъедется по швам под давлением дедлайнов.