Webpack 优化篇

前言

1
2
npm init -y
npm install webpack webpack-cli webpack-dev-server html-webpack-plugin css-loader style-loader mini-css-extract-plugin babel-loader @babel/core @babel/preset-env @babel/preset-react file-loader --save-dev
1
npm install react react-dom --save-dev

package.json

1
2
3
4
5
"scripts": {
"dev": "webpack-dev-server --env=development",
"dev:build": "webpack --env=development",
"build": "webpack --env=production"
},

新建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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = mode => {
return {
mode: mode, // 这个env就是package.json script里面=后面传过来的值
entry: "./src/index.js", // 入口
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist") // 出口
},
module: {
rules: [
{
test: /\.(png|jpe?g|gif)$/,
use: "file-loader"
},
{
test: /\.js$/,
use: {
loader: "babel-loader",
options:{ // .babelrc 也可以写在这个文件中
presets:[
"@babel/preset-env",
"@babel/preset-react"
]
}
}
},
{
test: /\.css$/,
use: [
mode !== "development"
? MiniCssExtractPlugin.loader
: "style-loader",
"css-loader"
]
}
]
},
plugins: [
new PurgecssPlugin({
paths: glob.sync(`${path.join(__dirname, "src")}/**/*`, { nodir: true }) // 不匹配目录,只匹配文件
}),
mode !== "development" &&
new MiniCssExtractPlugin({
filename: "css/[name].css"
}),
new HtmlWebpackPlugin({
template: "./src/template.html",
filename: "index.html"
})
].filter(Boolean)
};
};

1.删除无用的Css样式

先来看编写的代码

1
2
3
4
import './index.css'
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(<div>hello</div>,document.getElementById('root'));

index.css

1
2
3
4
5
6
body{
background: red
}
.class1{
background: red
}
1
npm install purgecss-webpack-plugin glob --save-dev

这里的.class1显然是无用的,我们可以搜索src目录下的文件,删除无用的样式

1
2
3
4
5
6
7
8
9
// webpack.config.js
const glob = require('glob'); // 主要功能就是查找匹配的文件
// 主要的作用删除无意义的css,只能配合mini-css-extract-plugin使用
const PurgecssPlugin = require('purgecss-webpack-plugin');

// 需要配合mini-css-extract-plugin插件
mode !== "development" && new PurgecssPlugin({
paths: glob.sync(`${path.join(__dirname, "src")}/**/*`, { nodir: true }) // 不匹配目录,只匹配文件
}),

编译 npm run build
可以看到 dist/css/main.css 中没有.class这个无用的样式

2.图片压缩插件

将打包后的图片进行优化

1
npm install image-webpack-loader --save-dev

在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
27
28
29
30
31
32
33
{ // 降低分辨率 清晰度
test: /\.(png|jpe?g|gif)$/,
use: [
{
loader: 'file-loader'
},
mode !== 'development' && {
// 可以在使用file-loader之前 对图片进行压缩
loader: 'image-webpack-loader',
options: { // nodejs.org => image-webpack-loader
mozjpeg: {
progressive: true,
quality: 65
},
// optipng.enabled: false will disable optipng
optipng: {
enabled: false,
},
pngquant: {
quality: [0.65, 0.90],
speed: 4
},
gifsicle: {
interlaced: false,
},
// the webp option will enable WEBP
webp: {
quality: 75
}
}
}
].filter(Boolean)
},

可以发现图片大小是有了明显的变化

3.CDN加载文件

我们希望通过cdn的方式引入资源

1
npm install jquery --save

index.js

1
2
//import $ from 'jquery';
console.log($)

全局引入cdn https://www.bootcdn.cn/

1
2
<!-- template.html -->
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>

但现在是在代码中还希望引入jquery来获得提示

1
2
import $ from 'jquery'; // 注释打开, 使这个文件应该是cdn加载进来的,如下配置
console.log('$',$)

但是打包时依然会将jquery进行打包
webpack.config.js配置如下

1
2
3
externals:{
'jquery':'$' // 不去打包代码中的jquery,这个$代表文件中写的$,jquery代表文件中引入的这个jquery包
},

使用插件
在配置文件中标注jquery是外部的,这样打包时就不会将jquery进行打包了,不用在html文件中引入cdn

1
npm i --save-dev add-asset-html-cdn-webpack-plugin  // (这个用的比较少)
1
2
3
4
const AddAssetHtmlCdnPlugin = require('add-asset-html-cdn-webpack-plugin')
new AddAssetHtmlCdnPlugin(true,{
'jquery':'https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js'
})

4.Tree-shaking && Scope-Hoisting

4.1 Tree-shaking

tree-shaking 默认只支持 es6语法, 因为es6叫静态导入(import),像动态的导入(require(“”)),这种可以放到if else里面,涉及到业务逻辑的,打包的时候是不知道是否需要打包的。

顾名思义就是将没用的内容摇晃掉,来看下面代码

index.js

1
2
import { minus } from "./calc";
console.log(minus(1,1));

calc.js

1
2
3
4
5
6
export const sum = (a, b) => {
return a + b + 'sum';
};
export const minus = (a, b) => {
return a - b + 'minus';
};

观察上述代码其实我们主要使用minus方法, 没有使用sum方法

本项目中使用的是webpack4,只需要将mode设置为production即可开启tree shaking

默认mode:production时 即生产环境下,会自动tree-shaking,但是打包后sum依然会被打印出来
在开发环境下默认tree-shaking不会生效,可以配置标识提示
webpack.base.js

1
2
3
optimization:{
usedExports:true // 配置了之后在开发模式下,打包生成的bundle文件中会有提示文字没有用sum这个模块,但是还是会打包出来
}

移除副作用

calc.js

1
2
3
4
5
6
7
import {test} from './test';
export const sum = (a, b) => {
return a + b + 'sum';
};
export const minus = (a, b) => {
return a - b + 'minus';
};

test.js

1
2
3
4
export const test = ()=>{
console.log('hello')
}
console.log(test());

可以看到上面calc.js中引入了test,但是没有用到test,test文件内部有他自己的业务逻辑。
副作用的代码可能在开发时是无意义的,但是webpack打包后'hello'还是会被打印出来。

package.json中配置

1
"sideEffects":false, // 表示不要副作用

如果页面中引入的变量没有使用,就移除。

注意

如果这样设置,默认就不会导入css文件啦,因为我们引入css也是通过import './style.css'

这里重点就来了,tree-shaking主要针对es6模块,我们可以使用require语法导入css,但是这样用起来有点格格不入,所以我们可以配置css文件不是副作用
package.json文件:

1
2
3
"sideEffects":[
"**/*.css"
]
  • webpack配置目的:
    1) 打包大小
    2)打包速度
    3)模块拆分

4.2 Scope Hoisting

每个模块都是个函数,函数过多会导致内存过大,每调用一个函数都会产生一个作用域。
作用域提升,可以减少代码体积,节约内存

1
2
3
4
5
6
7
8
let a = 1;
let b = 2;
let c = 3;
let d = a+b+c
export default d;
// 引入d
import d from './d';
console.log(d)

最终打包后的结果会变成 console.log(6)

  • 代码量明显减少
  • 减少多个函数后内存占用也将减少

5.DllPlugin && DllReferencePlugin

一般用在开发环境下,dll功能:在开发之前就先抽离好 打好包,以后就不用打包了。
每次构建时第三方模块都需要重新构建,这个性能消耗比较大,我们可以先把第三方库打包成动态链接库,以后构建时只需要查找构建好的库就好了,这样可以大大节约构建时间

1
2
3
4
import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(<h1>hello</h1>,document.getElementById('root'))

5.1 DllPlugin

动态链接库

这里我们可以先将reactreact-dom单独进行打包

单独打包创建webpack.dll.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');
module.exports = {
entry:['react','react-dom'], // 将第三方模块react,react-dom进行打包放到react.dll.js中
mode:'production',
output:{
filename:'react.dll.js',
path:path.resolve(__dirname,'dll'),
library:'react', // 打包后接收自执行函数的名字叫react
libraryTarget: 'var', // 打包后默认用var模式接收 这个打包后的自执行函数,这里也可以写“commonjs” “commonjs2” “umd” “this”...
},
plugins:[
new DllPlugin({
name:'react',
path:path.resolve(__dirname,'dll/manifest.json')// 生成一个缓存列表
})
]
}

执行"webpack --config webpack.dll.js命令,可以看到dll目录下创建了两个文件分别是manifest.json,react.dll.js

本地使用了import React 这样的语法,需要先去 manifest.json查找,找到后会加载对应的库的名字,可能会引用某个模块,会去.dll.js文件中查找

5.2 DllReferencePlugin

现在我们的项目中可以引用刚才打包好的动态链接库

1
npm install add-asset-html-webpack-plugin --save-dev

webpack.config.js

1
2
3
4
5
6
7
8
9
10
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');
// 构建时会引用动态链接库的内容
new DllReferencePlugin({
manifest:path.resolve(__dirname,'dll/manifest.json')
}),
// 需要手动引入react.dll.js
new AddAssetHtmlWebpackPlugin(
{ filepath: path.resolve(__dirname,'dll/react.dll.js') }
)

使用DllPlugin可以大幅度提高构建速度

6.动态加载

实现点击后动态加载文件

1
2
3
4
5
6
7
8
9
10
11
let btn = document.createElement('button');
btn.innerHTML = '点击加载视频';
btn.addEventListener('click',()=>{
// 动态导入 类比 路由的懒加载 import语法
// import() 和 import是两个东西
// import()返回的是一个promise
import('./video').then(res=>{
console.log(res.default);
});
});
document.body.appendChild(btn);

这就是懒加载原理,webpack会默认把 这样import('./video') 的语法单独打包成一个文件,等点击的时候会使用jsonp动态的去加载这个文件
可以实现代码分割

给动态引入的文件增加名字

1
2
3
4
5
6
7
8
output:{
filename:'bundle.js', // 同步打包的名字
chunkFilename:'[name].min.js', // 异步打包的名字,[name]默认从0开始
}
// 也可以自定义 如下:
import(/* webpackChunkName: "video" */ './video').then(res=>{
console.log(res.default);
})

这样打包后的结果最终的文件就是 video.min.js

原理就是 jsonp 动态导入,动态创建 script 标签 去加载这个文件。

7.打包文件分析工具

可以分析打包后的代码依赖关系,包括打包的大小。

安装 webpack-bundle-analyzer插件

1
npm install --save-dev webpack-bundle-analyzer

使用插件

1
2
const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer');
mode !== "development" && new BundleAnalyzerPlugin()

默认就会展现当前应用的分析图表

8.SplitChunks

这是webpack自身的一个配置,基本上每个开发者都会用到,它可以在生产环境下帮我们把很多的第三方包进行分离。
dllPlugin 不要和 splitChunks 共同使用,一般会先走dllPlugin,就不会走splitChunks。

我们在来看下SplitChunks这个配置,他可以在编译时抽离第三方模块、公共模块

将项目配置成多入口文件

1
2
3
4
entry:{
a:'./src/a.js',
b:'./src/b.js'
}

我们让a,b两个模块同时引用jquery,别忘了去掉之前的externals配置

如上所示,同样的 jquery被打包了2次

  • 抽离第三方模块
    1)不要和业务逻辑放在一起
    2)增加缓存

配置SplitChunks插件

配置在此,已做部分修改,默认配置可在https://npmjs.com 上自行查找。

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
optimization:{
splitChunks: {
// initial 只操作同步的,all 所有的,async异步的(默认)
chunks: 'async', // 默认支持异步的代码分割import()
minSize: 30000, // 文件超过30k 就会抽离
maxSize: 0, // 没有最大上限
minChunks: 1, // 最少模块引用一次才抽离
maxAsyncRequests: 5, // 最大异步请求数,最多5个
maxInitialRequests: 3, // 最大初始化请求数,即最多首屏加载3个请求
automaticNameDelimiter: '~', // 抽离的命名分隔符 xxx~a~b (如果是a、b文件引用)
automaticNameMaxLength: 30, // 名字最大长度
name: true,
cacheGroups: { // 缓存组 这里面也可以配置上面的配置
vendors: { // 先抽离第三方
test: /[\\/]node_modules[\\/](jquery)|(lodash)/,
priority: -1
},
react:{
test: /[\\/]node_modules[\\/](react|react-dom)/,
priority: -2,
},
default: {
minChunks: 2,
priority: -20, // 优先级, -2比 -20大
reuseExistingChunk: true
}
}
}
}

我们将async改为initial

可以看到jquery 被单独打包出来了。

注意
我们再为每个文件动态导入lodash库,并且改成async

1
import('lodash')

可以看到现在配置的是异步代码分割,同步的jquery还是分别在a、b中打包了,而异步引入的 loader 被抽离出来打包。

注意
现在为每个入口引入c.js,并且改造配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
splitChunks: {
chunks: 'all', // 不管异步同步 全部抽离
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minSize:1, // 这里将大小改成1,表示大于1,并且如下的minChunks最少引用2次就会被抽离打包,不是第三方模块,被引入两次也会被抽离
minChunks: 2,
priority: -20,
}
}
}

9.热更新

模块热替换(HMR - Hot Module Replacement)是 webpack 提供的最有用的功能之一。它允许在运行时替换,添加,删除各种模块,而无需进行完全刷新重新加载整个页面

  • 保留在完全重新加载页面时丢失的应用程序的状态
  • 只更新改变的内容,以节省开发时间
  • 调整样式更加快速,几乎等同于就在浏览器调试器中更改样式

启用热更新,默认样式可以支持热更新,如果不支持热更新则采用强制刷新

1
2
3
4
devServer:{
hot:true
}
new webpack.NamedModulesPlugin(),

js支持热更新

1
2
3
4
5
import sum from './sum';
console.log(sum(1,2));
if(module.hot){ // 如果支持热更新
module.hot.accept(); // 当入口文件变化后重新执行当前入口文件
}

10.IgnorePlugin

忽略 importrequire语法

1
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)

当代码中是在全局的地方配置的语言包,如下:

1
2
3
4
5
6
import {ConfigProvider} from 'antd';
import zhCN from 'antd/es/locale/zh_CN';

<ConfigProvider locale={zhCN}>
......
</ConfigProvider>

所以配置如下:

1
new webpack.IgnorePlugin(/^\.\/locale$/, /antd\/es$/)

打包之后的体积分析,并没有变化…待解释。

11.费时分析

可以计算每一步执行的运行速度

1
2
3
4
const SpeedMeasureWebpackPlugin = require('speed-measure-webpack-plugin');
const smw = new SpeedMeasureWebpackPlugin();
module.exports =smw.wrap({
});

12.noParse

module.noParse,对类似jq这类依赖库,内部不会引用其他库,我们在打包的时候就没有必要去解析,这样能够增加打包速率

1
noParse:/jquery/

这里用noParse:/lodash/测试,构建和打包时间分析都没有明显的变化….待解释。

13.resolve

1
2
3
4
5
resolve: {
extensions: [".js",".jsx",".json",".css"],
alias:{},
modules:['node_modules']
},

这里用modules:[path.resolve(__dirname, "/node_modules")]测试,报依赖错误…待解释。

14.include/exclude

在使用loader时,可以指定哪些文件不通过loader,或者指定哪些文件通过loader

1
2
3
4
5
6
{
test: /\.js$/,
use: "babel-loader",
// include:path.resolve(__dirname,'src'),
exclude:/node_modules/
},

exclude 权重更高,exclude 会覆盖 include 里的配置。
webpack 里几乎所有 loader 都支持 include 和 exclude 属性,上述例子虽然是基于 babel-loader,但以上结论一样适用于其它 loader。

[举例]

[使用include]

1
2
3
4
5
{
test: /\.js$/,
use: 'babel-loader',
include: path.resolve(__dirname, "../src")
},

没有配置include时,打包耗时如下:

配置了include后,打包耗时如下:

[使用exclude]

1
2
3
4
5
{
test: /\.js$/,
use: 'babel-loader',
exclude: path.resolve(__dirname, "../node_modules/")
},

配置了exclude后,构建耗时如下:

15.happypack

多线程打包,我们可以将不同的逻辑交给不同的线程来处理

1
npm install --save-dev happypack

使用插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const HappyPack = require('happypack');
rules:[
{
test: /\.js$/,
use: 'happypack/loader?id=jsx'
},

{
test: /\.less$/,
use: 'happypack/loader?id=styles'
},
]
new HappyPack({
id: 'jsx',
threads: 4,
loaders: [ 'babel-loader' ]
}),

new HappyPack({
id: 'styles',
threads: 2,
loaders: [ 'style-loader', 'css-loader', 'less-loader' ]
})