webpack5入门级配置【上】

203 阅读6分钟

为什么还要学习webpack

  1. 我们很多项目用的还是webpack🐶,只能说不得不学~
  2. 根据 State-of-JS 2021 的统计数据,2021年 Webpack 还保持高达 89% 的使用率,依然是绝对的大多数!虽然未来必然会有许多用户在特定场景下选用其它构建工具,但短期内还不太可能撼动 Webpack 的头部地位 image.png
  3. webpack5的更新,提供了持久化缓存,lazyCompilation等特性,极大的提升了构建性能,虽然不可能超越不需要编译的框架,但是应该也会持续的缩小这个差距

名词解释

  • module:每个文件都是一个模块(module
  • chunk: 过程中的代码块,一个或多个module的集合。比如a模块依赖b模块,b模块依赖c模块,webpack通过引用关系逐个打包模块,最终形成一个chunk。形成chunk有3种情况,entry入口async模块代码分割splitChunks
  • bundle: 输出的一个或多个打包文件。这里注意,bundle不等于chunk,比如一般情况下一个chunk产出一个bundle,但是如果配了sourcemap时,一个chunk产出2个bundle
  • runtime: webpack用来连接模块化应用程序所需的所有代码
  • loader: 预处理器,webpack只能识别jsonjavascript文件,loader的作用就是将webpack不识别的文件转为可识别模块
  • plugin: 插件(拓展器),拓展loader无法做的事情,例如打包优化,注入环境变量等

基础类配置

entry

告诉webpack模块的起点,webpack会根据起点查找模块的依赖关系绘制模块依赖图,然后根据入口和模块之间的依赖关系生成一个或多个chunkentry有三种类型,stringstring[]对象。不同类型构建出来的模块依赖图会有所不同

  • 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-多入口.png 多页面配置

entry: {
  main: './src/index.js',
  main1: './src/other.js'
}

entry-多页面.png 为什么会有多页面? 当我们有2个及以上的活动页,数据都不互通,但是技术架构一样,并且共用一个弹窗组件,那么这里最适合的就是多页面打包的方案。

resolve

配置模块如何去解析,日常项目中,resolve.aliasresolve.extensions使用的最多 resolve.aliasrequireimport设置别名,确保模块引入更简单 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指定的目录下。

基于entryruntime生成的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]: chunkid,受optimization.chunkIds影响。non-initial还可以通过/* webpackChunkName: "自定义名称" */来控制chunk名称
  • [name]: chunk的文件名,只有chunkFilename[name]optimization.chunkIds影响,non-initial可以通过/* webpackChunkName: "自定义名称" */来控制chunk名称
  • [fullhash]: 所有模块的hash都相同,也就是当任意一个模块有改动时,所有模块的hash都会修改,浏览器缓存全部失效

fullhash.png

  • [chunkhash]: 与entry相关,根据不同的entry进行依赖解析,只有和修改的模块相关联的chunk才会重新生成hash,所以只有部分浏览器缓存失效

chunkhash.png

  • [contenthash]: 与文件内容相关,只有修改过的文件的hash发生了变化。比如一个文件(chunkhash)引用了css时,自身发生了变化时,却发现抽取的csshash也发生了变化,这时候contenthash的好处就出现了
plugins: [
  new MiniCssExtractPlugin({
    filename: 'css/[name].[contenthash:8].css',
  })
]

contenthash.png 一般项目中,最常用的就是如下配置

{
  output: {
    filename: '[name].[chunkhash:8].js',
    chunkFilename: `[id].[chunkhash:8].js`
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
    })  
  ]
}

webpack5之前默认的chunkIdsnatural,所以在模块顺序发生变化时,该模块后续的缓存全部失效,webpack5新增长期缓存算法deterministic,在正式环境默认开启

clean
在生成文件之前清空output目录。在webpack5之前,我们需要使用clean-webpack-plugin实现这个功能。

优化类配置

optimization

runtimeChunk

webpackruntime代码单独生成一个chunk。 默认值为false,会将runtime代码注入到entry chunk中,如果设置为truemultiple,会为每个entry chunk添加一个额外的runtime chunk。如果设置为single,会生成一个所有entry chunk共享的runtime chunk

runtimeChunk.png

tree shaking

通常用于描述移除模块中的无用代码(dead-code),前提是模块源代码要符合ESM规范。webpack中的tree shaking分为2种级别module levelstatement 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-code
    • optimization.usedExports设置为trueproduction默认为true),使用unused harmony export [函数名]标记未引用的代码
    • optimization.minimize设置为trueproduction默认为true),告知webpack使用TerserPlugin或其它在optimization.minimizer定义的插件移除标记的无用代码
  • module level: 移除无用的模块
    • optimization.sideEffects设置为trueproduction默认为true)。告知webpack去识别package.json中的sideEffects
      • true表示有副作用,webpack不会删除模块
      • false表示没有副作用,webpack可以安心的删掉模块(import x.css会被当作没副作用的模块,直接删除,所以一般需要设置为['**/*.css']
      • string[]表示数组内匹配到的模块有副作用,webpack不会删除这些模块 接下来我们用demo理解一下上述的配置
  1. 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}`)
}

statementLevel.png 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}`)
}

moduleLevel.png 总结一下配置项的意义:

  • usedExports: 标记dead-codeunused harmony export [函数名]的标记
  • minimize: 压缩并移除标记的dead-code
  • providedExports: 为export * from语句生成更加高效的代码,并不影响tree shaking结果,实操发现代码中存在export * from,在production环境下providedExportsfalse打包时会报错,目前不太了解原因
  • sideEffects: 告知webpack是否可以根据package.json中的sideEffects直接启动模块级别的tree shaking
splitChunk

为什么需要拆分chunk? 当然是为了减少bundle体积。 那为什么做代码拆分会减少体积? 比如有2个async chunk都引用了lodash,在没有splitChunk的情况下会在两个chunk中都包含lodash,明显有一个chunk里的lodash是多余的。那么如何优化呢?抽到一个vendors chunk里呗,两个async chunk都引用vendors chunklodash

// index.js
import('./util1')
import('./util2')
// util1.js、util2.js
import 'lodash'
// 配置
optimization: {
  splitChunks: {
    cacheGroups: {
      default: false,
      defaultVendors: false
    }
  }
}

noneSplit.png

minSize | minRemainingSize

  • minSize:生成chunk的最小体积,默认20kb
  • minRemainingSize:通过确保拆分后剩余的最小chunk体积超过限制来避免大小为零的模块。development下为0,production等于minSize
// index.js
import('./util1')
// util1.js
import 'dayjs'

minSize.png

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(){}

util3say出现在2个chunk里,所以会被分离出来

maxAsyncRequests | enforceSizeThreshold

  • maxAsyncRequests:按需加载时的最大并行请求数,默认30。
  • enforceSizeThreshold:强制执行拆分的体积阈值,默认50kb,其他限制(minRemainingSizemaxAsyncRequestsmaxInitialRequests)将被忽略
// 配置
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)
}

maxAsyncRequests.png

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'

initailSplit.png

asyncSplit.png

allSplit.png 总结一下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
}

参考文献

Webpack 理解 Chunk

如何快速成为一名熟练的 Webpack 配置工程师 - 上篇

Webpack Tree Shaking 使用小结

如何使用 splitChunks 精细控制代码分割