前言
作为一个前端工程师,前端工程化是必过的坎,而Webpack在前端工程化中扮演着至关重要的角色。 要明白webpack为什么重要,就要知道它为什么出现,解决了什么问题。
前端模块化从文件划分模块、命名空间划分模块、IIFE通过约定来实现模块化,到CommonJS、ES Module通过行业规范实现模块化,如今ES Module已经是最正统最主流的模块化规范,但是它依然还存在兼容问题,所以开发者还需要解决兼容问题。而且模块化开发,会划分出很多文件,每个文件就是一个模块,文件过大,浏览器请求、加载文件时间过长,影响页面渲染速度,文件过多浏览器请求频繁,也影响性能,所以需要对这些文件进行合并拆分。而项目复杂后,html、css、图片、字体文件等也需要模块化来管理。
于是webpack顺势而出,它是一个现代化的模块打包工具
,支持js、css等不同种类资源的模块化(项目中使用的每个文件都是一个模块),同时对这些资源做兼容性处理,最后对这些资源文件根据需要做拆分合并压缩后打包为静态资源
。
目前,webpack已经到了 webpack 5.89.0
了,应该静下心来好好看一看官网,上篇文章梳理了webpack5的基础配置,并用webpack5从零搭建一个vue3+ts项目,这篇文章梳理webpack5的优化配置,继续在上一篇文章的配置基础上修改。
首先要做的一步,当然是尽可能使用最新的webpack。
webpack文档中有专门的一块内容讲构建性能
查看打包时间和打包体积
安装 speed-measure-webpack-plugin,pnpm add -D speed-measure-webpack-plugin
,但是这个插件不兼容一些新版插件,比如mini-css-extract-plugin,打包会报错,要用只能将不兼容的插件降版本。
安装 webpack-bundle-analyzer 分析打包体积
js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [new BundleAnalyzerPlugin()]
}
一、优化构建速度
1、提高模块解析速度
resolve.alias
当在项目里面使用 import
或 require
写相对路径
,引入的资源文件所处的目录,被认为是上下文目录。在 import/require
中给定的相对路径,会拼接此上下文路径,来生成模块的绝对路径
。 配置别名alias,确保模块引入变得更简单,减少webpack的路径拼接查找时间, 从
js
import Utility from '../../utilities/utility';
改成
js
const path = require('path');
module.exports = {
//...
resolve: {
alias: {
Utilities: path.resolve(__dirname, 'src/utilities/'),
},
},
};
js
import Utility from 'Utilities/utility';
resolve.extensions
配置 resolve.extensions,webpack 会按顺序解析列在数组首位的后缀的文件并跳过其余的后缀,将使用频率高的文件放前面,可以加快webpack解析速度;
js
resolve: {
extensions: [".vue", ".ts", ".scss", "..."],
},
最好的实践,还是尽量带上后缀,减少匹配过程,加快解析速度
resolve.mainFields
一般我们在项目里面都是用的配置的别名resolve.alias,绝对路径、或者相对路径,但是引入npm包使用模块路径, 当我们从npm包中导入模块时,import 'xxx' from 'yyy',根据 模块解析规则,包里面的package.json里面一般都是用main、module、jsnext:main、browser、exports
字段指定入口文件,webpack里面关于优先级的说明:
exports
field is preferred over other package entry fields likemain
,module
,browser
or custom ones.
如果不能通过npm包的package.json里面的exports字段直接找到入口文件,或者没有exports字段 可以修改一下 resolve.mainFields,web端默认 ['browser', 'module', 'main']
,node端默认['module', 'main']
,根据情况,看项目使用esm还是cjs模块化方案,调整mainFields的顺序,加快查找速度
js
module.exports = {
//...
resolve: {
mainFields: ['main','module', 'browser'],
},
};
resolve.modules
如果在项目中使用较多模块路径,比如import math.js from 'utils'
,可以配置 resolve.modules,它默认是['node_modules'],webpack 查找当前目录以及祖先路径(即 ./node_modules
, ../node_modules
等等)
如果有很多模块路径来自src,可以优先搜索src,
js
module.exports = {
//...
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
},
};
2、不需要解析的模块 module.noParse
项目中如果用了类似jquery或者loadsh这种库,没有使用AMD/CommonJs规范,没有模块化,就可以使用noparse排除解析,因为它们两个没有其他依赖,
js
module.exports = {
//...
module: {
noParse: /jquery|lodash/,
},
};
排除的文件里面不应该含有 import
, require
, define
的调用,或任何其他导入机制,打包后浏览器不兼容,就报错; 比如我安装了loadsh-es,它里面是使用了import引入其他文件,如果不解析它,就会报错
3、排除多余模块 IgnorePlugin
有些npm包,模块被捆绑在了一起,比如webpack官网的例子,moment这个包,我只需要zh-cn这个模块,不需要其他国家的语言模块。
js
new webpack.IgnorePlugin({
resourceRegExp: /^./locale$/,
contextRegExp: /moment$/,
});
4、减少loader处理文件
使用loader的时候,使用include和exclude缩小查询范围,exclude优先级更高,使用include包含更精确范围
js
const resolve = (dir: string) => path.resolve(__dirname, dir);
{
test: /\.vue$/,
include: resolve("./src"),
loader: "vue-loader"
},
5、使用多进程构建
多余耗时的loader,像babel-loader可以使用thread-loader每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。
请仅在耗时的操作中使用此 loader!
js
module.exports = {
module: {
rules: [
{
test: /.js$/,
include: path.resolve('src'),
use: [
"thread-loader",
"babel-loader" // 耗时的 loader (例如 babel-loader)
],
},
],
},
};
可以通过预警 worker 池来防止启动 worker 时的高延时。
这会启动池内最大数量的 worker 并把指定的模块加载到 node.js 的模块缓存中。
js
const threadLoader = require('thread-loader');
threadLoader.warmup(
{
// 池选项,例如传递给 loader 选项
// 必须匹配 loader 选项才能启动正确的池
workers: 2,
// 一个 worker 进程中并行执行工作的数量
workerParallelJobs: 50,
// 闲置时定时删除 worker 进程
poolTimeout: 2000 //(默认500ms),
},
[
// 加载模块
// 可以是任意模块,例如
'babel-loader',
'babel-preset-es2015',
'sass-loader',
]
);
6、缓存
在 webpack 配置中使用 cache
选项实现持久化缓存。 缓存生成的 webpack 模块和 chunk,来改善构建速度。cache
会在开发模式被设置成 type: 'memory'
内存缓存, 而且在生产模中没有默认开启。 cache: true
与 cache: { type: 'memory' }
配置作用一致
js
module.exports = {
cache: {
type: "filesystem",
buildDependencies: {
// This makes all dependencies of this file - build dependencies
config: [__filename],
},
},
};
二、优化开发体验
1、source map
浏览器运行的代码通常是压缩转换过的,这样可以节约文件下载时间,但是debug就困难了,出现问题,不好准确定位到报错行,source map就是一个将压缩转换后的代码映射到原始代码的文件,它能够让浏览器重新构建原始代码文件,并把情况展示在console面板这样的调试器中。
要做到这一点:
- 生成source map文件
- 并且在转换后的代码文件底部添加特殊注释指向source map文件,注释的语法格式:
js
//# sourceMappingURL=main.c8400759.js.map
同时在chrome浏览器中,(已经默认勾选),必须将 devtool->sources->settings->preference->ennable javascript source maps
勾选上,浏览器才会下载source map文件进行解析映射。
使用webpack的devtool
选项控制是否生成,以及如何生成 source map。
对于开发环境以下选项非常适合:
eval
- 每个模块都使用eval()
执行,并且都有//# sourceURL
。此选项会非常快地构建。主要缺点是,由于会映射到转换后的代码,而不是映射到原始代码(没有从 loader 中获取 source map),所以不能正确的显示行数。eval-source-map
- 每个模块使用eval()
执行,并且 source map 转换为 DataUrl 后添加到eval()
中。初始化 source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 source map。eval-cheap-source-map
- 类似eval-source-map
,每个模块使用eval()
执行。这是 "cheap(低开销)" 的 source map,因为它没有生成列映射(column mapping),只是映射行数。它会忽略源自 loader 的 source map,并且仅显示转译后的代码,就像eval
devtool。eval-cheap-module-source-map
- 类似eval-cheap-source-map
,并且,在这种情况下,源自 loader 的 source map 会得到更好的处理结果。然而,loader source map 会被简化为每行一个映射(mapping)。
在大多数情况下,最佳选择是
eval-cheap-module-source-map
。
三、优化构建产物
1、代码分割
代码分割是 webpack 中最主要的特性之一。此特性能够把代码分离到不同的 bundle 中,然后便能按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle、控制资源加载优先级,如果使用合理,会极大减小加载时间,提升页面加载速度。
常用的代码分离方法有三种:
- 多个入口 :使用
entry
配置手动地分离代码。 - 防止重复 :使用 入口依赖 或者
SplitChunksPlugin
去重和分离 chunk。 - 动态导入:通过模块的内联函数调用分离代码。
(1)多个入口
js
module.exports = {
//...
entry: {
index: './src/index.ts',
home: './src/home.ts',
},
};
此时output.filename不能写死,不然会报错,多个入口,无法对应出口文件,可以写成以下方式:
js
module.exports = {
//...
output: {
// 使用入口名称
filename: '[name].bundle.js',
// 使用内部 chunk id
// filename: '[id].bundle.js',
// 使用由生成的内容产生的 hash
// filename: '[contenthash].bundle.js'
// 结合多个替换组合使用
// filename: '[name].[contenthash].bundle.js'
// 使用函数返回 filename
// filename: (pathData) => {
// return pathData.chunk.name === 'main' ?
// '[name].js' : '[name]/[name].js';
},
},
};
但是这种方式有隐患:如果index.ts和home.ts都引入了lodash-es这个库,
js
// index.ts
import { join } from "lodash-es";
console.log(join(["Another", "module", "loaded!"], " "));
// home.ts
import { join } from "lodash-es";
console.log(join(["Another", "module", "loaded!"], " "));
join
函数会重复打包,在配置文件中配置 dependOn
选项,以在多个 chunk 之间共享join模块。同时设置 optimization.runtimeChunk : 'single' ,创建一个在所有生成 chunk 之间共享的运行时文件,join模块只能实例化一次。这种保证允许模块的顶级作用域用于全局状态,并在join模块的所有使用者之间共享
js
module.exports = {
entry: {
index: {
import: "./src/index.ts",
dependOn: "shared"
},
home: {
import: "./src/home.ts",
dependOn: "shared"
},
shared: ["lodash-es"]
},
output: {
filename: "[name].[contenthash:8].js"
},
optimization: {
runtimeChunk: "single"
}
};
(2)import() 动态导入
js
// index.ts
const btnEl = document.createElement('button')
btnEl.textContent = 'import导入'
btnEl.onclick=function(){
import("./home").then((res) => {
res.sendName();
});
}
document.body.appendChild(btnEl)
// home.ts
export const sendName = () => {
console.log("dynamic import");
};
home.ts会被单独打包成一个bundle文件,点击按钮的时候,才会创建一个script标签去请求它,做到按需加载
在使用vue-router或者react-router路由懒加载时,就是这个原理,依赖于打包工具的切割
(3)optimization.splitChunks
webpack v4+ 开始提供的全新的通用分块策略,配置 optimization.splitChunks,默认只对按需加载的chunk分包,比如import(),如果想要分更多包,就要配置optimization.splitChunks
默认配置:
js
module.exports = {
//...
optimization: {
splitChunks: {
// 默认只将异步import()引入代码分割,
chunks: "async",
// 最小20kb才会分割
minSize: 20000,
// 除了满足minsize,还要减少主chunk的大小才会分割
minRemainingSize: 0,
// 拆分前必须共享模块的最小chunks数
minChunks: 1,
// 按需加载时的最大并行请求数
maxAsyncRequests: 30,
// 入口文件的最大并行请求数
maxInitialRequests: 30,
enforceSizeThreshold: 50000,
// 缓存组可以继承和/或覆盖来自 splitChunks.* 的任何选项
// test、priority 和 reuseExistingChunk 只能在缓存组级别上进行配置。
// 可以配置多个组,如果一个模块满足多个组条件,最终由priority决定打包到哪个组
cacheGroups: {
// 默认将所有来自node_modules目录的模块打包至vendors组
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
// 如果当前 chunk 包含已从主 bundle 中拆分出的模块,
// 则它将被重用,而不是生成新的模块。这可能会影响 chunk 的结果文件名。
reuseExistingChunk: true
},
// 两个以上的chunk所共享的模块打包至default组
default: {
minChunks: 2,
// 优先级没有defaultVendors高
priority: -20,
reuseExistingChunk: true
}
}
}
}
};
根据项目不同情况配置
js
optimization: {
usedExports: true,
splitChunks: {
cacheGroups: {
// 包括整个应用程序中 node_modules 的所有代码。
vendor: {
name: "vendor",
chunks: "all",
priority: 20,
test: /[\\/]node_modules[\\/]/
},
// 包括入口(entry points)之间所有共享的代码
commons: {
name: "commons",
chunks: "initial",
priority: 10,
minSize: 0,
// 至少被两个chunk共享才分离
minChunks: 2
}
}
}
}
(4)预获取和预加载
ts
// home.ts
export const sendName = () => {
console.log("dynamic import");
};
// index.ts
const btnEl = document.createElement("button");
btnEl.textContent = "import导入";
btnEl.onclick = function () {
import(/* webpackPrefetch: true */ "./home").then((res) => {
res.sendName();
});
};
document.body.appendChild(btnEl);
上面的代码在构建时会生成 <link rel="prefetch" href="./home.ts">
并追加到页面头部,指示浏览器在闲置时间预获取 home.ts
文件,不要等点击按钮再获取,优化用户体验。
js
import(/* webpackPreload: true */ "./home").then((res) => {
res.sendName();
});
preload具有更高优先级,home.ts会和index.ts并行下载,但不会解析执行
2、tree-shaking
sideEffects
和 usedExports
(更多地被称为 tree shaking)是两种不同的优化方式
sideEffects
更为有效 是因为它允许跳过整个模块/文件和整个文件子树。
usedExports
依赖于 terser 检测语句中的副作用。它是一个 JavaScript 任务而且不像 sideEffects
一样简单直接。并且由于规范认为副作用需要被评估,因此它不能跳过子树/依赖项。
一种方式是在package.json里面配置sideEffects
字段:
所有代码都没有副作用,直接设置false,
json
"sideEffects": false
但是这样做,在首页引入的 css文件也不会被打包,换成数组方式
json
"sideEffects": [
"*.css"
]
还有一种方式,在生产模式下打包,webpack会自动tree-shaking,相当于配置
js
module.exports = {
// ...
optimization: {
usedExports: true,
},
}
同时可以使用/*#__PURE__*/
注释放到函数调用之前,用来标记此函数调用是无副作用的。
要对css做tree-shaking,安装插件purgecss-webpack-plugin,
js
const path = require("path");
const { globSync } = require("glob");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const PurgeCSSPlugin = require("purgecss-webpack-plugin");
module.exports = {
//...
module: {
rules: [
{
test: /\.s?css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
"postcss-loader",
"sass-loader"
]
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: "style/[name]_[contenthash:8].css",
chunkFilename: "style/[name]_[chunkhash:8].css"
}),
new PurgeCSSPlugin({
paths: globSync(`${path.resolve(__dirname, "src")}/**/*`, { nodir: true })
})
]
};
3、代码压缩
webpack5 在生产环境下已经使用 terser-webpack-plugin 自动对js代码做了压缩,如果要自定义配置,那么仍需要安装 terser-webpack-plugin
,
同时也发现在生产模式下css代码也是被压缩了的,不用在手动的配置 css-minimizer-webpack-plugin,而且被注释的css代码也被删除了
总结
Webpack在前端工程化中提供的能力:
- 模块打包和依赖管理:Webpack可以将前端应用程序拆分为多个模块,并通过各种加载器(Loaders)和插件(Plugins)来处理和转换这些模块。它可以解析模块之间的依赖关系,并生成一个或多个打包后的文件,以供浏览器加载和执行。
- 资源管理和优化:Webpack不仅可以打包JavaScript模块,还可以处理其他类型的静态资源,如样式表(CSS、Sass、Less)、图片、字体等。通过加载器和插件,它可以对这些资源进行压缩、合并、优化和缓存等处理,以提高应用程序的加载性能和用户体验。
- 代码分割和懒加载:Webpack支持代码分割和懒加载,可以将应用程序代码拆分为多个块(chunks),并按需加载这些块。这种方式可以减小初始加载的文件大小,提高页面的加载速度,并实现按需加载,降低了用户首次访问时的等待时间。
- 开发环境和生产环境的配置:Webpack提供了强大的配置能力,可以根据开发环境和生产环境的需求来进行不同的配置。它支持开发服务器、热模块替换(Hot Module Replacement)、代码调试等功能,使开发人员能够更高效地进行开发和调试。
- 构建流程的自动化:Webpack可以与其他构建工具(如Grunt、Gulp)集成,并通过配置文件定义整个构建流程。它可以自动化处理资源依赖关系、编译、压缩、合并和输出最终的生产代码,简化了前端开发的构建过程,提高了开发效率。