Skip to content

Tree-shaking 友好:ESM + sideEffects: false,确保只打包用到的函数

在现代前端工程中,包体积直接影响用户体验:更小的体积意味着更快的加载速度、更低的带宽消耗、更优的 Lighthouse 评分。而一个工具库若无法实现“按需打包”,即使功能再强大,也难以在生产环境中被接受。

这就是 Tree-shaking 的意义:通过静态分析,移除未引用的代码,确保最终打包产物中只包含真正被使用的函数。

要实现真正的 Tree-shaking 友好,不能仅靠打包工具(如 Webpack、Rollup、Vite)的默认配置,而必须从库的设计层面进行支持。其核心是两个关键机制:ESM 模块系统sideEffects: false 配置。

什么是 Tree-shaking?

Tree-shaking 并非字面意义上的“摇树”,而是一种死代码消除(Dead Code Elimination)技术。

它基于 ES6 模块的静态结构,在编译时分析模块间的导入导出关系:

  • 如果某个函数或变量未被任何模块引用,它将被标记为“可移除”。
  • 只有被 import 的代码才会进入最终打包。

例如:

ts
// utils.ts
export const debounce = (fn, delay) => { /* ... */ }
export const throttle = (fn, delay) => { /* ... */ }
export const sleep = (ms) => new Promise(r => setTimeout(r, ms))
ts
// main.ts
import { debounce } from './utils'
debounce(search, 300)

在启用 Tree-shaking 的构建流程中,throttlesleep 不会被打包,即使它们存在于 utils.ts 中。

ESM:Tree-shaking 的前提

Tree-shaking 能够工作的前提是:模块系统必须是静态的、可静态分析的

CommonJS 的局限

Node.js 传统的 require 机制是动态的:

js
// commonjs.js
const utils = require('utils')
const { debounce } = require('utils')

// 更严重的是:
const funcName = 'debounce'
const fn = require('utils')[funcName] // 动态键,无法静态分析

由于 require 可以出现在条件语句中,或通过变量动态导入,打包工具无法在编译时确定哪些代码被使用,因此无法安全地移除“未使用”的代码。

ESM 的优势

ES6 模块(ESM)的 import 语句是静态的,必须位于模块顶层,且导入的标识符必须是字面量:

ts
import { debounce } from 'radash/function'

这种静态性使得打包工具可以在不执行代码的情况下,构建出完整的依赖图谱,精确判断每个导出项的引用情况。

因此,一个支持 Tree-shaking 的工具库必须提供 ESM 构建版本

sideEffects: false:开启全量摇树

即使使用 ESM,Tree-shaking 也未必能完全生效。这是因为打包工具默认保守:它假设任何模块都可能有副作用

什么是副作用?

在模块上下文中,副作用(Side Effect)指模块在导入时执行的、除导出值之外的行为。例如:

ts
// with-side-effect.ts
console.log('This module is loaded!') // 副作用:打印日志

export const fn = () => {}

当你 import { fn } from './with-side-effect' 时,console.log 也会执行。如果打包工具移除了这个模块,副作用就消失了,可能导致程序行为改变。

因此,Webpack 等工具默认不会对有副作用的模块进行 Tree-shaking。

如何声明无副作用?

package.json 中设置:

json
{
  "sideEffects": false
}

这一配置向打包工具声明:本库的所有模块都是无副作用的,可以安全地移除未引用的代码

从此,即使模块中有顶层语句,只要其导出未被使用,整个模块都会被摇掉。

精细控制副作用

如果库中确实存在有副作用的文件(如全局样式、Polyfill),可以显式列出:

json
{
  "sideEffects": [
    "./dist/style.css",
    "./src/polyfill.ts"
  ]
}

但对于 radash 这类纯函数式工具库,所有函数都应是纯函数,模块本身也不应产生任何副作用。因此,sideEffects: false 是完全安全且必要的。

模块组织:扁平化 vs 深层路径导入

即使支持 ESM 和 sideEffects: false,模块的组织方式也直接影响 Tree-shaking 效果。

反模式:单一入口导出所有

ts
// index.ts
export * from './debounce'
export * from './throttle'
export * from './omit'
// ... 导出所有函数
ts
// main.ts
import { debounce } from 'radash'

这种写法看似方便,但存在严重问题:export * 创建了“星号绑定”,打包工具难以确定你只用了 debounce,可能被迫打包整个库。

正确模式:按功能拆分模块

src/
  function/
    debounce.ts
    throttle.ts
  object/
    omit.ts
    pick.ts
  async/
    retry.ts
    sleep.ts
  index.ts

并通过深层路径导入:

ts
import { debounce } from 'radash/function'
import { omit } from 'radash/object'

每个函数独立成模块,确保:

  1. 模块粒度足够小。
  2. 依赖关系清晰。
  3. 打包工具能精确摇掉未使用的模块。

许多现代工具库(如 lodash-esdate-fns)均采用此模式。

构建输出:同时支持 ESM 和 CJS

尽管 ESM 是 Tree-shaking 的基础,但为兼容不同环境,一个生产级工具库通常需提供多格式输出:

json
{
  "main": "./dist/cjs/index.js",  
  "module": "./dist/esm/index.js",
  "types": "./dist/types/index.d.ts"
}

构建工具(如 Rollup、Vite、tsup)可同时生成这些版本,确保:

  • Node.js 用户可通过 require 使用。
  • 前端项目可通过 import 获得 Tree-shaking 优势。

验证 Tree-shaking 是否生效

可通过以下方式验证:

1. 构建分析

使用 webpack-bundle-analyzer 或 Vite 的 build.reportCompressedSize 查看打包产物,确认未使用的函数未被包含。

2. 手动检查

在代码中只导入一个函数,然后检查打包后的代码是否包含其他函数的实现。

3. 测试副作用

确保设置 sideEffects: false 后,程序行为不变——这证明库中确实没有意外的副作用。

结语:Tree-shaking 是生产级库的底线

对于一个工具库而言,功能完整是基础,类型安全是加分项,而 Tree-shaking 友好是生产可用的底线。

ESM 提供了静态分析的可能性,sideEffects: false 解除了打包工具的顾虑,合理的模块组织确保了摇树的粒度。

当你手写 radash 时,从第一天起就应:

  • 使用 ESM 语法。
  • package.json 中声明 sideEffects: false
  • 按功能拆分模块,支持深层路径导入。
  • 避免任何模块级副作用。

最终,你的库不仅是一个函数集合,更是一个可被精确裁剪的工具箱——开发者用一个函数,就只打包一个函数。

这才是对“按需加载”最彻底的尊重。