用 Vite define 与 TypeScript 类型收窄终结运行时功能开关:编译期常量折叠的零开销治理
功能开关(Feature Flag)几乎出现在每一个生产项目中。它本是为了灰度、回滚、按环境切换能力服务的,但在大多数实现里,它成了运行时开销与代码腐化的温床。
// 一段典型的运行时开关代码
import { features } from './config'
export function renderToolbar() {
if (features.enableAISuggest) {
return <AiSuggestPanel />
}
return <BasicToolbar />
}
这段代码的问题不在于正确性,而在于:无论 enableAISuggest 是 true 还是 false,AiSuggestPanel 及其整个依赖子树都会被打包进产物,运行时还必须维护一份 features 对象、执行一次条件判断。对于从未启用的功能,这是一笔纯纯的浪费。
反例:运行时开关的三重税
第一重是产物税。下面这个开关决定了 A/B 两个实现,二者都依赖了重达几十 KB 的第三方库:
import cheap from 'lightweight-impl'
import heavy from 'feature-rich-impl'
export const handler = USE_EXPERIMENTAL ? heavy : cheap
只要 USE_EXPERIMENTAL 是运行时变量,Rollup 就无法确定性地剪掉 heavy。两个库都被打入 bundle,用户被迫下载他们永远用不到的代码。
第二重是分支税。开关在热路径(如列表的每项渲染)中出现时,即便预测器能命中,也仍然存在不可消除的指令与状态:
items.map(it => (
<Row key={it.id} advanced={features.advancedClick} ... />
))
第三重是类型税。运行时的 features 是 Record<string, boolean>,类型层完全不知道某个开关当前是开是关,也就无法利用字面量类型做收窄。
正例:把开关抬到编译期
Vite 的 define 会在构建阶段做文本级常量替换。关键在于,我们替换进去的必须是一个字面量(而不是表达式),这样 TypeScript / esbuild 才能把它当作真正的字面量类型来收窄。
vite.config.ts:
import { defineConfig } from 'vite'
export default defineConfig(({ mode }) => {
const experimental = mode === 'experimental'
return {
define: {
__FEATURE_AI_SUGGEST__: JSON.stringify(experimental),
__FEATURE_TRACKING__: JSON.stringify(mode !== 'privacy'),
},
}
})
然后在类型声明里把它声明成字面量型的联合,而不是宽泛的 boolean:
// env.d.ts
declare const __FEATURE_AI_SUGGEST__: boolean
// 更好的做法:声明成常量化字面量,让 TypeScript 参与收窄
小提醒:
declare const X: boolean只会告诉 TS「它是布尔」,不会触发常量折叠式收窄。要享受编译期收窄,要么用as const思路声明,要么直接依赖 esbuild 的常量折叠——一旦define替换成false,if (false)的整块在 transform 阶段就会被definePlugin与 dead-code elimination 联手删掉。真正让类型层受益的,是配合具名常量。
对于需要「按环境注入不同实现」的场景,用字面量联合做穷尽映射:
// 通过 define 注入:__TRACKING_BACKEND__ = '"none"' | '"beacon"' | '"collector"'
declare const __TRACKING_BACKEND__: 'none' | 'beacon' | 'collector'
function createTracker() {
switch (__TRACKING_BACKEND__) {
case 'none':
return noopTracker
case 'beacon':
return beaconTracker
case 'collector':
return collectorTracker
default: {
const _exhaustive: never = __TRACKING_BACKEND__
return _exhaustive
}
}
}
当 __TRACKING_BACKEND__ 被 define 成 "none" 时,esbuild 会把 switch 折叠为只剩 return noopTracker,beaconTracker、collectorTracker 的 import 都会被 Tree Shaking 掉。穷尽性检查(never)则保证未来新增 backend 时编译会直接报错。
与运行时开关库的边界
编译期开关并非万能。它适合「构建时已经确定、不会在同一份产物内动态翻转」的能力:
- 按环境(dev/staging/prod)切换的埋点后端
- 灰度期结束后应当被永久剪除的实验功能
- 面向不同白标客户的定制构建
对于「运行时按用户动态翻转」的开关,编译期替换无能为力,这类仍应交由服务端下发的 flag 处理。二者的边界可以这样区分:能被构建参数回答的,用 define;必须由运行时数据回答的,才留给 flag 库。
关键约束:千万别 define 一个可变的运行时代理
一个高频反模式,是以为 define 只是「把变量换成另一个变量」:
// 错误示范:替换进去一个运行时会变的东西
import { getConfig } from './runtime'
define: { __VERSION__: "getConfig().version" } // 运行时求值,收窄失效,还污染全局
define 的替换是字面上的,它不会帮你「引用」另一个变量,而是把 __VERSION__ 这个词条直接替换成 getConfig().version 这段文本。这既破坏类型收窄,又会在每次引用处重复求值。牢记:define 只用来注入字面量或极简的纯常量表达式。
落地清单
- 用
mode/.env驱动define,替换值为JSON.stringify(value)确保字面量语义; - 为每个开关写
declare const类型声明,尽量使用字面量联合而非boolean; - 用
switch+never做穷尽性检查,让新增分支在编译期暴露; - 构建后盯着产物体积(
rollup-plugin-visualizer)确认死代码确实被剪除。
编译期常量折叠不是炫技,而是把「运行时白付的三重税」一次清零:产物更小、热路径更短、类型更诚实。下一版上线时,让那些永远 false 的功能,彻底从 bundle 里消失。
评论区
登录 后参与评论