25 KiB
vite-layers
Framework-agnostic слои в стиле Nuxt, портированные на чистый Vite: файловые оверрайды через
extends + мёрж конфигов, плюс побрендовый build-time dead-code elimination. Работает с любым
фреймворком — у ядра нет фреймворк-зависимостей; vue()/react()/и т.п. подключаются на уровне слоя.
Логика стека слоёв (порядок, дедуп, авто-скан, алиасы) портирована напрямую из исходников Nuxt
(@nuxt/kit loadNuxtConfig + @nuxt/schema), с тремя осознанными улучшениями.
Установка
pnpm add -D vite-layers # peer-зависимость: vite ^8
Использование
Каждое приложение/бренд — это директория с app.config.ts и однострочным vite.config.ts:
// apps/main/app.config.ts
import { defineLayerConfig } from 'vite-layers'
export default defineLayerConfig({
name: 'main',
features: { billing: true },
vite: { plugins: [vue()] }, // фреймворк-плагин живёт здесь, а не в ядре
})
// apps/brand/app.config.ts — только диффы
import { defineLayerConfig } from 'vite-layers'
export default defineLayerConfig({
name: 'brand',
extends: ['../main'],
features: { billing: false }, // бренд полностью убирает страницу billing
})
// apps/<любой>/vite.config.ts
import { buildViteConfig } from 'vite-layers'
export default buildViteConfig(import.meta.dirname)
Чтобы перекрыть файл, положите его по тому же относительному пути в слое с более высоким приоритетом
(apps/brand/src/components/Header.vue затеняет apps/main/src/components/Header.vue).
Гейтите опциональные страницы так, чтобы выключенные исчезали из бандла (а не просто переставали роутиться):
import { feature } from '#feature' // алиас регистрирует vite-layers; типы — из .vite-layers/features.d.ts
const routes = [
{ path: '/', component: () => import('@/pages/Home') },
...(feature('billing') ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
]
feature('key') — компайл-тайм макрос: плагин заменяет вызов на литерал значения флага,
одинаково в dev и в build (один AST-transform, без расхождений). Подставленный false делает ветку
статически мёртвой, и Rollup/rolldown вырезает её вместе с import() — чанк выключенной фичи не эмитится.
Тип feature генерируется из merged.features литеральными типами, поэтому опечатка ключа
(feature('biling')) — ошибка компиляции.
Правила (они enforced: нарушение валит сборку — и в dev, и в build, ничего не «протекает» молча):
- ключ — строковый литерал:
feature('billing'), неfeature(name); - вызывайте напрямую — без алиасов (
const f = feature), деструктуризации и передачи как значения; - вложенные флаги — дотированным ключом:
feature('payments.stripe'); - ключ должен существовать в
merged.features(иначе — ошибка сборки).
Тестам ничего дублировать не нужно — тот же transform работает в Vitest (плагин — часть конфига).
В .vue-шаблоне напрямую feature('x') использовать нельзя (компилятор делает из него _ctx.feature —
это не вызов макроса): читайте флаг в <script setup>/JSX и используйте в шаблоне локальную переменную.
При изменении любого app.config.* dev-сервер автоматически перезапускается (app.config грузится
c12, вне графа Vite — сам он не следит), подхватывая новые значения флагов.
Префиксы импортов
| Префикс | Куда резолвится | Примечания |
|---|---|---|
@/…, ~/… |
первый совпавший файл по srcDir слоёв, high→low |
слоёвый резолвер; self-skip даёт super() |
#super, #super/… |
первый совпавший файл строго ниже слоя импортёра | явный super() — предпочтительная форма, см. ниже |
~~/…, @@/… |
rootDir проекта |
обычный alias |
#layers/<name>/… |
rootDir соответствующего слоя |
обычный alias, first-wins по имени |
#feature |
entry макроса feature('key') |
алиас регистрируется автоматически; вызовы сворачиваются в литералы |
super(): доступ к затенённому файлу
Оверрайд часто хочет не заменить базовый файл целиком, а обернуть его. Для этого есть #super:
// apps/brand/src/main.ts — бренд без собственного bootstrap: реиспользует entry базы
import '#super' // мой же путь (main.ts), слоем ниже → apps/main/src/main.ts
// apps/brand/src/components/Header.vue — оборачиваем базовый компонент
import BaseHeader from '#super/components/Header.vue'
#super/<path>— резолвит<path>начиная со слоя строго ниже слоя импортёра (работает из любого файла слоя, не только из одноимённого оверрайда);- голый
#super— сахар для «мой собственный путь, слоем ниже».
#super/* типизируется в сгенерированном tsconfig (paths → слои ниже проектного), так что
go-to-definition ведёт в правильный файл; голая форма покрыта ambient-декларацией
(.vite-layers/super.d.ts) — типов не даёт, но и ошибок не создаёт (подходит для side-effect
импортов). Legacy-форма — self-import собственного пути (import '@/main.ts' изнутри main.ts,
Nuxt-парити) — продолжает работать, но не грепается, меняет смысл при копировании в другой файл и
резолвится TypeScript'ом в самого себя; в новом коде используйте #super.
Модель приоритета (из Nuxt)
layers[0] — это сам проект (высший приоритет); далее extends слева-направо, в глубину;
авто-сканированные layers/* сортируются по убыванию (Z > A, выше числовой префикс — выше приоритет).
Коллизии решаются как меньший индекс слоя выигрывает. Конфиги мёржатся через defu (проект
выигрывает; массивы конкатятся).
Слоёвые public/-ассеты (брендинг)
У каждого слоя может быть своя public/ — резолвится first-match по слоям, как @/:
brand/public/logo.svg затеняет main/public/logo.svg, а favicon.svg из базы наследуется.
Удобно для лого/favicon/шрифтов per-brand. Работает и в dev (отдаётся через sirv по приоритету),
и в build (эмитится в outDir, верхний слой перетирает нижний). publicDir Vite при этом
отключается автоматически (он одиночный) — плагин берёт обслуживание на себя.
apps/main/public/{logo.svg, favicon.svg} # база
apps/brand/public/logo.svg # бренд перекрывает только лого
→ dist/brand/{logo.svg = brand, favicon.svg = main}
Env-оверрайды слоёв
Слой в app.config.ts может переопределять себя по Vite mode (через c12 $<env>/$env):
export default defineLayerConfig({
features: { analytics: false },
$production: { features: { analytics: true } }, // применится при mode=production
})
Объявляйте все ключи фич в базовом features (как analytics выше), а $env-блоки используйте
только чтобы менять их значения. Ключ, существующий лишь в $production, будет «неизвестен» в dev
(feature('x') → ошибка сборки) и не попадёт в типы. Литеральные типы в features.d.ts отражают тот
mode, в котором их сгенерировали (dev/build/prepare) — поэтому флаг с разными значениями по mode
типизируется значением текущего mode; держите ключи в базе для предсказуемости.
Опции
buildViteConfig(appDir, options?):
tsconfig: false— выключить автоген tsconfig;tsconfig: {...}—GenerateTsConfigOptions.resolver: { prefixes?, extensions? }— сменить слоёвые префиксы / расширения резолвера (напр. добавить.svelte).hooks: {...}— программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.devtools: false— не монтировать панели в Vite DevTools (см. ниже). По умолчанию включено.outDir,vite— выходная папка и финальный Vite-фрагмент (высший приоритет).
Хуки жизненного цикла
Как в Nuxt (на unjs/hookable): типизированные, серийные в порядке слоёв (база первой),
mutation-style (хендлер мутирует общий аргумент). Хуки каждого слоя из app.config.ts
накапливаются (одноимённые из разных слоёв все выполняются), не перетираются.
export default defineLayerConfig({
hooks: {
'layers:resolved': (stack) => { stack.merged.features ??= {}; /* править merged/features/layers */ },
'vite:config': (ctx) => { ctx.config.plugins?.push(myPlugin()) }, // финальный Vite-конфиг
'tsconfig:generate': (ctx) => { ctx.tsconfig.compilerOptions!.strict = true }, // перед записью
},
})
| Хук | Аргумент | Когда |
|---|---|---|
layers:resolved |
LayerStack |
после резолва стека (до чтения features/алиасов) |
vite:config |
{ config, env, stack } |
финальный Vite-конфиг перед возвратом |
tsconfig:generate |
{ appDir, tsconfig, stack } |
сгенерированный tsconfig перед записью |
Программно: buildViteConfig(dir, { hooks: { … } }). Низкоуровнево экспортируются
createLayerHooks/registerLayerHooks/hooksFromStack и типы LayerHooks/LayerHookable.
DevTools
vite-layers умеет показывать свой резолвнутый стек прямо в Vite DevTools
(@vitejs/devtools). Добавьте хаб в dev — buildViteConfig сам подмонтирует панели; без хаба плагин
инертен (используются только типы из @vitejs/devtools-kit, никакой рантайм-зависимости):
// apps/main/app.config.ts
import { DevTools } from '@vitejs/devtools' // peer-зависимость только для dev
export default defineLayerConfig({
vite: ({ command }) => ({
plugins: [vue(), command === 'serve' && DevTools()], // хаб только в dev
}),
})
Четыре панели (свёрнуты под одной кнопкой vite-layers):
| Панель | Что показывает |
|---|---|
| Layers | дерево наследования (extends-граф box-drawing, с ромбами и авто-сканом), резолвнутый стек high→low, мёрж-конфиг (Tree), накопленные хуки |
| Features | мёрж-флаги с литеральными значениями и статусом DCE (kept / branch eliminated); бейдж = число выключенных |
| Resolver | плейграунд @/… (показывает кандидатов по слоям + победителя) и живой лог реальных слоёвых резолвов сессии (включая super()-self-skip) |
| Public & TS | слоёвые public/-ассеты (кто кого затеняет) и сгенерированные tsconfig.json / tsconfig.node.json / features.d.ts |
UI рисуется целиком на сервере (json-render спеки @vitejs/devtools-kit) — клиентский бандл не
нужен, vite-layers остаётся buildless. Панели читают тот же стек и тот же резолвер-кэш, что и сборка
(createLayeredResolution шарится между резолвер-плагином и панелью), поэтому показанное — ровно то,
что действует. При первом заходе DevTools попросит авторизовать браузер (разовый prompt в терминале).
Плагин можно подключить и вручную — layersDevtoolsPlugin экспортируется из vite-layers/devtools.
Улучшения над Nuxt/c12
super()— явный#super/#super/<path>(типизированный, greppable) плюс self-skip (оверрайд может импортировать собственный путь@/components/X, чтобы дотянуться до базового файла). В Nuxt такого механизма нет.- Cycle-guard — голый c12 уходит в stack overflow на обратном ребре (
A→B→A); дедуп Nuxt срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой вresolve-хуке c12 обрывает рекурсию. - Побрендовый DCE через
feature()-макрос — гейтированныеimport()выпиливаются из бандлов выключенных брендов: AST-transform заменяетfeature('key')на литерал (один механизм для dev и build), а любое не-сворачиваемое использование (алиас, динамический/неизвестный ключ) валит сборку с понятной ошибкой — вместо молчаливой деградации DCE, как приdefine-подходе Nuxt.
TypeScript (автогенерация tsconfig)
Framework-agnostic порт Nuxt prepare:types. buildViteConfig пишет на каждом dev/build
<appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts} (features.d.ts аугментирует
модуль #feature литеральными типами feature(), а сгенерированный paths['#feature'] резолвит сам
макрос); tsconfig.json приложения его расширяет:
// apps/brand/tsconfig.json
{ "extends": "./.vite-layers/tsconfig.json" }
// сюда добавляются фреймворк-опции (для Vue: "jsx": "preserve", "jsxImportSource": "vue")
Сгенерированный paths['@/*']/['~/*'] — это массив srcDir всех слоёв в порядке приоритета,
поэтому tsc/vue-tsc резолвит слоёвые импорты по first-existing-file — точно как рантайм-резолвер.
~~/@@ → корень проекта; #layers/<name> → каждый слой.
Два tsconfig — как в Nuxt (app + node). tsconfig.json — для кода приложения (слои, DOM, paths);
tsconfig.node.json — для конфиг-файлов (vite.config.*/app.config.* всех слоёв) с node-типами,
без DOM и без слоёвых paths. Проверять оба:
vue-tsc --noEmit -p apps/brand # код приложения
vue-tsc --noEmit -p apps/brand/.vite-layers/tsconfig.node.json # конфиг-файлы (node)
Настройка на уровне слоя через поле tsConfig в app.config.ts (это pkg-types
TSConfig), мёржится по стеку как Nuxt typescript.tsConfig —
сгенерированные paths всегда побеждают:
// apps/main/app.config.ts
export default defineLayerConfig({
name: 'main',
tsConfig: { compilerOptions: { jsxImportSource: 'vue', strict: true } }, // наследуется брендами
})
Ручная генерация для CI:
vite-layers prepare apps/brand # пишет apps/brand/.vite-layers/tsconfig.json
vue-tsc --noEmit -p apps/brand # или tsc --noEmit
Отключить авто-запись: buildViteConfig(dir, { tsconfig: false }). Добавьте .vite-layers/ в .gitignore.
API
buildViteConfig(appDir, options?)— дефолтный экспорт дляvite.config.ts(резолвер + автоген tsconfig;options.tsconfig: false— отключить,options.tsconfig: {...}— настроить).resolveLayerStack(cwd)→{ merged, layers }— резолвнутый упорядоченный стек.layersResolver({ roots, prefixes?, extensions? })— Vite-плагин резолвера (можно отдельно).createLayeredResolution({ roots, prefixes?, extensions?, record? })— чистое ядро резолвера (parse/candidates/resolveId/records); шарится между резолвер-плагином и DevTools-панелью.generateTsConfig(appDir, opts?)/writeTsConfig(appDir, opts?)/tsconfigPlugin(appDir, opts?)— генерация tsconfig.defineLayerConfig(config)— типизированный хелпер дляapp.config.ts.feature(key)(импорт из#feature/vite-layers/feature) — компайл-тайм макрос флагов;featurePlugin(features)— сам плагин (можно отдельно).layersDevtoolsPlugin(data)(импорт изvite-layers/devtools) — панели для Vite DevTools (автоматически подключаютсяbuildViteConfig).
Пример (Vue + Tailwind)
example/apps/{main,brand,aurora} — запускаемое мультибрендовое демо. Общий «каркас» (шапка, подвал,
страница профиля, страница биллинга, роутер, входной Tailwind-CSS) живёт только в базовом слое main;
каждый бренд меняет ровно три вещи — логотип, файл темы Tailwind и лендинг:
| Приложение | Слой | Тема | DCE-страница billing |
Beta-плашка |
|---|---|---|---|---|
main (Acme) |
база | светлая, индиго | ✅ есть | ✅ (в dev) |
brand (Northwind) |
extends ../main |
светлая, изумруд | ❌ вырезана | ✅ (унаследована) |
aurora (Aurora) |
extends ../main |
тёмная, роза/небо | ✅ есть | ❌ выключена |
pnpm example:dev # дев-сервер Acme (бренды: npx vite example/apps/{brand,aurora})
pnpm example:build # билд всех трёх — сравните эмитнутые чанки
pnpm example:check # prepare + vue-tsc для всех трёх (код приложения + node-конфиги)
Как меняется тема. Общий @/style.css (только в базе) подключает Tailwind и мапит токены на
runtime-переменные через @theme inline (--color-brand: var(--c-brand) и т.д.). Каждый бренд кладёт
свой @/assets/theme.css с :root { --c-* }; слоёвый резолвер выбирает версию верхнего слоя — и весь
общий UI перекрашивается, без правки единого компонента. @tailwindcss/vite резолвит CSS-@import
своим резолвером (мимо слоёв), поэтому тему подключаем JS-импортом import '@/assets/theme.css',
который идёт через слоёвый резолвер.
Что демонстрирует демо:
- Общий каркас:
AppHeader,AppFooter,Profile,Billing, роутер есть только вmain, но рендерятся во всех брендах через@/…. Уbrandбольше нет своегоAppHeader— шапка общая. - Перекраска темой: один и тот же
Profile/Billingрендерится светлым у Acme/Northwind и тёмным у Aurora — разница только вtheme.css(10 токенов цвета/радиуса/шрифта). - Перекрытие ассета:
*/public/logo.svgзатеняется послойно;favicon.svgнаследуется из базы. - DCE: у
brandfeatures.billing: false→feature('billing')сворачивается вfalse→import('@/pages/Billing.vue')мёртв → чанкBilling-*.jsне эмитится (и ссылки в навигации нет):
ls example/apps/main/dist/main/assets | grep -i billing # Billing-*.js есть
ls example/apps/brand/dist/brand/assets | grep -i billing # пусто → DCE
ls example/apps/aurora/dist/aurora/assets | grep -i billing # Billing-*.js есть
- Разные наборы фич:
brandубирает биллинг, но оставляет beta-плашку;aurora— наоборот.$productionгасит beta-плашку в проде у всех (env-оверрайд слоя). - tsconfig:
app.config.tsправит tsconfig (jsxImportSource: 'vue',types: ['vite/client']),vue-tscзелёный для всех трёх:
vite-layers prepare example/apps/aurora
npx vue-tsc --noEmit -p example/apps/aurora
Тесты
pnpm test # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, feature()-макрос, tsconfig
pnpm type-check
Сборка
Разработка buildless — example-приложения и тесты импортируют src/* напрямую, а exports
пакета указывают на ./src/*.ts. Для публикации pnpm build (tsdown) собирает ESM + .d.ts в
dist/ по одному выходу на каждый сабпас (., ./feature, ./devtools); зависимости и peer'ы
(vite, @vitejs/devtools-kit) внешние. publishConfig.exports переключает пакет на dist/ —
prepack пересобирает автоматически, в tarball едут только dist/ + bin/ (проверено publint).
pnpm build # tsdown → dist/{index,feature,devtools}.{js,d.ts}
pnpm pack # prepack-сборка + публикуемый tarball (dist + bin)
CLI vite-layers prepare и алиас #feature работают в обоих режимах: из исходников (feature.ts,
jiti) и из собранного dist/ (feature.js).