为什么还要学习webpack
- 我们很多项目用的还是webpack🐶,只能说不得不学~
- 根据 State-of-JS 2021 的统计数据,2021年 Webpack 还保持高达 89% 的使用率,依然是绝对的大多数!虽然未来必然会有许多用户在特定场景下选用其它构建工具,但短期内还不太可能撼动 Webpack 的头部地位
- webpack5的更新,提供了持久化缓存,lazyCompilation等特性,极大的提升了构建性能,虽然不可能超越不需要编译的框架,但是应该也会持续的缩小这个差距
名词解释
module:每个文件都是一个模块(module)chunk: 过程中的代码块,一个或多个module的集合。比如a模块依赖b模块,b模块依赖c模块,webpack通过引用关系逐个打包模块,最终形成一个chunk。形成chunk有3种情况,entry入口、async模块、代码分割splitChunksbundle: 输出的一个或多个打包文件。这里注意,bundle不等于chunk,比如一般情况下一个chunk产出一个bundle,但是如果配了sourcemap时,一个chunk产出2个bundleruntime:webpack用来连接模块化应用程序所需的所有代码loader: 预处理器,webpack只能识别json和javascript文件,loader的作用就是将webpack不识别的文件转为可识别模块plugin: 插件(拓展器),拓展loader无法做的事情,例如打包优化,注入环境变量等
基础类配置
entry
告诉webpack模块的起点,webpack会根据起点查找模块的依赖关系绘制模块依赖图,然后根据入口和模块之间的依赖关系生成一个或多个chunk。
entry有三种类型,string、string[]、对象。不同类型构建出来的模块依赖图会有所不同
string:单页面单入口文件打包,会构建一个模块依赖图string[]:单页面多入口文件打包,会构建一个模块依赖图对象:多/单页面(多入口/单入口)文件打包,会构建一个或多个模块依赖图 为了能够更直观的理解entry与依赖图的关系,我们通过一个简单的demo理解一下
// index.js
import { toast } from './util'
import { model } from './util1'
toast('index')
model('index')
// other.js
import { toast } from './util'
toast('other')
// util.js
export function toast(data) {
console.log(data + 'toast')
}
// util1.js
export function model(data) {
console.log(data + 'model')
}
单页面多入口配置
entry: ['./src/index.js', './src/other.js']
// 等价于
entry: {
main: ['./src/index.js', './src/other.js']
}
多页面配置
entry: {
main: './src/index.js',
main1: './src/other.js'
}
为什么会有多页面?
当我们有2个及以上的活动页,数据都不互通,但是技术架构一样,并且共用一个弹窗组件,那么这里最适合的就是多页面打包的方案。
resolve
配置模块如何去解析,日常项目中,resolve.alias和resolve.extensions使用的最多
resolve.alias为require或import设置别名,确保模块引入更简单
resolve.extensions允许我们在引入模块时不写后缀,webpack会按数组顺序解析文件。如果resolve.enforceExtension: true将不允许无扩展名文件
// index.js
import { sayHello } from './lib/util.ts'
sayHello('jg')
// ./lib/util.ts
export function sayHello(name) {
console.log(`hello ${name}`)
}
// 配置alias和extensions
resolve: {
alias: {
'@lib': path.resolve(__dirname, 'resolve-demo/lib')
},
// ...表示默认配置(依次为js、json、wasm)
extensions: ['.ts', '...']
}
// 配置完后在任何地方引入sayHello就方便了很多,不管路径有多深,直接@lib即可,文件后缀会依次读取ts、...
import { sayHello } from '@lib/util'
output
告诉webpack如何向硬盘写入编译文件。注意,即使可以存在多个entry起点,但只能指定一个output配置。
publicPath
载入资源(js、css、img等)时的基础路径。
// 配置
{
entry: './output-demo/index.js',
output: {
path: path.resolve(__dirname, 'output-dist'),
publicPath: '/output-dist/'
}
// 最终js的访问路径为
<script defer src="/output-dist/main.js"></script>
filename
决定了initial chunk类型输出bundle的文件名称。这些bundle将写入到output.path指定的目录下。
chunkFilename
决定了non-initial chunk类型输出bundle的文件名称。这些bundle将写入到output.path指定的目录下。
基于entry、runtime生成的chunk通常称之为initial chunk,异步模块(比如() => import(a.vue))称为non-initial chunk
module.exports = {
entry: './output-demo/index.js',
output: {
path: path.resolve(__dirname, 'output-dist'),
filename: '[name].initial.js',
chunkFilename: '[name].async.js',
},
optimization: {
runtimeChunk: true
}
}
// './output-demo/index.js'
import * as _ from 'lodash'
import('./util1')
import('./util2')
// bundle
main.initial.js // initial chunk
runtime~main.initial.js // initial chunk
output-demo_util1_js.async.js // async chunk
output-demo_util2_js.async.js // async chunk
模版字符串
前文使用的[name]其实就是模版字符串,模版字符串一方面是为了可以定义chunk名称,另一方面是为了充分利用浏览器的缓存。
[id]:chunk的id,受optimization.chunkIds影响。non-initial还可以通过/* webpackChunkName: "自定义名称" */来控制chunk名称[name]:chunk的文件名,只有chunkFilename的[name]受optimization.chunkIds影响,non-initial可以通过/* webpackChunkName: "自定义名称" */来控制chunk名称[fullhash]: 所有模块的hash都相同,也就是当任意一个模块有改动时,所有模块的hash都会修改,浏览器缓存全部失效
[chunkhash]: 与entry相关,根据不同的entry进行依赖解析,只有和修改的模块相关联的chunk才会重新生成hash,所以只有部分浏览器缓存失效
[contenthash]: 与文件内容相关,只有修改过的文件的hash发生了变化。比如一个文件(chunkhash)引用了css时,自身发生了变化时,却发现抽取的css的hash也发生了变化,这时候contenthash的好处就出现了
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
})
]
一般项目中,最常用的就是如下配置
{
output: {
filename: '[name].[chunkhash:8].js',
chunkFilename: `[id].[chunkhash:8].js`
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash:8].css',
})
]
}
在
webpack5之前默认的chunkIds为natural,所以在模块顺序发生变化时,该模块后续的缓存全部失效,webpack5新增长期缓存算法deterministic,在正式环境默认开启
clean
在生成文件之前清空output目录。在webpack5之前,我们需要使用clean-webpack-plugin实现这个功能。
优化类配置
optimization
runtimeChunk
将webpack的runtime代码单独生成一个chunk。
默认值为false,会将runtime代码注入到entry chunk中,如果设置为true或multiple,会为每个entry chunk添加一个额外的runtime chunk。如果设置为single,会生成一个所有entry chunk共享的runtime chunk。
tree shaking
通常用于描述移除模块中的无用代码(dead-code),前提是模块源代码要符合ESM规范。webpack中的tree shaking分为2种级别module level和statement level。一般tree shaking是指module level+statement level
在这之前,我们先理解一下什么是dead-code
- 代码不会被执行,不可到达
- 代码执行的结果不会被用到
- 代码只会影响死变量,只写不读
// 不可到达,不执行
function a() {}
// 结果不会被用到
function b() {return 1}
b()
// 只写不读
var c = 1
function d() {c = 2}
d()
statement level:移除dead-codeoptimization.usedExports设置为true(production默认为true),使用unused harmony export [函数名]标记未引用的代码optimization.minimize设置为true(production默认为true),告知webpack使用TerserPlugin或其它在optimization.minimizer定义的插件移除标记的无用代码
module level: 移除无用的模块optimization.sideEffects设置为true(production默认为true)。告知webpack去识别package.json中的sideEffectstrue表示有副作用,webpack不会删除模块false表示没有副作用,webpack可以安心的删掉模块(import x.css会被当作没副作用的模块,直接删除,所以一般需要设置为['**/*.css'])string[]表示数组内匹配到的模块有副作用,webpack不会删除这些模块 接下来我们用demo理解一下上述的配置
statement level配置
// webpack.config.js
{
mode: 'production',
// ...
optimization: {
concatenateModules: false, /** 是否合并模块,默认为true,设置为false是为了更好的看到usedExports的标记 */
usedExports: true, /** 标记dead-code */
sideEffects: false, /** 暂时不开启module level */
minimize: true /** 是否压缩和移除dead-code */
}
}
// index.js
import { sayAge } from './util1'
import { sayHello } from './util2'
sayAge(1)
// util1
export function sayAge(age) {
console.log(`age ${age}`)
}
export function sayName(name) {
console.log(`name ${name}`)
}
// util2
export function sayHello(name) {
console.log(`hello ${name}`)
}
2.
module level配置
// webpack.config.js
{
mode: 'production',
// ...
optimization: {
concatenateModules: false,
usedExports: true,
sideEffects: true, /** 开启module level */
minimize: false
}
}
// package.json,无副作用
"sideEffects": false
// index.js
import { sayAge } from './util1'
import { sayHello } from './util2'
// util1
export function sayAge(age) {
console.log(`age ${age}`)
}
export function sayName(name) {
console.log(`name ${name}`)
}
// 新增一行副作用代码
console.log(sayAge('18'))
// util2
export function sayHello(name) {
console.log(`hello ${name}`)
}
总结一下配置项的意义:
usedExports: 标记dead-code,unused harmony export [函数名]的标记minimize: 压缩并移除标记的dead-codeprovidedExports: 为export * from语句生成更加高效的代码,并不影响tree shaking结果,实操发现代码中存在export * from,在production环境下providedExports为false打包时会报错,目前不太了解原因sideEffects: 告知webpack是否可以根据package.json中的sideEffects直接启动模块级别的tree shaking
splitChunk
为什么需要拆分chunk?
当然是为了减少bundle体积。
那为什么做代码拆分会减少体积?
比如有2个async chunk都引用了lodash,在没有splitChunk的情况下会在两个chunk中都包含lodash,明显有一个chunk里的lodash是多余的。那么如何优化呢?抽到一个vendors chunk里呗,两个async chunk都引用vendors chunk的lodash
// index.js
import('./util1')
import('./util2')
// util1.js、util2.js
import 'lodash'
// 配置
optimization: {
splitChunks: {
cacheGroups: {
default: false,
defaultVendors: false
}
}
}
minSize | minRemainingSize
minSize:生成chunk的最小体积,默认20kbminRemainingSize:通过确保拆分后剩余的最小chunk体积超过限制来避免大小为零的模块。development下为0,production等于minSize值
// index.js
import('./util1')
// util1.js
import 'dayjs'
minChunk
拆分前必须共享模块的最小chunks数,比如2就表示模块必须出现在2个及以上的chunk里
// index.js
import('./util1')
import('./util2')
// util1.js
import { say } from './util3'
// util2.js
import { say } from './util3'
// util3.js
export function say(){}
util3的say出现在2个chunk里,所以会被分离出来
maxAsyncRequests | enforceSizeThreshold
maxAsyncRequests:按需加载时的最大并行请求数,默认30。enforceSizeThreshold:强制执行拆分的体积阈值,默认50kb,其他限制(minRemainingSize,maxAsyncRequests,maxInitialRequests)将被忽略
// 配置
splitChunks: {
minChunks: 1,
minSize: 0,
maxAsyncRequests: 1, // 为了更好测试先定为1
enforceSizeThreshold: Infinity // lodash和vue都超过50kb,为了不强行分chunk,所以设置Infinity
}
// index.js
import('./util1')
import('./util2')
// util1.js
import 'lodash'
import { say } from './util3'
// util2.js
import 'Vue'
import { say } from './util3'
// util3.js
export function say(){
console.log(1)
}
maxInitialRequests
与maxAsyncRequests基本一致,只不过设置的是入口点的最大并行请求数
chunks
initial:只取initial chunk进行代码拆分async:默认配置,只取async chunk进行代码拆分all:对所有chunk进行代码拆分。推荐使用!
// index.js
import('./util1')
import('./util2')
// import 'lodash'
// util2.js
import 'lodash'
// util1.js
import 'lodash'
总结一下
webpack拆分chunks失败的可能性:
- 拆分前共享模块的小于
minChunks - 新的
chunk体积小于minSize或者剩余的chunk体积小于minRemainingSize - 当按需加载时时,并行请求的最大数量小于或等于
maxAsyncRequests - 当加载初始化页面时,并发请求的最大数量小于或等于
maxInitialRequests chunks设置的不对
cache
缓存生成的webpack模块和chunk,来极大的提高二次构建效率。目前我在项目中使用filesystem,默认缓存时间为一个月
cache: {
type: 'filesystem',
cacheDirectory: resolve('.temp_cache'),
buildDependencies: {
defaultWebpack: ["webpack/lib/"],
config: [__filename]
},
}
lazyCompilation
懒编译。目前还是试验性配置,可能会存在一些bug,但是在分割合理的巨大项目中,也可以做到秒开的效果,缺陷就是在跳转页面时需要等待。
experiments: {
lazyCompilation: true
}