vue-feature-toggles — управление feature flags во Vue 3

Написал плагин для управления feature flags во Vue 3. Расскажу, зачем, как работает и какие задачи решает.
Проблема
Стандартный подход выглядит так:
vue
<template>
<div v-if="user.features.includes('newDashboard')">
<NewDashboard />
</div>
<div v-else-if="betaFlags.betaSearch">
<BetaSearchBar />
</div>
</template>
Флаги размазаны по компонентам, логика включения дублируется, никакого единого места где посмотреть что включено. QA хочет проверить новую фичу — деплоим специальную ветку или комментируем код. Нужно экстренно отключить сломавшуюся фичу — лезем в код, деплоим фикс.
Плагин решает это: единый провайдер флагов, три интерфейса (компонент, директива, composable), URL-оверрайды для QA и runtime-управление.
Три интерфейса
Компонент <Feature>
Основной способ работы — оборачиваешь контент в компонент:
vue
<Feature name="newDashboard">
<NewDashboard />
</Feature>
Если newDashboard === false — ничего не рендерится. Через слоты — fallback:
vue
<Feature name="betaSearch">
<template #default>
<BetaSearchBar />
</template>
<template #fallback>
<LegacySearchBar />
</template>
</Feature>
Fallback можно задать и строкой через prop — когда не хочется создавать отдельный слот:
vue
<Feature name="betaSearch" fallback="Функция в разработке">
<BetaSearchBar />
</Feature>
Есть inverted — показать именно когда флаг выключен. Удобно для режима обслуживания:
vue
<Feature name="maintenanceMode" :inverted="true">
<MainContent />
</Feature>
Prop tag оборачивает содержимое в HTML-тег. Без него — фрагмент, лишнего DOM-узла нет.
Пока флаги грузятся через loader — показывается слот #loading.
Директива v-feature
Работает как v-show — через display: none, не пересоздаёт DOM:
vue
<!-- включить если флаг ON -->
<div v-feature="'newDashboard'">...</div>
<!-- включить если флаг OFF -->
<div v-feature:not="'betaSearch'">...</div>
<!-- включить если ОБА включены (AND) -->
<div v-feature="['newDashboard', 'betaSearch']">...</div>
Директива реактивна — когда флаг меняется (через setFlag или URL-оверрайд), display обновляется автоматически. Внутри — watchEffect с WeakMap для cleanup при unmount.
Composable useFeature
Для логики в <script setup>:
ts
// Один флаг → Ref<boolean>
const isNewDashboard = useFeature('newDashboard')
// Несколько → Record<string, Ref<boolean>>
const { newDashboard, betaSearch } = useFeature(['newDashboard', 'betaSearch'])
// AND по нескольким → Ref<boolean>
const allEnabled = useFeature('newDashboard', 'betaSearch')
Всё реактивно — computed внутри отслеживает флаги и пересчитывается при изменениях.
Как настраивается
ts
app.use(FeatureToggles, {
// Статические флаги
flags: {
newDashboard: true,
betaSearch: false,
},
// Загрузка с бэкенда — любая async-функция
loader: async () => {
const res = await fetch('/api/flags')
return res.json()
},
reloadInterval: 60_000, // перезагрузка каждую минуту
urlOverrides: true, // по умолчанию true в dev, false в prod
urlPrefix: 'feature', // ?feature:flagName=true
defaultValue: false, // если флаг не найден нигде
})
loader — это просто функция, которая возвращает Record<string, boolean>. Можно подключить что угодно: REST, LaunchDarkly, localStorage, роль пользователя:
ts
// Флаги на основе подписки
loader: async () => {
const user = await getUser()
return {
advancedReports: user.plan === 'pro',
apiAccess: user.plan === 'enterprise',
newDashboard: true,
}
}
Порядок приоритетов
URL override → runtime setFlag() → loader → static flags → defaultValue
URL-параметр перекрывает всё. setFlag — runtime-оверрайд, который живёт в памяти. Loader-результат подхватывается после загрузки и реактивно обновляет UI. Статический объект flags — база. defaultValue — запасной вариант.
Низкоуровневый доступ
Через useFeatureProvider — полный контроль:
ts
const {
flags, // Ref<Record<string, boolean>> — все флаги реактивно
isLoading, // Ref<boolean>
isReady, // Ref<boolean>
isEnabled, // (name: string) => boolean
getFlagSource, // (name: string) => 'url' | 'runtime' | 'loader' | 'static' | 'default'
setFlag, // runtime override
resetFlag, // убрать runtime override
resetAll, // убрать все runtime overrides
reload, // перезапустить loader
} = useFeatureProvider()
getFlagSource возвращает откуда конкретно пришло значение флага — полезно для дебага и для DevTools.
URL-оверрайды
Когда urlOverrides: true, параметры вида ?feature:flagName=true перекрывают значение флага. Оверрайды читаются реактивно через popstate/hashchange — перезагрузка страницы не нужна.
https://staging.app.com/checkout?feature:newCheckout=true&feature:legacyCart=false
QA открывает ссылку — видит новую фичу. Остальные пользователи её не видят. Флаг в бэкенде не тронут.
Оверрайды живут только в памяти — не попадают в localStorage, не влияют на других.
DevTools-панель

<FeatureDevTools> — фиксированный оверлей в правом нижнем углу для работы с флагами во время разработки.
vue
<FeatureDevTools v-if="isDev" />
Что показывает: имя флага, текущее значение ON/OFF, источник значения (цветной бейдж: url, runtime, loader, static, default), кнопки toggle и reset для каждого флага, кнопки «Reset all» и «↺ reload». Панель сворачивается.
Все стили инлайновые — конфликтов с проектом нет, можно подключить и забыть.
Nuxt-модуль
Для Nuxt — отдельный модуль:
ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['vue-feature-toggles/nuxt'],
featureToggles: {
flags: {
newDashboard: true,
betaSearch: false,
},
urlOverrides: true,
},
})
<Feature> и v-feature регистрируются глобально. Провайдер доступен вне компонентов:
ts
// plugins/my-plugin.ts
export default defineNuxtPlugin((nuxtApp) => {
const { $featureToggles } = nuxtApp
await $featureToggles.reload()
})
Если нужен loader с Nuxt — создаёшь плагин напрямую, без модуля:
ts
// plugins/feature-toggles.ts
import { FeatureToggles } from 'vue-feature-toggles'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(FeatureToggles, {
loader: async () => $fetch('/api/flags'),
urlOverrides: true,
})
})
Сценарии применения
Тарифы подписки
Фичи доступны в зависимости от плана пользователя. Флаги вычисляются на бэкенде после авторизации:
ts
loader: async () => {
const user = await getUser()
return {
advancedReports: user.plan === 'pro',
exportPDF: user.plan !== 'free',
apiAccess: user.plan === 'enterprise',
}
}
vue
<Feature name="advancedReports">
<AdvancedReports />
<template #fallback>
<UpgradeBanner feature="advancedReports" />
</template>
</Feature>
Изменить доступность фичи — задача бэкенда, не деплоя фронтенда.
QA тестирует через URL без деплоя
Новая checkout-страница готова, но флаг в продакшне выключен — идёт ревью. QA хочет протестировать на стейдже без отдельной ветки:
https://staging.app.com/checkout?feature:newCheckout=true
Открыл ссылку — видит новую версию. Закрыл вкладку — всё вернулось. Ссылку можно передать коллеге — он увидит то же самое.
Работает без изменений в коде: urlOverrides: true включён по умолчанию в dev-режиме.
Экстренное отключение фичи
В продакшне сломалась фича — приходит алерт от мониторинга. Нужно убрать её из UI немедленно, не ждать деплоя:
ts
const { setFlag } = useFeatureProvider()
setFlag('newPaymentFlow', false)
Изменение реактивно — все <Feature name="newPaymentFlow"> и v-feature="'newPaymentFlow'" в DOM скрываются мгновенно. Если флаги хранятся на бэкенде и включён reloadInterval — достаточно поменять значение там, фронтенд подхватит при следующей загрузке.
Защита роутов
Фича доступна только пользователям с нужным планом. Вместо проверок в каждом компоненте — один guard:
ts
// router/guards.ts
import { useFeatureProvider } from 'vue-feature-toggles'
router.beforeEach((to) => {
const { isEnabled } = useFeatureProvider()
if (to.meta.feature && !isEnabled(to.meta.feature as string)) {
return { name: 'NotFound' }
}
})
ts
// routes
[
{ path: '/analytics', component: Analytics, meta: { feature: 'advancedReports' } },
{ path: '/api-console', component: ApiConsole, meta: { feature: 'apiAccess' } },
]
Попытка открыть недоступный раздел — 404. Флаги загружаются один раз при старте через loader, guard работает синхронно.
NPM: https://www.npmjs.com/package/vue-feature-toggles
GitHub: https://github.com/macrulezru/vue-feature-toggles