从“防御”到“契约”:如何设计一套让 Tree-shaking 永远不翻车的架构?

25 阅读3分钟

🚀 省流助手(速通结论):

  1. 物理隔离:将副作用代码(如全局初始化)单独存放在 src/setup.ts 中。
  2. 显式导出:强迫将副作用写成 export const _effect = init(),利用工具的“保守性”对抗其“激进性”。
  3. 逻辑耦合:确保“功能被引用 = 副作用被保留”,这是最符合直觉的架构设计。
  4. 强制决断:配置 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)→ 相关的副作用函数执行会被同步保留。
  • 当功能区未引用 → 副作用随之消失。
    这种“共生关系”避免了全局污染,也确保了按需加载的安全性。 

四、 物理保障:副作用红区与独立入口 

对于那些必须存在的全局副作用(如全局错误捕获),建议采取物理隔离: 

  1. 设立红区:建立 src/_init/ 目录,专放初始化脚本。
  2. 独立打包:在 vite.config.ts 中为这些文件设置独立的打包入口
  3. 精准注册:在 package.jsonsideEffects 数组中仅指向这些编译后的产物路径。 

五、 开发者决策流:ESLint 强约束 

最后,我们需要一个“守门员”。通过引入 eslint-plugin-tree-shaking,在编码阶段强制开发者做出决断: 

当编辑器报红提示“检测到副作用”时:  

  1. 误写删除
  2. 确认为纯净计算 → 添加 /* @__PURE__ */ 注释(红线消失,没用时可删)。
  3. 确认为必要副作用 → 移入 _init/ 目录(该目录豁免检查,且已在白名单注册)。 

结语 

工程化的本质不是靠文档口头约定,而是通过物理隔离语法规则强制,让代码的去留变得有据可查、无法误读。从防御式编程转向“契约式”架构,你的 Tree-shaking 才能真正立于不败之地。