一份不可多得的 Webpack 学习指南(1万字长文带你入门 Webpack 并掌握常用的进阶配置)

本文最后更新于:2022年3月22日 下午

🧨 大家好,我是 Smooth,一名大二的 SCAU 前端er
🏆 本篇文章会带你入门 Webpack 并对基本配置以及进阶配置做比较通俗易懂的介绍!
🙌 如文章有误,恳请评论区指正,谢谢!

自定义 Loader 内容于 2022/02/25 更新
自定义 Plugin 内容于 2022/02/26 更新

Webpack

本教程包管理方式统一使用 npm 进行讲解

学习背景

由于在平时使用 vue、react 框架进行项目开发时,vue-cli 和 create-react-app 脚手架已经为你默认配置了 webpack 的常用参数,所以没有额外需求不用另外配置 webpack。

但在一次项目开发中,突然自己想给项目增加一个优化打包速度和体积的需求,所以就开始学习起了 webpack,逐渐明白了这打包工具的强大之处,在查看了脚手架给我们默认配置好的 webpack 配置文件后,也明白了脚手架这种东西的便捷之处。

当然,系统学习后 webpack,你也可以做到去阉割脚手架的 webpack 配置文件用不到的一些配置选项,并增加一些你需要的配置,例如优化打包体积、提升打包速度等等。

此篇文章便为你进行系统地讲解 webpack 的基本使用

PS:对于 webpack 的配置文件,vue-cli 可以通过修改 vue.config.js 进行配置修改,create-react-app 需要通过 craco 覆盖,或 eject 进行暴露。

webpack介绍

webpack 是什么

bundler:模块打包工具


webpack 作用

对项目进行打包,明确项目入口,文件层次结构,翻译代码(将代码翻译成浏览器认识的代码,例如import/export)


webpack 环境配置

webpack安装前提:已安装 node (node安装在此不做赘述),用指令 node -v 和 npm -v 来测试node安装有没成功


1
2
3
4
5
6
7
8
9
10
11
12
npm install webpack webpack-cli --save-dev // 推荐,--save-dev结尾(或直接一个 -D),该项目内安装
npm install webpack webpack-cli -g // 不推荐,-g结尾,全局安装(如果两个项目用的两个webpack版本,会造成版本冲突)

安装后查询版本:
webpack -v:查找全局的 webpack 版本,非 -g 全局安装是找不到的
npx webpack -v:查找该项目下的 webpack 版本

其他指令:
npm init -y:初始化 npm 仓库,-y 后缀意思是创建package.json文件时默认所有选项都为yes
npm info webpack:查询 webpack 有哪些版本号
npm install webpack@版本号 webpack-cli -D:安装指定版本号的webpack
npx webpack;进行打包

webpack-cli 和 webpack 区别:

webpack-cli 能让我们在命令行运行webpack 相关指令,例如 webpack, npx webpack 等等


webpack 配置文件

默认配置文件:webpack.config.js

1
2
3
4
5
6
7
8
9
const path = require('path');
module.exports = {
mode: "production", // 环境,默认 production 即生产环境,打包出来的文件经过压缩(可以不写),development没压缩
entry: 'index.js', // 入口文件(要写路径)
output: { // 出口位置
filename: 'bundle.js', // 出口文件名
path: path.resolve(__dirname, 'bundle'), // 出口文件打包到哪个文件夹下,参数(绝对路径根目录下,文件名)
}
}

如果想让 webpack 按其他配置文件规则进行打包,比如叫做 webpackconfig.js

1
npx webpack --config webpackconfig.js

小问题:

为什么使用react、vue框架打包项目文件时不是输入 npx webpack 而是输入 npm start/npm run dev等等?

原因:更改 package.json 文件里的 scripts 脚本指令(该文件:项目的说明,包括所需依赖、可运行脚本、项目名、版本号等等)

1
2
3
4
5
{
"scripts": {
"bundle": "webpack" // 运行 npm run 脚本名,相当于运行 原始指令,即 `npm run bundle -> webpack`
}
}

回顾:

1
2
3
webpack index.js // 全局安装 webpack 后,单独对这个js文件进行打包
npx webpack index.js // 局部(项目内)安装 webpack 后,单独对这个js文件进行打包
npm run bundle -> webpack // 运行脚本,进行 webpack 打包,先在项目内查找webpack进行打包,没有再全局----前两者融合

后面开始用 npm run bundle 代替 webpack 进行打包


Webpack 基本概念

webpack 的 Concepts 板块

官方文档

Loader

Loader 是什么?

由于 webpack 默认只认识、支持打包js文件,想要拓展其能力进行打包 css文件、图片文件等等,需要安装 Loader 进行拓展

Loader 的使用

在 webpack.config.js 的配置文件中进行配置

在文件中新增 module 字段,module 中新增 rules 的数组,进行一系列规则的配置,每个规则对象有两个字段

test :匹配所有以 xxx 为后缀的文件的打包,用正则表达式进行匹配

use :指明要使用的 loader 名称 ,且要对该 loader 进行安装

拓展,use 还有 options 可选择字段,name 指明打包后的文件命名,[name].[ext] 代表打包后和打包前 命名 和 后缀 一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
module: {
rules: [
{
test: /.jpg$/,
use: {
loader: 'file-loader',
options: {
// placeholder 占位符
name: '[name].[ext]'
}
}
}
]
}
}

在项目根目录下使用 npm install loader名字 或 yarn add loader名字 进行所需 loader 的安装


常用 Loader 推荐

babel-loader、style-loader、css-loader、less-loader、sass-loader、postcss-loader、url-loader、file-loader 等等


图片(Images)

打包图片文件

图片静态资源,所以都对应 file-loader ,且一般项目中这些静态资源被放到 images 文件夹,通过 use 字段配置额外参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
module: {
rules: [
{
test: /.(jpg|png|gif)$/,
use: {
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'images/' // 匹配到上面后缀的文件时,都打包到的的文件夹路径
}
}
}
]
}
}

当然,对于 file-loader,url-loader 会更具拓展性

推荐用 url-loader 进行替换,因为可以设置limit参数,当图片大于对应字节大小,会打包到指定文件夹目录,若小于,则会生成base64(不会打包图片到文件夹下,而是生成 base64 到 output的js文件里 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
module: {
rules: [
{
test: /.(jpg|png|gif)$/,
use: {
loader: 'url-loader',
options: {
name: '[name].[ext]',
outputPath: 'images/', // 匹配到上面后缀的文件时,都打包到该文件夹路径
limit: 2048 // 指定大小
}
}
}
]
}
}



样式(CSS)

打包样式文件

需要 css-loader 和 style-loader ,在 use 字段进行配置

说明:设置 css 样式后,挂载到 style 的属性上,所以要两个

1
2
3
4
5
6
7
8
9
10
{
module: {
rules: [
{
test: /.css$/,
use: ['style-loader', 'css-loader']
}
]
}
}

对于 scss 文件,除了上面两个 loader 以外,还要在 use 中额外配置 sass-loader,然后安装两个文件

npm install sass-loader node-sass webpack --save-dev

注意事项:

use 中的 loader 数组,是有打包顺序的,按从右到左,从上到下,即 scss,要先 style,然后 css,最后sass,从右到左

1
use: ['style-loader', 'css-loader', 'sass-loader']

postcss.loader

对于样式,如果老版本的浏览器可能需要兼容,即在 css 属性中加 -webkit 等前缀,可以通过 postcss.loader 实现,在上面例子在后面加上这个 loader 并进行下载后,新建一个 postcss.config.js 文件进行该 loader 的配置即可

1
2
3
4
5
module.exports = {
plugins: [
require('autoprefixer')
]
}

样式拓展

如何让 webpack 识别 less 文件内再引入的 less 文件,并进行打包?

如何模块化导出和使用样式?(css in js)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
module: {
rules: [
{
test: /.scss$/,
use: ['style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 2, // 允许less文件内引入less文件
modules: true // 允许模块化导入导出使用css,css in js 同理
}
},
'sass-loader,
'postcss-loader'
]
}
]
}
}



字体(Fonts)

打包字体文件(借助iconfont)

从 iconfont 网站下载对应图标的字体文件并压缩到目录后,会发现由于下载的 iconfont.css 文件内部又引入了 eot、ttf、svg文件,webpack无法识别,引入需给这三个后缀的文件再配置打包规则,用 file-loader 即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
module: {
rules: [
{
test: /.scss$/,
use: ['style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 2, // 允许less文件内引入less文件
modules: true // 允许模块化导入导出使用css,css in js 同理
}
},
'sass-loade,
'postcss-loader'
]
},
{ // 配置这个规则即可
test: /.(eot|ttf|svg)$/,
use: {
loader: 'file-loader'
}
}
]
}
}



自定义 Loader

首先明确最基本的,编写 Loader 其实就是编写一个函数并暴露出去给 Webpack 使用

例如编写一个 replaceLoader ,作用是当遇到某个字符时替换成其他字符,例如遇到 hello 字符串时,替换成 hi

1
2
3
4
5
// 在根目录的 loaders 文件夹下的 replaceLoader.js    即路径:'./loaders/replaceLoader.js'

module.exports = function(source) {
return source.replace('hello', 'hi');
}

这样,一个简易的 Loader 就写好啦

注意

暴露的函数不能写成箭头函数,即不能写成如下:

1
2
3
4
5
// replaceLoader.js

module.exports = (source) => {
return source.replace('hello', 'hi');
}

由于箭头函数没有 this 指针,而 Webpack在使用 Loader 时会做些变更,绑定一些方法到 this 上,所以会没法调用原本属于 this 的一些方法了。

例如:获取传入 Loader 的参数是通过 this.query 获取

当然,要用你自定义的 Loader,除了上面的编写 Loader 外,还需要对他进行使用,在 Webpack 配置文件进行相关配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// webpack.config.js

const path = require('path');

module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
module: {
rules: [{
test: /.js/,
use: [
path.resolve(__dirname, './loaders/replaceLoader.js') // 这里要书写该 js 文件的路径
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

如果你想往你的自定义 Loader 传入一些参数,传参的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// webpack.config.js

const path = require('path');

module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
module: {
rules: [{
test: /.js/,
use: [
{
loader: path.resolve(__dirname, './loaders/replaceLoader.js'), // 这里要书写该 js 文件的路径
options: {
name: 'hi'
}
}
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

这样,Webpack 在进行打包时,会将 {name: 'hi'} 参数传入 replaceLoader.js

在 replaceLoader.js 中,参数的接收形式如下:

1
2
3
4
5
// replaceLoader.js

module.exports = (source) => {
return source.replace('hello', this.query.name);
}

这样,原项目所有 js 文件中的 hello 字符串都被替换成了 hi

这样,一个简易的 Loader 就完成啦


更多

loader-utils

但有时往自定义 Loader 传参时会比较诡异,例如上述例子,传入的明明是一个对象,但可能变成只有一个字符串 ,此时就需要用到 loader-utils 模块,对传入的参数进行分析,解析成正确的内容

使用方法

先运行 npm install loader-utils --save-dev 安装,然后

1
2
3
4
5
6
7
8
// replaceLoader.js

const loaderUtils = require('loader-utils'); // 引入该模块

module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
return source.replace('hello', options.name);
}



callback()

有时,除了用自定义 Loader 对原项目做出更改以外,如果启用了 sourceMap,还希望 sourceMap 对应的映射也发生更改,

由于该函数只返回了项目内容的更改,而没返回 sourceMap 的更改,所以要用 callback 做一些配置

1
2
3
4
5
6
this.callback(
err: Error | null,
content: string | Buffer,
sourceMap?: SourceMap,
meta?: any
)

通过该函数进行回调,可以返回除了项目内容更改外,还可以返回 sourceMap 、错误、meta 的更改

由于,我只需要返回项目内容以及 sourceMap 的更改,所以配置示例如下:

1
2
3
4
5
6
7
8
9
10
// replaceLoader.js

const loaderUtils = require('loader-utils'); // 引入该模块

module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
const result = source.replace('hello', options.name);

this.callback(null, result, source);
}



async()

自定义 Loader 中有时会有异步操作,例如设置延时器1s后再进行打包(方便摸鱼),那如果直接 setTimeout(),设置一个延时器再返回肯定是不行的,会报错无返回内容,因为正常来说是不允许在延时器中返回内容的。

我们可以通过 async() 来解决,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// replaceLoader.js

const loaderUtils = require('loader-utils'); // 引入该模块

module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
const callback = this.async();

setTimeout(() => {
const result = source.replace('hello', options.name);
callback(null, result); // 参数同上面的 callback()
}, 1000);
}

可以看出,其实 async() 跟 callback() 很类似,只不过用于异步返回而已


同时自定义多个 Loader

例如想实现一个需求:打包后项目先是将项目中的所有字符串 hello 替换成 hi,再把 hi 替换成 Wow

那么就要编写两个 Loader,第一个将 hello 替换成 hi,第二个将 hi 替换成 Wow

第一个 replaceLoader.js

1
2
3
4
5
6
7
8
9
10
11
12
13
// replaceLoader.js

const loaderUtils = require('loader-utils'); // 引入该模块

module.exports = function(source) {
const options = loaderUtils.getOptions(this); // 使用
const callback = this.async();

setTimeout(() => {
const result = source.replace('hello', options.name);
callback(null, result); // 参数同上面的 callback()
}, 1000);
}

第二个 replaceLoader2.js

1
2
3
4
5
// replaceLoader2.js

module.exports = function(source) {
return source.replace('hi', 'wow');
}

同时对 webpack.config.js 进行配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// webpack.config.js

const path = require('path');

module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
module: {
rules: [{
test: /.js/,
use: [
{
loader: path.resolve(__dirname, './loaders/replaceLoader2.js')
},
{
loader: path.resolve(__dirname, './loaders/replaceLoader.js') // 这里要书写该 js 文件的路径,
options: {
name: 'hi'
}
},
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

要注意的地方

由于前面提到 Loader 执行顺序是从下到上,从右到左,所以要将第一个写在下面,第二个写在上面


Loader 引入转换成官方的引入方式

在上面的例子中,引入 loader 时,方式都是

1
loader: path.resolve(__dirname, './loaders/replaceLoader2.js')

太长了,太麻烦了,不美观,想更换成官方的引入方式,该怎么做呢

1
loader: 'replaceLoader2'

Webpack 配置文件中配置 resolveLoader 字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// webpack.config.js

const path = require('path');

module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
resolveLoader: {
modules: ['node_modules', './loaders']
},
module: {
rules: [{
test: /.js/,
use: [
{
loader: 'replaceLoader2'
},
{
loader: 'replaceLoader', // 这里要书写该 js 文件的路径,
options: {
name: 'hi'
}
},
]
}]
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

参数意思:如果在 node_modules 文件夹下没找到配置的 Loader,那么就会进入同级目录下的 loaders 文件夹进行查找


更多 Loader 的设计思考

推荐一些自定义的实用的 loader

  1. 全局异常监控,思路:给所有函数外面包裹 try{} catch(err) {console.log(err)} 语句
  2. style-loader
1
2
3
4
5
6
7
8
module.exports = function(source) {
const style = `
let style = document.createElement("style");
style.innerHTML = ${JSON.stringify(source)};
document.head.appendChild(style)
`
return style;
}



Plugins

  • 使用插件让打包更快捷、多样化

  • 在打包的某个生命周期,插件会帮助你做一些事情

下面介绍几个常用插件

html-webpack-plugin

作用:由于 webpack 默认打包不会生成 index.html 文件, htmlWebpackPlugin 会在打包结束后,自动生成一个html文件,并把打包生成的js自动引入到这个html文件中

插件运行生命周期:打包之后

参数:对象

template 指定一个模板,打包生成的 html 文件根据这个模板来生成,比如会多生成一个 <div id="root"></div>

1
2
3
new HtmlWebpackPlugin({
template: 'index.html'
})

clean-webpack-plugin

作用:打包前删除某个目录下的所有内容,主要用于删除之前的打包内容,防止重复

插件运行生命周期:打包之前

参数:数组形式

[‘要删除的文件夹名’]

1
new HtmlWebpackPlugin(['dist'])



自定义一个 Plugin

首先明确最基本的,编写 plugin 其实就是编写一个类并暴露出去给 Webpack 在打包的某个生命周期进行相关操作。

例如编写一个 copyright-webpack-plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
// copyright-webpack-plugin.js  我定义该文件位于根目录的 plugins 文件夹下

class CopyrightWebpackPlugin {
constructor() {
console.log('插件被使用了')
}

apply(compiler) {

}
}

module.exports = CopyrightWebpackPlugin;

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.config.js

const path = require('path');
const CopyRightWebpackPlugin = require('./plugins/copyright-webpack-plugin');

module.exports = {
mode: 'development',
entry: {
main: './src/index.js',
},
plugins: [
new CopyRightWebpackPlugin()
],
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}

这样,一个简易的 Plugin 就完成啦


更多

往插件里传参

在 Webpack 配置文件创建插件实例时,同时传入参数就行

1
2
3
4
5
6
plugins: [
new CopyRightWebpackPlugin({
name: 'Smoothzjc'
})
],

这样,就可以在类的构造函数中接收到该参数了

1
2
3
4
5
6
7
8
9
10
11
class CopyrightWebpackPlugin {
constructor(options) {
console.log('我是', options.name)
}

apply(compiler) {

}
}

module.exports = CopyrightWebpackPlugin;



不同生命周期

前面我有提到,在打包的某个生命周期,插件会帮助你做一些事情,

所以我们可以在 Webpack 打包的不同生命周期时,写一些想让 Webpack 帮我们做的事

常用生命周期:

  • emit 异步钩子,打包完成准备将打包内容放到生成目录前,即打包完成的最后时刻
  • compile 同步钩子,准备进行打包前

下面示例的一些参数解释:

compiler 配置的所有内容,包括打包相关的内容

compilation 本次打包的所有内容

如果你想在打包完成前新加一个文件到打包目录下,可以配置 compilation 的 assets 属性

相关代码运行在 apply 属性中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class CopyrightWebpackPlugin {
constructor(options) {
console.log('我是', options.name)
}

apply(compiler) {

// 同步钩子 compile
compiler.hooks.compile.tap('CopyrightWebpackPlugin', () => {
console.log('同步钩子 compile 生效');
})

// 异步钩子 emit
compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
compilation.assets['copyright.txt'] = {
// 文件内容配置在 source 属性中,该例子意思是文件内容是一个函数,且返回值是如下
source: funciton() {
return 'copyright write by Smoothzjc'
},
// 该文件大小
size: function() {
return 28
}
}
// 由于异步钩子,所以要运行回调函数
cb();
})
}
}

module.exports = CopyrightWebpackPlugin;



编写插件时进行调试

大部分调试工具都是基于 node 编写,在此我举个例子,如何在编写 plugin 时使用调试工具进行 debug

  1. 先添加脚本指令,通过 node 运行调试工具
1
2
3
4
5
6
7
8
// package.json

{
"scripts": {
"debug": node --inspect --inspect-brk node_modules/webpack/bin/webpack.js,
"build": "webpack"
}
}
  1. 在需要调试的地方打断点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class CopyrightWebpackPlugin {
constructor(options) {
console.log('我是', options.name)
}

apply(compiler) {

compiler.hooks.compile.tap('CopyrightWebpackPlugin', () => {
console.log('compiler');
})

compiler.hooks.emit.tapAsync('CopyrightWebpackPlugin', (compilation, cb) => {
debugger; // 在此处打断点
compilation.assets['copyright.txt'] = {
// 文件内容配置在 source 属性中,该例子意思是文件内容是一个函数,且返回值是如下
source: funciton() {
return 'copyright write by Smoothzjc'
},
// 该文件大小
size: function() {
return 28
}
}
// 由于异步钩子,所以要运行回调函数
cb();
})
}
}

module.exports = CopyrightWebpackPlugin;
  1. 控制台运行 debug 指令 npm run debug 后

  2. 打开浏览器按 F12 打开控制台,可以在开发者工具左上角看到 node 图标,点击即可进入 webpack 打包时经过的一些页面

image.png

  1. 可以将鼠标放上想查看的变量的属性

image.png

  1. 或在右边的 Watch 属性输入想查看的属性名称进行查看

image.png



Entry

打包的入口文件,并指定打包后生成的 js文件名

参数:字符串 或 一个对象,默认生成的文件名是 main,即 生成的文件名:'入口文件路径'

1
2
3
4
5
6
entry: './src/index.js'
或
entry: {
main: './src/index.js'
}
同时上面跟下面的等价

同时也可以打包成多个 js 文件,即多入口

1
2
3
4
entry:{
main: './src/index.js',
sub: './src/index.js'
}



Output

输出js文件名

参数:

  • filename 最后打包出来的 js 文件名,可以直接 bundle.js 指定,也可以 [name].js 根据 entry 指定的名字,也可以 [hash].js 根据 entry 指定的哈希值
  • chunkFilename 通过异步引入的文件的文件名
  • path 打包后所处文件夹和路径
  • publicPath 给打包出来的 js 文件的 src 引入路径都加个前缀,一般用于 cdn 配置

publicPath 详解

例如:

index.html 中引入 js 板块的代码

1
<script type="text/javascript" src="main.js"></script>

如果想将打包后的js 文件都放到 cdn上,减少打包后体积(此时该js就不用放在打包后的文件夹中了),例如

1
<script type="text/javascript" src="http://cdn.com.cn/main.js"></script>

可配置成如下

1
publicPath: 'http://cdn.com.cn'

output配置示例

1
2
3
4
5
6
output: {
publicPath: 'http://cdn.com.cn',
filename: '[name].js',
chunkFilename: '[name].chunk.js',
path: path.resolve(__dirname, 'dist')
}



resolve

模块引入时的更多拓展操作

  • extensions 模块引入时的后缀名查找
  • alias 为路径配置别名
1
2
3
4
5
6
resolve: {
extensions: ['.css', '.jpg', '.js', '.jsx'],
alias: {
'@': '/src/pages' // 当输入 @ 时,自动替换为 /src/pages
}
}

extensions

在项目中以下面的方式引入模块时

1
import Child from './child'

由于没写文件后缀名,会通过上面的配置按序查找,先找 child.css 存不存在如果存在就引入这个,如果不存在,则继续找 child.jpg 存不存在,一直找到 child.jsx,如果还不存在,就会报错

alias

为路径配置别名

1
2
3
alias: {
'@': '/src/pages'
}

解释:当输入 @ 时,自动替换为 /src/pages

常用场景:当你频繁使用根路径的方式引用某些文件,比如:

1
2
3
4
import a from '/src/pages/a.js';
import b from '/src/pages/b.js';
import c from '/src/pages/c.js';
import d from '/src/pages/d.js';

写多次 /src/pages 会很麻烦,用 @ 代替 /src/pages 会简化了输入,提高开发效率

注意:resolve 要进行合理配置,不然会降低性能,因为如果你要找 child.jsx,按照上面配置,要经过前面三个没必要的步骤


SourceMap

打包后的文件是否开启映射关系,他知道打包后文件与打包前源代码文件的代码映射

例如:知道 dist 目录下 main.js 文件96行报错,实际上对应的是 src 目录下 index.js 文件中的第一行

通常不用开启,默认 none 关闭,因为开启后会减缓打包速度和增大打包体积

参数

1
2
3
4
5
6
7
devtool: '参数'

'none' // 不开启source-map
'source-map' // 开启source-map进行映射
'inline-source-map' // 开启source-map进行映射的前提下,精确到哪一行哪一列
'cheap-source-map' // 开启source-map进行映射的前提下,只精确到哪一行
'eval' // 通过 eval 开启映射,效率最快,但不全面

推荐:

开发环境:devtool: 'cheap-module-eval-source-map'

生产环境(线上环境):devtool: 'cheap-module-source-map'

生产环境一般不用配置 devtool,但如果想报错时快速定位错误,可开启,建议使用上面推荐的参数

mode: 'development' 是开发环境

mode: 'production' 是生产环境

更多其他参数查看下表

image.png

SourceMap 配置示例

1
devtool: 'cheap-module-source-map'



WebpackDevServer

开启一个本地web服务器,可提高开发效率

webpack 指令(一般直接配置第二个脚本就行)

1
2
1. webpack --watch  保存后自动重新打包
2. webpack-dev-server 启动一个web服务器,并将对应目录资源进行打开,对应目录资源修改后保存会重新进行打包,且自动对网页进行刷新

我们下载的每个项目都经过两条指令(安装依赖 + 打包运行),下面以 create-react-app 脚手架生成的 react 项目为例

1
2
npm install
npm run start

第二步,其实就是运行 WebpackDevServer,你会发现,start 后会直接打开浏览器,且每次保存后都会自动重新打包、重新刷新网页。

WebpackDevServer 隐藏特性:打包后的资源不会生成一个 dist 文件夹,而是将打包后资源放在电脑内存中,能有效提高打包速度

参数

  • contentBase 将哪个目录下的文件放到 web 服务器上进行打开
  • open 是否在打包时同时打开浏览器访问项目对应预览 url
  • port 端口号
  • proxy 设置代理

WebpackDevServer 配置示例

1
2
3
4
5
6
7
8
webpackDevServer: {
contentBase: './dist',
open: true,
port: 8080,
proxy: {
'api': 'xxxxx'
}
}

拓展内容

其实相当于自己手写一个 webpack-dev-server,但人家官方已经帮我们写好一个各配置项都齐全的一个了,自己不用手写了,只是带大家进行拓展,理解一下 webpack-dev-server 背后的源码是如何搭配 node 实现的

在 node 中使用 webpack

查看官方文档 的 Node.js API 板块

在命令行中使用 webpack

查看官方文档 的 Command Line Interface 板块


Hot Module Replacement

热模块更新 HMR

当内容发生更改时,只有更改的那部分发生变化,其他已加载的部分不会重新加载(例如修改css样式,只有对应样式更改,js不会改变)

参数

  • hot 开启热模块更新
  • hotOnly 设置为 true 后,无论热更新是否开启,都禁用 webpackDevServer 的保存后自动刷新浏览器功能

也是配置到 webpackDevServer 里

HMR配置示例

1
2
3
4
5
6
7
8
const webpack = require('webpack');
devServer: {
hot: true,
hotOnly: true
}
plugins: [
new webpack.HotModuleReplacementPlugin()
]

上面是让 HMR 生效,下面是对 HMR 进行使用

1
2
3
4
5
6
7
8
// 例子:当 number.js 发生更改时,会调用函数
import number from './number';
number();
if(module.hot) {
module.hot.accept('./number', () => {
number();
})
}

但上面的 使用 一般不用写,因为其实很多地方都已经写好了,内置了 HMR 组件,例如css的话 css-loader 里面给你写好了,vue 的话 vue-loader 里写好了,react 的话 babel-preset 写好了。

如果你要引入比较冷门的数据文件,没有内置 HMR,就需要写。


使用 Babel 处理 ES6 语法

babel-loader、@babel/preset-env、@babel/polyfill

  • babel-loader 的配置选项可以单独写进 .babelrc 文件里
  • 除了将 ES6 转换成 ES5 还不够,有些低版本浏览器还需要将 Promise、Array.map 注入额外代码,需要引入 @babel/polyfill
1
2
3
4
5
6
7
@babel/preset-env 的参数

useBuiltIns: 'usage' // 对于使用的代码,才转译成 ES5 并打包至 dist 文件夹
targets: 该代码运行环境,根据环境来判定是否要做 ES6 的转化
{
chrome: '67' // 谷歌浏览器版本大于67,对 ES6 能直接正常编译,所以没必要做 ES5 的转换了
}

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
// 以下演示两种方案
presets: [["@babel/preset-env", {
targets: {
edge: '17',
firefox: '60',
chrome: '67',
safari: '11.1'
},
useBuiltIns: 'usage'
}]]

// 以下是生成第三方库或源组件,不希望 babel 污染时才使用,可替换上面的 presets
plugins: [["babel/plugin-transform-runtime", {
"corejs": 2,
"helpers": true,
"regenerator": true,
"useESModules": false
}]]
}
}
]
}

如果你想在 react 使用 babel

实现对 React 框架代码的打包

下载 @babel/preset-react

.babelrc 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
{
presets: [
[
"babel/preset-env", {
targets: {
chrome: "67",
},
useBuiltIns: "usage"
}
],
"@babel/preset-react"
]
}

Webpack 高级概念

webpack 的 Guides 板块

官方文档

Tree Shaking

只会对引入进行使用的代码进行打包,没引入进行使用的代码不会打包(可减少代码体积),webpack 2.0 之后默认开启该功能

  • 只支持 ES Module(静态引入)
  • 不支持 Common JS(动态引入)
1
2
import { } from '' // ESM  支持
const xxx = require('') // Common JS 不支持

development(开发环境) 默认不打开 Tree Shaking

因为如果开发环境进行调试时,如果每次重新编译打包后的代码都进行了 Tree Shaking,那就会让 debug 时代码行数对不上,不利于调试

如果你想开发环境打开

1
2
3
4
5
6
7
8
9
10
// package.json
{
"sideEffects": false 或 数组
}

如果是数组,则配置你不想哪些代码进行 Tree Shaking
例如:如果不想 css 文件进行 Tree Shaking,则
{
"sideEffects": ["*.css"]
}



Development 和 Production 模式的区分打包

通常来说,两个环境的 webpack 配置文件不会有变化,但如果非得区分,可以不同文件形式

webpack.dev.js 根据名字可知,是 development(开发环境)

webpack.proud.js 根据名字可知,是 production(生产环境)

打包脚本也要更改,如果区别开

1
2
3
4
5
6
7
8
9
10
// package.json
{
"scripts": {
"dev-build": webpack --config webpack.dev.js, // 相对路径

或

"proud-build": webpack --config webpack.proud.js, // 相对路径
}
}



Webpack 和 Code Splitting

为什么要进行代码分割?

如果用户一个页面要加载的 js 文件很大,足足有2MB,那么用户每次访问这个页面,都要加载完2MB的资源页面才能正常显示,但其中可能有很多代码块是当前页面不需要使用的,那么将没使用的代码分割成其他 js 文件,当要使用的时候再进行加载、当页面变更时只有那部分进行重新加载,这样可以大大加快页面加载速度。

即通过配置进行合理的代码分割,能让文件结构更清晰,项目运行更快,比如如果用到 lodash 库,就分割出来。

下面通过一个例子进行解释:

1
2
3
4
5
6
7
8
9
假设现在有 main.js(2MB),里面含有 lodash.js(1MB)
1. 该种方式
首次访问页面时,加载 main.js(2MB)
当页面业务逻辑发生变化时,又要重新加载2MB内容

2. 将 lodash.js 抽离出来,即现在是 main.js(1MB) 和 lodash.js(1MB)
由于浏览器的并行机制,首次访问页面时,并行渲染两个1MB的文件是要比只渲染一个2MB的文件要快的。
其次,当页面业务逻辑发生变化时,只要重新加载 main.js(1MB) 即可。

同时,如果对于两个文件,如果都有用到某个模块,如果两个文件各自写一次这个模块,就会有重复,此时如果将这个公共模块抽离出来,两个文件分别去引用他,那么就会减少一次该模块的撰写(减少包体积)。

即对代码进行合理分割,还可以加快首屏加载速度,加快重新打包速度(包体积减少)

代码分割:通俗解释就是将一坨代码分割成多个 js 文件

代码分割自己可以手动,例如我们平时的抽离公共组件,但为什么现在 webpack 几乎跟 Code Splitting 绑定在一起了呢?

因为 webpack中有一个插件 SplitChunksPlugin,会让代码分割变得非常简单, 这也是 webpack 的一个强大的竞争力点

1
2
3
4
5
6
7
8
// webpack.config.js
{
optimization: {
splitChunks: {
chunks: 'all'
}
}
}

总结一下

Webpack 进行 Code Splitting 有两种方式

1. 通过配置插件 SplitChunksPlugin

1
2
3
4
5
6
7
8
// webpack.config.js
{
optimization: {
splitChunks: {
chunks: 'all'
}
}
}
然后编写同步代码
1
2
import _ from 'lodash';
// 编写业务代码

2. 通过异步地动态引入

1
2
3
4
5
6
7
8
9
10
11
function getComponent() {
return import('lodash').then(({ default: _ }) => {
let element = document.createElement('div');
element.innerHTML = _.join(['zjc', 'handsome'], '-');
return element;
})
}

getComponent().then(element => {
document.body.appendChild(element);
})

当然,想要支持异步地动态引入某个模块,需要先下载 babel-plugin-dynamic-import-webpack

然后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// .babelrc
{
presets: [
[
"@babel/preset-env", {
targets: {
chrome: "67"
},
useBuiltIns: 'usage'
}
],
"@babel/preset-react"
],
plugins: ["dynamic-import-webpack"]
}



SplitChunksPlugin

该板块会对该插件配置参数进行详解

重要作用是可以减少包体积,缓存组中的 reuseExistingChunk 属性:开启true后,如果要分割进该组的模块在之前已经被缓存到了某个组内,那就不会再缓存

配置示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'all', // 只对哪些代码进行分割(all 的话全部都,async 的话只对异步代码进行分割)
minSize: 30000, // 要做代码分割的引入库的最小大小,即30000代表如果你引入的库大小超过30KB才做代码分割
minRemainingSize: 0,
minChunks: 1, // 一个库被引入至少多少次才做代码分割
maxAsyncRequests: 5, // 同时分割的库数
maxInitialRequests: 3, // 最多能分割出多少个 js 文件
automaticNameDelimiter: '~', // 代码分割出来的 js 文件名 和下面的组名 用什么符号进行连接
name: 'true' // 当为 true 时,下面组的 filename 属性才会生效
enforceSizeThreshold: 50000,
// 缓存组,当打包同步代码时,除了走完上面的设置流程,还会额外再走进下面的组设置,即代码分割进下面符合要求的各组
cacheGroups: {
vendors: {
test: /[\/]node_modules[\/]/, // 只有 node_modules 里面的库被引入时,才做代码分割到 vendor 组
priority: -10, // 优先级,越大优先级越高
reuseExistingChunk: true,
filename: 'vendors.js', // vendors 组的代码分割都分割到 filename 文件内
name: 'vendors' // 生成 vendors.chunk.js,该属性和上面的 filename 写一个就行
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true, // 开启true后,如果要分割进该组的模块在之前已经被缓存到了某个组内,那就不会再缓存
},
},
},
},
};

更多配置项请看官方文档


Lazy Loading

通过异步地动态引入某个模块,通常在路由配置页面,对引入的组件进行懒加载

1
2
3
4
5
6
7
8
9
10
11
function getComponent() {
return import('lodash').then(({ default: _ }) => {
let element = document.createElement('div');
element.innerHTML = _.join(['zjc', 'handsome'], '-');
return element;
})
}

getComponent().then(element => {
document.body.appendChild(element);
})



打包分析

应用 webpack 官方工具 Bundle Analysis


Preloading、Prefetching

Preloading 懒加载,当进行某个事件时,才会引入某个组件,例如点击某个元素时,才会 import 引入组件

PreFetching 预加载,当主页面的核心功能和交互都加载完成后,如果网络空闲,那么就会预先加载某个组件,这样在某个时刻引入该组件时,就能一下子打开

实现预加载:

webpack 搭配魔法注释,在引入的路径前加上 webpackPrefetch: xxx

1
2
3
4
5
document.addEventListener('click', () => {
import(/* webpackPrefetch: true */ './click.js').then((func) => {
func()
})
})

这也是 webpack 最为推荐的首屏加载优化手段,异步引入 + 预加载


CSS 文件的代码分割

前面的 Code splitting 都是针对 js 的,将 js 文件进行代码分割,而打包出来的 css 都在 js 文件里

如果想 CSS 文件也代码分割出来,可以使用 MiniCssExtractPlugin 插件,该插件由于依赖热更新,所以只能运行在线上打包环境中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
plugins: [ new MiniCssExtractPlugin() ],
module: {
rules: [
{
test: /.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
};

且默认将引入的各 css 文件合并到同一个 css 文件里

如果你想代码分割出来的 css 文件做代码压缩,重复属性合并到一起,可以使用 OptimizeCSSAssetsPlugin 插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = {
optimization: {
minimizer: [
new OptimizeCSSAssetsPlugin({})
]
},
plugins: [ new MiniCssExtractPlugin() ],
module: {
rules: [
{
test: /.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
}

如果想多入口引入的 css文件也合并在一起,同样需要用到代码分割

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
styles: {
name: 'styles', // 都打包进 styles.css 的文件里
test: /.css$/,
chunks: 'all',
enforce: true // 无视默认参数,如果你在代码分割时设置过一些参数,当你对css文件进行代码分割时可以无视
}
}
},
plugins: [ new MiniCssExtractPlugin() ],
module: {
rules: [
{
test: /.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
}
}



Webpack 与浏览器缓存(Cache)

当浏览器加载过某项资源时会在本地进行缓存记忆,这样当用户下次再重新访问该页面加载该资源时,浏览器可以根据缓存过的文件快速加载该资源,直到该文件名发生改变,浏览器才知道该文件发生改变,需要重新渲染。

浏览器该特性的作用

可以加快加载速度,当该页面某个部分发生改变时,可以只重新渲染改变的部分,做到局部渲染

问题:如何保证每次打包时只有做了更改的文件的文件名发生更改,没做更改的文件的文件名不变?

如果在 webpack 配置文件中的 output 属性设置为如下

1
2
3
4
output: {
filename: '[name].js',
chunkFilename: '[name].chunk.js'
}

如果对项目中文件做了更改,而文件名没变,打包的 filename 没变,由于浏览器已经加载过该文件,缓存了这个文件名,当你该文件发生更改而文件名没改变时,浏览器不会重新渲染,为了让每次文件更改后文件名都发生改变,可以使用哈希值命名

1
2
3
4
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].chunk.js'
}

让 webpack 根据文件内容创建对应独立的一个哈希值,同时在文件内容发生改变时,由于哈希值也会改变,所以文件名也会改变

拓展

在老版本 webpack 中(webpack 4.0 以下),如果在每次运行 npm run build 进行打包时,发现即使文件没变更,每次重新打包他们的哈希值都会变,可以通过配置 runtimeChunk 解决

1
2
3
4
5
6
optimization: {
runtimeChunk: {
name: 'runtime'
}
}
// 同时打包后会多生成一个 runtime.hash.js 的文件,hash每次都不一样

runtimeChunk 原理

假设我通过 webpack 打包出来有两个 js文件,A文件是业务逻辑相关代码,B文件是作代码分割时的库代码(例如 lodash),由于业务逻辑中有引入库的操作,所以他们之间会有关联,而这个关联的相关代码同时存在于A和B文件(这种关联我们一般称之为 manifest),而在每次打包时,manifest 内置的包和包的关系、js和js文件的嵌套关系会发生微小改变,所以即使A和B文件没做更改时,打包出来的哈希值还是会发生变化。

通过配置 runtimeChunk ,可以将这些 manifest 相关的代码抽离出来单独放在 runtimeChunk 中,因此每次重新打包,改变的只有runtime.hash.js,A文件只有业务逻辑,B文件只有库文件,A和B文件内都不会有任何 manifest 的代码了,这样A和B文件都不会发生改变了,因此哈希值就不会变了


Shimming

打包兼容,自动引入

在你页面使用到某个库,但没进行引入时,webpack 打包后的代码会帮你自动、”偷偷”进行引入

1
2
3
4
5
6
7
plugins: [
new webpack.ProvidePlugin({
$: 'jquery', // 当使用 $ 时,会自动在那个页面引入 jquery 库
_: 'lodash', // 当使用 _ 时,会自动在那个页面引入 lodash 库
_join: ['lodash', 'join'] // 当输入 _join 时,会引入 lodash库的 join 方法
})
]



更多

环境变量的使用

对于开发环境和生产环境,可能有时真的需要单独写不同的 webpack 配置文件进行配置

而单独写,肯定有许多属性是重复的,又不想多写,怎么办呢?

例如A和B文件是两个环境中不同的配置参数,而C文件是共同的配置文件,那么开发环境打包时希望按照 A+C 的打包规则,生产环境打包时希望按照 B+C的打包规则

可以通过 webpack-merge 配置 开发环境 和 生产环境 的 不同配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// webpack.common.js

const merge = require('webpack-merge');
const devConfig = require('./webpack.dev.js'); // 假设有这个文件,且导出的是开发环境的一些配置参数
const prodConfig = require('./webpack.prod.js'); // 假设有这个文件,且导出的是生产环境的一些配置参数
const commonConfig = {
// 这里放开发和生产环境共有的一些配置参数
}

module.exports = (env) => {
// 如果 env 参数存在,且传进来了 production 属性,说明是生产环境
if(env && env.production) {
return merge(commonConfig, prodConfig);
} else {
return merge(commonConfig, devConfig);
}
}

同时 package.json 文件中修改配置

1
2
3
4
5
6
{
scripts: {
"dev": "webpack-dev-server --config webpack.common.js",
"build": "webpack --env.production --config webpack.common.js" // 通过--env.production 传递参数进文件,执行线上环境的打包配置文件
}
}

当然,脚本配置时,向配置文件传入参数也可以如下方式:

1
"build": "webpack --env production --config webpack.common.js"

同时,webpack.common.js 参数判断时也要改成

1
2
3
4
5
6
7
8
module.exports = (env, production) => {
// 如果 env 参数存在,且传进来了 production 属性,说明是生产环境
if(env && production) {
return merge(commonConfig, prodConfig);
} else {
return merge(commonConfig, devConfig);
}
}



🎁 谢谢你读完本篇文章,希望对你能有所帮助,如有问题欢迎各位指正。
🎁 我是 Smoothzjc,如果觉得写得可以的话,请点个赞吧❤
🎁 我也会在今后努力产出更多好文。
🎁 感兴趣的小伙伴也可以关注我的公众号:Smooth前端成长记录,公众号同步更新

写作不易,「点赞」+「收藏」+「转发」 谢谢支持❤

往期推荐

《都2022年了还不考虑来学React Hook吗?6k字带你从入门到吃透》

《Github + hexo 实现自己的个人博客、配置主题(超详细)》

《10分钟让你彻底理解如何配置子域名来部署多个项目》

《一文理解配置伪静态解决 部署项目刷新页面404问题

《带你3分钟掌握常见的水平垂直居中面试题》

《React实战:使用Antd+EMOJIALL 实现emoji表情符号的输入》

《【建议收藏】长达万字的git常用指令总结!!!适合小白及在工作中想要对git基本指令有所了解的人群》

《浅谈javascript的原型和原型链(新手懵懂想学会原型链?看这篇文章就足够啦!!!)》


作者:Smooth

文章链接:http://example.com/2022/03/14/%E4%B8%80%E4%BB%BD%E4%B8%8D%E5%8F%AF%E5%A4%9A%E5%BE%97%E7%9A%84%20Webpack%20%E5%AD%A6%E4%B9%A0%E6%8C%87%E5%8D%97%EF%BC%881%E4%B8%87%E5%AD%97%E9%95%BF%E6%96%87%E5%B8%A6%E4%BD%A0%E5%85%A5%E9%97%A8%20Webpack%20%E5%B9%B6%E6%8E%8C%E6%8F%A1%E5%B8%B8%E7%94%A8%E7%9A%84%E8%BF%9B%E9%98%B6%E9%85%8D%E7%BD%AE%EF%BC%89/

版权说明:本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!