前端开发··1 阅读·预计 15 分钟

Vite Tree Shaking 深度剖析:从 ESM 静态分析到 sideEffects 的完整链路

引言

Tree Shaking 是前端性能优化中被提及最多却又最容易被误解的技术之一。很多开发者认为只要用了 Vite(底层 Rollup),import 的模块就会自动被「摇掉」。然而在实际项目中,打包产物经常包含大量预期之外的死代码。本文将从一个真实案例出发,追踪 Vite 中 Tree Shaking 的完整链路。

1. 前置知识:Tree Shaking 的前提条件

Tree Shaking 依赖三个前提:

  • ESM 静态导入/导出import / export 语法必须在编译期可分析,这排除了 CJS 的 require()
  • 构建工具的 Dead Code Elimination (DCE):Rollup / esbuild / Terser 等;
  • 模块无副作用声明:告诉构建工具「这个模块的顶层代码没有副作用,未使用的导出可以安全删除」。

三条缺一不可。这里给出一个最小示例:

// math.js
export const add = (a, b) => a + b
export const sub = (a, b) => a - b
export const mul = (a, b) => a * b
// main.js
import { add } from './math'
console.log(add(1, 2))

在 Vite 生产构建中,submul 会被移除。这是最理想的情况——所有导出都是纯函数、无顶层副作用。

但现实代码远比这复杂。

2. 副作用判定的核心机制

Rollup 判定模块「有副作用」的规则非常具体:

2.1 顶层副作用代码

// logger.js — 反例
export const log = (msg) => console.log(msg)
console.log('logger module loaded')     // ⚠️ 顶层调用
window.__LOGGER_INSTALLED__ = true       // ⚠️ 顶层赋值

即使 main.js 完全没有 import logger.js 的任何导出,Rollup 仍然会保留 console.logwindow 赋值这两行——因为它们是模块加载时的副作用,删除会改变程序行为。

修复方式:将副作用包裹在函数中,或标记 "sideEffects": false

// logger.js — 正例
export const log = (msg) => console.log(msg)
export const install = () => {
  window.__LOGGER_INSTALLED__ = true
}
// 所有顶层代码已被移除,模块自身无副作用

💡 一个模块只要包含任何顶层副作用,就会连带它的所有导出一起被打包,即便只引入其中一个。

2.2 package.json 中的 sideEffects 字段

package.jsonsideEffects 字段是 Tree Shaking 的「安全声明」:

{
  "name": "my-lib",
  "sideEffects": false
}

声明 false 意味着该包内所有模块都没有顶层副作用,Rollup 可以激进地删除未使用的导出。

但有一个经典反例——CSS 导入:

{
  "sideEffects": ["*.css", "*.scss"]
}

因为 import './style.css' 在 ESM 中被视为副作用导入(只执行、不使用导出),如果 sideEffects: false,这个 import 会被删除,样式丢失。

3. Barrel Export 的死代码陷阱

Barrel 文件(即 index.js 统一导出子模块)在前端项目中极为常见,但它对 Tree Shaking 非常不友好:

// components/index.js — Barrel 文件
export { Button } from './Button'
export { Modal } from './Modal'
export { Table } from './Table'
export { Dropdown } from './Dropdown'
// App.jsx
import { Button } from './components'

问题在于:Rollup 在做静态分析时并不清楚 ./Modal./Table./Dropdown 是否有特殊副作用。在 sideEffects 未设置或设置不当的情况下,Rollup 会选择保守策略——将它们全部打包。这就是为什么你明明只用了一个 Button,产物里却出现了所有组件。

验证实验

我在一个 Vite + React 项目中做了对照实验。项目结构如下:

src/components/
  Button.tsx    (1.2 KB)
  Modal.tsx     (4.8 KB)
  Table.tsx     (6.3 KB)
  Dropdown.tsx  (3.1 KB)
  index.ts      (Barrel)

对照组 A(Barrel + 无 sideEffects 声明):

// App.tsx 只 import Button
import { Button } from './components'

构建结果:产物 47.3 KB(所有组件都被打包)

对照组 B(直接路径导入):

// App.tsx 直接 import
import { Button } from './components/Button'

构建结果:产物 38.1 KB(仅 Button 被打包)

对照组 C(Barrel + sideEffects: false):

// package.json
{ "sideEffects": false }

构建结果:产物 38.1 KB(与 B 相同)

对比结论清晰:Barrel 文件本质依赖 sideEffects 声明来告知 Rollup「未被导入的子模块是安全的」。

4. /*#__PURE__*/ 标注实战

有时候副作用出现在函数调用参数位置,Rollup 无法自动推断其纯性:

// 反例 — Rollup 保守保留
const store = createStore(reducer, applyMiddleware(thunk))
// createStore 的返回值可能被判定为「有副作用」,即使 store 未被使用

export const getState = () => store.getState()
// 正例 — 显式标注纯调用
const store = /*#__PURE__*/ createStore(reducer, applyMiddleware(thunk))
// Rollup 知道 createStore(...) 没有副作用,如果 store 未被导出引用则整体移除

export const getState = () => store.getState()

更常见的场景是框架中的辅助函数:

// Vue 3 中的 h() 调用
const vnode = /*#__PURE__*/ h('div', { class: 'container' }, [
  /*#__PURE__*/ h('span', null, 'hello')
])

这些 /*#__PURE__*/ 注释被 Rollup 识别后传递给 Terser/esbuild 做 DCE,允许在未使用时安全丢弃整个 VNode 树。

5. Class 与原型方法的 Tree Shaking 困境

class 语法对 Tree Shaking 是一个特殊挑战:

// 反例 — 整个 class 不可分割
export class Utils {
  static formatDate(d) { /* ... */ }
  static formatNumber(n) { /* ... */ }
  static formatCurrency(c) { /* ... */ }
}

即使只使用了 Utils.formatDate,整个 Utils class 都会被保留,因为 Rollup 不能把 class 拆开删掉个别方法。

// 正例 — 独立命名导出
export const formatDate = (d) => { /* ... */ }
export const formatNumber = (n) => { /* ... */ }
export const formatCurrency = (c) => { /* ... */ }

现在每个函数都是独立的导出,未使用的函数会在 Tree Shaking 中安全移除。

更激进的正例 — 对象字面量 + 动态访问

// 反例 — 统一对象导出
export const Formatters = {
  date: (d) => { /* ... */ },
  number: (n) => { /* ... */ },
  currency: (c) => { /* ... */ }
}
// Formatters.date 的使用会导致整个对象被保留
// 因为 Rollup 不知道 Formatters['currency'] 是否会被动态访问

6. 在 Vite 项目中系统性排查

6.1 使用 Rollup Plugin Visualizer

npm install -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    react(),
    visualizer({ open: true, gzipSize: true })
  ]
})

构建后会生成可视化树状图,一眼就能看出哪些模块占据了意外的大体积。

6.2 模块级分析 CLI

也可以直接用 Rollup 构建 JSON 分析输出:

npx rollup src/main.js --format es \
  --plugin @rollup/plugin-node-resolve \
  --plugin @rollup/plugin-commonjs \
  --generateStats \
  --statsFile stats.json
// 统计被纳入打包但未被使用的模块
const stats = require('./stats.json')
stats.modules
  .filter(m => m.renderedLength > 0 && m.importedBindings.length === 0)
  .forEach(m => console.log(`⚠️  Potentially dead: ${m.id}`))

7. 最佳实践总结

实践说明
"sideEffects": false包的默认声明,逐模块或文件类型精确列举
避免顶层副作用不在模块顶层执行 I/O、DOM 操作、全局赋值
命名导出优于默认导出export { fn }export default {} 更利于摇树
函数级导出代替 Class每个工具函数独立导出
避免 Barrel 重导出动态模块或用 sideEffects 明确声明安全
/*#__PURE__*/ 标注对构建工具无法自动推断的纯函数调用显式标注
可视化产物用 visualizer 定期审查打包体积组成

结语

Tree Shaking 不是一根魔法棒——它是一套需要开发者与构建工具协作的约定体系。理解 ESM 的静态可分析性、副作用判定的边界、以及 Barrel / Class 等模式的反作用,才能写出真正「可摇」的代码。Vite 的零配置体验掩盖了这些细节,但当你的打包体积出现异常时,回到 Rollup 的第一性原理复盘,会比盲目引入代码分割更有效。


本文所有示例基于 Vite 5.x / Rollup 4.x,在 Chrome 与 Node.js 22 环境下验证。

0 评论

评论区

登录 后参与评论