本文主要内容来自《深入浅出Webpack》这本书,目的是为了把一些概念和内容学习记录一遍,在以后的工作中使用Webpack的时候可以少走一些弯路。
Webpack概念
Webpack 是一个打包模块化 JavaScript 的工具,它会从 main.js 出发,识别出源码中的模块化导入语句, 递归的寻找出入口文件的所有依赖,把入口和其所有依赖打包到一个单独的文件中。 从 Webpack2 开始,已经内置了对 ES6、CommonJS、AMD 模块化语句的支持。
Webpack流程
- webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。
- 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。
- 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
- webpack 通过 Tapable 来组织这条复杂的生产线。
- webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。
- webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
Webpack构建
构建就是将源代码转换成可执行的JavaScript, CSS, HTML代码。
- 代码转换:TypeScript 编译成 JavaScript、SCSS 编译成 CSS 等。
- 文件优化:压缩 JavaScript、CSS、HTML 代码,压缩合并图片等。
- 代码分割:提取多个页面的公共代码、提取首屏不需要执行部分的代码让其异步加载。
- 模块合并:在采用模块化的项目里会有很多个模块和文件,需要构建功能把模块分类合并成一个文件。
- 自动刷新:监听本地源代码的变化,自动重新构建、刷新浏览器。
- 代码校验:在代码被提交到仓库前需要校验代码是否符合规范,以及单元测试是否通过。
- 自动发布:更新完代码后,自动构建出线上发布代码并传输给发布系统。
构建其实是工程化、自动化思想在前端开发中的体现,把一系列流程用代码去实现,让代码自动化地执行这一系列复杂的流程。
和其他构建工具的比较
Npm Script
Npm Script 是一个任务执行者。Npm 是在安装 Node.js 时附带的包管理器,Npm Script 则是 Npm 内置的一个功能,允许在 package.json 文件里面使用 scripts 字段定义任务:
{
"scripts": {
"dev": "node dev.js",
"pub": "node build.js"
}
}
Npm Script的优点是内置,无须安装其他依赖。其缺点是功能太简单,虽然提供了 pre 和 post 两个钩子,但不能方便地管理多个任务之间的依赖。
Grunt
Grunt 和 Npm Script 类似,也是一个任务执行者。Grunt 有大量现成的插件封装了常见的任务,也能管理任务之间的依赖关系,自动化执行依赖的任务,每个任务的具体执行代码和依赖关系写在配置文件 Gruntfile.js 里
Grunt的优点是:
灵活,它只负责执行你定义的任务; 大量的可复用插件封装好了常见的构建任务。 Grunt的缺点是集成度不高,要写很多配置后才可以用,无法做到开箱即用。
Gulp
Gulp 是一个基于流的自动化构建工具。 除了可以管理和执行任务,还支持监听文件、读写文件。
- 通过 gulp.task 注册一个任务;
- 通过 gulp.run 执行任务;
- 通过 gulp.watch 监听文件变化;
- 通过 gulp.src 读取文件;
- 通过 gulp.dest 写文件。
Gulp 的最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递。
Gulp 的优点是好用又不失灵活,既可以单独完成构建也可以和其它工具搭配使用。其缺点是和 Grunt 类似,集成度不高,要写很多配置后才可以用,无法做到开箱即用。
可以将Gulp 看作 Grunt 的加强版。相对于 Grunt,Gulp增加了监听文件、读写文件、流式处理的功能。
Webpack
Webpack 是一个打包模块化 JavaScript 的工具,在 Webpack 里一切文件皆模块,通过 Loader 转换文件,通过 Plugin 注入钩子,最后输出由多个模块组合成的文件。Webpack 专注于构建模块化项目。
一切文件:JavaScript、CSS、SCSS、图片、模板,在 Webpack 眼中都是一个个模块,这样的好处是能清晰的描述出各个模块之间的依赖关系,以方便 Webpack 对模块进行组合和打包。 经过 Webpack 的处理,最终会输出浏览器能使用的静态资源。
Webpack的优点是:
- 专注于处理模块化的项目,能做到开箱即用一步到位;
- 通过 Plugin 扩展,完整好用又不失灵活;
- 使用场景不仅限于 Web 开发;
- 社区庞大活跃,经常引入紧跟时代发展的新特性,能为大多数场景找到已有的开源扩展;
- 良好的开发体验。
Webpack的缺点是只能用于采用模块化开发的项目。
为什么选择 Webpack
- 在 Npm Script 和 Grunt 时代,Web 开发要做的事情变多,流程复杂,自动化思想被引入,用于简化流程;
- 在 Gulp 时代开始出现一些新语言用于提高开发效率,流式处理思想的出现是为了简化文件转换的流程,例如将 ES6 转换成 ES5。
- 在 Webpack 时代由于单页应用的流行,一个网页的功能和实现代码变得庞大,Web 开发向模块化改进。
经过多年的发展, Webpack 已经成为构建工具中的首选,这是有原因的:
- 大多数团队在开发新项目时会采用紧跟时代的技术,这些技术几乎都会采用“模块化+新语言+新框架”,Webpack 可以为这些新项目提供一站式的解决方案;
- Webpack 有良好的生态链和维护团队,能提供良好的开发体验和保证质量;
- Webpack 被全世界的大量 Web 开发者使用和验证,能找到各个层面所需的教程和经验分享。
Webpack常用插件(webpack v5.32.0)
1. html-webpack-plugin
单入口文件将webpack打包出来的js文件引入到html中
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
mode:'development',
entry: path.resolve(__dirname,'../src/main.js'), // 入口文件
output: {
filename: '[name].[hash:8].js', // 打包后的文件名称
path: path.resolve(__dirname,'../dist') // 打包后的目录
},
plugins:[
new HtmlWebpackPlugin()
]
}
多入口文件通过生成多个html-webpack-plugin实例来实现
npm i -D html-webpack-pulgin (or) npm i --save-dev html-webpack-plugin
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
main: path.resolve(__dirname,'../src/main.js'),
sub: path.resolve(__dirname,'../src/other.js')
}, // 入口文件
output: {
filename: '[name].[hash:8].js', // 打包后的文件名称
path: path.resolve(__dirname,'../dist') // 打包后的目录
},
plugins: [
new HtmlWebpackPlugin({
minify:{
removeAttributeQuotes: true,
collapseWhitespace: true
},
filename: 'index.html',
chunks: ['main']
}),
new HtmlWebpackPlugin({
minify:{
removeAttributeQuotes: true,
collapseWhitespace: true
},
filename: 'sub.html',
chunks: ['sub']
})
]
}
2. clean-webpack-plugin 每次执行npm run build会发现dist文件夹里会残留上次打包的文件,通过插件clean-webpack-plugin来清理
npm i -D clean-webpack-plugin
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
...
plugins: [
new CleanWebpackPlugin()
]
3. 引入CSS
- 使用less
npm i -D style-loader css-loader
npm i -D less less-loader
module:{
rules:[
{
test:/\.css$/,
use:['style-loader','css-loader'] // 从右向左解析原则
},
{
test:/\.less$/,
use:['style-loader','css-loader','less-loader'] // 从右向左解析原则
}
]
}
- 为css添加浏览器前缀
npm i -D postcss postcss-loader autoprefixer postcss-preset-env
// postcss.config.js
module.exports = {
plugins: [
[
"postcss-preset-env",
{
// 其他选项
},
],
],
};
// webpack.config.js
module:{
rules:[
{
test:/\.css$/,
use:['style-loader','css-loader'] // 从右向左解析原则
},
{
test:/\.less$/,
use:['style-loader','css-loader', 'postcss-loader', 'less-loader']
}
]
}
- 拆分css
npm i -D mini-css-extract-plugin
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const devMode = process.env.NODE_ENV !== 'production';
...
module:{
rules:[
{
test:/\.css$/,
use:[MiniCssExtractPlugin.loader,'css-loader'] // 从右向左解析原则
},
{
test:/\.less$/,
use:[
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'less-loader'
] // 从右向左解析原则
}
]
}
...
plugins: [
new MiniCssExtractPlugin({
filename: devMode ? '[name].css' : '[name].[contenthash].css',
chunkFilename: devMode ? '[id].css' : '[id].[contenthash].css',
})
]
- hash,chunkhash,contenthash
hash 是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的 hash 值都会更改,并且全部文件都共用相同的 hash 值。使用 hash,每次构建生成的哈希值都不一样。所以一旦修改了任何一个文件,整个项目的文件缓存都将失效。
output:{
path:path.resolve(__dirname,'./dist'),
publicPath: '/dist/',
filename: '[name]-[hash].js'
}
既然hash的用法有这种缺陷,那是否有更好的办法,使只有被修改了的文件的文件名hash值修改呢?答案就是使用chunkhash。
chunkhash和hash不一样,它
根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的哈希值。我们在生产环境里把一些公共库和程序入口文件区分开,单独打包构建,接着我们采用chunkhash的方式生成哈希值,那么只要我们不改动公共库的代码,就可以保证其哈希值不会受影响。
output:{
path:path.resolve(__dirname,'./dist'),
publicPath: '/dist/',
filename: '[name]-[chunkhash].js'
}
contenthash 根据当前文件的内容,来计算hash值。文件内容不变,hash值不变。假如js中引入了css,可以将css通过mini-css-extract-plugin提取出来。这样,假如css文件没有变化的情况下,生成的css的hash值就不会变。
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
chunkFilename: '[id].[contenthash].css'
})
]
4. 打包 图片、字体、媒体、等文件
npm i -D file-loader url-loader
file-loader就是将文件在进行一些处理后(主要是处理文件名和路径、解析文件url),并将文件移动到输出的目录中。
url-loader 一般与file-loader搭配使用,功能与 file-loader 类似,如果文件小于限制的大小。则会返回 base64 编码,否则使用 file-loader 将文件移动到输出的目录中。
// webpack.config.js
module.exports = {
// 省略其它配置 ...
module: {
rules: [
// ...
{
test: /\.(jpe?g|png|gif)$/i, //图片文件
use: [
{
loader: 'url-loader',
options: {
limit: 10240,
fallback: {
loader: 'file-loader',
options: {
name: 'img/[name].[hash:8].[ext]'
}
}
}
}
]
},
{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/, //媒体文件
use: [
{
loader: 'url-loader',
options: {
limit: 10240,
fallback: {
loader: 'file-loader',
options: {
name: 'media/[name].[hash:8].[ext]'
}
}
}
}
]
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/i, // 字体
use: [
{
loader: 'url-loader',
options: {
limit: 10240,
fallback: {
loader: 'file-loader',
options: {
name: 'fonts/[name].[hash:8].[ext]'
}
}
}
}
]
},
]
}
}
5. babel转义js文件
npm i -D babel-loader @babel/preset-env @babel/core
// webpack.config.js
module.exports = {
// 省略其它配置 ...
module:{
rules:[
{
test:/\.js$/,
use:{
loader:'babel-loader',
options:{
presets:['@babel/preset-env']
}
},
exclude:/node_modules/
},
]
}
}
上面的babel-loader只会将 ES6/7/8语法转换为ES5语法,但是对新api并不会转换 例如(promise、Generator、Set、Maps、Proxy等)。此时我们需要借助babel-polyfill来帮助我们转换。
npm i @babel/polyfill
// webpack.config.js
const path = require('path')
module.exports = {
entry: ["@babel/polyfill",path.resolve(__dirname,'../src/index.js')],
// 入口文件
}
webpack优化
优化打包速度
1.合理的配置mode参数与devtool参数
mode可设置development production两个参数。如果没有设置,webpack4 会将 mode 的默认值设置为 production。production模式下会进行tree shaking(去除无用代码)和uglifyjs(代码压缩混淆)。
2.缩小文件的搜索范围(配置include/exclude alias noParse extensions)
-
优化
loader配置(excluede/include):module中rules中添加excluede排除掉node_modules中的文件。 -
优化
resolve.alias配置:resolve.alias 配置项通过别名来把原导入路径映射成一个新的导入路径,使webpack查找打包第三库更加快速。
// 相关Webpack配置:
module.exports = {
resolve: {
// 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.min.js 文件,
// 减少耗时的递归解析操作
alias: {
'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
}
},
};
注意:除了 React 库外,大多数库发布到 Npm 仓库中时都会包含打包好的完整文件,对于这些库你也可以对它们配置 alias,但是对于有些库使用本优化方法后会影响到 使用 Tree-Shaking 去除无效代码的优化,因为打包好的完整文件中有部分代码你的项目可能永远用不上。 一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。 但是对于一些工具类的库,例如 lodash,你的项目可能只用到了其中几个工具函数,你就不能使用本方法去优化,因为这会导致你的输出代码中包含很多永远不会执行的代码。
- 优化
resolve.extensions:extensions 用于配置在尝试过程中用到的后缀列表。webpack 会自动带上后缀后去尝试询问文件是否存在(频率较高的文件类型优先写在前面)。
extensions: ['.js', '.json', 'jsx']
// 相关Webpack配置:
module.exports = {
resolve: {
// 尽可能的减少后缀尝试的可能性
extensions: ['js'],
},
};
- 优化
resolve.modules配置:resolve.modules 用于配置 Webpack 去哪些目录下寻找第三方模块。
// 相关Webpack配置:
module.exports = {
resolve: {
// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
// 其中 __dirname 表示当前工作目录,也就是项目根目录
modules: [path.resolve(__dirname, 'node_modules')]
},
};
- 优化
resolve.mainFields配置:resolve.mainFields 用于配置第三方模块使用哪个入口文件。
// 相关Webpack配置:
module.exports = {
resolve: {
// 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
mainFields: ['main'],
},
};
注意: 使用本方法优化时,你需要考虑到所有运行时依赖的第三方模块的入口文件描述字段,就算有一个模块搞错了都可能会造成构建出的代码无法正常运行。
- 优化
module.noParse配置:可以让 Webpack 忽略对部分没采用模块化的文件的递归解析和处理,不去解析属性值代表的库的依赖(提升打包速度)。
const path = require('path');
module.exports = {
module: {
// 完整的react.min.js文件就没有采用模块化,忽略对react.min.js文件的递归解析处理
noParse: [/react\.min\.js$/],
},
};
注意: 被忽略掉的文件里不应该包含 import 、 require 、 define 等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。
4. 使用HappyPack开启多进程Loader转换
在webpack构建过程中,实际上耗费时间大多数用在loader解析转换以及代码的压缩中。日常开发中我们需要使用Loader对js,css,图片,字体等文件做转换操作,并且转换的文件数据量也是非常大。由于js单线程的特性使得这些转换操作不能并发处理文件,而是需要一个个文件进行处理。HappyPack的基本原理是将这部分任务分解到多个子进程中去并行处理,子进程处理完成后把结果发送到主进程中,从而减少总的构建时间`。
npm i -D happypack
// 省略不相关部分
const os = require('os');
const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });
plugins:[
new Webpack.HotModuleReplacementPlugin(),
new HappyPack({
//用id来标识 happypack处理那里类文件
id: 'happyBabel',
//如何处理 用法和loader 的配置一样
loaders: [{
loader: 'babel-loader?cacheDirectory=true',
}],
//共享进程池
threadPool: happyThreadPool,
//允许 HappyPack 输出日志
verbose: true,
})
]
5. 使用webpack-parallel-uglify-plugin 增强代码压缩
上面对于loader转换已经做优化,那么下面还有另一个难点就是优化代码的压缩时间。
npm i -D webpack-parallel-uglify-plugin
6. 使用 DllPlugin 和 DllReferencePlugin 抽离第三方模块
对于开发项目中不经常会变更的静态依赖文件。类似于我们的elementUi、vue全家桶等等。因为很少会变更,所以我们不希望这些依赖要被集成到每一次的构建逻辑中去。 这样做的好处是每次更改我本地代码的文件的时候,webpack只需要打包我项目本身的文件代码,而不会再去编译第三方库。以后只要我们不升级第三方包的时候,那么webpack就不会对这些库去打包,这样可以快速的提高打包的速度。
这里我们使用webpack内置的DllPlugin DllReferencePlugin进行抽离
在与webpack配置文件同级目录下新建webpack.dll.config.js代码如下:
为什么给 Web 项目构建接入动态链接库的思想后,会大大提升构建速度呢? 原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。 由于动态链接库中大多数包含的是常用的第三方模块,例如 react、react-dom,只要不升级这些模块的版本,动态链接库就不用重新编译。
-
DllPlugin 插件:用于打包出一个个单独的动态链接库文件。
-
DllReferencePlugin 插件:用于在主要配置文件中去引入 DllPlugin 插件打包好的动态链接库文件。
-
使用动态链接库文件
构建出的动态链接库文件用于给其它地方使用,在这里也就是给执行入口使用。
注意: 在 webpack_dll.config.js 文件中,DllPlugin 中的 name 参数必须和 output.library 中保持一致。 原因在于 DllPlugin 中的 name 参数会影响输出的 manifest.json 文件中 name 字段的值, 而在 webpack.config.js 文件中 DllReferencePlugin 会去 manifest.json 文件读取 name 字段的值, 把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名。
// webpack.dll.config.js
const path = require("path");
const webpack = require("webpack");
module.exports = {
// 你想要打包的模块的数组
entry: {
vendor: ['vue','element-ui']
},
output: {
path: path.resolve(__dirname, 'static/js'), // 打包后文件输出的位置
filename: '[name].dll.js',
library: '[name]_library'
// 这里需要和webpack.DllPlugin中的`name: '[name]_library',`保持一致。
},
plugins: [
new webpack.DllPlugin({
path: path.resolve(__dirname, '[name]-manifest.json'),
name: '[name]_library',
context: __dirname
})
]
};
在package.json中配置如下命令
"dll": "webpack --config build/webpack.dll.config.js"
接下来在我们的webpack.config.js中增加以下代码
module.exports = {
plugins: [
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('./vendor-manifest.json')
}),
new CopyWebpackPlugin([ // 拷贝生成的文件到dist目录 这样每次不必手动去cv
{from: 'static', to:'static'}
]),
]
};
会发现生成了我们需要的集合第三地方 代码的vendor.dll.js 我们需要在html文件中手动引入这个js文件。
7. 使用 ParallelUglifyPlugin(加快压缩发布代码的速度)
在使用 Webpack 构建出用于发布到线上的代码时,都会有压缩代码这一流程。 最常见的 JavaScript 代码压缩工具是 UglifyJS,并且 Webpack 也内置了它。
用过 UglifyJS 的你一定会发现在构建用于开发环境的代码时很快就能完成,但在构建用于线上的代码时构建一直卡在一个时间点迟迟没有反应,其实卡住的这个时候就是在进行代码压缩。
由于压缩 JavaScript 代码需要先把代码解析成用 Object 抽象表示的 AST 语法树,再去应用各种规则分析和处理 AST,导致这个过程计算量巨大,耗时非常多。
ParallelUglifyPlugin 就做了这个事情。 当 Webpack 有多个 JavaScript 文件需要输出和压缩时,原本会使用 UglifyJS 去一个个挨着压缩再输出, 但是 ParallelUglifyPlugin 则会开启多个子进程,把对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过 UglifyJS 去压缩代码,但是变成了并行执行。 所以 ParallelUglifyPlugin 能更快的完成对多个文件的压缩工作。
使用 ParallelUglifyPlugin 也非常简单,把原来 Webpack 配置文件中内置的 UglifyJsPlugin 去掉后,再替换成 ParallelUglifyPlugin。
8. 使用自动刷新
借助自动化的手段,可以把这些重复的操作交给代码去帮我们完成,在监听到本地源码文件发生变化时,自动重新构建出可运行的代码后再控制浏览器刷新。
文件监听是在发现源码文件发生变化时,自动重新构建出新的输出文件。
9. 开启模块热替换
模块热替换还面临着和自动刷新一样的性能问题,因为它们都需要监听文件变化和注入客户端。 要优化模块热替换的构建性能,思路和在4-5 使用自动刷新中提到的很类似:监听更少的文件,忽略掉 node_modules 目录下的文件。 但是其中提到的关闭默认的 inline 模式手动注入代理客户端的优化方法不能用于在使用模块热替换的情况下, 原因在于模块热替换的运行依赖在每个 Chunk 中都包含代理客户端的代码。
10. 如何区分环境
if (process.env.NODE_ENV === 'production') {
console.log('你正在线上环境');
} else {
console.log('你正在使用开发环境');
}
const DefinePlugin = require('webpack/lib/DefinePlugin');
module.exports = {
plugins: [
new DefinePlugin({
// 定义 NODE_ENV 环境变量为 production
'process.env': {
NODE_ENV: JSON.stringify('production')
}
}),
],
};
11. 压缩代码
压缩Javascript:
- UglifyJS
- ParallelUglifyPlugin
压缩 ES6:
- UglifyESPlugin
压缩 CSS:
- cssnano:把 cssnano 接入到 Webpack 中也非常简单,因为 css-loader 已经将其内置了,要开启 cssnano 去压缩代码只需要开启 css-loader 的 minimize 选项。
12. CDN加速
虽然前面通过了压缩代码的手段来减小网络传输大小,但实际上最影响用户体验的还是网页首次打开时的加载等待。 导致这个问题的根本是网络传输过程耗时大,CDN 的作用就是加速网络传输。
13. 使用Tree Shaking
Tree Shaking 可以用来剔除 JavaScript 中用不上的死代码。它依赖静态的 ES6 模块化语法,例如通过 import 和 export 导入导出。
14. 配置externals
防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖(external dependencies)。
module.exports = {
//...
externals: {
react: 'react',
},
// 或者
externals: {
lodash: {
commonjs: 'lodash',
amd: 'lodash',
root: '_' // 指向全局变量
},
},
// 或者
externals: {
subtract: {
root: ['math', 'subtract'],
},
},
};
15. 配置缓存
我们每次执行构建都会把所有的文件都重复编译一遍,这样的重复工作是否可以被缓存下来呢,答案是可以的,目前大部分 loader 都提供了cache 配置项。比如在 babel-loader 中,可以通过设置cacheDirectory 来开启缓存,babel-loader?cacheDirectory=true 就会将每次的编译结果写进硬盘文件(默认是在项目根目录下的node_modules/.cache/babel-loader目录内,当然你也可以自定义)
优化打包文件体积
打包的速度我们是进行了优化,但是打包后的文件体积却是十分大,造成了页面加载缓慢,浪费流量等,接下来让我们从文件体积上继续优化。
参考资料:
本文部分内容转载自以下文章: