前阵子去回顾一下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 能实现的主要原因有三个:
import
,export
语句只能在模块顶层的语句出现- import 的模块名只能是字符串常量
- 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
,新建的一个库:
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: ???),Set
,String.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'
}]
]
} |
corejs
与useBuiltIns
需要配合使用,corejs
通常可以指定两个版本2/3
;corejs@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
来处理,每一层都必不可少,了解整个打包流程才使得得到最终的减少打包体积效果...