Tree Shaking

Tree Shaking 翻译过来的意思就是“摇树”。伴随着摇树的动作,树上的枯树枝和树叶就会掉落下来。
我们这里要介绍的 Tree-shaking 也是同样的道理,不过通过 Tree-shaking “摇掉”的是代码中那些没有用到的部分,这部分没有用的代码更专业的说法应该叫作未引用代码(dead-code)。

Tree-shaking 最早是 Rollup 中推出的一个特性,Webpack 从 2.0 过后开始支持这个特性。

我们使用 Webpack 生产模式打包的优化过程中,就使用自动开启这个功能,以此来检测我们代码中的未引用代码,然后自动移除它们。

我们可以先来体验一下这个功能的效果,这里我的源代码非常简单,只有两个文件。

  1. └─ 09-tree-shaking
  2. ├── src
  3. ├── components.js
  4. └── main.js
  5. ├── package.json
  6. └── webpack.config.js

其中 components.js 中导出了一些函数,这些函数各自模拟了一个组件,具体代码如下:

  1. // ./src/components.js
  2. export const Button = () => {
  3. return document.createElement('button')
  4. console.log('dead-code')
  5. }
  6. export const Link = () => {
  7. return document.createElement('a')
  8. }
  9. export const Heading = level => {
  10. return document.createElement('h' + level)
  11. }

其中 Button 组件函数中,在 return 过后还有一个 console.log() 语句,很明显这句代码永远都不会被执行,所以这个 console.log() 就属于未引用代码。
在 main.js 文件中只是导入了 compnents.js,具体代码如下:

  1. // ./src/main.js
  2. import { Button } from './components'
  3. document.body.appendChild(Button())

但是注意这里导入 components 模块时,我们只提取了模块中的 Button 成员,那这就导致components 模块中很多地方都不会被用到,那这些地方就是冗余的,具体冗余部分如下:

  1. // ./src/components.js
  2. export const Button = () => {
  3. return document.createElement('button')
  4. // 未引用代码
  5. console.log('dead-code')
  6. }
  7. // 未引用代码
  8. export const Link = () => {
  9. return document.createElement('a')
  10. }
  11. // 未引用代码
  12. export const Heading = level => {
  13. return document.createElement('h' + level)
  14. }

去除冗余代码是生产环境优化中一个很重要的工作,Webpack 的 Tree-shaking 功能就很好地实现了这一点。
我们打开命令行终端,这里我们尝试以 production 模式运行打包,具体命令如下:

  1. $ npx webpack --mode=production

Webpack 的 Tree-shaking 特性在生产模式下会自动开启。打包完成以后我们打开输出的 bundle.js,具体结果如下:
image.png通过搜索你会发现,components 模块中冗余的代码根本没有输出。这就是经过 Tree-shaking 处理过后的效果。
试想一下,如果我们在项目中引入 Lodash 这种工具库,大部分情况下我们只会使用其中的某几个工具函数,而其他没有用到的部分就是冗余代码。通过 Tree-shaking 就可以极大地减少最终打包后 bundle 的体积。
需要注意的是,Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果,这组功能在生产模式下都会自动启用,所以使用生产模式打包就会有 Tree-shaking 的效果。

开启 Tree Shaking

由于目前官方文档中对于 Tree-shaking 的介绍有点混乱,所以我们这里再来介绍一下在其他模式下,如何一步一步手动开启 Tree-shaking。通过这个过程,还可以顺便了解 Tree-shaking 的工作过程和 Webpack 其他的一些优化功能。
这里还是上述的案例结构,我们再次运行 Webpack 打包,不过这一次我们不再使用 production 模式,而是使用 none,也就是不开启任何内置功能和插件,具体命令如下:

  1. $ npx webpack --mode=none

打包完成过后,我们再次找到输出的 bundle.js 文件,具体结果如下:
image.png这里的打包结果跟我们在第二讲中分析的是一样的,源代码中的一个模块对应这里的一个函数。
我们这里注意一下 components 对应的这个模块,虽然外部没有使用这里的 Link 函数和 Heading 函数,但是仍然导出了它们,具体如下图所示:
显然这种导出是没有任何意义的。
明确目前打包结果的状态过后,我们打开 Webpack 的配置文件,在配置对象中添加一个 optimization 属性,这个属性用来集中配置 Webpack 内置优化功能,它的值也是一个对象。
在 optimization 对象中我们可以先开启一个 usedExports 选项,表示在输出结果中只导出外部使用了的成员,具体配置代码如下:

  1. // ./webpack.config.js
  2. module.exports = {
  3. // ... 其他配置项
  4. optimization: {
  5. // 模块只导出被使用的成员
  6. usedExports: true
  7. }
  8. }

配置完成后,重新打包,然后我们再来看一下输出的 bundle.js,具体结果如下图:
image.png
此时你会发现 components 模块所对应的函数,就不再导出 Link 和 Heading 这两个函数了,那它们对应的代码就变成了未引用代码。而且如果你使用的是 VS Code,会发现 VS Code 将这两个函数名的颜色变淡了,这是为了表示它们未被引用。
对于这种未引用代码,如果我们开启压缩代码功能,就可以自动压缩掉这些没有用到的代码。
我们可以回到配置文件中,尝试在 optimization 配置中开启 minimize,具体配置如下:

  1. // ./webpack.config.js
  2. module.exports = {
  3. // ... 其他配置项
  4. optimization: {
  5. // 模块只导出被使用的成员
  6. usedExports: true,
  7. // 压缩输出结果
  8. minimize: true
  9. }
  10. }

然后再次回到命令行重新运行打包,具体结果如下图所示:
image.png
仔细查看打包结果,你会发现,Link 和 Heading 这些未引用代码都被自动移除了。
这就是 Tree-shaking 的实现,整个过程用到了 Webpack 的两个优化功能:

  • usedExports - 打包结果中只导出外部用到的成员;
  • minimize - 压缩打包结果。

如果把我们的代码看成一棵大树,那你可以这样理解:

  • usedExports 的作用就是标记树上哪些是枯树枝、枯树叶;
  • minimize 的作用就是负责把枯树枝、枯树叶摇下来。

    合并模块(扩展)

    除了 usedExports 选项之外,我们还可以使用一个 concatenateModules 选项继续优化输出。
    普通打包只是将一个模块最终放入一个单独的函数中,如果我们的模块很多,就意味着在输出结果中会有很多的模块函数。
    concatenateModules 配置的作用就是尽可能将所有模块合并到一起输出到一个函数中,这样既提升了运行效率,又减少了代码的体积。
    我们回到配置文件中,这里我们在 optimization 属性中开启 concatenateModules。同时,为了更好地看到效果,我们先关闭 minimize,具体配置如下: ```javascript // ./webpack.config.js

module.exports = { // … 其他配置项 optimization: { // 模块只导出被使用的成员 usedExports: true, // 尽可能合并每一个模块到一个函数中 concatenateModules: true, // 压缩输出结果 minimize: false } }

  1. 然后回到命令行终端再次运行打包。那此时 bundle.js 中就不再是一个模块对应一个函数了,而是把所有的模块都放到了一个函数中,具体结果如下:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/349593/1628759362688-3ead4117-6783-4069-9aab-3c5efa45b977.png#clientId=u20da40e4-0d25-4&from=paste&id=ub60e2c4e&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1520&originWidth=1484&originalType=url&ratio=1&size=188630&status=done&style=none&taskId=u2ed1a05d-04dc-4dbb-9b8f-6cc05cc9472)这个特性又被称为 Scope Hoisting,也就是作用域提升,它是 Webpack 3.0 中添加的一个特性。<br />如果再配合 minimize 选项,打包结果的体积又会减小很多。
  2. <a name="rhrfI"></a>
  3. #### 结合 babel-loader 的问题
  4. 因为早期的 Webpack 发展非常快,那变化也就比较多,所以当我们去找资料时,得到的结果不一定适用于当前我们所使用的版本。而 Tree-shaking 的资料更是如此,很多资料中都表示“_ JS 模块配置 babel-loader,会导致 Tree-shaking 失效_”。<br />针对这个问题,这里我统一说明一下:<br />首先你需要明确一点:**Tree-shaking 实现的前提是 ES Modules**,也就是说:最终**交给 Webpack 打包的代码,必须是使用 ES Modules 的方式来组织的模块化**。<br />为什么这么说呢?<br />我们都知道 Webpack 在打包所有的模块代码之前,先是将模块根据配置交给不同的 Loader 处理,最后再将 Loader 处理的结果打包到一起。<br />很多时候,我们为了更好的兼容性,会选择使用 [babel-loader](https://github.com/babel/babel-loader) 去转换我们源代码中的一些 ECMAScript 的新特性。而 Babel 在转换 JS 代码时,很有可能处理掉我们代码中的 ES Modules 部分,把它们转换成 CommonJS 的方式,如下图所示:<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/349593/1628759414173-3f5d38b5-4629-415a-a10a-a7d3fc794179.png#clientId=u20da40e4-0d25-4&from=paste&id=u06489fa9&margin=%5Bobject%20Object%5D&name=image.png&originHeight=112&originWidth=1025&originalType=url&ratio=1&size=19783&status=done&style=none&taskId=u8fea16aa-d52c-4d4c-a4d1-b2f01d059a7)当然了,Babel 具体会不会处理 ES Modules 代码,取决于我们有没有为它配置使用转换 ES Modules 的插件。<br />很多时候,我们为 Babel 配置的都是一个 preset(预设插件集合),而不是某些具体的插件。例如,目前市面上使用最多的 [@babel/preset-env](https://babeljs.io/docs/en/babel-preset-env),这个预设里面就有[转换 ES Modules 的插件](https://babeljs.io/docs/en/babel-plugin-transform-modules-commonjs)。所以当我们使用这个预设时,代码中的 ES Modules 部分就会被转换成 CommonJS 方式。那 Webpack 再去打包时,拿到的就是以 CommonJS 方式组织的代码了,所以 Tree-shaking 不能生效。<br />那我们这里具体来尝试一下。为了可以更容易分辨结果,我们只开启 usedExports,完整配置如下:
  5. ```javascript
  6. // ./webpack.config.js
  7. module.exports = {
  8. mode: 'none',
  9. entry: './src/main.js',
  10. output: {
  11. filename: 'bundle.js'
  12. },
  13. module: {
  14. rules: [
  15. {
  16. test: /\.js$/,
  17. use: {
  18. loader: 'babel-loader',
  19. options: {
  20. presets: [
  21. ['@babel/preset-env']
  22. ]
  23. }
  24. }
  25. }
  26. ]
  27. },
  28. optimization: {
  29. usedExports: true
  30. }
  31. }

配置完成过后,我们打开命令行终端,运行 Webpack 打包命令,然后再找到 bundle.js,具体结果如下:
image.png仔细查看你会发现,结果并不是像刚刚说的那样,这里 usedExports 功能仍然正常工作了,此时,如果我们压缩代码,这些未引用的代码依然会被移除。这也就说明 Tree-shaking 并没有失效。
那到底是怎么回事呢?为什么很多资料都说 babel-loader 会导致 Tree-shaking 失效,但当我们实际尝试后又发现并没有失效?
其实,这是因为在最新版本(8.x)的 babel-loader 中,已经自动帮我们关闭了对 ES Modules 转换的插件,你可以参考对应版本 babel-loader 的源码,核心代码如下:

image.png通过查阅 babel-loader 模块的源码,我们发现它已经在 injectCaller 函数中标识了当前环境支持 ES Modules。
然后再找到我们所使用的 @babal/preset-env 模块源码,部分核心代码如下:
image.png在这个模块中,根据环境标识自动禁用了对 ES Modules 的转换插件,所以经过 babel-loader 处理后的代码默认仍然是 ES Modules,那 Webpack 最终打包得到的还是 ES Modules 代码,Tree-shaking 自然也就可以正常工作了
我们也可以在 babel-loader 的配置中强制开启 ES Modules 转换插件来试一下,具体配置如下:

// ./webpack.config.js
module.exports = {
  mode: 'none',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { modules: 'commonjs' }]
            ]
          }
        }
      }
    ]
  },
  optimization: {
    usedExports: true
  }
}

给 Babel preset 添加配置的方式比较特别,这里很多人都会配错,一定要注意。它需要把预设数组中的成员定义成一个数组,然后这个数组中的第一个成员就是所使用的 preset 的名称,第二个成员就是给这个 preset 定义的配置对象。
我们在这个对象中将 modules 属性设置为 “commonjs”,默认这个属性是 auto,也就是根据环境判断是否开启 ES Modules 插件,我们设置为 commonjs 就表示我们强制使用 Babel 的 ES Modules 插件把代码中的 ES Modules 转换为 CommonJS。
完成以后,我们再次打开命令行终端,运行 Webpack 打包。然后找到 bundle.js,结果如下:
image.png此时,你就会发现 usedExports 没法生效了。即便我们开启压缩代码,Tree-shaking 也会失效。
总结一下,这里通过实验发现,最新版本的 babel-loader 并不会导致 Tree-shaking 失效。如果你不确定现在使用的 babel-loader 会不会导致这个问题,最简单的办法就是在配置中将 @babel/preset-env 的 modules 属性设置为 false,确保不会转换 ES Modules,也就确保了 Tree-shaking 的前提。
另外,我们刚刚探索的过程也值得你仔细再去琢磨一下,通过这样的探索能够帮助你了解很多背后的原因,做到“知其然,知其所以然”。