Why

日常开发中常有同学提出包体积的问题,那倒底 tree shaking 做了些什么,怎么做的,了解的并不清楚。
package.json 中 sideEffects 字段怎么影响 tree shaking 的,很少有资料提及。
由于日常绝大多数时候使用是的 webpack,后面也主要讨论 webpack 怎么做 tree shaking 的。

What

Tree shaking

Tree shaking (Dead Code Elimination)最初是由 rollup 提出并实现的一种优化包体积的方法,他利用 ESModule 的特性在打包时进行静态分析,去掉未用到的 module exports;后来通常用来指删除一切Dead Code ,包括:unused exports, unused variable, unused statements 等。
如果把整体要 tree shaking 的代码看 AST ,tree shaking 就是一种剪枝(pruning)算法,剪掉 AST 中未使用到的枝干。

Dead code

Dead code 作为 tree shaking 的对象,在 tree shaking 各种资料中经常会出现,有必要先懂明白 dead code 到底指什么呢?
在 tree shaking 上下文中指:Dead code = unreachable code + unused code

The term dead code has multiple definitions. Some use the term to refer to code (i.e. instructions in memory) which can never be executed at run-time.([1])([2])([3]) In some areas of computer programming, dead code is a section in the source code of a program which is executed but whose result is never used in any other computation.([4])([5]) The execution of dead code wastes computation time and memory.

From: https://en.wikipedia.org/wiki/Dead_code

Unused-code sample

  1. const a = 'a'
  2. const b = () => 'hello b' // all line is unused, but excuted
  3. console.log(a)

Unreachable-code sample

function(){
  return 123;
  console.log('hello world!') // unreachable 
}()

if(false) { // unreachable 
  xxxx
}

注意在其他上下文中 dead code 表示的意思可能有所不同,如 UglifyJS 中就分别用两个参数对应两种情况:

  • dead_code (default: true) — remove unreachable code
  • unused (default: true) — drop unreferenced functions and variables (simple direct variable assignments do not count as references unless set to “keep_assign”)

    How

    Tree shaking 整体有三个关键动作,逐步进行优化:
  1. 针对 modules(files) 进行优化,删除 unused modules,可能是单个 module,也可能直接删除以此 module 为 root 的整个依赖子树。
  2. 标记 used exports,为后面 minimizer 进一步优化做准备
  3. 删除 dead code,包括 unused variables, unused statements, unreachable code 等

在 webpack 中,1、2 都由 webpack 完成,3 由 terser 完成(也可能是别的 minimizer 工具,如 Uglify),大致过程为:
image.png

Pruning modules

Webpack 根据当前 module 的三个特征判断要不要把 module 加入到最终的 bundle:

  • 是否有 sideEffect
  • 是否有直接导出被使用
  • 是否有间接导出被使用
是否有 sideEffects 或
是否有直接导出被使用
是否有间接导出被使用
x 包含 module
不包含 module,但进一步分析间接依赖的 module
不包含 module 及 module 的所有依赖,剪掉整个子树

Module 的 sideEffects 如何判断?
Webpack 通过引用的包的 package.json 中 sideEffects 字段来判断,如果没有 sideEffects 字段就当作都有 sideEffects,针对 module 级别的 tree shaking 就不会生效。

试验
得益于 webpack 良好的配置,可以通过配置文件关闭第 3 步 minimer 的工作,来看看 webpack 怎么做 module 的 tree shaking。

# 测试环境
# "webpack": "^5.64.1",
# "webpack-cli": "^4.9.1"

# 目录结构
├── biz
│   ├── dist
│   │   └── main.js
│   ├── index.js
│   ├── package.json
│   └── webpack.config.js
│ 
└── foo
    ├── component
    │   ├── button
    │   │   ├── button.css.js
    │   │   ├── button.js
    │   │   └── index.js
    │   └── color
    │       ├── color.css.js
    │       ├── color.js
    │       └── index.js
    ├── index.js
    └── package.json
// webpack.config.js
{
  ...
  optimization: {
    // concatenateModules: false,
    minimize: false,
  },
  ...
}
// foo/packalge.json
{
  ...
  "sideEffects": [
    "*.css.js",
    "./index.js"
  ],
}
// 为了减少打包代码,使用 .css.js 代替实际组件中 .css 文件
// foo/index.js
export { Color, ColorColor } from './component/color'
export { Button, IconButton } from './component/button'
export const ComponentIndex = 'index'

// foo/commonent/button/index.js
export * from './button'
export const buttonIndex = () => 'button index'

// foo/commonent/button/button.js
import './button.css'
export const Button = () => 'button components'
export const IconButton = () => 'icon button components'

// foo/commonent/button/button.css.js
console.log('button.css')

// foo/commonent/color/index.js
import './color.css'
export * from './color'
export const colorIndex = () => 'color index'

// foo/commonent/color/color.js
export const Color = () => 'Color components'
export const ColorColor = () => 'Color color components'

// foo/commonent/color/color.css.js
console.log('color.css for color component')

// biz/index.js
import * as all from '../foo'
console.log(all.Color)

// biz/webpack.config.js
{
  optimization: {
    // 关闭 compressor ,方面查看 webpack 做了哪些工作
    minimize: false,
  },
}

结果

└── foo
    ├── component
    │   ├── button
    │   │   ├── button.css.js  # 保留文件:有 sideEffects
    │   │   ├── button.js      # 保留文件:直接导出被使用
    │   │   └── index.js       # 去掉 index, 进一步分析使用到的间接导出模块
    │   └── color              
    │       ├── color.css.js
    │       ├── color.js
    │       └── index.js       # 去掉 index 及所有依赖
    ├── index.js               # 保留文件:有sideEffets

image.png
思考
如果 biz/index.js 使用的 color 组件,则 color.css 能不能正确引入?注意 color 的 css 文件是在 color/index.js 中引入的。

// biz/index.js
import { Color } from '../foo'
console.log(Color)

Pruning unused exports

Module 合并的情况

更多 webpack concatenate modules

在合并 modules 的情况下,所有的 modules 合并成了一个更大的 module。原来通过 import 引用的其他的 module 的 export,现在通过本地变量(module scope declaration)直接引用,unused exports 就变成了 unused variables,后续的 minimizer(如:terser)可直接处理掉。
image.png
Module 不合并的情况
不合并的情况下,Webpack 标记 used exports,unused exports 实际上不再被导出,使得 unused exports 形成一个 unused variables/statements,从而可被后续的 minimizer 处理掉。
image.png

Pruning dead code

Minimizer 进一步分析代码,删除 dead 且 no-sideEffect 的代码。Minimizer 还会一些其他的优化(如 mangle 等)这里只看与 tree shaking 有关的部分。
Minimizer 处理后:

  • IconButton 没有了
  • ComponentIndex 也没有了
  • button.css 还保留着

Tree Shaking - 图5

SideEffect

Tree shaking 中另一个很关键的词就是 sideEffect,学术定义是这样的:

In computer science, an operation, function or expression) is said to have a side effect if it modifies some state) variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the intended effect) to the invoker of the operation.

from: https://en.wikipedia.org/wiki/Dead_code

在 tree shaking 中判断有无 sideEffect 有点不一样,基本原则是:
删除的代码对功能没有影响,则认为没有 sideEffect。
以下面代码为例,module 中的代码向 module scope 外的控制台打印了文本,但若删除后对功能无影响,则可以认为是无 sideEffect 的。

// 此 module 就无 sideEffects
console.log('1234')

函数(function)的定义不会产生 sideEffect,函数的执行可能会产生 sideEffect。
例如下面代码:

const num = Math.random();

在 Terser 中会认为 Math.random() 会产生 sideEffects,因为 Math.random 方法可能被用户改写了。若想申明这 function 执行没有 sideEffects ,可以在有 function 执行前加一个 /@PURE/ 注释。

const num = /*@__PURE__*/Math.random();

Rollup

Rollup 和 webpack 有些不一样。在理念上 ,rollup 采取优化优先的策略,webpack 则是代码正确性优先;从而导致实现上的差异, webpack 依赖 package.json 中的 sideEffects 字段来判断 module 是不是有 sideEffect,如果 pcakage.json 没有写 sideEffects,webpack 则谨慎的认为这个包下的所有 module 都有 sideEffect;rollup 并不依赖 package.json 中的 sideEffects ,而是自己做了相对激进的 sideEffects 分析,Math.random() 这种 function 调用 rollup 认为是没有 sideEffect 的。

// biz/index.js
import { bar } from '../bar'
console.log(bar)

// bar/index.js
export const bar = 'bar module'
export const rand = Math.random()

image.png
Rollup 通过 node-ressolve 插件也会识别 package.json 中的 sideEffects,再综合自己对 sideEffect 的判断,偏向于选择无 sideEffect。 Package.json 中申明某个文件有 sideEffect,但自己的判断没有,则认为没有 sideEffect。
若 package.json 中申明某个文件无 sideEffect,则认为无 sideEffect。

// foo/package.json
{
...
  "sideEffects": [
    "./a.js" // a 有 sideEffect, b 无 sideEffet
  ],
...
}

// foo/a.js
window.name = 'aaa'
// foo/b.js
window.name = 'bbb' 

// biz/index.js
import '../foo/a'
import '../foo/b'

打包结果是:
image.png

Check

业务开发

业务开发过程中若发现某个包没有按预期的进行 tree shaking,可以从几个方面进行排查:

1. mode = production

检查对应用打包工具配置是否正确,webpack 需要在 mode = production 下才进行 tree shaking

2. 是否 import 了正确的代码

  • 包是否有 ESModule 产物?
  • 业务中 import 的代码是不是 ESModule 产物?
  • 包的 package.json 是否配置了 sideEffects?

    3. 检查 transformer 后的产物是不是 ESModule

    Unused exports 需要在 ESModule 的基础上做静态分析得出,babel-loader、ts-loader 等 transformer 可能会代码转成非 ESModule,导致分析失败。
    // .bablerc
    module.exports = {
      presets: [
        // 检查这里的 module 设置
        // 正确情况下是:false
        // auto 也可能正确
        // https://babeljs.io/docs/en/babel-preset-env#modules-auto
        ['@babel/env', { modules: false | 'auto' }]
      ]
    }
    // tsconfig.js
    {
    ...
    // https://www.typescriptlang.org/tsconfig/#module
    module: 'es6|es2015|es2020|es2022|esnext'
    ...
    }
    

    类库开发

    组件、类库开发的同学检查

    1. 提供 ESModule 的产物

    开放开的组件、类库是需提供了 ESModule 的产物,方便使用方最终 bundle 时进行 tree shaking。

    2. 正确配置 package.json sideEffects 字段

    Package.json 中的 sideEffects 已经成了一种实际标准, webpack、rollupesbuild等都在已使用。正确配置 package.json sideEffects 字段可提高 tree shaking 效率。

    3. 提供合理的导出粒度

    合理的导出粒度,可以方便使用方利用 tree shaking 进行有效的体积优化。 ```javascript // 👎 export const command = { “UNKNOWN_COMMAND”: 0, “MGET_MESSAGES”: 1, … // 1000 lines }

// 👍 export const MGET_MESSAGES = 0 export const MGET_MESSAGES = 1 … // 1000 lines

<a name="sfJrg"></a>
### 4. 副作用代码对哪个 module 起作用就在哪引入
副作用代码对哪个 module 起作用就在哪引入,不能让别的 module 代劳,会存在被 tree shaking 掉的风险。
```javascript
// 👎 在 color.js 中起作用,但在 index.js 中引入
// foo/commonent/color/index.js
import './color.css'
export * from './color'
export const colorIndex = () => 'color index'
// foo/commonent/color/color.js
export const Color = () => 'Color components'
export const ColorRed = () => 'Color Red components'


// 👍 在 color.js 中起作用就在 color.js 中引入
// foo/commonent/color/color.js
import './color.css'
export const Color = () => 'Color components'
export const ColorRed = () => 'Color Red components'