经过一周,终于有时间来把剩下的打包优化以及开发环境优化进行总结,书上其实写的很好,不过个人觉得有部分内容的描述不清晰,给读者来一些麻烦,因此阅读此书时推荐搭配着
Webpack
官方文档进行学习。推荐直接看
Webpack5
的文档。
代码分片
代码分片(code splitting
)的目的很清晰,就是为了每次只加载必要的资源,优先级不高的资源可以通过延迟加载的机制提高页面的加载速度。当然,这样的话需要关注对哪些模块进行分片、分片后的资源如何管理等。
在 Webpack
中的每一个入口都讲生成一个对应的资源文件,通过入口的配置可以进行一些简单的代码拆分。对于 Web
应用来说,许多库和工具模块都不经常变动,可以把它们放在单独的入口中,由于此入口的资源不会经常更新,因此可以有效利用客户端缓存,不必每次都去加载。
entry: {
app: './app.js',
lib: ['lib-a', 'lib-b', 'lib-c']
}
这种通过手动去配置和提取公共模块的方法很是繁琐,因此 Webpack
提供了专门的插件用来解决手动配置的问题,在 Webpack4
之前内部有自带插件 CommonsChunkPlugin
,在 Webpack4
之后正对前者的不足,重新设计之后替换成了 optimization.SplitChunks
。例如,如下代码,在不采用代码分片时,输出的文件都包含 react
。
// webpack.config.js
module.exports = {
entry: {
foo: './foo.js',
bar: './bar.js',
},
output: {
filename: '[name].js',
},
}
// foo.js
import React from 'react'
document.write('foo.js', React.version)
// bar.js
import React from 'react'
document.write('bar.js', React.version)
CommonsChunkPlugin
主要用于将多个入口之间的公共模块提取出来之后,也可以提取单个入口文件,有利于:
- 开发过程中减少了重复模块的打包,提高开发速度。
- 减少整体资源体积。
- 合理分片有利于客户端的缓存。
其简单用法如下所示,可以将公共依赖提取出来。
// webpack.config.js
module.exports = {
entry: {
foo: './foo.js',
bar: './bar.js'
},
output: {
filename: '[name].js'
},
plugin: {
new webpack.optimize.CommonsChunkPlugin({
name: 'commons', // name -> commons chunk name
filename: 'commons.js' // asset
})
}
};
注意:页面中添加一个新的
script
标签引入commons.js
,并且其一定要在其他JS
之前引入。
当配置 vendor 时,可以将单个入口中的模块提取出来,同时采用上面 new webpack.optimize.CommonsChunkPlugin
方法制定 chunk name
和 asset
。在多个入口文件中,可以制定从那些入口文件进行提取公共模块,如下代码:
// webpack.config.js
module.exports = {
entry: {
foo: './foo.js',
bar: './bar.js',
baz: './baz.js'
},
output: {
filename: '[name].js'
},
plugin: {
new webpack.optimize.CommonsChunkPlugin({
name: 'commons', // name -> commons chunk name
filename: 'commons.js', // asset
chunks: ['foo', 'baz'] // set the chunkname to be extracted
})
}
};
除此之外,其可以配置相关的提取规则,其默认的规则是只要一个模块被两个入口 chunk
所使用就会被提取出来。通过其 minChunks
属性可以配置提取规则,当设置为 n
时,只有该模块被 n
个入口同时引入才会被提取出来。
最后使用 CommonsChunkPlugin
绕不开的一个问题就是 hash
与长效缓存。提取的资源内部不仅仅是模块的代码,还包含 Webpack
的运行时(runtime
:创建模块缓存对象、声明模块加载函数等)。这样会导致模块的 id
改变,使得 runtime
内部代码发生变化,从而影响 chunk hash
的生成,进而导致版本号变化,用户频繁的更新资源。为了解决这个问题,只需要将 runtime
的代码单独提取出来。
plugin: {
new webpack.optimize.CommonsChunkPlugin({
name: 'commons', // name -> commons chunk name
filename: 'commons.js', // asset
chunks: ['foo', 'baz'] // set the chunkname to be extracted
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
}
manifest
必须出现在最后,否则Webpack
无法正常提取模块。
其不足也显而易见:
- 一个
CommonsChunkPlugin
只能提取一个vendor
,想要提取多个vendor
需要配置多个插件。 manifest
实际会使得浏览器多加载一个资源,对页面渲染速度不友好。- 内部的设计缺陷,
CommonsChunkPlugin
在提取公共模块会破坏原来Chunk
中模块的依赖关系,难以进行更多的优化。例如异步加载。
optimization.SplitChunks
optimization.SplitChunks 简称为 SplitChunks
,是 Webpack4
为了改进 CommonsChunkPlugin
重新设计和实现的代码分片特性。前面所提到异步加载的问题换成 SplitChunks
之后可以自动提取出 react
,如下代码:
// webpack.config.js
module.exports = {
entry: {
foo: './foo.js',
},
output: {
filename: 'foo.js',
publicPath: '/dist/',
},
mode: 'development',
optimization: {
splitChunks: {
chunks: 'all',
},
},
}
// foo.js
import React from 'react'
import('./bar.js') // Asynchronous import of bar.js
document.write('foo.js', React.version)
// bar.js
import React from 'react'
document.write('bar.js', React.version)
指定 SplitChunks
的 chunks
为 all
,表示会对所有的 chunks
生效。mode
是 Webpack4
新增的配置项,正对当前不同的开发环境自动添加对应的一些 Webpack
配置。
打包的结果应该是
foo.js
以及0.foo.js
(异步加载bar.js
的结果),由于SplitChunks
的存在,有生成一个vendors~main.foo.js
,并且把react
提取到里面。
默认情况下的提取条件如下:
- 提取后的
chunk
可被共享或者来自nodes_modules
目录。这样更倾向于通用模块适合被提取。 - 提取后的
JS chunk
体积大于30kB
,CSS chunk
体积大于50kB
。提取资源过于小带来的优化很一般。 - 按需加载过程中,并行请求的资源最大值小于等于
5
。不希望同时加载过多的资源,每一个请求都要花费建立链接和释放链接的成本。 - 首次加载,并行请求的资源最大值小于等于
3
。对首次要求更高。
有了以上的提取规则,对上面的代码进行验证,满足所有条件才会提取出 react
。
react
属于nodes_modules
目录下模块。react
体积大于30kB
。- 按需加载时并行请求数量为
1
,为0.foo.js
。 - 首次加载时并行请求数量为
2
,为foo.js
和vendors~main.foo.js
。这里vendors~main.foo.js
算在第四条的原因是在页面初始化时就需要进行加载。
为更好的了解 SplitChunks
的工作原理,其默认配置如下:
module.exports = {
//...
optimization: {
splitChunks: {
// 匹配模式: async、initial、all
chunks: 'async',
// 匹配条件
minSize: 20000,
minChunks: 1,
maxAsyncRequests: 30,
maxInitialRequests: 30,
// Webpack5 新增通过确保拆分后剩余的最小 chunk 体积超过限制来避免大小为零的模块。
// 'development' 模式 中默认为 0。
minRemainingSize: 0,
// 强制执行拆分的体积阈值和其他限制(minRemainingSize,maxAsyncRequests,maxInitialRequests)将被忽略。
enforceSizeThreshold: 50000,
// 分离 chunks 时的规则。
cacheGroups: {
// 提取所有 node_modules 中符合条件的模块
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
// 作用于被多次引用的模块
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
}
对于资源的异步加载来说,主要是采用 import()
函数来加载模块,返回一个 Promise
对象。首屏加载的 JS
资源通过页面的 script
标签进行引入,间接资源(异步加载的 JS
)的位置需要 output.publicPath
来制定。
import
函数还有一个重要的特性,在ES6 Module
中要求import
必须出现在代码的顶层作用域,而Webpack
的import
函数则可以在任何需要的时候调用。
异步加载打包后的资源名称都是数字 id
(0.foo.js
、1.foo.js
),没有可读性,可以通过相关 Webpack
配置添加 name
。如下配置代码:
// webpack.config.js
module.exports = {
entry: {
foo: './foo.js',
},
output: {
filename: '[name].js',
publicPath: '/dist/',
chunkFilename: '[name].js',
},
mode: 'development',
}
// foo.js
import(/* webpackChunkName: bar */ './bar.js').then(({ add }) => {
console.log(add(2, 3))
})
这里配置 output.chunkFilename
指定异步 chunk
的文件名,并且设置 /* webpackChunkName: bar */
特定的注释,获得异步 chunk
名字。
/* webpackChunkName: bar */
这个注释叫做魔法注释,在vue-router
中,也这样使用,配置输出的静态资源名称。
生产环境配置
生产环境即线上环境,更加关注更快地加载资源、涉及如何压缩资源、优化打包、利用缓存等。生产环境的配置和开发环境的配置有所不同,需要设置 mode
、环境变量、chunk hash
作为版本号。Webpack
可以按照不同的环境采用不同的配置。
// package.json
{
"scripts": {
"dev": "NODE_ENV=development webpack-dev-server",
"build": "NODE_ENV=production webpack"
}
}
// webpack.config.js
const ENV = process.env.NODE_ENV
const isProd = ENV === 'production'
module.exports = {
output: {
filename: isProd ? 'bundle@[chunkhash].js' : 'bundle.js',
},
mode: ENV,
}
通过 npm
脚本命令传入了一个 ENV
环境变量,webpack.config.js
则根据他的值来确定采用什么环境。
DefinePlugin
大部分只设置 mode
是不够的,还需要其他的自定义配置。可以使用 DefinePlugin
设置 ENV
以及其他相关的环境配置。
plugin: [
new webpack.DefinePlugin({
ENV: JSON.stringify('production'),
IS_PRODUCTION: true,
}),
]
SourceMap
主要就是在打包后,产生的压缩资源运行发现错误时,可以在 devtool
中定位到错误代码产生的位置。会有一个对应的 map
文件,如 bundle.js.map
。在生成 mapping
文件的同时,bundle
文件中会追加一句注释来标识 map
文件的位置。
// bundle.js
;(function () {
// ...
})()
//# sourceMappingURL=bundle.js.map
虽然可以通过文件映射找到错误代码,但是这样伴随着一个问题,任何人都可以通过 sourceMap
文件查看工程源码。
对于 soureMap
的配置很简单,只需要在 webpack.config.js
中添加 devtool
即可。
module.exports = {
devtool: 'source-map',
}
对于 CSS
、SCSS
、Less
来说,则需要添加额外的配置,添加响应的 options
选项。
module.exports = {
devtool: 'source-map',
module: {
rules: [
{
test: /\.scss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
// css-loader config
sourceMap: true,
},
},
{
loader: 'sass-loader',
options: {
// sass-loader config
sourceMap: true,
},
},
],
},
],
},
}
回到刚刚所提到的,任何人都可以通过 sourceMap
查看源代码,因此需要对其安全性进行严格的管理。如:hidden-source-map
以及 nosources-source-map
两种策略提高安全性,这两者具体配置可以参考官方教程。笔者认为最好的方式是通过服务器的 Nginx
配置,将 .map
文件只对公司内网开放,这样开发者仍然可以看到源码,一般用户的浏览器无法获取。
资源压缩
资源发布到线上之前,会进行代码压缩,叫做 uglify
,意思是移除多余的空格、换行以及执行不到的代码,缩短变量名,同时进行这些操作之后代码基本上不可读,一定程度上有利于提高安全性。
对于不同资源有不同的压缩插件,对于 JavaScript
而言,在 Webpack3
集成了 UglifyJS
,从 Webpack4
开始采用 terser
进行代码压缩,支持 ES6+
,默认使用了 terser
的插件 terser-webpack-plugin
。在 Webpack4
之后,这项配置移动到了 optimization.minimize
。如下实例代码,如果开启了 --mode=production
则不需要进行配置:
module.exports = {
entry: './app.js',
output: {
filename: 'bundle.js',
},
optimization: {
minimize: true,
},
}
也可以使用 terser-webpack-plugin
,这里不进行演示。
对于 CSS
文件而言,需要使用 mini-css-extract-plugin
将样式提取出来之后,使用 optimiza-css-assets-webpack-plugin
进行压缩,本质是使用压缩器 cssnano
。
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: 'css-loader',
}),
},
],
},
plugins: [new ExtractTextPlugin('styles.css')],
optimization: {
minimizer: [
new OptimizeCSSAssetsPlugin({
// 生效范围,只压缩匹配到的资源
assetNameRegExp: /\.optimize\.css$/g,
// 压缩处理器,默认为 cssnano
cssProcessor: require('cssnano'),
// 压缩处理器配置
cssProcessorOptions: { discardComments: { removeAll: true } },
// 是否展示 log
canPrint: true,
}),
],
},
}
缓存
合理使用缓存是提升客户端性能的一个关键因素。缓存时间由服务器决定,浏览器会在资源过期前一直使用本地缓存进行响应。这样也带来了一个问题,加入开发者想对代码进行 bug fix
,并且希望立刻更新到用户浏览器,此时最好的方法是更改资源的 URL
,强制所有客户端去下载资源。
module.exports = {
entry: './app.js',
output: {
filename: 'bundle@[chunkhash].js',
},
mode: 'production',
}
每当代码发生变化时,其对应的 hash
也会发生变化。这样伴随着一个问题,在 HTML
中引用的路径也会发生改变,手动去维护很不方便,可以使用 html-webpack-plugin
将最新的资源名同步过去,并且可以设置自己的 HTML
模版,可以放入许多个性化的内容。
注意:在使用
Webpack3
及以前的版本,使用CommonsChunkPlugin
要注意vendor.js
中chunk hash
变动的问题,Webpack
为每个模块指定的id
是按照数字递增的,当插入进来新的模块时会导致其他模块的id
也发生变化,进而影响vendor.js
中的内容。
打包优化
通过优化 Webpack
配置的方法,使得打包速度更快,输出的资源更小。
注意:不要过早进行优化,在初期不要看到任何优化的地方就加到项目中,这样不但增加复杂度,而且优化效果不理想。
HappyPack
通过多线程来提高 Webpack
打包速度的工具。在打包过程中,使用 loader
将各种资源进行转译非常耗时,转译的工作原理流程如下:
-
获取打包入口。
-
匹配
loader
,对入口模版进行转译。 -
转译后模块进行依赖查找(如
a.js
中加载了b.js
和c.js
)。 -
对新找到的模块重复上面两个步骤,知道没有新模块。
Webpack
是一个单线程,假如一个模块依赖于几个模块,Webpack
必须对这些模块逐个进行转译。而 HappyPack
就是通过多线程同时加载多个模块,从而达到不错的效果。
const HappyPack = require('happypack')
module.exports = {
mode: 'development',
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loaders: 'happypack/loader?id=js',
},
{
test: /\.ts$/,
exclude: /node_modules/,
loaders: 'happypack/loader?id=ts',
},
],
},
plugins: [
new HappyPack({
id: 'js',
loaders: [
{
loader: 'babel-loader',
options: {}, // babel options
},
],
}),
new HappyPack({
id: 'ts',
loaders: [
{
loader: 'ts-loader',
options: {}, // ts options
},
],
}),
],
}
采用 HappyPack
使用 happypack/loader
替换了原来的 babel-loader
,并在 plugin
中添加了 HappyPack
插件,将原来 babel-loader
的配置插入进去。并且在多个 loader
同时,意味着要插入多个 HappyPack
的插件,每个插件通过标识 id 来进行区分。
缩小打包作用域
- 增加资源:通过更多的
CPU
或内存,缩短执行时间。 - 缩小范围:针对任务本身,去掉冗余的流程。
前面提到的 HappyPack
采用的就是增加资源,下面说一下如何缩小范围。
-
采用前面提到的 exclude 和 include。
-
noParse
忽略掉文件名中包含的模块,虽然会打包进资源文件,但是不会被任何解析。module.exports = { module: { noParse: function (fullPath) { // fullPath是绝对路径,如 /Users/me/app/webpack-no-parse/lib/lodash.js return /lib/.test(fullPath) }, }, }
上面配置会忽略掉所有
lib
目录下的资源解析。 -
IgnorePlugin
可以完全排除一些模块,被排除的模块即使被引用了也不会被打包进资源文件中。如,Moment.js
中做了许多其他地区的语言包,会占许多体积,这时就可以用IgnorePlugin
进行去掉。plugins: [ new webpack.IgnorePlugin({ // 匹配资源文件 resourceRegExp: /^\.\/locale$/, // 匹配检索目录 contextRegExp: /moment$/, }), ]
-
有些
loader
还有cache
配置项,编译代码后,同时保存一份缓存,下一次编译之前,检查源文件是否发生变化,如果没有就直接采用cache
。
tree shaking
之前提到过 ES6 Module 依赖关系是在编译期间就建立的,基于这一点 Webpack
提供了 tree shaking
的功能,可以在打包过程检测工程中没有被引用过的模块,也就是“死代码”,Webpack
会对这部分代码进行标记,从而在资源压缩的时候将它们从最终的 bundle
中去掉。
// index.js
import { foo } from './util'
foo()
// util.js
export function foo() {
console.log('foo')
}
export function bar() {
// 没有引用,属于“死代码”
console.log('bar')
}
Webpack
打包时候会标记 bar
,在正常的开发模式下仍然存在,在生产环境中会直接移除掉。tree shaking
只能对 ES6 Module
生效,对于一些 npm
包同时提供了 ES6 Module
和 CommonJS
的形式,尽可能的选择 ES6 Module
。
- 更多细节内容可以参考:Webpack 4 Tree Shaking 终极优化指南
开发环境优化
这部分内容,书中提到了一些 Webpack
开发效率的插件,这些插件因人而异,不一定每个人都喜欢这样的插件,例如:
-
webpack-dashboard
构建出控制台 -
webpack-merge
进行不同环境的配置 -
speed-measure-webpack-plugin
分析出Webpack
打包过程中在各个loader
和plugin
上的耗时 -
size-plugin
监控资源的体积。
这部分我觉得最值得关注的是模块热替换。
模块热替换
模块热替换(hot module replacement
或 HMR
)是 webpack
提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。
plugin: [
new webpack.HotModuleReplacementPlugin()
],
devServer: {
hot: true
}
上面配置产生的结果是 Webpack
会为每个模块绑定一个 module.hot
对象这个对象包含了 HMR
的 API
。调用 HMR API
可以通过手动方式添加代码,借助现成工具,如 react-hot-loader
、vue-loader
等。不过还是推荐使用现成工具,可以避免很多预想不到的问题。开启 HMR 之后资源体积会比原来更大,这是因为 Webpack
为了实现 HMR
注入了许多相关代码。
在本地开发环境下,浏览器是客户端,webpack-dev-server
(WDS
)相当于是服务端。HMR
核心就是客户端从服务端拉去更新后的资源(不是整个资源,而是 chunk diff
)。
- 实际上
WDS
与浏览器之间维护了一个websocket
,在本地资源发生变化的时,WDS
回想浏览器推送更新事件,并且带上这次构建的hash
,让客户端遇上一次进行对比。这样也解释了为什么开启多个本地页面时,代码一改所有的页面都会更新。live reload
也是依赖于websocket
。 - 监听到需要拉取资源,就需要知道拉取什么资源,但是这部分信息没有包含在
websocket
当中。通常会发起一个名为[hash].hot-update.json
的请求,返回结果{"h":"e388ea0f0e0054e37cee","c":{"main":true}}
。告诉客户端需要更新的chunk
为main
,版本为e388ea0f0e0054e37cee
。 - 客户端就可以借助这些信息继续想
WDS
发起请求获取该chunk
的增量更新。
总结
最近花了一段时间阅读了**《Webpack 入门、进阶与调优》**,对 Webpack
有了一个新的认识,从以前自己的项目走过来,没有做过太多的 Webpack
相关的内容,这次算是系统的学习了 Webpack
,目的主要是为了提升自己,以及为了在产品上能够进行维护甚至优化打包过程。阅读下来整体感受还算可以,书中从不同方面对 Webpack
进行了全面的讲解,这两篇博客主要是为了记录一下重要的内容,方便之后回顾,当然只是阅读还是远远不够的,需要有相关的经验,之后会对阮一峰 Webpack Demo进行初步的练习,再进行其他复杂的 Webpack
操作。
前端技术更新迭代速度非常的快,打包工具也层出不穷,例如还有追求更快打包速度的 Parcel
、专注于 JavaScript
打包的 Rollup
。以及由尤雨溪开发的一种新型前端构建工具 Vite
,都值得学习研究。
目前对打包工具进行系统学习后,可以继续研究探讨一下目前的前端包管理工具优劣,其内部的原理也值得我进行学习。