性能优化
该笔记来自coderwhy老师webpack5教程及参考官方文档总结而来。
环境分离
- 方案一: 编写两个不同的配置文件,开发和生产时分别加载不同的配置文件
- 方案二:使用相同的一个入口文件配置,通过设置参数区分他们
//package.json
"scripts": {
"build": "webpack --config ./config/webpack.prod.js",
"serve": "webpack serve --config ./config/webpack.dev.js",
"build2": "webpack --config ./config/webpack.common.js --env production",
"serve2": "webpack serve --config ./config/webpack.common.js --env development"
},
-
入口文件解析:
- 我们运行webpack时module.exports的entry文件的入口,当为相对路径的时候,该路径相对于context所配置的路径,该路径默认为webpack的启动目录( process.cwd() )。
module.exports = {
context: path.resolve(__dirame,'./')
entry: '../src/index.js'
}
- 环境分离代码
//webpack.commo.js
const resolveApp = require("./paths");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const VueLoaderPlugin = require("vue-loader/lib/plugin");
const { merge } = require("webpack-merge");
const prodConfig = require("./webpack.prod");
const devConfig = require("./webpack.dev");
const commonConfig = {
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: resolveApp("./build"),
},
resolve: {
extensions: [".wasm", ".mjs", ".js", ".json", ".jsx", ".ts", ".vue"],
alias: {
"@": resolveApp("./src"),
pages: resolveApp("./src/pages"),
},
},
module: {
rules: [
{
test: /.jsx?$/i,
use: "babel-loader",
},
{
test: /.vue$/i,
use: "vue-loader",
},
{
test: /.css/i,
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: "./index.html",
}),
new VueLoaderPlugin(),
]
};
module.exports = function(env) {
//cli传入的参数可以作为函数参数接收
const isProduction = env.production;
//当--env production的值时
//env: { WEBPACK_BUNDLE: true, WEBPACK_BUILD: true, production: true }
//当--evn development时
//env : { WEBPACK_SERVE: true, development: true }
process.env.NODE_ENV = isProduction ? "production": "dev elopment";
//为node设置环境标识,babel可以读取到该环境
const config = isProduction ? prodConfig : devConfig;
//通过判断来判断加载哪个配置文件
const mergeConfig = merge(commonConfig, config);
//通用配置文件与环境配置文件合并
return mergeConfig;
//return的结果作为最终的配置文件
};
//babel.config.js
const presets = [
["@babel/preset-env"],
["@babel/preset-react"],
];
const plugins = [];
const isProduction = process.env.NODE_ENV === "production";
// React HMR -> 模块的热替换 必然是在开发时才有效果
if (!isProduction) {
plugins.push(["react-refresh/babel"]);
} else {
}
module.exports = {
presets,
plugins
}
代码分离
-
代码分离的目的
- 它主要的目的是将代码分离到不同的bundle中,之后我们可以按需加载,或者并行加载这些文件;
- 比如默认情况下,所有的JavaScript代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载, 就会影响首页的加载速度;代码分离可以分出出更小的bundle,以及控制资源加载优先级,提供代码的加载性能;
-
Webpack中常用的代码分离有三种:
- 入口起点:使用entry配置手动分离代码;
- 防止重复:使用Entry Dependencies或者SplitChunksPlugin去重和分离代码;
- 动态导入:通过模块的内联函数调用来分离代码;
多入口起点
-
多入口起点就是指我们可以配置多个入口文件
-
此时输出的文件名字不能为固定值,否则就会报错,可以使用占位符
-
Entry Dependencies(入口依赖)
- 假如我们的index.js和main.js都依赖两个库:lodash、dayjs,如果我们单纯的进行入口分离,那么打包后的两个bunlde都有会有一份lodash和dayjs,事实上我们可以对他们进行共享;
-
用于描述入口的对象。你可以使用如下属性:
dependOn
: 默认情况下,每个入口 chunk 保存了全部其用的模块(modules)。使用dependOn
选项你可以与另一个入口 chunk 共享模块:filename
: 指定要输出的文件名称。import
: 启动时需加载的模块。library
: 指定 library 选项,为当前 entry 构建一个 library。runtime
: 运行时 chunk 的名字。如果设置了,就会创建一个新的运行时 chunk。在 webpack 5.43.0 之后可将其设为false
以避免一个新的运行时 chunk。publicPath
: 当该入口的输出文件在浏览器中被引用时,为它们指定一个公共 URL 地址。请查看 output.publicPath。
-
entry: {
main: "./src/main.js",
index: "./src/index.js"
// main: { import: "./src/main.js", dependOn: "shared" },
// index: { import: "./src/index.js", dependOn: "shared" },
// lodash: "lodash",
// dayjs: "dayjs"
// shared: ["lodash", "dayjs"] //默认从node_modules中寻找
},
output: {
path: resolveApp("./build"),
filename: "[name].bundle.js",
chunkFilename: "[name].[hash:6].chunk.js"
},
-
在不使用
import
样式文件的应用程序中(预单页应用程序或其他原因),使用一个值数组结构的 entry,并且在其中传入不同类型的文件,可以实现将 CSS 和 JavaScript(和其他)文件分离在不同的 bundle。-
举个例子。我们有一个具有两种页面类型的 PHP 应用程序:home(首页) 和 account(帐户)。home 与应用程序其余部分(account 页面)具有不同的布局和不可共享的 JavaScript。我们想要从应用程序文件中输出 home 页面的
home.js
和home.css
,为 account 页面输出account.js
和account.css
。 -
由于我们未指定其他输出路径,因此使用以上配置运行 webpack 将输出到
./dist
。./dist
目录下现在包含四个文件:- home.js
- home.css
- account.js
- account.css
-
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: process.env.NODE_ENV,
entry: {
home: ['./home.js', './home.scss'],
account: ['./account.js', './account.scss'],
},
output: {
filename: '[name].js',
},
module: {
rules: [
{
test: /.scss$/,
use: [
// fallback to style-loader in development
process.env.NODE_ENV !== 'production'
? 'style-loader'
: MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
}),
],
};
-
Dynamic entry
module.exports = {
entry() {
return fetchPathsFromSomeExternalSource();
// 返回一个会被用像 ['src/main-layout.js', 'src/admin-layout.js'] 的东西 resolve 的 promise
},
};
SplitChunks
-
另外一种分包的模式是splitChunk,它是使用SplitChunksPlugin来实现的:
-
Webpack提供了SplitChunksPlugin默认的配置,我们也可以手动来修改它的配置:
- 比如默认配置中,chunks仅仅针对于异步(async)请求,我们可以设置为initial或者all;
-
chunks
- 默认值是async
- 另一个值是initial,表示对通过的代码进行处理
- all表示对同步和异步代码都进行处理
-
minSize
- 拆分包的大小, 至少为minSize;
- 如果一个包拆分出来达不到minSize,那么这个包就不会拆分;
-
maxSize
- 将大于maxSize的包,拆分为不小于minSize的包
-
minChunks
- 至少被引入的次数,默认是1
- 如果我们写一个2,但是引入了一次,那么不会被单独拆分
-
name:设置拆包的名称
- 可以设置一个名称,也可以设置为false;
- 设置为false后,需要在cacheGroups中设置名称;
-
cacheGroups
- 用于对拆分的包就行分组,比如一个lodash在拆分之后,并不会立即打包,而是会等到有没有其他符合规则的包一起来打包
- test属性:匹配符合规则的包
- name属性:拆分包的name属性
- filename属性:拆分包的名称,可以自己使用placeholder属性
optimization: [
// natural: 使用自然数(不推荐),
// named: 使用包所在目录作为name(在开发环境推荐)
// deterministic: 生成id, 针对相同文件生成的id是不变
// chunkIds: "deterministic", //与生成的chunk的id有关
splitChunks: {
// async异步导入
// initial同步导入
// all 异步/同步导入
chunks: 'all',
// 最小尺寸: 如果拆分出来一个, 那么拆分出来的这个包的大小最小为minSize
minSize: 20000,
// 将大于maxSize的包, 拆分成不小于minSize的包
maxSize: 20000,
// minChunks表示引入的包, 至少被导入了几次
minChunks: 1,
cacheGroups: {
vendor: {
test: /[\/]node_modules[\/]/,
filename: "[id]_vendors.js", //可以使用placehold
// name: "vendor-chunks.js", //一般是固定的
//打包规则冲突谁的优先级高按照哪个规则,同等级优先使用靠前的
priority: -10
},
// bar: {
// test: /bar_/,
// filename: "[id]_bar.js"
// }
default: {
minChunks: 2,
filename: "common_[id].js",
priority: -20
}
}
}
]
动态导入
-
webpack提供了两种实现动态导入的方式
- 第一种,使用ECMAScript中的 import() 语法来完成,也是目前推荐的方式;
- 第二种,使用webpack遗留的 require.ensure,目前已经不推荐使用;
-
比如我们希望在代码运行时加载一个模块,但我们又不确定一定会用到,最后拆分成独立的js文件,这样可以保证用不到该内容时,浏览器无需处理该文件,此时可以使用动态导入。
-
动态导入的文件命名:
- 因为动态导入通常是一定会打包成独立的文件的,所以并不会再cacheGroups中进行配置;
- 那么它的命名我们通常会在output中,通过 chunkFilename 属性来命名;
output: {
path: resolveApp("./build"),
filename: "[name].bundle.js",
chunkFilename: "[name].[hash:6].chunk.js"
},
-
会发现默认情况下我们获取到的 [name] 是和id ( chunkIds) 的名称保持一致的
- 如果我们希望修改name的值,可以通过magic comments(魔法注释)的方式;
import(/* webpackChunkName: "foo" */"./foo").then(res => {
console.log(res);
});
其他优化
-
optimization.chunkIds: 用于告知webpack模块的id采用什么算法生成。
-
natural:按照数字的顺序使用id;
-
named:development下的默认值,一个可读的名称的id;
-
deterministic:确定性的,在不同的编译中不变的短数字id
- 在webpack4中是没有这个值的;
- 那个时候如果使用natural,那么在一些编译发生变化时,就会有问题;
-
最佳实践:
- 开发过程中,我们推荐使用named;
- 打包过程中,我们推荐使用deterministic;
-
-
optimization. runtimeChunk: 配置runtime相关的代码是否抽取到一个单独的chunk中
-
runtime相关的代码指的是在运行环境中,对模块进行解析、加载、模块信息相关的代码;
-
抽离出来后,有利于浏览器缓存的策略
- 比如我们修改了业务代码(main),那么runtime和component、bar的chunk是不需要重新加载的;
- 比如我们修改了component、bar的代码,那么main中的代码是不需要重新加载的;
-
常见值:
- true/multiple:针对每个入口打包一个runtime文件;
- single:打包一个runtime文件;
- 对象:name属性决定runtimeChunk的名称;
-
runtimeChunk: {
name: function(entrypoint) {
return `xxx-{entrypoint.name}`
}
}
Prefetch与Preload
-
Profetch与Preload都可使用magic Comments进行使用
- prefetch(预获取):将来某些导航下可能需要的资源
- preload(预加载):当前导航下可能需要资源
button.addEventListener("click", () => {
// prefetch -> 魔法注释(magic comments)
/* webpackPrefetch: true */
/* webpackPreload: true */
import(
/* webpackChunkName: 'element' */
/* webpackPrefetch: true */
"./element"
).then(({default: element}) => {
document.body.appendChild(element);
})
});
-
与 prefetch 指令相比,preload 指令有许多不同之处
- preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载
- preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载
- preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻
Shimming
-
webpack
compiler 能够识别遵循 ES2015 模块语法、CommonJS 或 AMD 规范编写的模块。然而,一些 third party(第三方库) 可能会引用一些全局依赖(例如jQuery
中的$
)。因此这些 library 也可能会创建一些需要导出的全局变量。这些 "broken modules(不符合规范的模块)" 就是 shimming(预置依赖) 发挥作用的地方。 -
webpack 背后的整个理念是使前端开发更加模块化。也就是说,需要编写具有良好的封闭性(well contained)、不依赖于隐含依赖(例如,全局变量)的彼此隔离的模块。请只在必要的时候才使用这些特性。
-
我们可以通过使用ProvidePlugin来实现shimming的效果:
- ProvidePlugin能够帮助我们在每个模块中,通过一个变量来获取一个package;
- 如果webpack看到这个模块,它将在最终的bundle中引入这个模块;
- 另外ProvidePlugin是webpack默认的一个插件,所以不需要专门导入;
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new webpack.ProvidePlugin({
_: 'lodash',
}),
],
};
MiniCssExtractPlugin
- MiniCssExtractPlugin可以帮助我们将css提取到一个独立的css文件中
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
plugins: [new MiniCssExtractPlugin({
filename: "css/[name].[hash:8].css"
})],
module: {
rules: [
{
test: /.css/i,
// style-lodader -> development
use: [
isProduction ? MiniCssExtractPlugin.loader: "style-loader",
"css-loader"
],
},
],
},
};
Hash、ContentHash、ChunkHash
-
Hash:hash值的生成和整个项目有关系
- 一旦项目中有文件改变了,Hash值就会改变
-
chunkhash可以有效的解决上面的问题,它会根据不同的入口进行借来解析来生成hash值
- 修改一个入口的文件,和这个入口相关的文件hash值都会改变
-
contenthash表示生成的文件hash名称,只和内容有关系
- 仅改变当前文件内容会改变hash值
代码压缩与打包效率
DLL
- DLL全程是动态链接库(Dynamic Link Library),是为软件在Windows中实现共享函数库的一种实现方式;
- 那么webpack中也有内置DLL的功能,它指的是我们可以将可以共享,并且不经常改变的代码,抽取成一个共享的库
- 这个库在之后编译的过程中,会被引入到其他项目的代码中
- 打包DLL库
const path = require('path');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
entry: {
react: ["react", "react-dom"]
},
output: {
path: path.resolve(__dirname, "./dll"),
filename: "dll_[name].js",
library: 'dll_[name]'
},
optimization: {
minimizer: [
new TerserPlugin({
extractComments: false
})
]
},
plugins: [
new webpack.DllPlugin({
name: "dll_[name]",
path: path.resolve(__dirname, "./dll/[name].manifest.json")
})
]
}
-
使用DLL库
- 第一步:通过DllReferencePlugin插件告知要使用的DLL库;
- 第二步:通过AddAssetHtmlPlugin插件,将我们打包的DLL库引入到Html模块中
plugins: [
new webpack.DllReferencePlugin({
context: resolveApp("./"),
manifest: resolveApp("./dll/react.manifest.json")
}),
new AddAssetHtmlPlugin({
filepath: resolveApp('./dll/dll_react.js')
})
],
Terser
- Terser是从 uglify-es fork 过来的,并且保留它原来的大部分API以及适配 uglify-es和uglify-js@3等;
- Terser可以帮助我们压缩、丑化我们的代码,让我们的bundle变得更小。
命令行使用Terser
terser [input files] [options]
# 举例说明
terser js/file1.js -o foo.min.js -c -m
-
Compress option:
- arrows:class或者object中的函数,转换成箭头函数;
- arguments:将函数中使用 arguments[index]转成对应的形参名称;
- dead_code:移除不可达的代码(tree shaking);
-
Mangle option
- toplevel:默认值是false,顶层作用域中的变量名称,进行丑化(转换);
- keep_classnames:默认值是false,是否保持依赖的类名称;
- keep_fnames:默认值是false,是否保持原来的函数名称;
npx terser ./src/abc.js -o abc.min.js -c arrows,arguments=true,dead_code -m toplevel=true,keep_classnames=true,keep_fnames=true
Terser在Webpack中的使用
-
在webpack中有一个minimizer属性,在production模式下,默认就是使用TerserPlugin来处理我们的代码的;
-
也可以自己来创建TerserPlugin的实例,并且覆盖相关的配置;
-
首先,我们需要打开minimize,让其对我们的代码进行压缩(默认production模式下已经打开了)
-
其次,我们可以在minimizer创建一个TerserPlugin:
-
extractComments:默认值为true,表示会将注释抽取到一个单独的文件中;
- 在开发中,我们不希望保留这个注释时,可以设置为false;
-
parallel:使用多进程并发运行提高构建的速度,默认值是true,并发运行的默认数量: os.cpus().length - 1;
- 我们也可以设置自己的个数,但是使用默认值即可
-
terserOptions:设置我们的terser相关的配置
- compress:设置压缩相关的选项
- mangle:设置丑化相关的选项,可以直接设置为true
- toplevel:底层变量是否进行转换
- keep_classnames:保留类的名称
- keep_fnames:保留函数的名称
-
HTML文件中的代码压缩
-
HtmlWebpackPlugin插件来生成HTML的模板,事实上它还有一些其他的配置
- inject:设置打包的资源插入的位置(true、 false 、body、head)
- cache:设置为true,只有当文件改变时,才会生成新的文件(默认值也是true)
- minify:默认会使用一个插件html-minifier-terser
new HtmlWebpackPlugin({
template: "./index.html",
// inject: "body"
cache: true, // 当文件没有发生任何改变时, 直接使用之前的缓存
minify: isProduction ? {
removeComments: false, // 是否要移除注释
removeRedundantAttributes: false, // 是否移除多余的属性
removeEmptyAttributes: true, // 是否移除一些空属性
useShortDoctype: true, //使用HTML5的文档声明
collapseWhitespace: false, //折叠空格
removeStyleLinkTypeAttributes: true, //移除一些不必要的属性,例如link中的type=“text/css”
keepClosingSlash: true, //是否保存单元素尾部
minifyCSS: true, //是否压缩CSS
minifyJS: {
mangle: {
toplevel: true
}
}
}: false
}),
-
InlineChunkHtmlPlugin: 可以辅助将一些chunk出来的模块,内联到html中
- 比如runtime的代码,代码量不大,但是是必须加载的,那么我们可以直接内联到html中
-
这个插件是在react-dev-utils中实现的
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
plugins: [
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime.*.js/,])
]
CSS压缩
- CSS压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;
- CSS的压缩我们可以使用另外一个插件:css-minimizer-webpack-plugin;
- css-minimizer-webpack-plugin是使用cssnano工具来优化、压缩CSS(也可以单独使用)
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
plugins: [
new CssMinimizerPlugin()
]
}
TreeShaking
-
webpack实现Tree Shaking采用了两种不同的方案
- usedExports:通过标记某些函数是否被使用,之后通过Terser来进行优化的
- sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用
-
如何在项目中对JavaScript的代码进行TreeShaking
- 在optimization中配置usedExports为true,来帮助Terser进行优化;
- 在package.json中配置sideEffects,直接对模块进行优化;
usedExports
-
将mode设置为development模式
- 为了可以看到 usedExports带来的效果,我们需要设置为 development 模式
- 因为在 production 模式下,webpack默认的一些优化会带来很大额影响
-
设置usedExports为true和false对比打包后的代码
- 在usedExports设置为true时,会有一段注释:unused harmony export mul;
- 这段注释的意义是什么呢?告知Terser在优化时,可以删除掉这段代码;
-
这个时候, minimize设置true:
- usedExports设置为false时,mul函数没有被移除掉;
- usedExports设置为true时,mul函数有被移除掉;
-
所以,usedExports实现Tree Shaking是结合Terser来完成的。
sideEffects
-
sideEffects用于告知webpack compiler哪些模块时有副作用的
-
在package.json中设置sideEffects的值
- 如果我们将sideEffects设置为false,就是告知webpack可以安全的删除未用到的exports;
- 如果有一些我们希望保留,可以设置为数组;
'sideEffects': [
"./xxxx.js"
]
-
比如我们有一个format.js、style.css文件
- 该文件在导入时没有使用任何的变量来接收
- 那么打包后的文件,不会保留format.js、style.css相关的任何代码;
Css实现TreeShaking
-
CSS的Tree Shaking需要借助于一些其他的插件
- 在早期的时候,我们会使用PurifyCss插件来完成CSS的tree shaking
- 目前我们可以使用另外一个库来完成CSS的Tree Shaking:PurgeCSS
-
配置插件
- paths:表示要检测哪些目录下的内容需要被分析,这里我们可以使用glob;
- 默认情况下,Purgecss会将我们的html标签的样式移除掉,如果我们希望保留,可以添加一个safelist的属性;
const PurgeCssPlugin = require('purgecss-webpack-plugin');
module.exports = {
plugins: [
new PurgeCssPlugin({
paths: glob.sync(`${resolveApp("./src")}/**/*`, {nodir: true}),
safelist: function() {
return {
standard: ["body", "html"]
}
}
})
]
}
Scope Hoisting
-
Scope Hoisting 功能是对作用域进行提升,并且让webpack打包后的代码更小、运行更快
-
无论是从最开始的代码运行,还是加载一个模块,都需要执行一系列的函数, Scope Hoisting可以将函数合并到一个模块中来运行
-
使用Scope Hoisting非常的简单,webpack已经内置了对应的模块
- 在production模式下,默认这个模块就会启用
- 在development模式下,我们需要自己来打开该模块
plugins: [
new webpack.optimize.ModuleConcatenationPlugin(),
]
HTTP压缩
-
HTTP压缩是一种内置在 服务器 和 客户端 之间的,以改进传输速度和带宽利用率的方式
-
HTTP压缩的流程什么呢
- 第一步:HTTP数据在服务器发送前就已经被压缩了
- 第二步:兼容的浏览器在向服务器发送请求时,会告知服务器自己支持哪些压缩格式(例如:Accept-Encoding: gzip)
- 第三步:服务器在浏览器支持的压缩格式下,直接返回对应的压缩后的文件,并且在响应头中告知浏览器(例如:Content-Encoding:gzip)
-
Webpack中对文件的压缩
- webpack中相当于是实现了HTTP压缩的第一步操作,我们可以使用CompressionPlugin
new CompressionPlugin({
test: /.(css|js)$/i, //匹配需要压缩的文件
threshold: 0, //设置文件多大开始压缩
minRatio: 0.8, //至少压缩的比例
algorithm: "gzip", //采用的压缩算法
// exclude
// include
}),
封装Library
- webpack可以帮助我们打包自己的库文件
- 配置webpack.config.js文件
const path = require('path');
module.exports = {
mode: "production",
entry: "./index.js",
output: {
path: path.resolve(__dirname, "./build"),
filename: "library_name.js",
// AMD/CommonJS/浏览器
// CommnJoS: 社区规范的CommonJS, 这个里面是没有module对象
// CommonJS2: Node实现的CommonJS, 这个里面是有module对象, module.exports
libraryTarget: "umd",
library: "library",
globalObject: "self"
}
}
\