前端开发··2 阅读·预计 9 分钟

用 Vite define 与 TypeScript 类型收窄终结运行时功能开关:编译期常量折叠的零开销治理

功能开关(Feature Flag)几乎出现在每一个生产项目中。它本是为了灰度、回滚、按环境切换能力服务的,但在大多数实现里,它成了运行时开销与代码腐化的温床。

// 一段典型的运行时开关代码
import { features } from './config'

export function renderToolbar() {
  if (features.enableAISuggest) {
    return <AiSuggestPanel />
  }
  return <BasicToolbar />
}

这段代码的问题不在于正确性,而在于:无论 enableAISuggesttrue 还是 falseAiSuggestPanel 及其整个依赖子树都会被打包进产物,运行时还必须维护一份 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} ... />
))

第三重是类型税。运行时的 featuresRecord<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 替换成 falseif (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 noopTrackerbeaconTrackercollectorTracker 的 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 只用来注入字面量或极简的纯常量表达式

落地清单

  1. mode/.env 驱动 define,替换值为 JSON.stringify(value) 确保字面量语义;
  2. 为每个开关写 declare const 类型声明,尽量使用字面量联合而非 boolean
  3. switch + never 做穷尽性检查,让新增分支在编译期暴露;
  4. 构建后盯着产物体积(rollup-plugin-visualizer)确认死代码确实被剪除。

编译期常量折叠不是炫技,而是把「运行时白付的三重税」一次清零:产物更小、热路径更短、类型更诚实。下一版上线时,让那些永远 false 的功能,彻底从 bundle 里消失。

0 评论

评论区

登录 后参与评论