概述

Tree-shaking的字面意思就是摇树。

一般伴随着摇树这样一个动作我们树上的这些枯树枝和树叶就会掉落下来。

那我们这里要说的Tree-shaking也是相同的道理,不过我们这里摇掉的使我们代码当中那些没有用到的部分,那这部分代码更专业的说叫未引用代码(dead-code)。

webpack生产模式优化中就有这么一个非常有用的功能,那他可以自动检测出我们代码中那些未引用的代码,然后移除掉他们,那我们这里先来体验一下这样一个功能的效果。

我们这里的代码也非常简单只有两个文件,其中components.js文件中导出了一些函数, 然后每一个函数分别模拟了一个组件。

其中button组件函数中,在return过后还执行了一个console.log语句,很明显这就属于未引用代码。

export const Button = () => {
    return document.createElement('button')
    console.log('dead-code');
}

export const Link = () => {
    return document.createElement('a')
}

export const Heading = level => {
    return document.createElement('h' + level)
}

除此之外还有一个index.js文件,那index.js文件当中就是导入了components,但是需要注意的是我们这只是导入了components当中的button这样一个成员。

这也就会导致我们代码当中,特别是components里面会有很多的地方都用不到,那这些用不到的地方对于我们打包过后的结果就是冗余的。

取出冗余代码是我们生产环境优化当中非常重要的一个工作,而webpack的Tree-shaking就很好的实现了这样一点。

import { Button } from './components'

document.body.appendChild(Button())

我们尝试以production的模式去运行打包

yarn webpack --mode production

打包完成过后我们打开bundle.js我们可以发现这些冗余的代码根本就没有输出,那这就是tree-shaking这样一个特性,工作过后的一个效果。

那tree-shaking这个功能会在生产模式下自动取开启。

使用

需要注意的是tree-shaking并不是webpack中的某一个配置选项,他是一组功能搭配使用过后的效果。

那这组功能会在生产模式下自动开启,但是由于目前官方文档当中对于tree-shaking的介绍有点混乱,所以我们这里再来介绍一下它在其他模式下如何一步一步手动的去开启。

顺便通过这样一个过程我们去了解tree-shaking的工作过程,以及一些其他的优化功能,这里我们还是以刚刚相同的一个项目。

我们再次回到命令行终端,运行webpack打包,不过这一次我们不再使用production模式而是使用none,也就是我们不开启任何内置功能和插件。

yarn webpack

打包完成过后我们来找到输出的bundle.js文件,这里的打包结果和我们之前所看到是一样的。也就是我们一个模块会对应这里的一个函数。

这里我们需要注意看一下components.js这样一个模块所对应的函数,这里的link函数和heading函数虽然外部并没有使用,但我们这里仍然是导出了。

很明显这些导出是没有意义的,我们可以借助一些优化功能把他们去掉,我们打开webpack的配置文件。

这里我们在配置文件中添加一个optimization的属性。那这个属性呢就是集中去配置webpack内部的一些优化功能的。

在这个属性当中我们可以先开启一个叫做usedExports选项,表示我们在输出结果中只导出那些外部使用了的成员。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    optimization: {
        usedExports: true
    }
}

完成过后我们重新回到命令行打包,我们再来看下输出的bundle.js。

此时就会发现,components模块所对应的这个函数中就不再会去导出link和heading这两个函数了,而且我们vscode也非常友好的讲这两个函数的字体变淡,表示他们未被使用。

此时我们就可以去开启webpack的代码压缩功能,去压缩掉这些没有用到的代码。

我们再回到配置文件当中,这里我们再optimization中取开启minimize,然后我们回到命令行再次打包。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    optimization: {
        usedExports: true,
        minimize: true
    }
}

此时我们bundle.js当中这些未引用的代码就都被移除掉了,这就是tree-shaking的实现。整个过程我们用到了两个优化功能,一个是usedExports另一个是minimize。

如果说真的把我们的代码看做一棵大树的话,那你可以理解成usedExports就是用来在这个大树上标记哪些是枯树叶枯树枝,然后minimize就是负责把这些枯树叶树枝全都摇下来。

除了usedExports以外,我们还可以使用一个concatenteModules属性去继续优化我们的输出,普通的打包结果是将我们每一个模块最终放在一个单独的函数当中,这样的话如果我们的模块很多也就意味着我们在输出结果中会有很多这样的模块函数。

我们回到配置文件当中,这里开启concatenateModules, 为了可以更好的看到效果我们这里先去关掉minimize,然后我们重新打包。

module.exports = {
    mode: 'none',
    entry: './src/index.js',
    output: {
        filename: 'bundle.js'
    },
    optimization: {
        usedExports: true,
        concatenateModules: true,
        // minimize: true
    }
}

此时我们bundle.js当中就不再是一个模块对应一个函数了,而是把所有的模块都放到了同一个函数当中,那concatnateModules这样一个配置的作用呢就是尽可能将所有的模块全部合并到一起然后输出到一个函数中。

这样的话既提升了运行效率又减少了代码体积。那这个特性又被称之为Scope Hoisting 也就是作用域提升,他是webpack3中去添加的一个特性,如果说此时我们再去配合minimize那这样的话我们的代码体积就会又减小很多。

tree-shaking & babel

由于早期webpack早期发展非常快,那变化也就比较多,所以当我们去找资料时我们得到的结果并不一定适用于我们当前所使用的版本,对于tree-shaking的资料更是如此。

很多资料中都表示如果我们使用了babel-loader就会导致tree-shaking失效。针对于这个问题,这里我们来统一说明一下。

首先大家需要明确一点的是tree-shaking的实现,他的前提是必须要使用ES Modules去组织我们的代码,也就是我们交给webpack去处理的代码他必须还是使用ES Modules的方式来去实现的模块化。

那为什么这么说呢,我们都应该知道,webpack在打包所有模块之前,他先是将模块根据配置交给不同的loader去处理,最后再将所有loader处理过后的结果打包到一起。

我们为了转换我们代码当中ECMAScript的新特性很多时候我们都会选择babel-loader去处理我们的js。

而在babel转换我们的代码时就有可能处理掉我们代码当中ES Modules 把他们转换成CommonJS,当然了,这取决于我们有没有使用转换ES Modules的插件。

例如在我们的项目当中,我们所使用的的@babel/preset-env这个插件集合,它里面就有这么个插件,所以说大preset-env这个插件集合工作的时候,我们代码当中ES Modules的部分就应该会被他转换成CommonJS的方式。

那webpack再去打包时,他拿到的代码就是以CommonJS的方式组织的代码,所以说tree-shaking他就不能生效。我们具体来尝试一下。

需要注意,我们这为了可以更容易分别结果,我们只开启usedExports, 然后我们重新打包,查看bundle.js。

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

这里你会发现我们的结果并不是像刚刚说的那样,这里usedExports功能正常的工作了,这也就说明如果我们开启压缩代码的话,那这些为引用的代码依然会被移除。那tree-shaking并没有失效。

这是因为在最新版本的babel-loader当中就已经自动帮我们关闭了ES Modules转换的插件,我们可以在node_modules当中先去找到babel-loader的模块,我们可以看一下他的源代码,他在injectcaller这个文件中已经标识了我们当前的环境是支持ES Modules的。

然后我们再去找到我们所使用的到的preset-env这个模块,在200多行可以发现,这里根据我们injectcaller中的标识禁用了ES Module的转换。

所以说webpack最终打包时他得到的还是ES Modules的代码,那tree-shaking自然也就可以正常工作了,当然了,我们这里只是定位的找到了源代码当中相关的一些信息。如果你需要仔细了解这个东西的话,那你可以再去翻看一下babel的源代码。

那我们这里也可以尝试在babel的preset配置当中强制去开启这个插件来去试一下。不过给preset添加配置的方式比较特别,很多人都容易配错。所以一定要注意。

那他需要把我们预设这个数组中的成员再次定义成一个数组,然后这个数组当中的第一个成员就是我们所使用的的preset的名称。第二个成员就是我们给这个preset定义的对象,这里不能搞错,是数组套数组。

这里我们将这个对象的modules属性设置为commonjs,那默认这个属性值是auto 也就是根据环境去判断是否开启ES Module插件。

那我们这里将它设置为commonjs也就表示我们需要强制使用babel的ES Modules插件把我们代码当中的ES Moudle转换为commonjs。

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

我们打包后查看你就会发现我们刚刚所配置的usedExports就没有办法生效了。即便我们再去开启压缩代码,那tree-shaking也是没有办法正常工作的。

总结一下,我们这里通过实验发现,最新版本的babel-loader并不会导致tree-shaking失效,如果说你不确定,最简单的办法就是在配置文件当中将preset-env当中的这个modules这个数值为false,这样就确保我们这个preset-env里面不会再去开启ES Module转换的插件。

那这样就同时确保了我们tree-shaking的一个工作的前提。

['@babel/preset-env', { modules: false}]

另外呢我们刚刚这样一个探索的过程也值得你仔细再去琢磨一下,因为通过这样的探索,你能够了解到很多知其所以然的内容。

side Effects

weboack4中还新增了一个叫做sideEffects的新特性。

它允许我们通过配置的方式去标识我们的代码是否有副作用。从而为tree-shaking提供更大的压缩空间。

副作用是指模块执行时候除了导出成员是否还做了一些其他的事情,那这样一个特性他一般只有我们在去开发一个npm模块时才会用到。

但是因为官网当中把sideEffects的介绍跟tree-shaking混到了一起,所以很多人误认为他俩是因果关系。其实他俩真的没有那么大的关系。

那我们这先把side Effects搞明白,也就能理解为什么了。

这里我们先设计一下能够让side Effects 发挥效果的一个场景,我们基于刚刚这个案例的基础之上,我们把components拆分出了多个组件文件。然后在index.js当中我们集中导出,便于外界导入。

这是一种非常常见的同类文件组织方式。我们在回到入口文件当中去导入components当中的button成员。

那这样的话就会出现一个问题,因为我们在这载入的是components目录下的这个index.js, 那index.js当中又载入了所有的组件模块。

那这就会导致我们只想载入button组件,但是所有的组件模块都会被加载执行。

查看打包结果之后你会发现所有组件的模块确实都被打包了,那side effects特性就可以用来解决此类问题。

我们打开webpack的配置文件,然后在optimization中去开启这个属性 sideEffects: true,注意这个特性在production模式下同样也会自动开启。

{
    optimization: {
        sideEffects: true
        // usedExports: true,
        // concatenateModules: true,
        // minimize: true
    }
}

我们开启这个特性过后,webpack在打包时他就会先检查当前代码所属的这个package.json当中有没有sideEffects的标识,以此来判断这个模块是否有副作用。

如果说这个模块没有副作用,那这些没有用到的模块就不再会打包。我们可以打开我们的package.json,然后我们尝试去添加一个sideEffects的字段,我们把它设置为false。

{
    "sideEffects": false
}

那这样的话就标识我们当前这个package.json所影响的这个项目,他当中所有的代码都没有副作用。那一但这些没有用到的模块他没有副作用了,他就会被移除掉。

我们打包过后查看打包的bundle.js文件, 那此时我们那些没有用到的模块就不再会被打包进来了,这就是sideEffects的作用。

注意我们这里设置了两个地方,我们先在webpack的配置当中去开启了sideEffects,他是用来去开启这个功能,而在package.json当中我们添加sideEffects他是用来标识我们的代码是没有副作用的,他俩不是一个意思,不要弄混了。

sideEffects注意事项

使用sideEffects这个功能的前提就是确定你的代码没有副作用。否则的话在webpack打包时就会误删掉那些有副作用的代码。

例如我们准备了一个extend.js这样一个文件。在这个文件当中我们并没有向外导出任何成员。他仅仅是在Number这个对象的原型上挂载了一个pad方法,用来为数字去添加前面的导0。

Number.prototype.pad = functuon(size) {
    let result = this + '';
    while (result.lengtj < size>) {
        result = '0' + result
    }
    return result;
}

这是一种非常常见的基于原型的扩展方法,我们回到入口文件去导入这个extend.js, 我们导入了这个模块过后我们就可以使用他为Number所提供的扩展方法。

import './extend.js';

console.log((8).pad(3));

这里为Number做扩展方法的这样一个操作就属于我们extend这个模块的副作用,因为在导入了这个模块过后,我们的Number的原型上就多了一个方法,这就是副作用。

那此时如果我们还标识我们项目当中所有代码都没有副作用的话,那打包之后我们就会发现,我们刚刚的扩展操作他是不会被打包进来的。因为他是副作用代码。但是你在你的配置当中已经声明了没有副作用。所以说他们就被移除掉了。

那除此之外呢还有我们再代码当中载入的css模块,他也都属于副作用模块,同样会面临刚刚这样一种问题。

那解决的办法就是在我们的package.json当中去关掉副作用,或者是标识一下我们当前这个项目当中哪一些文件是有副作用的,那这样的话webpack就不会去忽略这些有副作用的模块了。

我们可以打开package.json,我们吧sideEffects的false改成一个数组。然后我们再去添加一下extend.js这个文件的路径,还有我们global.css这个文件的路径,当然了这里我们可以使用路径通配符的方式来去配置。*.css。

{
    "sideEffects": [
        "./src/extend.js",
        "./src/global.css"
    ]
}

此时我们再来找到打包结果我们就会发现,这个有副作用的两个模块也会被同时打包进来了。

那以上就是我们对webpack内置的一些优化属性的一些介绍,总之这些特性呢,他都是为了弥补javascript早起在设计上的一些遗留问题,那随着像webpack这一类技术的发展,javascript确实越来越好。