9. 玩转 Webpack 高级特性应对项目优化需求
Webpack 的两个高级特性:
- Tree Shaking
- sideEffects
它们都属于 Webpack 打包结果优化的必备特性,而且现在应用的也十分广泛。
9.1 Tree Shaking
Tree Shaking 翻译过来的意思就是“摇树”。伴随着摇树的动作,树上的枯树枝和树叶就会掉落下来。
我们这里要介绍的 Tree-shaking 也是同样的道理,不过通过 Tree-shaking “摇掉”的是代码中那些没有用到的部分,这部分没有用的代码更专业的说法应该叫作未引用代码(dead-code)。
Tree-shaking 最早是 Rollup 中推出的一个特性,Webpack 从 2.0 过后开始支持这个特性。
使用 Webpack 生产模式打包的优化过程中,就使用自动开启这个功能,以此来检测我们代码中的未引用代码,然后自动移除它们
我们可以先来体验一下这个功能的效果,这里我的源代码非常简单,只有两个文件。
└─ 09-tree-shaking
├── src
│ ├── components.js
│ └── main.js
├── package.json
└── webpack.config.js
其中 components.js 中导出了一些函数,这些函数各自模拟了一个组件,具体代码如下:
// ./src/components.js
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)
}
其中 Button 组件函数中,在 return 过后还有一个 console.log() 语句,很明显这句代码永远都不会被执行,所以这个 console.log() 就属于未引用代码。
在 main.js 文件中只是导入了 compnents.js,具体代码如下:
// ./src/main.js
import { Button } from './components'
document.body.appendChild(Button())
但是注意这里导入 components 模块时,我们只提取了模块中的 Button 成员,那这就导致components 模块中很多地方都不会被用到,那这些地方就是冗余的,具体冗余部分如下:
// ./src/components.js
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)
}
去除冗余代码是生产环境优化中一个很重要的工作,Webpack 的 Tree-shaking 功能就很好地实现了这一点。
打开命令行终端,这里我们尝试以 production 模式运行打包,具体命令如下:
$ npx webpack --mode=production
Webpack 的 Tree-shaking 特性在生产模式下会自动开启。打包完成以后我们打开输出的 bundle.js,具体结果如下:
通过搜索你会发现,components 模块中冗余的代码根本没有输出。这就是经过 Tree-shaking 处理过后的效果。
试想一下,如果我们在项目中引入 Lodash 这种工具库,大部分情况下我们只会使用其中的某几个工具函数,而其他没有用到的部分就是冗余代码。通过 Tree-shaking 就可以极大地减少最终打包后 bundle 的体积。
需要注意的是,Tree-shaking 并不是指 Webpack 中的某一个配置选项,而是一组功能搭配使用过后实现的效果,这组功能在生产模式下都会自动启用,所以使用生产模式打包就会有 Tree-shaking 的效果。
9.1.1 开启 Tree Shaking
由于目前官方文档中对于 Tree-shaking 的介绍有点混乱,所以我们这里再来介绍一下在其他模式下,如何一步一步手动开启 Tree-shaking。通过这个过程,还可以顺便了解 Tree-shaking 的工作过程和 Webpack 其他的一些优化功能。
这里还是上述的案例结构,我们再次运行 Webpack 打包,不过这一次我们不再使用 production 模式,而是使用 none,也就是不开启任何内置功能和插件,具体命令如下:
$ npx webpack --mode=none
打包完成过后,我们再次找到输出的 bundle.js 文件,具体结果如下:
这里的打包结果跟我们在第二讲中分析的是一样的,源代码中的一个模块对应这里的一个函数。
我们这里注意一下 components 对应的这个模块,虽然外部没有使用这里的 Link 函数和 Heading 函数,但是仍然导出了它们,具体如下图所示:
显然这种导出是没有任何意义的。
明确目前打包结果的状态过后,我们打开 Webpack 的配置文件,在配置对象中添加一个 optimization 属性,这个属性用来集中配置 Webpack 内置优化功能,它的值也是一个对象。
在 optimization 对象中我们可以先开启一个 usedExports 选项,表示在输出结果中只导出外部使用了的成员,具体配置代码如下:
// ./webpack.config.js
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员
usedExports: true
}
}
配置完成后,重新打包,然后我们再来看一下输出的 bundle.js,具体结果如下图:
此时你会发现 components 模块所对应的函数,就不再导出 Link 和 Heading 这两个函数了,那它们对应的代码就变成了未引用代码。而且如果你使用的是 VS Code,会发现 VS Code 将这两个函数名的颜色变淡了,这是为了表示它们未被引用。
对于这种未引用代码,如果我们开启压缩代码功能,就可以自动压缩掉这些没有用到的代码。
我们可以回到配置文件中,尝试在 optimization 配置中开启 minimize,具体配置如下:
// ./webpack.config.js
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 压缩输出结果
minimize: true
}
}
然后再次回到命令行重新运行打包,具体结果如下图所示:
仔细查看打包结果,你会发现,Link 和 Heading 这些未引用代码都被自动移除了。
这就是 Tree-shaking 的实现,整个过程用到了 Webpack 的两个优化功能:
- usedExports - 打包结果中只导出外部用到的成员
- minimize - 压缩打包结果
如果把我们的代码看成一棵大树,那你可以这样理解:
- usedExports 的作用就是标记树上哪些是枯树枝、枯树叶;
- minimize 的作用就是负责把枯树枝、枯树叶摇下来。
9.1.2 合并模块(扩展)
除了 usedExports 选项之外,我们还可以使用一个 concatenateModules 选项继续优化输出。
普通打包只是将一个模块最终放入一个单独的函数中,如果我们的模块很多,就意味着在输出结果中会有很多的模块函数。
concatenateModules配置的作用就是尽可能将所有模块合并到一起输出到一个函数中,这样既提升了运行效率,又减少了代码的体积
我们回到配置文件中,这里我们在 optimization 属性中开启 concatenateModules。同时,为了更好地看到效果,我们先关闭 minimize,具体配置如下:
// ./webpack.config.js
module.exports = {
// ... 其他配置项
optimization: {
// 模块只导出被使用的成员
usedExports: true,
// 尽可能合并每一个模块到一个函数中
concatenateModules: true,
// 压缩输出结果
minimize: false
}
}
然后回到命令行终端再次运行打包。那此时 bundle.js 中就不再是一个模块对应一个函数了,而是把所有的模块都放到了一个函数中,具体结果如下:
这个特性又被称为 Scope Hoisting,也就是作用域提升,它是 Webpack 3.0 中添加的一个特性。
如果再配合 minimize 选项,打包结果的体积又会减小很多。
9.1.3 结合 babel-loader 的问题
因为早期的 Webpack 发展非常快,那变化也就比较多,所以当我们去找资料时,得到的结果不一定适用于当前我们所使用的版本。而 Tree-shaking 的资料更是如此,很多资料中都表示“为 JS 模块配置 babel-loader,会导致 Tree-shaking 失效”。
针对这个问题,首先需要明确一点:Tree-shaking 实现的前提是 ES Modules,也就是说:最终交给 Webpack 打包的代码,必须是使用 ES Modules 的方式来组织的模块化。
为什么这么说呢?
Webpack 在打包所有的模块代码之前,先是将模块根据配置交给不同的 Loader 处理,最后再将 Loader 处理的结果打包到一起。
很多时候,为了更好的兼容性,会选择使用 babel-loader 去转换我们源代码中的一些 ECMAScript 的新特性。而 Babel 在转换 JS 代码时,很有可能处理掉我们代码中的 ES Modules 部分,把它们转换成 CommonJS 的方式,如下图所示:
当然了,Babel 具体会不会处理 ES Modules 代码,取决于我们有没有为它配置使用转换 ES Modules 的插件。
很多时候,我们为 Babel 配置的都是一个 preset(预设插件集合),而不是某些具体的插件。例如,目前市面上使用最多的 @babel/preset-env,这个预设里面就有转换 ES Modules 的插件。所以当我们使用这个预设时,代码中的 ES Modules 部分就会被转换成 CommonJS 方式。那 Webpack 再去打包时,拿到的就是以 CommonJS 方式组织的代码了,所以 Tree-shaking 不能生效。
那我们这里具体来尝试一下。为了可以更容易分辨结果,我们只开启 usedExports,完整配置如下:
// ./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']
]
}
}
}
]
},
optimization: {
usedExports: true
}
}
配置完成过后,我们打开命令行终端,运行 Webpack 打包命令,然后再找到 bundle.js,具体结果如下:
仔细查看你会发现,结果并不是像刚刚说的那样,这里 usedExports 功能仍然正常工作了,此时,如果我们压缩代码,这些未引用的代码依然会被移除。这也就说明 Tree-shaking 并没有失效。
那到底是怎么回事呢?为什么很多资料都说 babel-loader 会导致 Tree-shaking 失效,但当我们实际尝试后又发现并没有失效?
其实,这是因为在最新版本(8.x)的 babel-loader 中,已经自动帮我们关闭了对 ES Modules 转换的插件,你可以参考对应版本 babel-loader 的源码,核心代码如下:
通过查阅 babel-loader 模块的源码,我们发现它已经在 injectCaller 函数中标识了当前环境支持 ES Modules。
然后再找到我们所使用的 @babal/preset-env 模块源码,部分核心代码如下:
在这个模块中,根据环境标识自动禁用了对 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,结果如下:
此时,你就会发现 usedExports 没法生效了。即便我们开启压缩代码,Tree-shaking 也会失效。
总结一下,这里通过实验发现,最新版本的 babel-loader 并不会导致 Tree-shaking 失效。如果你不确定现在使用的 babel-loader 会不会导致这个问题,最简单的办法就是在配置中将 @babel/preset-env 的 modules 属性设置为 false,确保不会转换 ES Modules,也就确保了 Tree-shaking 的前提。
9.2. sideEffects
Webpack 4 中新增了一个 sideEffects 特性,它允许我们通过配置标识我们的代码是否有副作用,从而提供更大的压缩空间
TIPS:模块的副作用指的就是模块执行的时候除了导出成员,是否还做了其他的事情。
这个特性一般只有我们去开发一个 npm 模块时才会用到。因为官网把对 sideEffects 特性的介绍跟 Tree-shaking 混到了一起,所以很多人误认为它们之间是因果关系,其实它们没有什么太大的关系。
我们先把 sideEffects 特性本身的作用弄明白,你就更容易理解为什么说它跟 Tree-shaking 没什么关系了。
这里我先设计一个 sideEffects 能够发挥效果的场景,案例具体结构如下:
.
├── src
│ ├── components
│ │ ├── button.js
│ │ ├── heading.js
│ │ ├── index.js
│ │ └── link.js
│ └── main.js
├── package.json
└── webpack.config.js
基于上一个案例的基础上,我们把 components 模块拆分出多个组件文件,然后在 components/index.js 中集中导出,以便于外界集中导入,具体 index.js 代码如下:
// ./src/components/index.js
export { default as Button } from './button'
export { default as Link } from './link'
export { default as Heading } from './heading'
这也是我们经常见到一种同类文件的组织方式。另外,在每个组件中,我们都添加了一个 console 操作(副作用代码),具体代码如下:
// ./src/components/button.js
console.log('Button component~') // 副作用代码
export default () => {
return document.createElement('button')
}
我们再到打包入口文件(main.js)中去载入 components 中的 Button 成员,具体代码如下:
// ./src/main.js
import { Button } from './components'
document.body.appendChild(Button())
那这样就会出现一个问题,虽然我们在这里只是希望载入 Button 模块,但实际上载入的是 components/index.js,而 index.js 中又载入了这个目录中全部的组件模块,这就会导致所有组件模块都会被加载执行。
我们打开命令行终端,尝试运行打包,打包完成过后找到打包结果,具体结果如下:
根据打包结果发现,所有的组件模块都被打包进了 bundle.js。
此时如果我们开启 Tree-shaking 特性(只设置 useExports),这里没有用到的导出成员其实最终也可以被移除,打包效果如下:
但是由于这些成员所属的模块中有副作用代码,所以就导致最终 Tree-shaking 过后,这些模块并不会被完全移除。
可能你会认为这些代码应该保留下来,而实际情况是,这些模块内的副作用代码一般都是为这个模块服务的,例如这里我添加的 console.log,就是希望表示一下当前这个模块被加载了。但是最终整个模块都没用到,也就没必要留下这些副作用代码了。
所以说,Tree-shaking 只能移除没有用到的代码成员,而想要完整移除没有用到的模块,那就需要开启 sideEffects 特性了。
2.1 sideEffects 作用
我们打开 Webpack 的配置文件,在 optimization 中开启 sideEffects 特性,具体配置如下:
// ./webpack.config.js
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js'
},
optimization: {
sideEffects: true
}
}
TIPS:注意这个特性在 production 模式下同样会自动开启。
那此时 Webpack 在打包某个模块之前,会先检查这个模块所属的 package.json 中的 sideEffects 标识,以此来判断这个模块是否有副作用,如果没有副作用的话,这些没用到的模块就不再被打包。换句话说,即便这些没有用到的模块中存在一些副作用代码,我们也可以通过 package.json 中的 sideEffects 去强制声明没有副作用。
那我们打开项目 package.json 添加一个 sideEffects 字段,把它设置为 false,具体代码如下:
{
"name": "09-side-effects",
"version": "0.1.0",
"author": "zce <w@zce.me> (https://zce.me)",
"license": "MIT",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"sideEffects": false
}
这样就表示我们这个项目中的所有代码都没有副作用,让 Webpack 放心大胆地去“干”。
完成以后我们再次运行打包,然后同样找到打包输出的 bundle.js 文件,结果如下:
此时那些没有用到的模块就彻底不会被打包进来了。那这就是 sideEffects 的作用。
这里设置了两个地方:
- webpack.config.js 中的 sideEffects 用来开启这个功能
- package.json 中的 sideEffects 用来标识我们的代码没有副作用
目前很多第三方的库或者框架都已经使用了 sideEffects 标识,所以我们再也不用担心为了一个小功能引入一个很大体积的库了。例如,某个 UI 组件库中只有一两个组件会用到,那只要它支持 sideEffects,你就可以放心大胆的直接用了。
2.2 sideEffects 注意
使用 sideEffects 这个功能的前提是确定你的代码没有副作用,或者副作用代码没有全局影响,否则打包时就会误删掉你那些有意义的副作用代码。
例如,我这里准备的 extend.js 模块:
// ./src/extend.js
// 为 Number 的原型添加一个扩展方法
Number.prototype.pad = function (size) {
const leadingZeros = Array(size + 1).join(0)
return leadingZeros + this
}
在这个模块中并没有导出任何成员,仅仅是在Number的原型上挂载了一个 pad 方法,用来为数字添加前面的导零,这是一种很早以前常见的基于原型的扩展方法。
我们回到 main.js 中去导入 extend 模块,具体代码如下:
// ./src/main.js
import './extend' // 内部包含影响全局的副作用
console.log((8).pad(3)) // => '0008'
因为这个模块确实没有导出任何成员,所以这里也就不需要提取任何成员。导入过后就可以使用它为 Number 提供扩展方法了。
这里为 Number 类型做扩展的操作就是 extend 模块对全局产生的副作用。
此时如果我们还是通过 package.json 标识我们代码没有副作用,那么再次打包过后,就会出现问题。我们可以找到打包结果,如下图所示:
我们看到,对 Number 的扩展模块并不会打包进来。
缺少了对 Number 的扩展操作,我们的代码再去运行的时候,就会出现错误。这种扩展的操作属于对全局产生的副作用。
这种基于原型的扩展方式,在很多 Polyfill 库中都会大量出现,比较常见的有 es6-promise,这种模块都属于典型的副作用模块。
除此之外,在 JS 中直接载入的 CSS 模块,也都属于副作用模块,同样会面临这种问题。
所以说不是所有的副作用都应该被移除,有一些必要的副作用需要保留下来。
最好的办法就是在 package.json 中的 sideEffects 字段中标识需要保留副作用的模块路径(可以使用通配符),具体配置如下:
{
"name": "09-side-effects",
"version": "0.1.0",
"author": "zce <w@zce.me> (https://zce.me)",
"license": "MIT",
"scripts": {
"build": "webpack"
},
"devDependencies": {
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"sideEffects": [
"./src/extend.js",
"*.css"
]
}
这样 Webpack 的 sideEffects 就不会忽略确实有必要的副作用模块了。
sideEffects 总结下来其实也很简单:对全局有影响的副作用代码不能移除,而只是对模块有影响的副作用代码就可以移除。
10. 玩转 Webpack 高级特性应对项目优化需求
今天我将继续和你分享 Webpack 另外的一个高级特性,Code Splitting(分块打包)。
1. All in One 的弊端
通过 Webpack 实现前端项目整体模块化的优势固然明显,但是它也会存在一些弊端:它最终会将我们所有的代码打包到一起。试想一下,如果我们的应用非常复杂,模块非常多,那么这种 All in One 的方式就会导致打包的结果过大,甚至超过 4~5M。
在绝大多数的情况下,应用刚开始工作时,并不是所有的模块都是必需的。如果这些模块全部被打包到一起,即便应用只需要一两个模块工作,也必须先把 bundle.js 整体加载进来,而且前端应用一般都是运行在浏览器端,这也就意味着应用的响应速度会受到影响,也会浪费大量的流量和带宽。
所以这种 All in One 的方式并不合理,更为合理的方案是把打包的结果按照一定的规则分离到多个 bundle 中,然后根据应用的运行需要按需加载。这样就可以降低启动成本,提高响应速度。
可能你会联想到我们在开篇词中讲过,Webpack 就是通过把项目中散落的模块打包到一起,从而提高加载效率,那么为什么这里又要分离?这不是自相矛盾吗?
其实这并不矛盾,只是物极必反罢了。Web 应用中的资源受环境所限,太大不行,太碎更不行。因为我们开发过程中划分模块的颗粒度一般都会非常的细,很多时候一个模块只是提供了一个小工具函数,并不能形成一个完整的功能单元。
如果不将这些资源模块打包,直接按照开发过程中划分的模块颗粒度进行加载,那么运行一个小小的功能,就需要加载非常多的资源模块。
再者,目前主流的 HTTP 1.1 本身就存在一些缺陷,例如:
- 同一个域名下的并行请求是有限制的
- 每次请求本身都会有一定的延迟
- 每次请求除了传输内容,还有额外的请求头,大量请求的情况下,这些请求头加在一起也会浪费流量和带宽
综上所述,模块打包肯定是必要的,但当应用体积越来越大时,我们也要学会变通。
2. Code Splitting
为了解决打包结果过大导致的问题,Webpack 设计了一种分包功能:Code Splitting(分块打包/代码分割)
Code Splitting 通过把项目中的资源模块按照我们设计的规则打包到不同的 bundle 中,从而降低应用的启动成本,提高响应速度。
Webpack 实现分包的方式主要有两种:
- 根据业务不同配置多个打包入口,输出多个打包结果
- 结合 ES Modules 的动态导入(Dynamic Imports)特性,按需加载模块
2.1 多入口打包
多入口打包一般适用于传统的多页应用程序,最常见的划分规则就是一个页面对应一个打包入口,对于不同页面间公用的部分,再提取到公共的结果中。
Webpack 配置多入口打包的方式非常简单,这里准备了一个相应的示例,具体结构如下:
示例源码
- GitHub:github.com/zce/webpack…
- CodeSandbox:codesandbox.io/s/github/zc…
.
├── dist
├── src
│ ├── common
│ │ ├── fetch.js
│ │ └── global.css
│ ├── album.css
│ ├── album.html
│ ├── album.js
│ ├── index.css
│ ├── index.html
│ └── index.js
├── package.json
└── webpack.config.js
这个示例中有两个页面,分别是 index 和 album。代码组织的逻辑也很简单:
- index.js 负责实现 index 页面功能逻辑;
- album.js 负责实现 album 页面功能逻辑;
- global.css 是公用的样式文件;
- fetch.js 是一个公用的模块,负责请求 API。
回到配置文件中,这里我们尝试为这个案例配置多入口打包,具体配置如下:
// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
// ... 其他配置
plugins: [
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html'
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html'
})
]
}
一般 entry 属性中只会配置一个打包入口,如果我们需要配置多个入口,可以把 entry 定义成一个对象。
注意:这里 entry 是定义为对象而不是数组,如果是数组的话就是把多个文件打包到一起,还是一个入口
在这个对象中一个属性就是一个入口,属性名称就是这个入口的名称,值就是这个入口对应的文件路径。那我们这里配置的就是 index 和 album 页面所对应的 JS 文件路径。
一旦我们的入口配置为多入口形式,那输出文件名也需要修改,因为两个入口就有两个打包结果,不能都叫 bundle.js。我们可以在这里使用 [name] 这种占位符来输出动态的文件名,[name] 最终会被替换为入口的名称。
除此之外,在配置中还通过 html-webpack-plugin 分别为 index 和 album 页面生成了对应的 HTML 文件。
完成配置之后,我们就可以打开命令行终端,运行 Webpack 打包,那此次打包会有两个入口。打包完成后,我们找到输出目录,这里就能看到两个入口文件各自的打包结果了,如下图所示:
但是这里还有一个小问题,我们打开任意一个输出的 HTML 文件,具体结果如下图:
你就会发现 index 和 album 两个打包结果都被页面载入了,而我们希望的是每个页面只使用它对应的那个输出结果。
所以这里还需要修改配置文件,我们回到配置文件中,找到输出 HTML 的插件,默认这个插件会自动注入所有的打包结果,如果需要指定所使用的 bundle,可以通过 HtmlWebpackPlugin 的 chunks 属性来设置。分别为两个页面配置使用不同的 chunk,具体配置如下:
TIPS:每个打包入口都会形成一个独立的 chunk(块)。
// ./webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
// ... 其他配置
plugins: [
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/index.html',
filename: 'index.html',
chunks: ['index'] // 指定使用 index.bundle.js
}),
new HtmlWebpackPlugin({
title: 'Multi Entry',
template: './src/album.html',
filename: 'album.html',
chunks: ['album'] // 指定使用 album.bundle.js
})
]
}
完成以后我们再次回到命令行终端,然后运行打包,打包结果如下图:
这一次打包的结果就完全正常了。
那这就是配置多入口打包的方法,以及如何指定在 HTML 中注入的 bundle。
提取公共模块
多入口打包本身非常容易理解和使用,但是它也存在一个小问题,就是不同的入口中一定会存在一些公共使用的模块,如果按照目前这种多入口打包的方式,就会出现多个打包结果中有相同的模块的情况。
例如我们上述案例中,index 入口和 album 入口中就共同使用了 global.css 和 fetch.js 这两个公共的模块。这里是因为我们的示例比较简单,所以重复的影响没有那么大,但是如果我们公共使用的是 jQuery 或者 Vue.js 这些体积较大的模块,那影响就会比较大,不利于公共模块的缓存。
所以我们还需要把这些公共的模块提取到一个单独的 bundle 中。Webpack 中实现公共模块提取非常简单,我们只需要在优化配置中开启 splitChunks 功能就可以了,具体配置如下:
// ./webpack.config.js
module.exports = {
entry: {
index: './src/index.js',
album: './src/album.js'
},
output: {
filename: '[name].bundle.js' // [name] 是入口名称
},
optimization: {
splitChunks: {
// 自动提取所有公共模块到单独 bundle
chunks: 'all'
}
}
// ... 其他配置
}
我们回到配置文件中,这里在 optimization 属性中添加 splitChunks 属性,那这个属性的值是一个对象,这个对象需要配置一个 chunks 属性,我们这里将它设置为 all,表示所有公共模块都可以被提取。
完成以后我们打开命令行终端,再次运行 Webpack 打包,打包结果如下图:
此时在我们的 dist 下就会额外生成一个 JS 文件,在这个文件中就是 index 和 album 中公共的模块部分了。
除此之外,splitChunks 还支持很多高级的用法,可以实现各种各样的分包策略,这些我们可以在文档中找到对应的介绍。
2.2 动态导入
除了多入口打包的方式,Code Splitting 更常见的实现方式还是结合 ES Modules 的动态导入特性,从而实现按需加载。
按需加载是开发浏览器应用中一个非常常见的需求。一般我们常说的按需加载指的是加载数据或者加载图片,但是我们这里所说的按需加载,指的是在应用运行过程中,需要某个资源模块时,才去加载这个模块。这种方式极大地降低了应用启动时需要加载的资源体积,提高了应用的响应速度,同时也节省了带宽和流量。
Webpack 中支持使用动态导入的方式实现模块的按需加载,而且所有动态导入的模块都会被自动提取到单独的 bundle 中,从而实现分包
相比于多入口的方式,动态导入更为灵活,因为我们可以通过代码中的逻辑去控制需不需要加载某个模块,或者什么时候加载某个模块。而且我们分包的目的中,很重要的一点就是让模块实现按需加载,从而提高应用的响应速度。
接下来,我们具体来看如何使用动态导入特性,这里已经设计了一个可以发挥按需加载作用的场景,具体效果如下图所示:
在这个应用的主体区域,如果我们访问的是首页,它显示的是一个文章列表,如果我们访问的是相册页,它显示的就是相册列表。
回到代码中,我们来看目前的实现方式,具体结构如下:
.
├── src
│ ├── album
│ │ ├── album.css
│ │ └── album.js
│ ├── common
│ │ ├── fetch.js
│ │ └── global.css
│ ├── posts
│ │ ├── posts.css
│ │ └── posts.js
│ ├── index.html
│ └── index.js
├── package.json
└── webpack.config.js
文章列表对应的是这里的 posts 组件,而相册列表对应的是 album 组件。我在打包入口(index.js)中同时导入了这两个模块,然后根据页面锚点的变化决定显示哪个组件,核心代码如下:
// ./src/index.js
import posts from './posts/posts'
import album from './album/album'
const update = () => {
const hash = window.location.hash || '#posts'
const mainElement = document.querySelector('.main')
mainElement.innerHTML = ''
if (hash === '#posts') {
mainElement.appendChild(posts())
} else if (hash === '#album') {
mainElement.appendChild(album())
}
}
window.addEventListener('hashchange', update)
update()
在这种情况下,就可能产生资源浪费。试想一下:如果用户只需要访问其中一个页面,那么加载另外一个页面对应的组件就是浪费。
如果采用动态导入的方式,就不会产生浪费的问题了,因为所有的组件都是惰性加载,只有用到的时候才会去加载。具体实现代码如下:
// ./src/index.js
// import posts from './posts/posts'
// import album from './album/album'
const update = () => {
const hash = window.location.hash || '#posts'
const mainElement = document.querySelector('.main')
mainElement.innerHTML = ''
if (hash === '#posts') {
// mainElement.appendChild(posts())
import('./posts/posts').then(({ default: posts }) => {
mainElement.appendChild(posts())
})
} else if (hash === '#album') {
// mainElement.appendChild(album())
import('./album/album').then(({ default: album }) => {
mainElement.appendChild(album())
})
}
}
window.addEventListener('hashchange', update)
update()
P.S. 为了动态导入模块,可以将 import 关键字作为函数调用。当以这种方式使用时,import 函数返回一个 Promise 对象。这就是 ES Modules 标准中的 Dynamic Imports。
这里先移除 import 这种静态导入,然后在需要使用组件的地方通过 import 函数导入指定路径,那这个方法返回的是一个 Promise。在这个 Promise 的 then 方法中我们能够拿到模块对象。由于我们这里的 posts 和 album 模块是以默认成员导出,所以我们需要解构模块对象中的 default,先拿到导出成员,然后再正常使用这个导出成员。
完成以后,Webpack Dev Server 自动重新打包,再次回到浏览器,此时应用仍然是可以正常工作的。
那我们再回到命令行终端,重新运行打包,然后看看此时的打包结果具体是怎样的。打包完成以后我们打开 dist 目录,具体结果如下图所示:
此时 dist 目录下就会额外多出三个 JS 文件,其中有两个文件是动态导入的模块,另外一个文件是动态导入模块中公共的模块,这三个文件就是由动态导入自动分包产生的。
以上就是动态导入在 Webpack 中的使用。整个过程无需额外配置任何地方,只需要按照 ES Modules 动态导入的方式去导入模块就可以了,Webpack 内部会自动处理分包和按需加载。
如果使用的是 Vue.js 之类的 SPA 开发框架的话,那项目中路由映射的组件就可以通过这种动态导入的方式实现按需加载,从而实现分包。
2.3 魔法注释
默认通过动态导入产生的 bundle 文件,它的 name 就是一个序号,这并没有什么不好,因为大多数时候,在生产环境中我们根本不用关心资源文件的名称。
但是如果你还是需要给这些 bundle 命名的话,就可以使用 Webpack 所特有的魔法注释去实现。具体方式如下:
// 魔法注释
import(/* webpackChunkName: 'posts' */'./posts/posts')
.then(({ default: posts }) => {
mainElement.appendChild(posts())
})
所谓魔法注释,就是在 import 函数的形式参数位置,添加一个行内注释,这个注释有一个特定的格式:webpackChunkName: '',这样就可以给分包的 chunk 起名字了。
完成过后再次打开命令行终端,运行 Webpack 打包,那此时生成 bundle 的 name 就会使用刚刚注释中提供的名称了,具体结果如下:
除此之外,魔法注释还有个特殊用途:如果你的 chunkName 相同的话,那相同的 chunkName 最终就会被打包到一起,例如我们这里可以把这两个 chunkName 都设置为 components,然后再次运行打包,那此时这两个模块都会被打包到一个文件中,具体操作如下图所示:
借助这个特点,你就可以根据自己的实际情况,灵活组织动态加载的模块了。
参考:
- import 动态导入: developer.mozilla.org/zh-CN/docs/…
11. 如何优化 Webpack 的构建速度和打包结果
你好,我是汪磊,今天我们要一起探索的是 Webpack 在生产模式打包过程中的常用配置以及一些优化插件。
在前面的课时中,我们了解到的一些用法和特性都是为了在开发阶段能够拥有更好的开发体验。而随着这些体验的提升,一个新的问题出现在我们面前:我们的打包结果会变得越来越臃肿。
这是因为在这个过程中 Webpack 为了实现这些特性,会自动往打包结果中添加一些内容。例如我们之前用到的 Source Map 和 HMR,它们都会在输出结果中添加额外代码来实现各自的功能。
但是这些额外的代码对生产环境来说是冗余的。因为生产环境和开发环境有很大的差异,在生产环境中我们强调的是以更少量、更高效的代码完成业务功能,也就是注重运行效率。而开发环境中我们注重的只是开发效率。
那针对这个问题,Webpack 4 推出了 mode 的用法,为我们提供了不同模式下的一些预设配置,其中生产模式下就已经包括了很多优化配置。
同时 Webpack 也建议我们为不同的工作环境创建不同的配置,以便于让我们的打包结果可以适用于不同的环境。
11.1 不同环境下的配置
我们先为不同的工作环境创建不同的 Webpack 配置。创建不同环境配置的方式主要有两种:
- 在配置文件中添加相应的判断条件,根据环境不同导出不同配置
- 为不同环境单独添加一个配置文件,一个环境对应一个配置文件
我们分别尝试一下通过这两种方式,为开发环境和生产环境创建不同配置。
11.1.1 配置文件中添加判断的方式:
我们回到配置文件中,Webpack 配置文件还支持导出一个函数,然后在函数中返回所需要的配置对象。这个函数可以接收两个参数,第一个是 env,是通过 CLI 传递的环境名参数,第二个是 argv,是运行 CLI 过程中的所有参数。具体代码如下:
// ./webpack.config.js
module.exports = (env, argv) => {
return {
// ... webpack 配置
}
}
可以借助这个特点,为开发环境和生产环境创建不同配置。我先将不同模式下公共的配置定义为一个 config 对象,具体代码如下:
// ./webpack.config.js
module.exports = (env, argv) => {
const config = {
// ... 不同模式下的公共配置
}
return config
}
然后通过判断,再为 config 对象添加不同环境下的特殊配置。具体如下:
// ./webpack.config.js
module.exports = (env, argv) => {
const config = {
// ... 不同模式下的公共配置
}
if (env === 'development') {
// 为 config 添加开发模式下的特殊配置
config.mode = 'development'
config.devtool = 'cheap-eval-module-source-map'
} else if (env === 'production') {
// 为 config 添加生产模式下的特殊配置
config.mode = 'production'
config.devtool = 'nosources-source-map'
}
return config
}
例如这里,我们判断 env 等于 development(开发模式)的时候,我们将 mode 设置为 development,将 devtool 设置为 cheap-eval-module-source-map;而当 env 等于 production(生产模式)时,我们又将 mode 和 devtool 设置为生产模式下需要的值。
当然还可以分别为不同模式设置其他不同的属性、插件,这也都是类似的。
通过这种方式完成配置过后,我们打开命令行终端,这里我们再去执行 webpack 命令时就可以通过 --env 参数去指定具体的环境名称,从而实现在不同环境中使用不同的配置。
那这就是通过在 Webpack 配置文件导出的函数中对环境进行判断,从而实现不同环境对应不同配置。这种方式是 Webpack 建议的方式。
也可以直接定义环境变量,然后在全局判断环境变量,根据环境变量的不同导出不同配置。这种方式也是类似的。
11.1.2 不同环境的配置文件
通过判断环境名参数返回不同配置对象的方式只适用于中小型项目,因为一旦项目变得复杂,配置也会一起变得复杂起来。所以对于大型的项目来说,还是建议使用不同环境对应不同配置文件的方式来实现。
一般在这种方式下,项目中最少会有三个 webpack 的配置文件。其中两个用来分别适配开发环境和生产环境,另外一个则是公共配置。因为开发环境和生产环境的配置并不是完全不同的,所以需要一个公共文件来抽象两者相同的配置。具体配置文件结构如下:
.
├── webpack.common.js ···························· 公共配置
├── webpack.dev.js ······························· 开发模式配置
└── webpack.prod.js ······························ 生产模式配置
首先我们在项目根目录下新建一个 webpack.common.js,在这个文件中导出不同模式下的公共配置;然后再来创建一个 webpack.dev.js 和一个 webpack.prod.js 分别定义开发和生产环境特殊的配置。
在不同环境的具体配置中我们先导入公共配置对象,然后这里可以使用 Object.assign 方法把公共配置对象复制到具体环境的配置对象中,并且同时去覆盖其中的一些配置。具体如下:
// ./webpack.common.js
module.exports = {
// ... 公共配置
}
// ./webpack.prod.js
const common = require('./webpack.common')
module.exports = Object.assign(common, {
// 生产模式配置
})
// ./webpack.dev.js
const common = require('./webpack.common')
module.exports = Object.assign(common, {
// 开发模式配置
})
Object.assign 这个方法会完全覆盖掉前一个对象中的同名属性。这个特点对于普通值类型属性的覆盖都没有什么问题。但是像配置中的 plugins 这种数组,我们只是希望在原有公共配置的插件基础上添加一些插件,那 Object.assign 就做不到了。
所以我们需要更合适的方法来合并这里的配置与公共的配置。可以使用 Lodash 提供的 merge 函数来实现,不过社区中提供了更为专业的模块 webpack-merge,它专门用来满足我们这里合并 Webpack 配置的需求。
11.1.2.1 webpack-merge
先通过 npm 安装一下 webpack-merge 模块。具体命令如下:
$ npm i webpack-merge --save-dev
# or yarn add webpack-merge --dev
安装完成过后我们回到配置文件中,这里先载入这个模块。那这个模块导出的就是一个 merge 函数,我们使用这个函数来合并这里的配置与公共的配置。具体代码如下:
// ./webpack.common.js
module.exports = {
// ... 公共配置
}
// ./webpack.prod.js
const merge = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
// 生产模式配置
})
// ./webpack.dev.jss
const merge = require('webpack-merge')
const common = require('./webpack.common')
module.exports = merge(common, {
// 开发模式配置
})
使用 webpack-merge 过后,我们这里的配置对象就可以跟普通的 webpack 配置一样,需要什么就配置什么,merge 函数内部会自动处理合并的逻辑。
分别配置完成过后,我们再次回到命令行终端,然后尝试运行 webpack 打包。不过因为这里已经没有默认的配置文件了,所以我们需要通过 --config 参数来指定我们所使用的配置文件路径。例如:
$ webpack --config webpack.prod.js
当然,如果你觉得这样操作让我们的命令变得更复杂了,那你可以把这个构建命令定义到 npm scripts 中,方便使用。
11.2 生产模式下的优化插件
在 Webpack 4 中新增的 production 模式下,内部就自动开启了很多通用的优化功能。
如果想要深入了解 Webpack 的使用,可以去单独研究每一个配置背后的作用。这里学习 production 模式下几个主要的优化功能,顺便了解一下 Webpack 如何优化打包结果。
11.2.1 DefinePlugin
DefinePlugin 是用来为我们代码中注入全局成员的。在 production 模式下,默认通过这个插件往代码中注入了一个 process.env.NODE_ENV。很多第三方模块都是通过这个成员去判断运行环境,从而决定是否执行例如打印日志之类的操作。
这里我们来单独使用一下这个插件。我们回到配置文件中,DefinePlugin 是一个内置的插件,所以我们先导入 webpack 模块,然后再到 plugins中添加这个插件。这个插件的构造函数接收一个对象参数,对象中的成员都可以被注入到代码中。具体代码如下:
// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
/ // ... 其他配置
plugins: [
new webpack.DefinePlugin({
API_BASE_URL: 'https://api.example.com'
})
]
}
例如我们这里通过 DefinePlugin 定义一个 API_BASE_URL,用来为我们的代码注入 API 服务地址,它的值是一个字符串。
然后我们回到代码中打印这个 API_BASE_URL。具体代码如下:
// ./src/main.js
console.log(API_BASE_URL)
完成以后打开控制台,然后运行 webpack 打包。打包完成过后我们找到打包的结果,然后找到 main.js 对应的模块。具体结果如下:
发现 DefinePlugin 其实就是把我们配置的字符串内容直接替换到了代码中,而目前这个字符串的内容为 api.example.com,字符串中并没有包含引号,所以替换进来语法自然有问题。
正确的做法是传入一个字符串字面量语句。具体实现如下:
// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
// ... 其他配置
plugins: [
new webpack.DefinePlugin({
// 值要求的是一个代码片段
API_BASE_URL: '"https://api.example.com"'
})
]
}
这样代码内的 API_BASE_URL 就会被替换为 "api.example.com"。具体结果如下:
另外,这里有一个非常常用的小技巧,如果我们需要注入的是一个值,就可以通过 JSON.stringify 的方式来得到表示这个值的字面量。这样就不容易出错了。具体实现如下:
// ./webpack.config.js
const webpack = require('webpack')
module.exports = {
// ... 其他配置
plugins: [
new webpack.DefinePlugin({
// 值要求的是一个代码片段
API_BASE_URL: JSON.stringify('https://api.example.com')
})
]
}
DefinePlugin 的作用虽然简单,但是却非常有用,我们可以用它在代码中注入一些可能变化的值。
11.2.2 Mini CSS Extract Plugin
对于 CSS 文件的打包,一般我们会使用 style-loader 进行处理,这种处理方式最终的打包结果就是 CSS 代码会内嵌到 JS 代码中。
mini-css-extract-plugin 是一个可以将 CSS 代码从打包结果中提取出来的插件,它的使用非常简单,同样也需要先通过 npm 安装一下这个插件。具体命令如下:
$ npm i mini-css-extract-plugin --save-dev
安装完成过后,我们回到 Webpack 的配置文件。具体配置如下:
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
// 'style-loader', // 将样式通过 style 标签注入
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
我们这里先导入这个插件模块,导入过后我们就可以将这个插件添加到配置对象的 plugins 数组中了。这样 Mini CSS Extract Plugin 在工作时就会自动提取代码中的 CSS 了。
除此以外,Mini CSS Extract Plugin 还需要我们使用 MiniCssExtractPlugin 中提供的 loader 去替换掉 style-loader,以此来捕获到所有的样式。
这样的话,打包过后,样式就会存放在独立的文件中,直接通过 link 标签引入页面。
不过这里需要注意的是,如果你的 CSS 体积不是很大的话,提取到单个文件中,效果可能适得其反,因为单独的文件就需要单独请求一次。个人经验是如果 CSS 超过 200KB 才需要考虑是否提取出来,作为单独的文件。
Optimize CSS Assets Webpack Plugin
使用了 Mini CSS Extract Plugin 过后,样式就被提取到单独的 CSS 文件中了。但是这里同样有一个小问题。
我们回到命令行,这里我们以生产模式运行打包。那按照之前的了解,生产模式下会自动压缩输出的结果,我们可以打开打包生成的 JS 文件。具体结果如下:
然后我们再打开输出的样式文件。具体结果如下:
这里我们发现 JavaScript 文件正常被压缩了,而样式文件并没有被压缩。
这是因为,Webpack 内置的压缩插件仅仅是针对 JS 文件的压缩,其他资源文件的压缩都需要额外的插件。
Webpack 官方推荐了一个 Optimize CSS Assets Webpack Plugin 插件。我们可以使用这个插件来压缩我们的样式文件。
我们回到命令行,先来安装这个插件,具体命令如下:
$ npm i optimize-css-assets-webpack-plugin --save-dev
安装完成过后,我们回到配置文件中,添加对应的配置。具体代码如下:
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
}
这里同样先导入这个插件,导入完成以后我们把这个插件添加到 plugins 数组中。
那此时我们再次回到命令行运行打包。
打包完成过后,我们的样式文件就会以压缩格式输出了。具体结果如下:
不过这里还有个额外的小点,可能你会在这个插件的官方文档中发现,文档中的这个插件并不是配置在 plugins 数组中的,而是添加到了 optimization 对象中的 minimizer 属性中。具体如下:
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
optimization: {
minimizer: [
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
那这是为什么呢?
其实也很简单,如果我们配置到 plugins 属性中,那么这个插件在任何情况下都会工作。而配置到 minimizer 中,就只会在 minimize 特性开启时才工作。
所以 Webpack 建议像这种压缩插件,应该我们配置到 minimizer 中,便于 minimize 选项的统一控制。
但是这么配置也有个缺点,此时我们再次运行生产模式打包,打包完成后再来看一眼输出的 JS 文件,此时你会发现,原本可以自动压缩的 JS,现在却不能压缩了。具体 JS 的输出结果如下:
那这是因为我们设置了 minimizer,Webpack 认为我们需要使用自定义压缩器插件,那内部的 JS 压缩器就会被覆盖掉。我们必须手动再添加回来。
内置的 JS 压缩插件叫作 terser-webpack-plugin,我们回到命令行手动安装一下这个模块。
$ npm i terser-webpack-plugin --save-dev
安装完成过后,这里我们再手动添加这个模块到 minimizer 配置当中。具体代码如下:
// ./webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const OptimizeCssAssetsWebpackPlugin = require('optimize-css-assets-webpack-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')
module.exports = {
mode: 'none',
entry: {
main: './src/index.js'
},
output: {
filename: '[name].bundle.js'
},
optimization: {
minimizer: [
new TerserWebpackPlugin(),
new OptimizeCssAssetsWebpackPlugin()
]
},
module: {
rules: [
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
},
plugins: [
new MiniCssExtractPlugin()
]
}
那这样的话,我们再次以生产模式运行打包,JS 文件和 CSS 文件就都可以正常压缩了。
总结
最后再来简单总结一下,本课时我们介绍了如何为 Webpack 添加不同环境下的不同配置,以及在生产模式打包时我们经常用到的几个插件。
这当中需要你理解的地方并没有太多,更多的是了解这些插件的具体作用和使用方法。除此之外,你也需要更多地了解社区当中其他的常用插件。
12. 如何选择打包工具:Rollup 和 Webpack ?
Rollup 是一款 ES Modules 打包器。它也可以将项目中散落的细小模块打包为整块代码,从而使得这些划分的模块可以更好地运行在浏览器环境或者 Node.js 环境。
从作用上来看,Rollup 与 Webpack 非常类似。不过相比于 Webpack,Rollup 要小巧的多。因为 Webpack 在配合一些插件的使用下,几乎可以完成开发过程中绝大多数前端工程化的工作。而 Rollup 可以说仅仅是一个 ES Modules 打包器,没有更多其他的功能了。
例如,在 Webpack 中支持 HMR 这种对开发过程十分友好的功能,而在 Rollup 中就没有办法完全支持。
Rollup 诞生的目的并不是要与 Webpack 这样的工具全面竞争。它的初衷只是希望能够提供一个高效的 ES Modules 打包器,充分利用 ES Modules 的各项特性,构建出结构扁平,性能出众的类库。
12.1 Rollup 快速上手
一个简单的示例,具体结构如下:
.
├── src
│ ├── index.js
│ ├── logger.js
│ └── messages.js
└── package.json
在这个示例的源代码中准备了三个文件,并且使用 ES Modules 组织的代码模块化。部分代码如下:
// ./src/messages.js
export default {
hi: 'Hey Guys, I am zce~'
}
// ./src/logger.js
export const log = msg => {
console.log('---------- INFO ----------')
console.log(msg)
console.log('--------------------------')
}
export const error = msg => {
console.error('---------- ERROR ----------')
console.error(msg)
console.error('---------------------------')
}
// ./src/index.js
import { log } from './logger'
import messages from './messages'
log(messages.hi)
如上述代码所示,其中:
- messages.js 文件中以默认导出的方式导出了一个对象;
- logger.js 文件中单个导出了两个函数成员;
- 最后在 index.js 文件中导入了这两个模块,并且使用了它们。
接下来,尝试使用 Rollup 完成这个示例应用的打包。这里需要先通过 npm 安装 rollup 这个模块。具体命令如下:
$ npm i rollup --save-dev
安装完成过后,rollup 这个模块同样会在 node_modules/.bin 目录中为我们提供一个 CLI 程序,我们就可以通过这个 CLI 去使用 Rollup 打包。具体命令如下:
$ npx rollup
P.S. 对于 node_modules/.bin 目录下的 CLI,我们可以使用 npx 命令或者 yarn 命令直接启动。
执行 rollup 命令,在不传递任何参数的情况下,这个命令会自动打印出它的帮助信息。具体如下图:
在这个帮助信息的一开始,就已经告诉我们 rollup 命令的正确用法了:我们应该通过参数指定一个打包入口文件。正确命令如下:
$ npx rollup ./src/index.js
这里指定的打包入口是 src/index.js 文件。再次执行 rollup 命令,具体执行结果如下:
根据控制台的输出结果,我们发现 Rollup 直接将打包结果打印到控制台中了。
当然,正常情况下还是需要将打包结果输出到一个文件中。具体就是通过 CLI 的 --file 参数指定输出文件路径,具体命令如下:
$ npx rollup ./src/index.js --file ./dist/bundle.js
这样打包的结果就会输出到文件中。成以后,找到 Rollup 打包输出的文件,具体结果如下:
在这个文件中我们的第一印象就是,Rollup 打包结果惊人的简洁,基本上就跟我们手写的代码一样。相比于 Webpack 大量的引导代码和一堆的模块函数,这里的输出结果没有任何多余代码,就是把打包过程中的各个模块按照依赖顺序,先后拼接到了一起。
而且我们仔细观察打包结果,会发现,在我们输出的结果中只会保留那些用到的部分,对于未引用部分都没有输出。这是因为 Rollup 默认会自动开启 Tree-shaking 优化输出结果,Tree-shaking 的概念最早也就是 Rollup 这个工具提出的。
12.2 配置文件
Rollup 同样支持以配置文件的方式去配置打包过程中的各项参数,在项目的根目录下新建一个 rollup.config.js 的配置文件.具体结构如下:
.
├── src
│ ├── index.js
│ ├── logger.js
│ └── messages.js
├── package.json
+└── rollup.config.js
这个文件虽然同样是运行在 Node.js 环境中,但是 Rollup 会额外处理配置文件,所以在 rollup.config.js 中我们可以直接使用 ES Modules 标准。具体代码如下:
// ./rollup.config.js
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es' // 输出格式
}
}
这个文件中需要导出一个配置对象,在这个对象中我们可以通过 input 属性指定打包的入口文件路径,通过 output 指定输出相关配置,output 属性是一个对象,在 output 对象中可以使用 file 属性指定输出的文件名,format 属性指定输出代码的格式。
完成以后,回到命令行,再次执行 rollup 命令,不过需要注意的是,这里需要通过 --config 参数来表明使用项目中的配置文件。你也可以通过这个参数来指定不同的配置文件名称。具体命令如下:
$ npx rollup --config # 使用默认配置文件
$ npx rollup --config rollup.prod.js # 指定配置文件路径
12.3 输出格式
Rollup 打包支持多种输出格式,配置文件中,配置同时输出所有格式下的文件,具体配置如下:
// ./rollup.config.js
// 所有 Rollup 支持的格式
const formats = ['es', 'amd', 'cjs', 'iife', 'umd', 'system']
export default formats.map(format => ({
input: 'src/index.js',
output: {
file: `dist/bundle.${format}.js`,
format
}
}))
在这个配置当中导出了一个数组,数组中的每个成员都是一个单独的打包配置,这样 Rollup 就会分别按照每个配置单独打包。这一点与 Webpack 非常相似。
配置完成之后,回到命令行终端,再次运行 Rollup 打包。那这次打包过后,dist 目录下就会生成不同格式的输出结果,如下图所示:
不同的输出格式大都是为了适配不同的运行环境,并没有什么需要额外理解的地方。
12.4. 使用插件
Rollup 自身的功能就只是 ES Modules 模块的合并,如果有更高级的要求,例如加载其他类型的资源文件或者支持导入 CommonJS 模块,又或是编译 ES 新特性,这些额外的需求 Rollup 同样支持使用插件去扩展实现。
Webpack 中划分了 Loader、Plugin 和 Minimizer 三种扩展方式,而插件是 Rollup 的唯一的扩展方式。
这里我们先来尝试使用一个可以让我们在代码中导入 JSON 文件的插件:@rollup/plugin-json,通过这个过程来了解如何在 Rollup 中使用插件。
首先需要将 @rollup/plugin-json 作为项目的开发依赖安装进来。具体安装命令:
$ npm i @rollup/plugin-json --save-dev
安装完成过后,我们打开配置文件。由于 rollup 的配置文件中可以直接使用 ES Modules,所以我们这里使用 import 导入这个插件模块。具体代码如下:
// ./rollup.config.js
import json from '@rollup/plugin-json'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es'
},
plugins: [
json()
]
}
@rollup/plugin-json 模块的默认导出就是一个插件函数。我们可以将这个函数的调用结果添加到配置对象的 plugins 数组中,注意这里是将调用结果放到数组中,而不是将这个函数直接放进去。
配置好这个插件过后,我们就可以在代码中通过 import 导入 json 文件了。我们回到 index.js 文件中,这里我们尝试通过 import 导入 package.json,具体代码如下:
// ./src/index.js
import { name, version } from '../package.json'
console.log(name, version)
那这个 JSON 文件中的每一个属性都会作为单独的导出成员。我们可以提取一下 JSON 中的 name 和 version,然后把它打印出来。
完成以后,我们打开命令行终端,再次运行 Rollup 打包。打包完成以后,我们找到输出的 bundle.js,具体结果如下:
此时你就能看到,package.json 中的 name 和 version 正常被打包进来了,而且其他没用到的属性也都被 Tree-shaking 移除掉了。
以上就是 Rollup 中插件的使用。
12.5 加载 NPM 模块
Rollup 默认只能够按照文件路径的方式加载本地的模块文件,对于 node_modules 目录中的第三方模块,并不能像 Webpack 一样,直接通过模块名称直接导入。
为了抹平这个差异,Rollup 给了一个@rollup/plugin-node-resolve 插件,通过使用这个插件,就可以在代码中直接使用模块名称导入模块了
同样,我们需要先安装这个插件,具体命令如下:
$ npm i @rollup/plugin-node-resolve --save-dev
安装完成过后,打开配置文件,这里同样导入插件函数,然后把它配置到 plugins 数组中。具体配置如下:
// ./rollup.config.js
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve'
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'es'
},
plugins: [
json(),
resolve()
]
}
完成以后可以回到代码中直接导入 node_modules 中的第三方模块了。例如:
// ./src/index.js
import { camelCase } from 'lodash-es'
console.log(camelCase('hello rollup'))
这里我导入的是我提前安装好的一个 lodash-es 模块,这个模块就是常用的 lodash 模块的 ESM 版本。导入过后我们就可以使用这个模块所提供的工具方法了。
P.S. 相比于普通的 lodash,lodash-es 可以更好地支持 Tree-shaking
完成过后我们再次打开命令行终端,运行 Rollup 打包,此时 lodash 就能够打包到我们的 bundle.js 中了。
这里使用Lodas 的ESM 版本而不是Lodash普通版本的原因是Rollup默认只能处理 ESM 模块。如果要使用普通版本则需要额外处理。
12.6 加载 CommonJS 模块
由于 Rollup 设计的是只处理 ES Modules 模块的打包,所以如果在代码中导入 CommonJS 模块,默认是不被支持的。但是目前大量的 NPM 模块还是使用 CommonJS 方式导出成员,所以为了兼容这些模块。官方给出了一个插件,叫作 @rollup/plugin-commonjs
这个插件在用法上跟前面两个插件是一样的,我就不单独演示了。我们直接看一下这个插件的效果。这里我添加了一个 cjs-module.js 文件,具体代码如下:
// ./src/cjs-module.js
module.exports = {
foo: 'bar'
}
这个文件中使用 CommonJS 的方式导出了一个对象。然后回到入口文件中通过 ES Modules 的方式导入,具体代码如下:
// ./src/index.js
// 导入 CommonJS 模块成员
import cjs from './cjs-module'
// 使用模块成员
console.log(cjs) // cjs => { foo: 'bar' }
入口文件导入的结果就是 cjs-module.js 中导出的对象了。
12.7 Code Splitting
Rollup 的最新版本中已经开始支持代码拆分了。我们同样可以使用符合 ES Modules 标准的动态导入方式实现模块的按需加载。例如:
// ./src/index.js
// 动态导入的模块会自动分包
import('./logger').then(({ log }) => {
log('code splitting~')
})
Rollup 内部也会处理代码拆分。不过按照之前的配置方式,这里直接打包会报出一个错误:
出现这个错误的原因是:在 Rollup 在分包过后会输出多个 JS 文件,需要我们在配置中指定输出的目录,而不是一个具体的文件名,具体配置如下:
// ./rollup.config.js
export default {
input: 'src/index.js',
output: {
// file: 'dist/bundle.js', // code splitting 输出的是多个文件
dir: 'dist',
format: 'es'
}
}
这里我们将 output 配置中的 file 选项删掉,取而代之的是添加一个 dir 选项,把它设置为 dist,也就是输出到 dist 目录中。
这样的话,再次打包就可以正常输出了。具体输出结果如下:
这次打包过程中,Rollup 就会自动提取动态导入的模块到单独的 JS 文件中了。
12.8 输出格式问题
目前采用的输出格式是 es,所以自动分包过后,得到的代码还是使用 ES Modules 实现的动态模块加载,具体输出结果如下:
很明显,这种方式的代码仍然会存在环境兼容性问题:如果在低版本浏览器,这种输出结果是无法正常执行的。
解决这个问题的办法就是修改 Rollup 打包输出的格式。目前所有支持动态导入的输出格式中,只有 amd 和 system 两种格式打包的结果适合于浏览器环境。
所以在这种情况下,我们可以选择以 amd 或者 system 格式输出。这里我们以 amd 为例,这里我们先将 Rollup 配置中的 format 设置为 amd。具体配置如下:
// ./rollup.config.js
export default {
input: 'src/index.js',
output: {
dir: 'dist',
format: 'amd'
}
}
这样的话,再次打包输出的结果就是采用 AMD 标准组织的代码了,具体如下:
需要注意一点,这种 AMD 标准在浏览器中也不是直接支持的,也就是说我们还是需要使用一个支持这个标准的库来加载这些输出的模块,例如 Require.js,具体使用方式参考:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AMD Format output</title>
</head>
<body>
<script src="https://unpkg.com/requirejs@2.3.6/require.js" data-main="dist/index.js"></script>
</body>
</html>
P.S. 本课时中所有的案例源代码:github.com/zce/rollup-…
总结
Rollup 的优势:
- 输出结果更加扁平,执行效率更高
- 自动移除未引用代码
- 打包结果依然完全可读
但是它的缺点也同样明显:
- 加载非 ESM 的第三方模块比较复杂
- 因为模块最终都被打包到全局中,所以无法实现 HMR
- 浏览器环境中,代码拆分功能必须使用 Require.js 这样的 AMD 库
综合以上特点,我们发现如果我们开发的是一个应用程序,需要大量引用第三方模块,同时还需要 HMR 提升开发体验,而且应用过大就必须要分包。那这些需求 Rollup 都无法满足。
而如果我们是开发一个 JavaScript 框架或者库,那这些优点就特别有必要,而缺点呢几乎也都可以忽略,所以在很多像 React 或者 Vue 之类的框架中都是使用的 Rollup 作为模块打包器,而并非 Webpack。
总结一下:Webpack 大而全,Rollup 小而美
在对它们的选择上,我的基本原则是:应用开发使用 Webpack,类库或者框架开发使用 Rollup。
不过这并不是绝对的标准,只是经验法则。因为 Rollup 也可用于构建绝大多数应用程序,而 Webpack 同样也可以构建类库或者框架。
另外随着近几年 Webpack 的发展,Rollup 中的很多优势几乎已经抹平了,所以这种对比慢慢地也就没有太大意义了。
13. 使用 Parcel 零配置完成打包任务
Parcel 是一款完全零配置的前端打包器,它提供了 “傻瓜式” 的使用体验,我们只需了解它提供的几个简单的命令,就可以直接使用它去构建我们的前端应用程序了。
13.1 快速上手
这里我们先创建一个空目录,然后通过 npm init 初始化一个项目中的 package.json 文件。
完成以后我们就可以安装 Parcel 模块了,具体命令如下:
$ npm install parcel-bundler --save-dev
这里需要注意 Parcel 的 npm 模块名称叫作 parcel-bundler,我们同样应该将它安装到项目的开发依赖中。
安装完成过后,parcel-bundler 模块就在 node_modules/.bin 目录中提供了一个叫作 parcel 的 CLI 程序,后续我们就是使用这个 CLI 程序执行应用打包。
既然是打包应用代码,那我们这里就得先有代码。我们回到项目中创建一些必需的文件,结构如下:
.
├── src
│ ├── index.html
│ ├── logger.js
│ └── main.js
└── package.json
首先在根目录下新建一个 src 目录,用于存放开发阶段编写的源代码,同时创建两个 JS 文件,分别是 logger.js 和 main.js,然后再创建一个 index.html 文件,这个 index.html 文件会将是 Parcel 打包的入口文件。
虽然 Parcel 跟 Webpack 一样都支持以任意类型文件作为打包入口,不过 Parcel 官方还是建议我们使用 HTML 文件作为入口。官方的理由是 HTML 是应用在浏览器端运行时的入口。
那在这个 HTML 入口文件中,我们可以像平时一样去编写代码,也可以正常去引用资源。在它里面引用的资源,最终都会被 Parcel 打包到一起。
我们这里先尝试在 index.html 中引入 main.js 脚本文件,具体代码如下:
<!-- ./src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Parcel Tutorials</title>
</head>
<body>
<script src="main.js"></script>
</body>
</html>
紧接着,在 main.js 中按照 ES Modules 的方式导入 logger.js 中的成员,具体代码如下:
// ./src/main.js
import { log } from './logger'
log('hello parcel')
// ./src/logger.js
export const log = msg => {
console.log('---------- INFO ----------')
console.log(msg)
}
Parcel 同样支持对 ES Modules 模块的打包。
完成以后,我们打开命令行终端,然后使用 npx 去运行 node_modules 目录下的 parcel 命令。具体命令如下:
$ npx parcel src/index.html
parcel 命令需要我们传入打包入口文件路径,那我们这里就应该是 src/index.html。
此时如果执行这个命令,Parcel 就会根据这里传入的参数,先找到 index.html,然后在根据 HTML 中的 script 标签,找到 main.js,最后再顺着 import 语句找到 logger.js 模块,从而完成整体打包。
回车执行过后,这里我们发现 Parcel 这个命令不仅仅帮我们打包了应用,而且还同时开启了一个开发服务器,这就跟 Webpack Dev Server 一样。
我们打开这个地址,启动浏览器,然后打开开发人员工具。Parcel 同样支持自动刷新这样的功能。具体效果如下:
以上就是 Parcel 的基本使用,相比于 Webpack,Parcel 在使用上的确简化了很多。
13.2 模块热替换
如果你需要的是模块热替换的体验,Parcel 中也可以支持。我们回到 main.js 文件中,这里同样需要使用 HMR 的 API。具体代码如下:
// ./src/main.js
import { log } from './logger'
log('hello parcel')
// HMR API
if (module.hot) {
module.hot.accept(() => {
console.log('HMR~')
})
}
我们需要先判断一下 module.hot 对象是否存在,如果存在则证明当前环境可以使用 HMR 的 API,那我们就可以使用 module.hot.accept 方法去处理热替换。
不过这里的 accept 方法与 Webpack 提供的 HMR 有点不太一样,Webpack 中的 accept 方法支持接收两个参数,用来处理指定的模块更新后的逻辑。
而这里 Parcel 提供的 accept 只需要接收一个回调参数,作用就是当前模块更新或者所依赖的模块更新过后自动执行传入的回调函数,这相比于之前 Webpack 中的用法要简单很多。
13.3 自动安装依赖
除了热替换,Parcel 还支持一个非常友好的功能:自动安装依赖。试想一下,你正在开发一个应用的过程中,突然需要使用某个第三方模块,那此时你就需要先停止正在运行的 Dev Server,然后再去安装这个模块,安装完成过后再重新启动 Dev Server。有了自动安装依赖的功能就不必如此麻烦了。
我们回到 main.js 文件中,假设我们这里想要使用一下 jquery。虽然我们并没有安装这个模块,但是因为有了自动安装依赖功能,我们这里只管正常导入,正常使用就好了。具体效果如下:
在文件保存过后,Parcel 会自动去安装刚刚导入的模块包,极大程度地避免手动操作。
13.4 其他类型资源加载
除此以外,Parcel 同样支持加载其他类型的资源模块,而且相比于其他的打包器,在 Parcel 中加载其他类型的资源模块同样是零配置的。
例如我们这里再来添加一个 style.css 的样式文件,并且在这个文件中添加一些简单的样式,具体如下:
.
├── src
│ ├── index.html
│ ├── logger.js
│ ├── main.js
+│ └── style.css
└── package.json
然后回到 main.js 中通过 import 导入这个样式文件,具体如下:
// ./src/main.js
import { log } from './logger'
import './style.css'
log('hello parcel')
保存过后,样式文件可以立即生效。效果如下:
你会发现,导入样式的操作,整个过程我们并没有停下来做额外的事情。
总之,Parcel 希望给开发者的体验就是想做什么,只管去做,其他额外的事情就交给工具来处理。
13.5 动态导入
另外,Parcel 同样支持直接使用动态导入,内部也会自动处理代码拆分,我们也一起来尝试一下。
这里我们先将静态导入的 jQuery 注释掉。然后使用动态导入的方式导入 jQuery 模块。具体代码如下:
// ./src/main.js
// import $ from 'jquery'
import { log } from './logger'
log('hello parcel')
import('jquery').then($ => {
$(document.body).append('<h1>Hello Parcel</h1>')
})
import 函数返回的就是一个 Promise 对象,在这个 Promise 对象 then 方法的回调中,我们就能够拿到导入的模块对象了,然后我们就可以把使用 jQuery 的代码移到这个回调函数中。
保存过后,回到浏览器,找到开发人员工具的 Network 面板,这里就能够看到拆分出来的 jquery 所对应的 bundle 文件请求了。具体效果如下图:
那以上基本上就是 Parcel 最常用的一些特性了,使用上根本没有任何难度,从头到尾我们都只是执行了一个 Parcel 命令。
13.6 生产模式打包
接下来我们来看,Parcel 如何以生产模式打包。生产模式打包,具体命令如下:
$ npx parcel build src/index.html
我们只需要执行 parcel build 然后跟上打包入口文件路径,就可以以生产模式运行打包了。
这里补充一点,相同体量的项目打包,Parcel 的构建速度会比 Webpack 快很多。因为 Parcel 内部使用的是多进程同时工作,充分发挥了多核 CPU 的性能。
P.S. Webpack 中也可以使用一个叫作 happypack 的插件实现这一点。
那我们这里再来看一下输出的打包结果,具体结果如下:
此时,dist 目录下就都是本次打包的结果了。这里的输出文件也都会被压缩,而且样式代码也会被单独提取到单个文件中。
那这就是 Parcel 的体验,整体体验下来就是一个感觉:舒服,因为它在使用上真的太简单了。
总结
Parcel 是 2017 年发布的,出现的原因是因为当时的 Webpack 使用过于烦琐,文档也不是很清晰明了,所以 Parcel 一经推出就迅速被推上风口浪尖。其核心特点就是:
- 真正做到了完全零配置,对项目没有任何的侵入;
- 自动安装依赖,开发过程更专注;
- 构建速度更快,因为内部使用了多进程同时工作,能够充分发挥多核 CPU 的效率。
但是目前看来,如果你去观察开发者的实际使用情况,绝大多数项目的打包还是会选择 Webpack。个人认为原因有两点:
- Webpack 生态更好,扩展更丰富,出现问题容易解决
- 随着这两年的发展,Webpack 越来越好用,开发者也越来越熟悉。