Files
2026-06-07 17:34:31 +07:00
..
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00
2026-06-07 17:34:31 +07:00

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).

Гейтите опциональные страницы так, чтобы выключенные исчезали из бандла (а не просто переставали роутиться):

// __FEATURES__ типизируется сгенерированным .vite-layers/features.d.ts — declare не нужен
const routes = [
  { path: '/', component: () => import('@/pages/Home') },
  ...(__FEATURES__.billing ? [{ path: '/billing', component: () => import('@/pages/Billing') }] : []),
]

Тип __FEATURES__ генерируется из merged.features в .vite-layers/features.d.ts, поэтому опечатка (__FEATURES__.biling) — ошибка компиляции, а не молчком-falsy.

Флаги вшиваются через define и сворачиваются esbuild ещё до построения графа Rollup, поэтому выключенная ветка и её import() физически не попадают в бандл. Дотированные литералы эмитятся на любую глубину (__FEATURES__.nested.enabled тоже сворачивается). Правила, чтобы DCE сработало:

  • обращайтесь напрямую__FEATURES__.billing; алиас/деструктуризация (const f = __FEATURES__; f.billing) и динамический доступ (__FEATURES__[name]) не сворачиваются;
  • гейт оборачивает сам import() (тернарник/&&/спред), а не .filter после — reachable-импорт не вырезается;
  • ключи фич — валидные JS-идентификаторы (kebab/пробел доступны в рантайме через объект __FEATURES__, но без DCE);
  • в тестах продублируйте definevitest.config) или гардите globalThis.__FEATURES__ ?? {}.

Dev-режим. В build флаги работают через define (+ DCE). В dev Vite 8 / rolldown-vite не инлайнит пользовательский define в исходники, поэтому vite-layers сам подставляет __FEATURES__ в рантайме (dev-only плагин). Плюс при изменении любого app.config.* слоя dev-сервер автоматически перезапускается (app.config грузится c12, вне графа Vite — сам он не следит) — так фичи обновляются без ручного рестарта. В шаблонах .vue __FEATURES__ напрямую использовать нельзя — компилятор префиксует его в _ctx.__FEATURES__ (define/рантайм-подстановка не матчат); читайте флаг в <script setup> и используйте в шаблоне локальную переменную.

Префиксы импортов

Префикс Куда резолвится Примечания
@/…, ~/… первый совпавший файл по srcDir слоёв, high→low слоёвый резолвер; self-skip даёт super()
~~/…, @@/… rootDir проекта обычный alias
#layers/<name>/… rootDir соответствующего слоя обычный alias, first-wins по имени

Модель приоритета (из 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
})

Опции

buildViteConfig(appDir, options?):

  • tsconfig: false — выключить автоген tsconfig; tsconfig: {...}GenerateTsConfigOptions.
  • resolver: { prefixes?, extensions? } — сменить слоёвые префиксы / расширения резолвера (напр. добавить .svelte).
  • hooks: {...} — программные lifecycle-хуки (см. ниже), регистрируются после слоёвых.
  • 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.

Улучшения над Nuxt/c12

  1. super() через self-skip — оверрайд может импортировать собственный путь (@/components/X), чтобы дотянуться до базового файла. В Nuxt такого механизма нет.
  2. Cycle-guard — голый c12 уходит в stack overflow на обратном ребре (A→B→A); дедуп Nuxt срабатывает только ПОСЛЕ рекурсивного обхода c12 и не спасает. Терминальный пустой слой в resolve-хуке c12 обрывает рекурсию.
  3. Побрендовый DCE — гейтированные динамические import() выпиливаются из бандлов выключенных брендов через дотированные __FEATURES__.<key> defines (esbuild сворачивает литерал ещё до того, как Rollup построит граф модулей).

TypeScript (автогенерация tsconfig)

Framework-agnostic порт Nuxt prepare:types. buildViteConfig пишет на каждом dev/build <appDir>/.vite-layers/{tsconfig.json, tsconfig.node.json, features.d.ts} (features.d.ts типизирует __FEATURES__); 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-плагин резолвера (можно отдельно).
  • generateTsConfig(appDir, opts?) / writeTsConfig(appDir, opts?) / tsconfigPlugin(appDir, opts?) — генерация tsconfig.
  • defineLayerConfig(config) — типизированный хелпер для app.config.ts.

Пример (Vue)

example/apps/{main,brand} — запускаемое Vue-демо. main — база (vue() + страница billing), brand расширяет её, перекрывает AppHeader.vue и выключает billing. Соберите оба и сравните:

npx vite build example/apps/main    # эмитит чанки Home + Billing
npx vite build example/apps/brand   # только Home (чанка Billing НЕТ → DCE); AppHeader перекрыт

Что демонстрирует демо:

  • Оверрайд: brand/src/components/AppHeader.vue затеняет версию из main (@/components/AppHeader.vue).
  • DCE: features.billing: false → динамический import('@/pages/Billing.vue') мёртв → чанк не эмитится.
  • Алиасы/резолвер: main.ts тянет страницы и компонент через @/… сквозь слои.
  • tsconfig: app.config.ts правит tsconfig (jsxImportSource: 'vue'), vue-tsc зелёный:
vite-layers prepare example/apps/brand
npx vue-tsc --noEmit -p example/apps/brand

Тесты

pnpm test          # порядок, diamond-дедуп, cycle-guard, авто-скан, self-skip резолвера, defines, tsconfig
pnpm type-check