🚀 省流助手(速通结论):
- 物理隔离:将副作用代码(如全局初始化)单独存放在
src/setup.ts中。- 显式导出:强迫将副作用写成
export const _effect = init(),利用工具的“保守性”对抗其“激进性”。- 逻辑耦合:确保“功能被引用 = 副作用被保留”,这是最符合直觉的架构设计。
- 强制决断:配置
eslint-plugin-tree-shaking,在编码时就逼开发者决定代码是“删”、“标”还是“移”。
一、 深层博弈:为什么编译器“不敢删”而宿主“敢删”?
在工具包的构建中,存在一个有趣的信任断裂:
- 库包自身构建时:编译器(Vite/Rollup)极其保守。即便你写了
import './setup.ts'且没有任何变量被使用,它也“不敢删”,因为它无法判断这是你的误写还是必要的全局逻辑。 - 宿主打包时:宿主工具极其激进。它只信任你的
sideEffects声明。一旦你承诺了false且宿主没引用你的导出,它就拥有了“外科手术式”的物理抹除权。
这种信息差,正是所有 Tree-shaking 事故的根源。
二、 架构契约:提高“副作用”的编写成本
好的架构不应寄希望于开发者的自觉,而应通过语法结构达成契约。与其在模块顶层写下“悄悄运行”的代码,不如将其转变为显式导出。
❌ 坏代码(隐性副作用):
typescript
// 这种顶层匿名执行极易被误杀,且开发者无感知
process.on('exit', () => cleanup());
请谨慎使用此类代码。
✅ 契约代码(显式副作用):
typescript
// 利用打包工具对“函数执行并导出”的保守性
export const _exitHandler = registerExitListener();
请谨慎使用此类代码。
原理: 当你将副作用封装并导出时,即使没有加上 /* @__PURE__ */ 标记,打包工具会因为无法识别该函数的行为而选择保守保留。通过这种方式,我们利用工具的“弱点”守住了逻辑的“底线”。
三、 逻辑耦合:“捆绑销售”是最稳健的方案
在架构设计上,副作用不应孤立存在。最稳健的方案是 “功能激活 = 副作用激活” 。
如果你的副作用是为某些工具函数服务的,请将它们放在同一个文件。
- 当功能区被激活(宿主引用了同文件 API)→ 相关的副作用函数执行会被同步保留。
- 当功能区未引用 → 副作用随之消失。
这种“共生关系”避免了全局污染,也确保了按需加载的安全性。
四、 物理保障:副作用红区与独立入口
对于那些必须存在的全局副作用(如全局错误捕获),建议采取物理隔离:
- 设立红区:建立
src/_init/目录,专放初始化脚本。 - 独立打包:在
vite.config.ts中为这些文件设置独立的打包入口。 - 精准注册:在
package.json的sideEffects数组中仅指向这些编译后的产物路径。
五、 开发者决策流:ESLint 强约束
最后,我们需要一个“守门员”。通过引入 eslint-plugin-tree-shaking,在编码阶段强制开发者做出决断:
当编辑器报红提示“检测到副作用”时:
- 误写 → 删除。
- 确认为纯净计算 → 添加
/* @__PURE__ */注释(红线消失,没用时可删)。 - 确认为必要副作用 → 移入
_init/目录(该目录豁免检查,且已在白名单注册)。
结语
工程化的本质不是靠文档口头约定,而是通过物理隔离与语法规则强制,让代码的去留变得有据可查、无法误读。从防御式编程转向“契约式”架构,你的 Tree-shaking 才能真正立于不败之地。