webpack 性能优化包括构建速度性能优化和打包结果页面性能优化,后者主要策略有
- 提出无用代码,减少构建体积
- 提取公共依赖,多脚本复用
- 首屏静态资源分离,异步加载
- 文件添加哈希,浏览器缓存复用
Tree Shaking
Tree Shaking 是一种用于移除 JavaScript 中未使用代码的技术。它依赖于 ES6 模块的静态结构,允许在构建过程中确定哪些部分代码没有被实际引用,进而从最终打包的文件中删除这些未使用的代码。就像摇晃一颗树,未用到的代码就像树叶一样落下
工作原理
- 静态分析:ES6 模块(使用 import 和 export 语法)提供了静态的模块依赖关系,在代码解析时候确定依赖关系
- 标记和移除未使用代码:在构建过程中,Webpack 和依赖的工具(如 Terser)会通过静态分析模块的引入和导出,标记哪些模块是不被使用的,然后删除未被引用的代码块,从而减小打包体积。
webpack 启用 tree shaking
首先要确保使用 ES module,然后修改 webpack.config.js mode 设置为 production后 webpack 自动开启 tree shaking
在 package.json 中加入 sideEffects 字段,这个字段告诉 webpack 哪些文件具有副作用,不应该被 tree shaking 剔除。可以设置为 false 表示所有模块都没有副作用,或者指定某些文件
{
"name": "your-project",
"version": "1.0.0",
"sideEffects": [
"./src/some-side-effectful-file.js"
]
}
如果使用了 @babel/preset-env 需要在 babel 配置中关闭ES6 模块语法转换成 CommonJS 模块语法
{
"presets": [
["@babel/preset-env", {
"modules": false
}]
]
}
移除未使用的 CSS
除了常规的 CSS 压缩,可以使用 PurgeCSS 通过扫描 HTML、JavaScript 等文件,找到真正使用的 CSS 类名,并移除未使用的部分,让 CSS 有类似 Tree Shaking 的效果
PurgeCSS 是 postcss 插件,项目需要 postcss 配置 postcss.config.js
const purgecss = require('@fullhuman/postcss-purgecss');
module.exports = {
plugins: [
require('autoprefixer'),
purgecss({
content: ['./src/**/*.html', './src/**/*.js'], // 指定包含CSS类名的文件路径
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
}),
],
};
如果不放心移除未被扫描出来的 CSS,可以使用 critters-webpack-plugin,插件通过解析 HTML 和 CSS 文件来确定哪些 CSS 规则在文档中被使用,将使用的 CSS 规则(关键 CSS)提取并内联到 HTML 文件的 中,没有使用到的 CSS 异步加载,从而减少首次加载时对外部 CSS 文件的依赖,从而提高首屏渲染速度
Code Splitting
Code Splitting 是为了根据加载实际和代码复用,将代码拆分成多个小块后 bundle 的性能优化手段
Entry Points 分割
最简单的方法是根据页面路由配置多个入口点,将不同的入口使用到的模块分割到单独的文件中。每个入口点会生成一个新的 bundle 文件
const path = require('path');
module.exports = {
mode: 'production',
entry: {
main: './src/index.js',
vendor: './src/vendor.js'
},
output: {
filename: '[name].bundle.js', // 生成 main.bundle.js 和 vendor.bundle.js
path: path.resolve(__dirname, 'dist')
}
};
SplitChunksPlugin
SplitChunksPlugin 是 Webpack 4 引入的一个内置插件,用于智能分割代码块。可以将共享的模块提取到一个单独的文件中,避免同一个模块在不同的 bundle 中重复出现
Webpack 通过 cacheGroups 选项来配置不同缓存组,以便灵活地管理代码分割的策略。不使用 cacheGroups 的时候,Webpack 会使用默认配置生成缓存组提取共享模块
- defaultVendors: 针对 node_modules 下的第三方模块
- default: 针对项目内共享的模块
通过 cacheGroups 可以指定特定的模块或文件夹以特定的方式进行分割
const path = require('path');
module.exports = {
mode: 'production',
entry: {
main: './src/index.js',
another: './src/anotherModule.js'
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist')
},
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
reactVendor: {
test: /[\/]node_modules[\/](react|react-dom)[\/]/,
name: 'react-vendor',
chunks: 'all',
},
utilityVendor: {
test: /[\/]node_modules[\/](lodash|moment)[\/]/,
name: 'utility-vendor',
chunks: 'all',
},
},
},
},
};
通过这些配置,Webpack 会将 React 和 ReactDOM 模块打包到 react-vendor 文件中,而将 Lodash 和 Moment.js 模块打包到 utility-vendor 文件中。
SplitChunksPlugin 提供了很多可以配置的选项,用来精细化控制代码分割行为:
module.exports = {
mode: 'production',
optimization: {
splitChunks: {
chunks: 'all', // 选择哪些 chunk 进行分割,默认值是 'async'
minSize: 20000, // 生成 chunk 的最小体积,单位是字节
maxSize: 0, // 生成 chunk 的最大体积,单位是字节,不限制大小
minChunks: 1, // 模块被引用的最少次数才会被分割
maxAsyncRequests: 30, // 按需加载时最大的并行请求数
maxInitialRequests: 30, // 入口点的最大并行请求数
automaticNameDelimiter: '~', // 文件名的连接符
cacheGroups: {
defaultVendors: {
test: /[\/]node_modules[\/]/, // 匹配哪些模块被分组到这个 cache group
priority: -10, // 缓存组的优先级
reuseExistingChunk: true, // 如果当前 chunk 包含已从主 bundle 分离出的模块,则重用它
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
chunks 有三个取值
- initial:只有入口点的代码会被分割,而动态加载的代码不会被应用分割规则
- async:只有动态导入的代码块会应用分割规则,初始加载的代码不会被影响
- all:初始加载和动态加载的模块都会被分割,最大化覆盖分割效果
Dynamic Imports
动态导入使用 ES6 import() 语法,在代码运行时而非编译时决定导入哪个模块,按需加载模块,Webpack 会自动处理这种语法并生成按需加载的 chunk。这种方式非常适用于需要延迟加载的模块,例如非首屏内容、路由懒加载等。
document.getElementById('loadButton').addEventListener('click', () => {
import(/* webpackChunkName: "lazy-loaded-module" */ './lazyModule.js')
.then(({ default: lazyModule }) => {
lazyModule();
})
.catch(err => console.error('Error loading the module:', err));
});
动态导入在 React 中非常有用,可以实现组件的懒加载:
import React, { Suspense, lazy } from 'react';
const LazyComponent = lazy(() => import('./LazyComponent'));
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
</div>
);
}
export default App;
为了提升动态 import 模块的家在性能,可以利用 webpackPrefetch 和 webpackPreload 指令,告诉浏览器在适当的时候预加载相关模块:
- webpackPrefetch: true:在浏览器空闲时加载。
- webpackPreload: true:在父 chunk 加载时立即加载。
// Prefetch
import(/* webpackPrefetch: true */ './moduleA');
// Preload
import(/* webpackPreload: true */ './moduleB');
首屏 CSS 提取
图片优化
在大部分时候图片优化应该通过专业的图片工具和 CDN 处理,在页面只负责使用,不应该通过构建工具处理,小型的个人项目可以通过 webpack 做一些简单优化
- 图片压缩:image-webpack-loader
- 小图转 base64:url-loader
- 图片格式:image-minimizer-webpack-plugin
- webp:imagemin-webp
- avif:imagemin-avif
- 雪碧图:webpack-spritesmith
const path = require('path');
const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
const SpritesmithPlugin = require('webpack-spritesmith');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /.(png|jpg|jpeg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192, // 小于8KB的图片将转换为Base64内联
name: '[name].[hash].[ext]',
outputPath: 'images',
},
},
{
loader: 'image-webpack-loader',
options: {
mozjpeg: {
progressive: true,
quality: 65,
},
pngquant: {
quality: [0.65, 0.90],
speed: 4,
},
gifsicle: {
interlaced: false,
},
webp: {
quality: 75,
},
avif: {
quality: 50,
},
},
},
],
},
{
test: /.(svg)$/i,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[hash].[ext]',
outputPath: 'images',
},
},
{
loader: 'image-webpack-loader',
options: {
svgo: {
plugins: [
{ removeViewBox: false },
{ cleanupIDs: false },
],
},
},
},
],
},
],
},
plugins: [
// 雪碧图插件配置
new SpritesmithPlugin({
src: {
cwd: path.resolve(__dirname, 'src/icons'), // 图标图片路径
glob: '*.png',
},
target: {
image: path.resolve(__dirname, 'src/sprite/sprite.png'),
css: [
[path.resolve(__dirname, 'src/sprite/sprite.css'), { format: 'css_template' }],
],
},
apiOptions: {
cssImageRef: '~sprite.png',
},
spritesmithOptions: {
padding: 4,
},
customTemplates: {
css_template: function (data) {
return data.sprites.map(sprite => {
return `.icon-${sprite.name} {
background-image: url(${sprite.escaped_image});
background-position: ${sprite.px.offset_x} ${sprite.px.offset_y};
width: ${sprite.px.width}; height: ${sprite.px.height};
}`;
}).join('\n');
}
}
}),
new ImageMinimizerPlugin({
minimizerOptions: {
plugins: [
['mozjpeg', { progressive: true, quality: 65 }],
['optipng', { enabled: true }],
['pngquant', { quality: [0.65, 0.90], speed: 4 }],
['gifsicle', { interlaced: false }],
['imagemin-webp', { quality: 75 }],
['imagemin-avif', { quality: 50 }],
],
},
generator: [
{
type: 'asset',
implementation: ImageMinimizerPlugin.imageminGenerate,
filename: 'images/[name][ext].webp',
options: {
plugins: ['imagemin-webp'],
},
},
{
type: 'asset',
implementation: ImageMinimizerPlugin.imageminGenerate,
filename: 'images/[name][ext].avif',
options: {
plugins: ['imagemin-avif'],
},
},
],
}),
],
};
CDN 处理
通过 CDN 加载第三方库
为了避免将某些第三方库打包到最终的 bundle 中,可以使用 Webpack 的 externals 选项,将指定的第三方库排出,在 HTML 通过 CDN 引用
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
externals: {
jquery: 'jQuery', // 全局变量 jQuery,来自于 CDN
lodash: '_', // 全局变量 Lodash,来自于 CDN
},
};
通过 public path 指定静态资源 CDN 地址
publicPath 是 webpack 非常重要的配置选项,定义了项目中所有输出文件的公共路径(public URL)
const path = require('path');
const isProduction = process.env.NODE_ENV === 'production';
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
publicPath: isProduction ? 'https://cdn.example.com/' : '/',
},
// ...
};
- Dynamic Import 默认使用 import 语句所在文件路径,但如果设置了 publicPath 则会使用 publicPath(也可以通过全局变量
__webpack_public_path__指定) - 上文提到的图片优化也会使用 publicPath 设置处理后的图片地址