5.2 特性开关与死代码消除
上一节把 src/ 的依赖方向管住了,但「边界干净」不等于「产物干净」。一个模块即使被正确分层,只要它被入口 import 到,就会被打进包——哪怕这个功能在移动端构建里根本不该存在。本节要做的,是把「这个模块要不要进包」也变成一条可执行的规则。
答案不是运行期的 if,而是构建期常量。让打包器在压缩前就知道某个分支永远为假,它会把分支连同分支独占的模块一起删掉,这就是死代码消除(DCE)。helio 用它来切阴影、后处理、2D 叠层这些「按目标平台可选」的重模块。
为什么必须在构建期做?因为「可选」这件事在游戏里通常有两个截然不同的含义。一种是同一个包在运行时决定要不要跑某功能——比如玩家在设置里关掉阴影,这时代码必须留在包里,只是不执行。另一种是这个平台根本不需要这份代码——移动端小包不做级联阴影,那这段代码连字节都不该出现。前者用运行期开关,后者必须用构建期开关。本节只做后者,因为它才是决定下载体积的那一半。
判断标准很简单:如果关掉这个功能后,你永远不会再在某个场景下需要它,那就是构建期开关;如果只是「默认关、玩家可开」,那它属于运行期配置。把两者混在一个 if 里,就会既省不了体积、又丢掉了运行期灵活性。
两种开关:模块常量与构建期 define
开关有两种写法,用途不同,混用会踩坑:
| 写法 | 例子 | 谁负责折叠 | 适合 |
|---|---|---|---|
| 模块常量 | export const FEATURE_SHADOWS = false | 打包器的跨模块常量传播 | 长期固定、随平台定制的功能 |
| 构建期 define | transform.define: { __FEATURE_X__: 'false' } | 打包器替换标识符后再摇 | 由 CI 环境变量或构建配置驱动的开关 |
两者能生效的前提是同一条:被判断的必须是编译期就能确定的常量。if (process.env.FEATURE_X) 不行——process.env 是运行期对象,打包器不敢假设它的值;if (readFlagFromConfig()) 更不行,那是函数调用。只有写成 if (FEATURE_SHADOWS)、且 FEATURE_SHADOWS 能被证明是字面量 true/false 时,DCE 才会动手。
模块常量这条路还有一个隐蔽的好处:它同时约束了谁能开这个开关。FEATURE_SHADOWS 只有一个定义处,改它必须改那一行,diff 里一眼可见;而散落在各处的 process.env 判断,谁都可以偷偷加一个。
define 那条路的形状长这样,它把环境变量在打包时就固化成字面量:
// snippet: rolldown 的 transform.define,把环境变量固化成字面量
const shipPostfx = process.env.SHIP_POSTFX === '1';
const bundle = await build({
input: 'src/apps/feature-frame.ts',
output: { format: 'esm' },
transform: { define: { __FEATURE_POSTFX__: String(shipPostfx) } },
});
源码里写 if (__FEATURE_POSTFX__),打包器先把它替换成 if (true) 或 if (false),再走同一套 DCE。注意 String(shipPostfx) 这一步:define 的值必须是源码文本,传布尔值会得到 "false" 这个字符串字面量,判断永远为真——这是最容易踩的一个坑。
两条路选哪条?默认用模块常量:它可被 tsc 检查、可被单测覆盖、diff 友好。只有当开关确实来自 CI 环境(比如同一条流水线出「完整版」和「试玩版」两个包)时,才用 define,并且把 define 的取值集中在一个构建脚本里,不要散落。
可运行产物:四种开关组合的产物体积
先看开关本身。三个开关都是模块级 const 字面量,另配一个 activeFeatures() 供运行期遥测。
// file: src/core/feature-flags.ts
// tier: L1
// verify: npx vitest run src/core/feature-flags.test.ts
//
// helio 工程化:构建期特性开关。
// 每个开关是模块级 const 字面量。打包器把它内联进 `if (FEATURE_X)`,
// 于是 false 分支连同「只被该分支用到的 import」一起被摇掉。
// 改一行字面量 + 重新打包 = 出包体积变化,这就是死代码消除。
export const FEATURE_SHADOWS = true;
export const FEATURE_POSTFX = true;
export const FEATURE_UI2D = false;
/** 运行期遥测用:列出这次构建真正编进去的特性。 */
export function activeFeatures(): string[] {
const out: string[] = [];
if (FEATURE_SHADOWS) out.push('shadows');
if (FEATURE_POSTFX) out.push('postfx');
if (FEATURE_UI2D) out.push('ui2d');
return out;
}
开关本身也要单测,否则「开关是不是真的布尔字面量」只能靠肉眼确认——而它恰恰是 DCE 能否生效的前提。
// file: src/core/feature-flags.test.ts
// tier: L1
// verify: npx vitest run src/core/feature-flags.test.ts
import { describe, expect, it } from 'vitest';
import {
FEATURE_POSTFX,
FEATURE_SHADOWS,
FEATURE_UI2D,
activeFeatures,
} from './feature-flags.ts';
describe('feature-flags', () => {
it('开关是布尔字面量,可被打包器常量折叠', () => {
expect(typeof FEATURE_SHADOWS).toBe('boolean');
expect(typeof FEATURE_POSTFX).toBe('boolean');
expect(typeof FEATURE_UI2D).toBe('boolean');
});
it('activeFeatures 只列出编进来的特性', () => {
expect(activeFeatures()).toEqual(['shadows', 'postfx']);
expect(activeFeatures()).not.toContain('ui2d');
});
});
入口按开关决定这一帧要跑哪些 pass。关键约定:每个重模块只被对应的那个 if 分支引用,否则它就会被别的分支拖住、摇不掉。
// file: src/apps/feature-frame.ts
// tier: L1
// verify: node tools/feature-bundle.mjs
//
// 帧组装入口:按编译期开关决定这一帧要跑哪些 pass。
// 每个 if 里的重模块都只被该分支引用,关掉开关它就会从产物里消失。
import { FEATURE_SHADOWS, FEATURE_POSTFX, FEATURE_UI2D } from '../core/feature-flags.ts';
import { buildShadowCascades } from '../render/shadow/shadow-cascade-build.ts';
import { bloomPass } from '../render/postfx/bloom-pass.ts';
import { buildOverlay } from '../render/ui2d/ui2d-overlay.ts';
const LUMA = new Float32Array(256);
export function buildFrame(): string[] {
const passes: string[] = ['base'];
if (FEATURE_SHADOWS) {
passes.push(`shadow:${buildShadowCascades(4, 200).length}`);
}
if (FEATURE_POSTFX) {
passes.push(`bloom:${bloomPass(LUMA, 0.8).length}`);
}
if (FEATURE_UI2D) {
passes.push(`ui2d:${buildOverlay(['hud', 'menu']).length}`);
}
return passes;
}
bloom-pass.ts 就是被 FEATURE_POSTFX 独占的重模块示例。它在源码里是一个完整的文件,但关掉开关后整个文件都不会出现在产物里。
// file: src/render/postfx/bloom-pass.ts
// tier: L1
// verify: node tools/feature-bundle.mjs
//
// 重模块示例:泛光。只有开后处理的构建才该带上它。
export function bloomPass(luma: Float32Array, threshold: number): Float32Array {
const out = new Float32Array(luma.length);
for (let i = 0; i < luma.length; i++) {
out[i] = Math.max(0, luma[i] - threshold);
}
return out;
}
打包器用 rolldown(Vite 8 的内置打包器,node_modules 里就有)。测量工具的做法是给打包器挂一个插件,把 feature-flags.ts 的内容按组合替换成字面量常量——等价于「改一行开关 + 重新打包」,只是不用手改文件。
// file: tools/feature-bundle.mjs
// tier: L1
// verify: node tools/feature-bundle.mjs
//
// helio 工程化:用不同特性开关组合打包同一个入口,打印产物体积。
// 做法是给打包器挂一个插件,把 src/core/feature-flags.ts 的内容按组合
// 替换成字面量常量;打包器的 tree-shaking 负责把死分支和它的独占模块摇掉。
import { build } from 'rolldown';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const ENTRY = resolve(ROOT, 'src/apps/feature-frame.ts');
const FLAGS = resolve(ROOT, 'src/core/feature-flags.ts');
/** 用字面量常量覆盖 flags 模块,模拟「改一行开关 + 重新打包」。 */
function flagPlugin(flags) {
return {
name: 'flag-override',
load(id) {
if (id !== FLAGS) return null;
return [
`export const FEATURE_SHADOWS = ${flags.shadows};`,
`export const FEATURE_POSTFX = ${flags.postfx};`,
`export const FEATURE_UI2D = ${flags.ui2d};`,
'export function activeFeatures() { return []; }',
].join('\n');
},
};
}
const combos = [
{ label: 'all-on', shadows: true, postfx: true, ui2d: true },
{ label: 'no-ui2d', shadows: true, postfx: true, ui2d: false },
{ label: 'shadows-only', shadows: true, postfx: false, ui2d: false },
{ label: 'mobile-min', shadows: false, postfx: false, ui2d: false },
];
const rows = [];
for (const combo of combos) {
const bundle = await build({
input: ENTRY,
output: { format: 'esm' },
platform: 'node',
treeshake: true,
plugins: [flagPlugin(combo)],
});
const code = bundle.output[0].code;
rows.push({ label: combo.label, bytes: Buffer.byteLength(code), lines: code.split('\n').length - 1 });
}
const base = rows[0].bytes;
console.log('combo bytes vs all-on');
for (const r of rows) {
const pct = (((r.bytes - base) / base) * 100).toFixed(1);
console.log(`${r.label.padEnd(14)} ${String(r.bytes).padStart(5)} ${pct.padStart(6)}%`);
}
本机实跑(node tools/feature-bundle.mjs):
combo bytes vs all-on
all-on 1140 0.0%
no-ui2d 903 -20.8%
shadows-only 515 -54.8%
mobile-min 80 -93.0%
四组数字来自同一次运行,可复现。全开 1140 字节,只关掉 2D 叠层降到 903(-20.8%),再关后处理降到 515(-54.8%),全关只剩 80 字节(-93.0%)。移动端最小包的实际产物长这样:
function buildFrame() {
return ["base"];
}
export { buildFrame };
buildShadowCascades、bloomPass、buildOverlay 三个模块整体消失,连 LUMA 这个 Float32Array 常量都不再分配——因为 bloomPass 被摇掉后它成了无人引用的纯值,打包器顺手把初始化也删了。这不是「运行期少跑一个 if」,是代码根本没进包。
80 字节这个数字本身不重要(真实引擎里这几个 pass 会有几百行),重要的是它证明了链路是通的:开关 → 常量折叠 → 分支删除 → 模块图收缩 → 产物体积下降,五步都在同一条流水线上自动完成。以后每加一个可选特性,只要遵守「只被一个分支引用 + 顶层无副作用」,它就会自动获得同样的待遇,不需要谁记得手动去删。
反过来,测量工具的价值在于把「裁剪有没有生效」从主观判断变成一行数字。没有它,你只能靠肉眼看 bundle 里有没有那段代码;有了它,CI 里跑一次就能发现「这次改动让移动端包大了 12%」。本节的 tools/feature-bundle.mjs 正是为此存在——它是体积回归测试的原型。
开关在源码态也能直跑,只是那时三个模块都在,属于开发期行为:
$ node -e "import('./src/apps/feature-frame.ts').then(m=>console.log(m.buildFrame().join(' | ')))"
base | shadow:4 | bloom:256
摇不掉的三种情况
DCE 不是魔法,下面三种写法会让重模块顽固地留在包里:
- 间接引用:
const passes = [FEATURE_UI2D ? buildOverlay(['hud']) : []]看着没问题,但如果buildOverlay同时出现在别的地方(比如一个调试函数),它就有了第二个引用点,摇不掉。 - 副作用:模块顶层只要有一句
registry.register('bloom', bloomPass),打包器就默认这个模块「有副作用」,if (false)也删不掉它。要让模块可摇,就得保证顶层没有可观察的副作用。 - 运行时数据:开关值来自
localStorage或 URL 参数时,它就不是常量了。这类「运行期特性开关」在游戏里确实有用(比如后台动态关掉某个特效),但它和本节的构建期开关是两码事,别指望它省体积。
把这三条反过来读,就是写可裁剪模块的三条纪律:重模块只被一个分支引用、顶层零副作用、开关是字面量常量。
还有一条工程纪律值得单独说:开关要有生命周期。一个特性开关如果永远开着,它就不该是开关,而应该是普通代码——留着它只会让每个 if 都多一层阅读成本。helio 的做法是给每个开关记一个「预期下线版本」:FEATURE_UI2D 这种长期存在的平台差异开关保留,FEATURE_NEW_WATER_SHADER 这种临时开关在特性稳定后连开关带旧分支一起删掉。开关是给「过渡期」用的,不是给「永久」用的。
下一节换个方向:既然有些模块可以被裁掉,那也有些模块是外部作者加进来的。我们做一套插件系统,并给引擎版本和插件接口版本定一套不破坏兼容的规则。
阅读导航:上一节:5.1 模块边界与依赖方向 · 下一节:5.3 插件系统与引擎版本 / API 稳定性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。