August 26, 2019

一切都从tree-shaking说起…

前阵子去回顾一下tree-shaking的简单原理,然后顺藤摸瓜,逐步把之前不清晰或者不明白的打包基础工具梳理了一遍。

tree-shaking

tree-shaking 就是可以把一些没有用到的代码在打包的过程剔除,进而减少最终的代码体积,例如:

1
2
3
4
5
6
// a.js
export const foo = () => {}
export const bar = () => {}

// b.js
import { foo } from './a.js'

因为b.js不包含bar函数,所以最后会被剔除,具体可以看webpack 这篇文章;但是实现tree-shaking的基础是使用ES Module;平常nodeJs所用到的CommonJs的模块定义方式暂时是不能够应用到tree-shaking。ES Module 能实现的主要原因有三个:

  1. import,export语句只能在模块顶层的语句出现
  2. import 的模块名只能是字符串常量
  3. import binding 是 immutable的

第一点大概意思是,在模块中,import 与 export 语句不能够嵌套在其他块当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ES Module
// correctly
import { foo } from './a.js'

// error
if (value) {
    import { foo } from './a.js'
}

// CommonJs
// correctly
var foo = require('./a.js').foo

// correctly too
if (value) {
    var foo = require('./a.js').foo
}

第二点的大概意思是,导入模块的时候,不能够使用变量进行拼接,只能是字符串常量:

1
2
3
4
// correctly
import foo from './a.js'
// error
import foo from 'some_module' + SUFFIX

第三点原因,可以结合对模块循环引用的处理不同,来说明一下;

CommonJs对模块的处理是:在遇到require的时候,就进入到对应模块,然后执行依赖模块的代码,引用的模块只会执行一次,后续再依赖相同模块的时候,就不会执行依赖模块的代码;

ES Module在遇到import的时候并不会去马上执行依赖模块代码,而至拿到依赖模块的变量引用,当本模块对依赖模块的变量进行计算的时候,才会根据引用去拿数据。

直接文字说明没有代码可能比较晦涩,具体可以参考阮一峰的文章解释循环引用问题。

另外对于ES Module想了解更多的,可以查看exploringjs的文章,讲解得十分清晰,而且带有例子代码。

tree-shaking大概情况就是这样子了,但是实际上,很多依赖的库为了兼容大多数情况,最后都是打包成CommonJs,所以发现很多都没什么用...当然,有部分库会分不同的入口文件,例如main就是CommonJs打包模块的入口,module表示ES Module打包的入口:

1
2
3
4
5
6
// package.json
{
    name: "package-name",
    main: "dist/index-cmd.js",
    module: "dist/index-esm.js"
}

这个时候我们需要在webpack设置优先规则:

1
2
3
4
5
6
7
8
9
// webpack.config.js
module.exports = {
    // ...
    resolve: {
        // 这是默认配置,可以根据需要进行更改
        // 解析路径的时候解析顺序从左往右优先级降低
        mainFields: ['module', 'main']
    }
}

这样子就能够处理那些导出包括ES Module 的库了。

ok,现在再回头看一下webpack的处理过程:babel => tree-shaking => 压缩;我们先从压缩的看起,无意中发现现在webpack默认的压缩工具改了!!!现在默认是:terser与对应的terser plugin,我对压缩工具的处理还停留在uglifyJs...简单看了一下terser的描述,大概意思就是,uglify-es已经停止维护了,uglify-js又不支持对ES6+的处理,所以就forkuglify-es,新建的一个库:

why-choose-terser

uglify-es is no longer maintained and uglify-js does not support ES6+.

terser is a fork of uglify-es that mostly retains API and CLI compatibility with uglify-es and uglify-js@3.

Babel

模块一开始就被babel来处理,但是默认babel会处理为CommonJs,所以配置需要更改。然后也顺便熟悉一下babel的部分插件,此处用@babel/preset-env为例:

1
2
3
4
5
6
// babel.config.js
module.exports = {
    presets: ['@babel/env', {
        modules: false // 不转换代码中的模块处理方式
    }]
}

看到这里,也顺便熟悉一下babel的@babel/preset-env@babel/plugin-transform-runtime

@babel/preset-env

@babel/preset-env 是一堆插件的组合,通常能够支持最新稳定的 js 语法,而不需要手动去配置;如果对于一些还没有完全确定的js语法,暂时不支持

It is important to note that @babel/preset-env does not support stage-x plugins.

需要注意的是,babel默认是不处理API,只支持语法,例如class语法,箭头函数语法;一些API,例如Promise(ie: ???),SetString.prototype.includes这些,默认不会转义,需要使用polyfill,这个后面就讲到。

@babel/plugin-transform-runtime

@babel/plugin-transform-runtime能够把一部分helper函数,使用模块引入的方式。

A plugin that enables the re-use of Babel's injected helper code to save on codesize.

例如在不使用的情况下(这里用class语法的helper函数作为例子):

1
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }

如果不使用@babel/plugin-transform-runtime插件的时候,这个_createClass函数,在转换的时候,每个包含class文件都会引入这个helper,到最后webpack打包的时候就会有多个这样子的函数,而最后打包的命名规则通常与文件夹与文件名有关系,例如:

1
2
3
4
5
6
// 使用前
// index.js
function index_createClass () {}

// util.js
function util_createClass () {}

最后打包出来就很多个这样类似函数,@babel/plugin-transform-runtime就是处理这种情况,能够统一引入,而不是直接把函数内容复制到文件:

1
2
// 使用后
var createClass = __webpack.require__(0) // 在文件顶部引入,0 是 webpack 定义模块的id

默认的情况下,@babel/plugin-transform-runtime插件是通过CommonJs方式引用,我们也可以改成ES Module的方式:

1
2
3
4
5
6
7
8
9
// babel.config.js
module.exports = {
    // ...
    plugins: [
        ['@babel/plugin-transform-runtime', {
            useESModules: true
        }]
    ]
}

polyfill

从babel7.4.0开始就放弃了@babel/polyfill的使用,取而代之的是使用core-js来实现polyfill,例子:

1
2
3
4
5
6
7
8
9
10
11
// babel.config.js
module.exports = {
    // ...
    presets: [
        ['@babel/env', {
            modules: false,
            corejs: 2,
            useBuiltIns: 'usage'
        }]
    ]
}

corejsuseBuiltIns需要配合使用,corejs通常可以指定两个版本2/3corejs@3版本比corejs@2厉害的地方在于,可以把实例的方法也处理了,例如String.prototype.includes,这个方法属于字符串实例的方法,如果用corejs@2是不能够对这种方法处理的,只能处理一些全局的API,例如Set,Map这些;在@babel/preset-env通过配合corejs + useBuiltIns实现的polyfill,能够根据所需要支持的浏览器(通常是.browserslistrc的内容)与浏览器不支持的API引入对应的polyfill。

例如,支持的浏览器列表中有一项是:safari > 9,而且代码中用到了Set相关,那么就会引入Set的polyfill;如果支持的浏览器列表都是非常新的chrome,那么就不会引入Set的polyfill。还有一个地方需要注意的是,preset中引入的polyfill是会污染全局的API,例如上面所说到的includes方法,会直接在原有的原型链中添加该方法。能否不污染原有的API而引入polyfill?

使用@babel/plugin-transform-runtime插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// babel.config.js
module.exports = {
    // ...
    presets: [
        ['@babel/env', {
            modules: false
        }]
    ]
    plugins: [
        ['@babel/plugin-transform-runtime', {
            corejs: 3, // 使用polyfill
            useESModules: true
        }]
    ]
}

处理完之后,原有的API会发生改变,例如:

1
2
3
4
5
6
7
// 18 只是一个模块的id,会随打包代码不同而改变
var set = __webpack_require__(18)
var set_default = __webpack_require.n(set)

var set = new Set(['foo'])
// 会转义为:
var set = new set_default.a(['foo']) // 这里举个例子,可能 a 变量会根据环境不同改变

可以看出,原有的代码,使用新的变量进行代替了,在文件顶部,则会引入对应的模块,这样子只是局部更改,没有污染全局变量;那么有什么不好的?

那就是不能根据.browserslistrc的浏览器进行按需引入,无论浏览器支持与否,都会进行引入对应的模块;假设应用只支持较新版本chrome,当使用@babel/plugin-transform-runtime配合corejs的时候,也会把已支持的API打包到最终的文件...因此有可能使得打包文件变大,所以需要根据情况进行取舍。这个issue的回答也有给到一些关于使用@babel/preset-env@babel/plugin-transform-runtime的一些建议,可以看一下。

小结

为了减少打包后的体积,首先想到到tree-shaking,但是发现现实的骨干使得情况不能那么简单,还需要配合webpack, terser, babel来处理,每一层都必不可少,了解整个打包流程才使得得到最终的减少打包体积效果...

参考文章