webpack
是如何打包资源优化,你有了解吗?或者一个经常被问的面试题,首屏加载如何优化,其实无非就是从http请求
、文件资源
、图片加载
、路由懒加载
、预请求
,缓存
这些方向来优化,通常在使用脚手架中,成熟的脚手架已经给你做了最大的优化,比如压缩资源,代码的tree shaking
等。
本文是笔者根据以往经验以及阅读官方文档总结的一篇关于webpack打包
方面的长文笔记,希望在项目中有所帮助。
正文开始...
在阅读之前,本文将从以下几个点去探讨webpack的打包优化
1、webpack
如何做treeShaking
2、webpack
的gizp压缩
3、css
如何做treeShaking
,
4、入口依赖文件拆包
5、图片资源
加载优化
treeShaking
在官网中有提到treeShaking[1],从名字上中文解释就是摇树,就是利用esModule
的特性,删除上下文未引用的代码。因为webpack可以根据esModule
做静态分析,本身来说它是打包编译前输出,所以webpack
在编译esModule
的代码时就可以做上下文未引用的删除操作。
那么如何做treeshaking
?我们来分析下
快速初始化一个webpack项目
在之前我们都是手动配置搭建webpack
项目,webpack
官方提供了cli
快速构建基本模版,无需像之前一样手动配置entry
、plugins
、loader
等
首先安装npm i webpack webpack-cli
,命令行执行
npx webpack init
一系列初始化操作后,就生成以下代码了
默认的webpack.config.js
// Generated using webpack-cli https://github.com/webpack/webpack-cli
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const WorkboxWebpackPlugin = require("workbox-webpack-plugin");
const isProduction = process.env.NODE_ENV == "production";
const stylesHandler = MiniCssExtractPlugin.loader;
const config = {
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
},
devServer: {
open: true,
host: "localhost",
},
plugins: [
new HtmlWebpackPlugin({
template: "index.html",
}),
new MiniCssExtractPlugin(),
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
module: {
rules: [
{
test: /.(js|jsx)$/i,
loader: "babel-loader",
},
{
test: /.less$/i,
use: [stylesHandler, "css-loader", "postcss-loader", "less-loader"],
},
{
test: /.css$/i,
use: [stylesHandler, "css-loader", "postcss-loader"],
},
{
test: /.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i,
type: "asset",
},
// Add your rules for custom modules here
// Learn more about loaders from https://webpack.js.org/loaders/
],
},
};
module.exports = () => {
if (isProduction) {
config.mode = "production";
config.plugins.push(new WorkboxWebpackPlugin.GenerateSW());
} else {
config.mode = "development";
}
return config;
};
运行命令npm run serve
现在修改一下index.js
,并在src
中增加utils
目录
// utils/index.js
export function add(a, b) {
return a b
}
export function square(x) {
return x * x;
}
index.js
import { add } from './utils'
console.log("Hello World!");
console.log(add(1, 2))
在index.js
中我只引入了add
,相当于square
这个函数在上下文中并未引用。
usedExports
不过我还需要改下webpack.config.js
...
module.exports = () => {
if (isProduction) {
config.mode = "production";
config.plugins.push(new WorkboxWebpackPlugin.GenerateSW());
} else {
config.mode = "development";
config.devtool = 'source-map'
config.optimization = {
usedExports: true
}
}
return config;
};
注意我只增加了devtool:source-map
与optimization.usedExports = true
我们看下package.json
"scripts": {
"test": "echo "Error: no test specified" && exit 1",
"build": "webpack --mode=production --node-env=production",
"build:dev": "webpack --mode=development",
"build:prod": "webpack --mode=production --node-env=production",
"watch": "webpack --watch",
"serve": "webpack serve"
},
默认初始化已经给们预设了多个不同的打包环境,因此我只需要运行下面命令就可以选择开发环境了
代码语言:javascript复制npm run build:dev
此时我们看到打包后的代码未引入的square
有一行注释
/* unused harmony export square */
function add(a, b) {
return a b;
}
function square(x) {
return x * x;
}
square
上下文未引用,虽然给了标记,但是未真正清除。
光使用usedExports:true
还不行,usedExports 依赖于 terser 去检测语句中的副作用
,因此需要借助terser
插件一起使用,官方webpack5
提供了TerserWebpackPlugin
这样一个插件
在webpack.config.js
中引入
...
const TerserPlugin = require("terser-webpack-plugin");
...
module.exports = () => {
if (isProduction) {
config.mode = "production";
config.plugins.push(new WorkboxWebpackPlugin.GenerateSW());
} else {
config.mode = "development";
config.devtool = 'source-map'
config.optimization = {
usedExports: true, // 设置为true 告诉webpack会做treeshaking
minimize: true, // 开启terser
minimizer: [new TerserPlugin({
extractComments: false, // 是否将注释剥离到单独文件,默认是true
})]
}
}
return config;
};
你会发现,那个square
函数就没有了
如果我将usedExports.usedExports = false
,你会发现square
没有被删除。
官方解释,当我们设置optimization.usedExports
必须为true
,当我们设置usedExports:true
,且必须开起minimize: true
,这样才会把上下文未使用的代码给清除掉,如果minimize: false
,那么压缩插件将会失效。
当我们设置usedExports: true
此时生成打包的代码会有一个这样的魔法注释,square
未使用
/* unused harmony export square */
function add(a, b) {
return a b;
}
function square(x) {
return x * x;
}
当我们设置minimize: true
时,webpack5
会默认开启terser
压缩,然后发现有这样的unused harmony export square
就会删掉对应未引入的代码。
sideEffects
这个是usedExports
摇树的另一种方案,usedExports
是检查上下文有没有引用,如果没有引用,就会注入魔法注释
,通过terser
压缩进行去除未引入的代码
而slideEffects
是对没有副作用
的代码进行去除
首先什么是副作用
,这是一个不太好理解的词,在react
中经常有听到
其实副作用
就是一个纯函数中存在可变依赖的因变量,因为某个因变量会造成纯函数产生不可控的结果
举个例子
没有副作用的函数,输入输出很明确
代码语言:javascript复制function watchEnv(env) {
return env === 'prd' ? 'product': 'development'
}
watchEnv('prd')
有副作用,函数体内有不确定性因素
代码语言:javascript复制export function watchEnv(env) {
const num = Math.ceil(Math.random() * 10);
if (num < 5) {
env = 'development'
}
return env === 'production' ? '生产环境' : '测试开发环境'
}
我们在index.js
中引入watch.js
import { add } from './utils'
import './utils/watch.js';
console.log("Hello World!");
console.log(add(1, 2))
然后运行npm run build:dev
,打包后的文件有watch
的引入
在index.js
中引入watch.js
并没有使用watch内部的方法,但是我们仍然打包了进去
为了去除这引入但未被使用的代码,因此你需要在optimization.sideEffects: true
,并且要在package.json
中设置sideEffects: false
,在optimization.sideEffects
设置true,告知webpack根据package.json中的sideEffects标记的副作用或者规则,从而告知webpack跳过一些引入但未被使用的模块代码,具体参考optimization.sideEffects[2]
module.exports = () => {
if (isProduction) {
config.mode = "production";
config.plugins.push(new WorkboxWebpackPlugin.GenerateSW());
} else {
config.mode = "development";
config.devtool = 'source-map',
config.optimization = {
sideEffects: true, // 开启sideEffects
usedExports: true,
minimize: true, // 开启terser
minimizer: [new TerserPlugin({
extractComments: false, // 是否将注释剥离到单独文件,默认是true
})]
}
}
return config;
};
代码语言:javascript复制{
"name": "my-webpack-project",
"version": "1.0.0",
"description": "My webpack project",
"main": "index.js",
"sideEffects": false,
...
}
此时你运行命令npm run build:dev
,查看打包文件
我们就会发现,引入的watch.js
就没有了
在官方中有这么一段话使用 mode 为 "production" 的配置项以启用更多优化项,包括压缩代码与 tree shaking。
因此在webpack5
中只要你设置mode:production
那些代码压缩、tree shaking
通通默认给你做了做了最大的优化,你就无需操心代码是否有被压缩,或者tree shaking
了。
对于能否被tree shaking
还补充几点
1、一定是esModule
方式,也就是export xxx
或者import xx from 'xxx'
的方式
2、cjs
方式不能被tree shaking
3、线上打包生产环境mode:production
自动开启多项优化,可以参考生产环境的构建production[3]
gizp压缩
首先是是在devServer
下提供了一个开发环境的compress:true
{
devServer: {
open: true,
host: "localhost",
compress: true // 启用zip压缩
}
}
- CompressionWebpackPlugin 插件gizp[4]压缩
需要安装对应插件
代码语言:javascript复制npm i compression-webpack-plugin --save-dev
webpack.config.js
中引入插件
// Generated using webpack-cli https://github.com/webpack/webpack-cli
...
const CompressionWebpackPlugin = require('compression-webpack-plugin');
const config = {
...
plugins: [
new HtmlWebpackPlugin({
template: "index.html",
}),
new MiniCssExtractPlugin(),
new CompressionWebpackPlugin(),
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
...
};
当你运行命令后,你就会发现打包后的文件有gzip
的文件了
但是我们发现html
以及map.js.map
文件也被gizp
压缩了,这是没有必要的
官方提供了一个exclude
,可以排除某些文件不被gizp
压缩
{
plugins: [
new HtmlWebpackPlugin({
template: "index.html",
}),
new MiniCssExtractPlugin(),
new CompressionWebpackPlugin({
exclude: /.(html|map)$/i // 排除html,map文件
})
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
}
对比开启gizp
压缩与未压缩,加载时间很明显有提升
css tree shaking
主要删除未使用的样式,如果样式未使用,就删除掉。
现在修改下index.js
我在body
中插入一个class
import { add } from './utils'
import './utils/watch';
import './css/index.css'
console.log("Hello World!");
console.log(add(1, 2))
// /*#__PURE__*/ watchEnv(process.env.NODE_ENV)
const bodyDom = document.getElementsByTagName('body')[0]
const divDom = document.createElement('div');
divDom.setAttribute('class', 'wrap-box');
bodyDom.appendChild(divDom);
对应的css如下
代码语言:javascript复制.wrap-box {
width: 100px;
height: 100px;
background-color: red;
}
执行npm run serve
但是我们发现,样式居然没了
于是苦思瞑想,不得其解,于是一顿排查,当我们把sideEffects: false
时,神奇的是,样式没有被删掉
原来是sideEffects:true
把引入的css当成没有副作用的代码给删除了,此时,你需要告诉webpack
不要删除我的这有用的代码,不要误删了,因为import 'xxx.css'
如果设置了sideEffects: true
,此时引入的css
会被当成无副作用的代码,就给删除了。
// package.json
{
"sideEffects": [
"**/*.css"
],
}
当你设置完后,页面就可以正常显示css了
官方也提供了另外一种方案,你可以在module.rules
中设置
{
module: {
rules: [
{
test: /.css$/i,
sideEffects: true,
use: [stylesHandler, "css-loader", "postcss-loader"],
},
]
}
}
以上与在package.json
设置一样的效果,都是让webpack
不要误删了无副作用的css的代码
但是现在有这样的css
代码
.wrap-box {
width: 100px;
height: 100px;
background-color: red;
}
.title {
color: green;
}
title
页面没有被引用,但是也被打包进去了
此时需要一个插件来帮助我们来完成css的摇树purgecss-webpack-plugin[5]
代码语言:javascript复制const path = require("path");
...
const glob = require('glob');
const PurgeCSSPlugin = require('purgecss-webpack-plugin');
const PATH = {
src: path.resolve(__dirname, 'src')
}
const config = {
...
plugins: [
...
new PurgeCSSPlugin({
paths: glob.sync(`${PATH.src}/**/*`, { nodir: true }),
})
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
...
};
未引用的css就已经被删除了
分包
主要是减少入口依赖文件包的体积,如果不进行拆包,那么我们根据entry
的文件打包就很大。那么也会影响首页加载的性能。
官方提供了两种方案:
- entry 分多个文件,举个栗子
引入loadsh
// index.js
import { add } from './utils';
import _ from 'loadsh';
import './utils/watch';
import './css/index.css';
console.log("Hello World!");
console.log(add(1, 2))
// /*#__PURE__*/ watchEnv(process.env.NODE_ENV)
const bodyDom = document.getElementsByTagName('body')[0]
const divDom = document.createElement('div');
divDom.setAttribute('class', 'wrap-box');
divDom.innerText = 'wrap-box';
bodyDom.appendChild(divDom);
console.log(_.last(['Maic', 'Web技术学苑']));
main.js
中将loadsh
打包进去了,体积也非常之大72kb
我们现在利用entry
进行分包
const config = {
entry: {
main: { import: ['./src/index'], dependOn: 'loadsh-vendors' },
'loadsh-vendors': ['loadsh']
},
}
此时我们再次运行npm run build:dev
此时main.js
的大小1kb
,但是loadsh
已经被分离出来了
生成的loadsh-vendors.js
会被单独引入
可以看下打包后的index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Webpack App</title>
<script defer src="main.js"></script>
<script defer src="loadsh-vendors.js"></script>
<link href="main.css" rel="stylesheet" />
</head>
<body>
<h1>Hello world!</h1>
<h2>Tip: Check your console</h2>
</body>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('service-worker.js')
.then((registration) => {
console.log('Service Worker registered: ', registration);
})
.catch((registrationError) => {
console.error('Service Worker registration failed: ', registrationError);
});
});
}
</script>
</html>
- 主要是在
optimzation.splitChunks
对于动态导入模块,在webpack4
就默认采取分块策略
const config = {
// entry: {
// main: { import: ['./src/index'], dependOn: 'loadsh-vendors' },
// 'loadsh-vendors': ['loadsh']
// },
entry: './src/index.js',
...
}
module.exports = () => {
if (isProduction) {
config.mode = "production";
config.plugins.push(new WorkboxWebpackPlugin.GenerateSW());
} else {
config.mode = "development";
config.devtool = 'source-map',
config.optimization = {
splitChunks: {
chunks: 'all' // 支持异步和非异步共享chunk
},
sideEffects: true,
usedExports: true,
minimize: true, // 开启terser
minimizer: [new TerserPlugin({
extractComments: false, // 是否将注释剥离到单独文件,默认是true
})]
}
}
return config;
};
当optimization.splitChunks.chunks:'all'
,此时可以把loash
分包出来了
关于optimization.splitChunks
的设置非常之多,有对缓存的设置,有对chunk
大小的限制,最常用的还是设置chunks:all
,建议SplitChunksPlugin[6]多读几遍,一定会找到不少收获。
runtimeChunk 主要减少依赖入口文件打包体积,当我们设置optimization.runtimeChunk
时,运行时依赖的代码会独立打包成一个runtime.xxx.js
...
config.optimization = {
runtimeChunk: true, // 减少入口文件打包的体积,运行时代码会独立抽离成一个runtime的文件
splitChunks: {
minChunks: 1, // 默认是1,可以不设置
chunks: 'all', // 支持异步和非异步共享chunk
},
sideEffects: true,
usedExports: true,
minimize: true, // 开启terser
minimizer: [new TerserPlugin({
extractComments: false, // 是否将注释剥离到单独文件,默认是true
})]
}
main.js
有一部分代码移除到一个独立的runtime.js
中
Externals 外部扩展 第三种方案就是,webpack
提供了一个外部扩展,将输出的bundle.js
排除第三方的依赖,参考Externals[7]
const config = {
// entry: {
// main: { import: ['./src/index'], dependOn: 'loadsh-vendors' },
// 'loadsh-vendors': ['loadsh']
// },
entry: './src/index.js',
...,
externals: /^(loadsh)$/i,
/* or
externals: {
loadsh: '_'
}
*/
};
module.exports = () => {
if (isProduction) {
config.mode = "production";
config.plugins.push(new WorkboxWebpackPlugin.GenerateSW());
} else {
config.mode = "development";
config.devtool = 'source-map',
config.optimization = {
runtimeChunk: true, // 减少入口文件打包的体积,运行时代码会独立抽离成一个runtime的文件
// splitChunks: {
// minChunks: 1,
// chunks: 'all', // 支持异步和非异步共享chunk
// },
sideEffects: true,
usedExports: true,
minimize: true, // 开启terser
minimizer: [new TerserPlugin({
extractComments: false, // 是否将注释剥离到单独文件,默认是true
})]
}
}
return config;
};
但是此时loash
已经被我们移除了,我们还需在HtmlWebpackPlugin
中加入引入的cdn
地址
...
plugins: [
new HtmlWebpackPlugin({
template: "index.html",
inject: 'body', // 插入到body中
cdn: {
basePath: 'https://cdn.bootcdn.net/ajax/libs',
js: [
'/lodash.js/4.17.21/lodash.min.js'
]
}
}),
]
修改模版,由于模版内容是ejs[8],所以我们循环取出js
数组中的数据
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Webpack App</title>
</head>
<body>
<h1>Hello world!</h1>
<h2>Tip: Check your console</h2>
<% for (var i in htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %>
<script src="<%= htmlWebpackPlugin.options.cdn.basePath %><%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
<% } %>
</body>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('service-worker.js')
.then((registration) => {
console.log('Service Worker registered: ', registration);
})
.catch((registrationError) => {
console.error('Service Worker registration failed: ', registrationError);
});
});
}
</script>
</html>
此时你运行命令npm run build:dev
,然后打开html页面
但是我们发现当我们运行npm run serve
启动本地服务,此时页面还是会引入loadsh
,在开发环境,其实并不需要引入,本身生成的bundle.js
就是在内存中加载的,很显然不是我们需要的
此时我需要做几件事
1、开发环境我不允许引入externals
2、模版html
中需要根据环境判断是否需要插入cdn
const isProduction = process.env.NODE_ENV == "production";
const stylesHandler = MiniCssExtractPlugin.loader;
const PATH = {
src: path.resolve(__dirname, 'src')
}
const config = {
// entry: {
// main: { import: ['./src/index'], dependOn: 'loadsh-vendors' },
// 'loadsh-vendors': ['loadsh']
// },
entry: './src/index.js',
output: {
path: path.resolve(__dirname, "dist"),
},
devServer: {
open: true,
host: "localhost",
compress: true
},
plugins: [
new HtmlWebpackPlugin({
env: process.env.NODE_ENV, // 传入模版中的环境
template: "index.html",
inject: 'body', // 插入到body中
cdn: {
basePath: 'https://cdn.bootcdn.net/ajax/libs',
js: [
'/lodash.js/4.17.21/lodash.min.js'
]
}
}),
new MiniCssExtractPlugin(),
new CompressionWebpackPlugin({
exclude: /.(html|map)$/i // 排除html,map文件不做gizp压缩
}),
new PurgeCSSPlugin({
paths: glob.sync(`${PATH.src}/**/*`, { nodir: true }),
})
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
...
// externals: /^(loadsh)$/i,
externals: isProduction ? {
loadsh: '_'
} : {}
};
根据传入模版的env
判断是否需要插入cdn
...
<% if (htmlWebpackPlugin.options.env === 'production') { %>
<% for (var i in htmlWebpackPlugin.options.cdn && htmlWebpackPlugin.options.cdn.js) { %>
<script src="<%= htmlWebpackPlugin.options.cdn.basePath %><%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
<% } %>
<% } %>
图片资源压缩
主要是有选择的压缩图片资源,我们可以看下module.rules.parser
module.rules.parser.dataUrlCondition 对应的资源文件可以限制图片的输出,比如静态资源模块类型
代码语言:javascript复制 module: {
rules: [
{
test: /.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
parser: {
dataUrlCondition: {
maxSize: 4 * 1024 // 小于4kb将会base64输出
}
}
},
],
},
另外官方提供了一个插件方案
ImageMinimizerWebpackPlugin[9]
我们需要安装
代码语言:javascript复制npm i image-minimizer-webpack-plugin imagemin --save-dev
在webpack.config.js
中引入image-minimizer-webpack-plugin
,并且在plugins
中引入这个插件,注意webpack5
官网那份文档很旧,参考npm
上npm-image-minimizer-webpack-plugin[10]
按照官网的,就直接报错一些配置参数不存在,我估计文档没及时更新
代码语言:javascript复制...
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
const config = {
plugins: [
...
new ImageMinimizerPlugin({
minimizer: {
// Implementation
implementation: ImageMinimizerPlugin.squooshMinify,
},
})
// Add your plugins here
// Learn more about plugins from https://webpack.js.org/configuration/plugins/
],
}
未压缩前
压缩后
使用压缩后,图片无损压缩体积大小压缩大小缩小一半,并且网络加载图片时间从18.87ms
减少到4.81ms
,时间加载上接近5倍的差距,因此可以用这个插件来优化图片加载。
这个插件可以将图片转成webp
格式,具体参考官方文档效果测试一下
总结
1、webpack
如何做treeShaking
,主要是两种
optimization中设置usedExports:true
,但是要配合terser
压缩插件才会生效
optimization中设置sideEffects: true
,在package.json
中设置sideEffects:false
去除无副作用的代码,但是注意css
引入会当成无副作用的代码,此时需要在rules的css规则中标记sideEffects: true
,这样就不会删除css了
2、webpack
的gizp压缩 主要是利用CompressionWebpackPlugin
官方提供的这个插件
3、css
如何做treeShaking
, 主要是利用PurgeCSSPlugin
这个插件,会将没有引用css删除
4、入口依赖文件拆包
- 第一种是在入口文件
entry
中分包处理,将依赖的第三方库独立打包成一个公用的bundle.js
,入口文件不会把第三方包打包到里面去 - 第二种利用
optimization.splitChunks
设置chunks:'all'
将同步或者异步的esModule
方式的代码进行分包处理,会单独打成一个公用的js - 利用外置扩展
externals
将第三方包分离出去,此时第三方包不会打包到入口文件中去,不过注意要在ejs
模版中进行单独引入
5、图片资源
加载优化
- 主要是利用静态资源模块对文件体积小的可以进行base64
- 利用社区插件
image-minimizer-webpack-plugin
做图片压缩处理
6、本文示例code-example[11]
参考资料
[1]treeShaking: https://webpack.docschina.org/guides/tree-shaking/
[2]optimization.sideEffects: https://webpack.docschina.org/configuration/optimization/#optimizationsideeffects
[3]production: https://webpack.docschina.org/guides/production/
[4]gizp: https://webpack.docschina.org/plugins/compression-webpack-plugin/
[5]purgecss-webpack-plugin: https://www.npmjs.com/package/purgecss-webpack-plugin
[6]SplitChunksPlugin: https://webpack.docschina.org/plugins/split-chunks-plugin/
[7]Externals: https://webpack.docschina.org/configuration/externals/
[8]ejs: https://ejs.bootcss.com/#docs
[9]ImageMinimizerWebpackPlugin: https://webpack.docschina.org/plugins/image-minimizer-webpack-plugin/
[10]npm-image-minimizer-webpack-plugin: https://www.npmjs.com/package/image-minimizer-webpack-plugin
[11]code-example: https://github.com/maicFir/lessonNote/tree/master/webpack/webpack-08-tree-shaking