一、定位错误信息 SourceMap
1、SourceMap是什么
SourceMap是源代码映射,使源代码和构建后代码可以一一映射。
它会生成一个xxx.map文件,里面包含源代码和构建后代码每一行和每一列的映射关系。当构建后的代码出错了,会通过xxx.map文件找到源代码出错的位置,从而让浏览器可以提示源代码中出错的位置
2、为什么需要配置SourceMap
在浏览器中运行的代码是经过webpack编译的,大概这个样子:
可以看到,所有的js和css都合并成了main.js,并且多了很多其他代码和注释。此时,如果项目中有一行代码运行出错了,很难定位到错误的位置。
例如,我现在故意将sum方法写错,控制台上会报错,但是点(main.js662)进到源代码后,没有明确的告知是哪里报错了,点(sum.js:11:8)进入源代码后,稍微好点,定位sum.js文件中有报错,但是如果sum.js中有一千行代码,这还是找不到错误的是哪一行。
3、开发模式下使用
配置文件(webpack.dev.js):
devtool: 'cheap-module-source-map' // 只包含行映射,没有列映射,打包速度快
重启:npm start
可以看到,配置devtool后,当我们写的代码里有错误时,webpack编译后可以精确定位到错误的文件和位置:
4、生产模式下使用
配置文件(webpack.prod.js):
devtool: 'source-map' // 包含行/列映射,打包速度慢
编译:npm run prod
可以看到,在编译后,生成了main.js.map,这个文件就是将编译后的代码和源代码的关系一一映射了,咱们运行index.html可以看到报错信息,会指出源代码中出错的位置:
二、提升打包构建速度
1、热模块替换HotModuleReplacement
概念:
在开发模式下,当我们改动一个文件时,只需要对这一个模块进行更新,而不需要加载整个页面。这样一来,会大大提高编译的速度
使用: 配置文件(webpack.dev.js):只需要在开发模式下配置
devServer: {
host: 'localhost',
port: 3000,
hot: true // 默认为true,开启状态
}
当hot为false时,修改任何一个文件,都会触发浏览器的刷新:
当hot为true时(默认状态),修改css文件时,可以看到,只更新了这一个文件:
但此时,如果改变js文件,还是会触发浏览器刷新,需要在main.js中设置:
// 判断是否支持HMR功能
if (module.hot) {
module.hot.accept('./js/sum.js', str => {
console.log('HMR>>>>>>>>>' + str + '发生改变')
})
}
可以看到,当sum.js发生改变时,只会加载这一个文件,而count.js改变时,会触发浏览器的更新:
实际开发中不会这样一个个引入js文件,可以使用vue-loader,react-hot-loader来解决。
可以在webpack-reactcli配置中搜一下react-refresh/babel和ReactRefreshWebpackPlugin,这是用来激活js的hmr
2、oneOf
在编译时,每个文件都会经过所有的loader,然后通过正则匹配到对应的loader处理。比如说一个less文件,会从rules中每个对象都进行一遍正则匹配,这样效率会比较低下,oneOf意思是一个文件只需要匹配到loader,就不再往下匹配
配置(开发和生产一样):将rules中的对象搬到oneOf这个数组中
module: {
rules: [
{
oneOf: [
{
test: /\.css$/, // 匹配 .css 结尾的文件
use: ['style-loader', 'css-loader'] // 执行顺序:从右到左
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader']
},
{
test: /\.s[ac]ss$/,
use: ['style-loader', 'css-loader', 'sass-loader']
},
{
test: /\.styl$/,
use: ['style-loader', 'css-loader', 'stylus-loader']
},
{
test: /\.(png|jpe?g|gif|webp)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 130 * 1024 // 小于130kb的图片会被base64处理
}
},
generator: {
filename: 'static/imgs/[hash:8][ext][query]'
}
},
{
test: /\.(ttf|woff2?|mp4|mp3|avi)$/,
type: 'asset/resource',
generator: {
filename: 'static/media/[hash:8][ext][query]'
}
},
{
test: /\.js$/,
exclude: /node_modules/, // 排除node_modules
loader: 'babel-loader'
}
]
}
]
}
3、include/exclude
- include:表示只处理某些文件
- exclude:表示除了某些文件外,其他文件都处理
在开发中,经常要下载一些第三方的插件,这些文件都下载到node_modules目录中了,这些文件是不需要webpack编译可以直接使用的,所以在使用webpack对js文件进行处理时,需要排除node_modules目录
配置:(开发和生产一样)
{
test: /\.js$/,
exclude: /node_modules/, // 排除node_modules
loader: 'babel-loader'
}
或者:
{
test: /\.js$/,
// exclude: /node_modules/, // 排除node_modules
include: path.resolve(__dirname, '../src'),
loader: 'babel-loader'
}
eslint排除node_modules的配置:
new ESLineWebpackPlugin({
context: path.resolve(__dirname, '../src'), // 检测src目录下的文件
exclude: 'node_modules' // exclude默认值是node_modules
})
4、cache
每次打包时,都要经过eslint检查和babel编译,我们可以缓存之前的eslint和babel,这样在第二次及往后的打包速度就会快些,cache就是来干这件事的
配置:(主要是生产环境)
{
test: /\.js$/,
exclude: /node_modules/, // 排除node_modules
loader: 'babel-loader',
options: {
cacheDirectory: true, // 开启babel缓存
cacheCompression: false // 关闭缓存文件压缩,因为压缩需要时间
}
}
new ESLineWebpackPlugin({
context: path.resolve(__dirname, '../src'), // 检测src目录下的文件
exclude: 'node_modules',
cache: true, // 开启eslint缓存
cacheLocation: path.resolve(
__dirname,
'../node_modules/.cache/eslintCache'
) // 设置缓存目录
})
再次执行npm run prod时,会发现.cache目录下多了一个目录babel-loader和一个文件eslintCache:
五、thread-loader多进程打包
当项目体积越来越大时,打包的速度会随之变慢,想要继续提升打包速度可以开启多进程进行打包,thread-loader就是用来开启多进程进行打包的,但是要注意,开启进程本身是有额外的开销的,每个进程的开启大约有600ms
使用:(开发和生产一样)
1、下载包
npm i thread-loader -D
2、配置文件
webpack处理js文件主要针对eslint、babel、terser(开启生产模式,webpack5会自动对js进行压缩,就是通过terser处理的)
const os = require('os')
...
const TerserPlugin = require('terser-webpack-plugin') // 内置插件,不需要下载
const { length: threads } = os.cpus()
...
// 1、babel处理
{
test: /\.js$/,
// exclude: /node_modules/, // 排除node_modules
include: path.resolve(__dirname, '../src'),
use: [
{
loader: 'thread-loader', // 开启多进程
options: { workers: threads } // 设置进程数
},
{
loader: 'babel-loader',
options: {
cacheDirectory: true, // 开启babel缓存
cacheCompression: false // 关闭缓存文件压缩,因为压缩需要时间
}
}
]
}
...
// 2、eslint处理
new ESLineWebpackPlugin({
context: path.resolve(__dirname, '../src'), // 检测src目录下的文件
threads // 开启多进程,并设置进程数(.cache目录下不生成eslintCache文件了。。。)
}),
// 3、terser处理
new TerserPlugin({ parallel: threads }) // 开启多进程,并设置进程数
webpack5推荐将压缩代码的配置从plugins中搬到optimization.minimizer中:
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(), // css压缩,仅生产环境需要
new TerserPlugin({ parallel: threads }) // webpack5会默认添加new TerserPlugin(),由于我们需要添加配置项,所以这里手动写上
]
}
三、减少代码体积
1、tree shaking
在开发时,定义或引入第三方工具库,没有特殊处理的话打包时会引入整个库,实际上可能只用上极少的功能,这样就造成了打包后的臃肿。
tree shaking就是将没有使用到的js代码,不用打包进来。
webpack已经默认开启了这个功能,不需要额外配置
举例说明:
- src/js/treeShaking.js:
export const a = () => {
console.log('aaa')
}
export const b = () => {
console.log('bbb')
}
exports.c = function () {
console.log('ccc')
}
- main.js:
import { a } from './js/treeShaking'
a()
- 执行打包命令
npm run prod
- 效果:可以看到,当使用
es6的模块化时,只有当你使用了a函数,才会被打包,b函数就没有被打包;当使用commonJS的模块化时,树摇的功能就失效了,不管有没有使用到c函数,c函数都会被打包
2、@babel/plugin-transform-runtime减少代码体积
babel为每个被编译的文件都插入辅助代码,使得代码体积变大。
babel对一些公共方法使用了辅助代码,比如_extend,假如有10个文件都使用了_extend,那么_extend就会被定义了10次。
可以将_extend作为一个独立模块,避免重复的定义。
@babel/plugin-transform-runtime禁用了babel自动对每个文件runtime注入,而是使用这个库里的辅助代码,避免了重复的定义。
怎么用:
- 下载包
npm i @babel/plugin-transform-runtime -D
- 配置(开发和生产一样)
{
loader: 'babel-loader',
options: {
cacheDirectory: true, // 开启babel缓存
cacheCompression: false, // 关闭缓存文件压缩,因为压缩需要时间
plugins: ['@babel/plugin-transform-runtime'] // 减少代码体积
}
}
3、image minimizer
针对项目中引用的本地图片,可以使用webpack对其进行压缩,压缩可以是无损压缩,也可以是有损压缩,减少打包后的体积
使用:
- 下载包:
npm i image-minimizer-webpack-plugin imagemin -D
无损压缩:
cnpm install imagemin-gifsicle imagemin-jpegtran imagemin-optipng imagemin-svgo -D
有损压缩:
cnpm install imagemin-gifsicle imagemin-mozjpeg imagemin-pngquant imagemin-svgo -D
- 配置:以无损压缩为例
webpack.prod.js
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin')
...
optimization: {
minimize: true,
minimizer: [
new CssMinimizerPlugin(), // css压缩
// new TerserPlugin({ parallel: threads })
// 压缩图片
new ImageMinimizerPlugin({
minimizer: {
implementation: ImageMinimizerPlugin.imageminGenerate,
options: {
plugins: [
['gifsicle', { interlaced: true }],
['jpegtran', { progressive: true }],
['optipng', { optimizationLevel: 5 }],
[
'svgo',
{
plugins: [
'preset-default',
'prefixIds',
{
name: 'sortAttrs',
params: { xmlnsOrder: 'alphabetical' }
}
]
}
]
]
}
}
})
]
}
- 运行命令
npm run prod
可以看到,压缩后,图片体积变小了:
- 注意
无损压缩那几个包挺难下,这里我使用cnpm安装好了,还有可能在打包时报错:
Error: Error with 'src\images\1.jpeg': '"C:\Users\86176\Desktop\webpack\webpack_code\node_modules\jpegtran-bin\vendor\jpegtran.exe"'
Error with 'src\images\3.gif': spawn C:\Users\86176\Desktop\webpack\webpack_code\node_modules\optipng-bin\vendor\optipng.exe ENOENT
这是因为在下载包时,有的包没下载下来
需要手动地将两个文件复制到node_modules中:
- 将jpegtran.exe复制到
node_modules\jpegtran-bin\vendor目录下 - 将optipng.exe复制到
node_modules\optipng-bin\vendor目录下 - 地址
四、优化代码运行性能
1、code split代码分割,按需加载
代码分割是什么
以上,在webpack配置文件中入口文件一直都是这样写的:entry: './src/main.js',这是只有一个入口文件的写法,通常入口文件都叫main.js,打包输出的通常也只有一个文件,一般和入口文件保持同名,也叫main.js。
但有的时候,我们希望在渲染首页的时候,就只加载首页的js文件,所以需要将打包生成的文件进行代码分割,生成多个js文件,渲染当前页面时只加载当前页面所需js文件,这样一来,就可以实现按需加载。
如何实现代码分割
多入口
- 下载包
npm i webpack webpack-cli html-webpack-plugin -D
- 文件目录
├── public
├── src
| ├── app.js
| └── main.js
├── package.json
└── webpack.config.js
public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>代码分割-多入口</title>
</head>
<body>
<h1>代码分割-多入口</h1>
</body>
</html>
src/app.js
console.log('----app')
src/main.js
console.log('----main')
webpack.confing.js
const path = require('path')
const HtmlWebplacePlugin = require('html-webpack-plugin')
module.exports = {
entry: {
main: './src/main.js',
app: './src/app.js'
},
output: {
path: path.resolve(__dirname, './dist'),
filename: 'js/[name].js', // 使用chunk作为bundle的文件名。chunk:entry中引入的文件就叫chunk,输出到dist中的文件就叫bundle
clean: true
},
plugins: [new HtmlWebplacePlugin({ template: './public/index.html' })],
mode: 'production'
}
- 执行命令
npx webpack
可以看到,配置了几个入口,就可以输出几个js文件:
提取重复代码
如果入口文件中都引用了同一份代码,我们不希望这份代码被打包到两个文件中,所以需要将这一部分代码抽取出来,生成一个js文件,在其他文件中引入就好。
- 修改文件和配置
src/math.js
export const sum = (...args) => args.reduce((a, b) => a + b, 0)
src/app.js
import { sum } from './math'
console.log('----app', sum(1, 2, 3))
src/main.js
import { sum } from './math'
console.log('----main', sum(1, 2, 3, 4))
webpack.config.js
optimization: {
// 代码分割配置
splitChunks: {
chunks: 'all', // 对所有模块都进行分割
// 以下是默认值
// minSize: 20000, // 分割代码最小的大小
// minRemainingSize: 0, // 类似于minSize,最后确保提取的文件大小不能为0
// minChunks: 1, // 至少被引用的次数,满足条件才会代码分割
// maxAsyncRequests: 30, // 按需加载时并行加载的文件的最大数量
// maxInitialRequests: 30, // 入口js文件最大并行请求数量
// enforceSizeThreshold: 50000, // 超过50kb一定会单独打包(此时会忽略minRemainingSize、maxAsyncRequests、maxInitialRequests)
// cacheGroups: { // 组,哪些模块要打包到一个组
// defaultVendors: { // 组名
// test: /[\\/]node_modules[\\/]/, // 需要打包到一起的模块
// priority: -10, // 权重(越大越高)
// reuseExistingChunk: true, // 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用,而不是生成新的模块
// },
// default: { // 其他没有写的配置会使用上面的默认值
// minChunks: 2, // 这里的minChunks权重更大
// priority: -20,
// reuseExistingChunk: true,
// },
// },
// 修改配置
cacheGroups: {
// 组,哪些模块要打包到一个组
// defaultVendors: { // 组名
// test: /[\\/]node_modules[\\/]/, // 需要打包到一起的模块
// priority: -10, // 权重(越大越高)
// reuseExistingChunk: true, // 如果当前 chunk 包含已从主 bundle 中拆分出的模块,则它将被重用,而不是生成新的模块
// },
default: {
// 其他没有写的配置会使用上面的默认值
minSize: 0, // 我们定义的文件(math.js)体积太小了,所以要改打包的最小文件体积
minChunks: 2, // math.js中的sum方法至少得在不同的入口文件中被引入和使用2次才会被抽离出来
priority: -20,
reuseExistingChunk: true
}
}
}
}
- 执行命令
npx webpack
可以看到,当app.js和main.js中都使用sum方法后,math.js中的sum方法会被单独打包出来,叫做52.js:
动态导入实现按需加载
上面的math.js中的sum方法被app.js和main.js引入后会抽离出来,打包成52.js,但是52.js是在页面打开后就立即被载入了:
我们希望当我用到其时再加载其,这个时候就需要用到动态导入了
public/index.html
<button id="btn">点击我载入sum方法</button>
src/app.js
console.log('----app被载入')
src/main.js
console.log('----main被载入')
const btn = document.querySelector('#btn')
btn.addEventListener('click', () => {
// import动态导入语法,只在这里引入一次,也会进行代码分割
import('./math.js').then(({ sum }) => {
console.log('----main', sum(1, 2))
})
})
执行npx webpack,打开dist/index.html
可以看到,52.js是在点击按钮后被加载进来的:
单入口
实际开发中都是单入口的模式,拿生产环境来测试:
public/index.html
<button id="btn">动态加载</button>
main.js
document.querySelector('#btn').onclick = function () {
import(/* webpackChunkName: 'sum' */ './js/sum').then(res => {
console.log('动态加载', res.default(1, 2, 3))
})
}
.eslintrc.js(需要支持动态导入的语法)
parserOptions: {
ecmaVersion: 11 // ES 语法版本
}
webpack.prod.js
entry: './src/main.js',
...
optimization: {
...
splitChunks: { chunks: 'all' }
}
执行打包命令:npm run prod,使用import动态引入的sum.js会被打包成258.main.js:
此时,页面加载时,还没有用到sum.js,就没有加载,当点击按钮时,动态载入:
更新配置
使用单入口+代码分割+动态导入的方式来进行配置
webpack.prod.js
entry: './src/main.js',
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'static/js/[name].js', // 使用chunk的名字命名bundle的名字
chunkFilename: 'static/js/[name].chunk.js', // 动态导入输出资源命名方式
assetModuleFilename: 'static/media/[name].[hash][ext]', // 图片、字体等资源命名方式
clean: true
}
// rules中对于图片、字体等资源的文件名配置可以注释掉
{
test: /\.(png|jpe?g|gif|webp)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 10 * 1024 // 小于10kb的图片会被base64处理
}
}
// generator: {
// filename: 'static/imgs/[hash:8][ext][query]'
// }
},
{
test: /\.(ttf|woff2?|mp4|mp3|avi)$/,
type: 'asset/resource'
// generator: {
// filename: 'static/media/[hash:8][ext][query]'
// }
}
optimization: {
...
splitChunks: { chunks: 'all' }
}
2、preload/prefetch
前面介绍了在【单入口】情况下进行代码分割,就是在optimization中设置splitChunks,再使用import动态导入语法,这样在打包时,会将sum方法打包成一个单独的js文件,这样便可以实现在用到sum方法时才加载sum,这个就是懒加载。
但是万一sum这个资源特别的大,用户在点击按钮时才进行加载,可能会造成卡顿,我们希望在浏览器空闲时间,默默地加载后续所需要的资源,就需要用到preload和prefetch
| 是什么 | 优先级 | 兼容性 | 使用场景 | |
|---|---|---|---|---|
| preload | 告诉浏览器立即加载资源 | 优先级高 | 兼容性稍好 | 只能加载当前页面所需资源,当前页面优先级高的资源用preload加载 |
| prefetch | 告诉浏览器在空闲时间加载资源 | 优先级低 | 兼容性低 | 可以加载当前页面资源,也可以加载下一个页面资源 |
prefetch可以加载下一个页面的资源,从特点来看,更适合实际使用,但它的兼容性更低,所以一般使用preload。
webpack5依赖@vue/preload-webpack-plugin,但实际我使用时没看出来效果,后面再看
3、contenthash和runtime结合使用
浏览器的缓存:以上的proload和prefetch都是利用了浏览器的缓存,但是当前后输出的文件名一样时,比如都叫main.js,可能会因为文件名没有变化导致浏览器直接读取缓存了,为了避免这种情况,从文件名入手,确保更新前后文件名发生改变。
更改文件名的几种方式:
- fullhash(webpack4是hash):每次修改任何一个文件,所有的hash值都改变,这意味着,一旦修改了任一文件,整个项目的缓存都将失效
-
chunkhash:根据不同的入口文件进行依赖文件解析、构建对应的chunk,生成对应的hash值。
-
contenthash:根据文件内容生成hash值,只有文件内容变化了,hash值才会变化,所有文件的hash值是独享且不同的
所以我们一般使用contenthash,但是这样还是不行,因为我改动sum.js后再进行打包,sum.js文件的hash值发生变化,这是很正常的,但是main.js的hash值也发生变化了,这就不合理了,这会导致main.js的缓存失败。
原因:main.js中引入了sum.js,当sum.js的hash值变化了,在main.js中的引入的代码也会跟着变,所以导致main.js也发生变化了,所以main.js的hash值跟着变
解决办法:将hash值单独保管在一个runtime文件中,最终会输出main.js、sum.js、runtime.js,此时当sum.js变化时,只会影响到sum.js和runtime.js,main.js不变
webpack.prod.js
optimization: {
...
// 提取runtime文件
runtimeChunk: { name: (entrypoint) => `runtime~${entrypoint.name}` }
}
可以看到,当我只修改sum.js时,main.js的hash没有变化:
总结:contenthash可以保证在文件内容发生变化时,缓存失效。runtime可以保证当前模块的变化只会导致当前文件的hash发生变化,不影响其他文件的hash。
4、core-js
用来兼容promise、async、includes等ES6后面出来的语法
5、pwa
开发web项目,一旦离线就没法访问了。pwa是一种可以提供类似于native app(原生应用程序)体验的web app的技术,在离线时可以继续运行,内部通过service workers技术实现。
使用:
- 下载包
npm i workbox-webpack-plugin -D
- webpack.prod.js
const WorkboxPlugin = require('workbox-webpack-plugin');
new WorkboxPlugin.GenerateSW({
// 这些选项帮助快速启用 ServiceWorkers
// 不允许遗留任何“旧的” ServiceWorkers
clientsClaim: true,
skipWaiting: true
})
- main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then(registration => {
console.log('SW registered: ', registration);
})
.catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
-
打包 npm run prod
-
打开dist/index.html
-
如果直接打开,控制台会出现
SW registration failed: ...此时,请求
service-worker.js文件的路径是http://127.0.0.1:5500/service-workers.js,而实际路径应该为http://127.0.0.1:5500/dist/service-workers.js -
解决路径问题,安装
npm i serve -g,执行server dist,浏览器访问http://localhost:3000/离线也能访问:
五、总结
从4个角度对webpack和代码进行优化:
1、定位错误信息
- 使用
SourceMap让开发或线上代码的报错信息更加准确
2、提升webpack打包构建速度
- 使用
hmr,在开发时,只重新编译发生变化的代码,不变的代码使用缓存,从而使热更新速度更快 - 使用
oneOf,可以让源文件一旦被某个loader处理了,就不会继续遍历了,减少无效的loader匹配 - 使用
include/exclude,排除或只检测某些文件,比如讲node_modules排除掉 - 使用
cache,对eslint和babel处理的结果进行缓存,让第二次及以后的打包速度更快 - 使用
thread-loader,开启多进程打包,但是进程的开启是有很大开销的,慎用
3、减少代码体积
- 使用
tree shaking,将用到的js代码打包,没有用到的js代码不用打包进来,这个是默认开启了的,但是需要使用es6的模块化语法 - 使用
@babel/plugin-transform-runtime,将babel的辅助代码独立出来,不用在每个文件中都生成辅助代码 - 使用
image minimizer,对本地图片进行压缩
4、优化代码运行性能
- 使用
code split,对代码进行分割(splitChunks),使单个js文件体积更小,结合import动态导入,可以实现按需加载 - 使用
preload/prefetch,利用浏览器空闲时间,提前加载需要用到的资源。preload只能加载当前页面的资源,prefetch可以加载下一个页面所需资源,但是兼容性更差 - 使用
contenthash和runtime,在利用network cache时,如果文件名都叫main.js时,可能会直接读取缓存。contenthash可以确保当文件内容发生变化时,文件名会发生变化,从而缓存失效;runtime通过runtimeChunk配置,可以将hash值抽离出来(runtime.js),可以确保当某个文件(如sum.js)发生变化时,打包时只会更新sum.xxx.js和runtime.xxx.js,而不会影响到其他文件,这样其他文件便可以读取缓存 - 使用
core-js,对高版本的ES语法做兼容性处理 - 使用
pwa,在离线(network offline)状态下,也可以访问