webpack高级

318 阅读10分钟

区分 Develoment 和 Production

  • 我们配置 webpack 的时候都会分 开发(dev) 和 生产环境(prod),可以粗浅理解它们的区别就是会不会压缩代码,我们可以对应做不同的处理
  • 总之,我们需要在不同的环境进行不同的配置

分开配置文件

  • 我们可以把 开发 和 生产 环境分别配置两个文件,通过指定 config 文件区分
  • 但是配置总会有很多相同的,我们要把这些相同的东西拿出来,最后再与环境独有的合并
  • 使用 webpack-merge 进行合并
// webpack.dev.js 开发
// webpack.prod.js 生产
// webpack.common.js 公共

const merge = require('webpack-merge');
const commonConfig = require('./webpack.common.js');

const devConfig = {}

module.exports = merge(commonConfig, devConfig);

使用环境变量

  • 我们也可以通过传入环境变量 envnode-env 去对配置文件进行区分
:: --env prod
:: --node-env production
npx webpack --env prod --node-env production
  • 导出改为函数
module.exports = env => {
  console.log(env.prod); // true
  console.log(process.env.NODE_ENV); // 'production'
}
  • 注意:除了传 prod 之外,其实我们还能传不同的东西;规则:env

DefinePlugin

  • webpack 内置了一个插件 DefinePlugin,它允许创建一个在编译时可以配置的全局常量,通过这个插件,我们也可以在开发过程中得知我们所在的环境
new webpack.DefinePlugin({
  NODE_ENV: process.env.NODE_ENV
})

Tree Shaking

概念

  • Tree Shaking - 树摇,这名字很贴切,就是把我们代码树中,不需要的叶子摇出来
  • 想象一个场景,一个模块导出 n 个方法,另一个模块引入其中一个方法,我们打包代码的时候肯定是希望只打包我们引入的这个方法,而不是整个模块
  • Tree Shaking 指的就是当我们引入一个模块的时候,不引入这个模块的所有代码,只引入我需要的代码
  • 它并不只是在 webpack 独有,它的本质是消除无用的 js 代码。无用代码消除在广泛存在于传统的编程语言编译器中,编译器可以判断出某些代码根本不影响输出,然后消除这些代码,这个称之为 DCE(dead code elimination)
  • 注意:在 webpackTree Shaking 只支持 ES Module,因为在 webpack 中,ESM 是静态引入,CommonJS 是动态引入(可变)

使用

  • 只有在 production 下,Tree Shaking 才会真正被使用
  • development 下,Tree Shaking 还是可以打开的,虽然不能直观看到代码量减少,但是可以通过 webpack 的备注看到哪些模块是未使用的
module.exports = {
  optimization: {
    usedExports: true
  }
}

sideEffects & 副作用

  • webpack 还未能完全识别出所有的副作用,这会在打包时产生一些无谓的代码
  • sideEffects 允许通过配置的方式去标识代码是否有副作用,从而为 Tree Shaking 提供更多的压缩空间

副作用

  • webpack 是一个模块打包工具,但是模块除了导出和导入之外,还可以写其他的东西,例如在 window 下绑定某些方法,或是一些行为,这些被称为副作用,典型例子就是 polyfill
export const fn = () => {
  console.log('fn');
}

// 下面这些就被称为副作用
window.fn = () => {
  // do something
}
console.log('副作用');
document.documentElement.append('副作用')

package.json[sideEffects]

  • webpack 会读取 package.jsonsideEffects 字段,它提供关于代码纯度的提示

    • true:默认,所有代码都有可能存在副作用,webpack 会通过 terser 检测语句中的副作用
    • false:所有代码均无副作用
    • Array:提供有副作用的文件或目录
    {
      "sideEffects": [
        "*.css", // 所有 css 文件
        "./src/effect.js", // 指定文件
        "polyfill/", // 指定目录
      ]
    }
    
  • 注意:不光是 jscss 等模块也是由 sideEffects 决定副作用去留,因为 css 都是副作用,请务必加上

optimization[sideEffects]

  • 因为是同名,而且两者有关系,千万不要搞混了,在 optimization 里,它的意思是是否去除副作用
    • true:默认 - 去除副作用
    • false:保留所有副作用

效果

  • 这里的设置为 package.json[sideEffects]=false optimization[sideEffects]=true
// a.js
export default 123;
console.log(123);

// b.js
window.fn = () => { };

// c.js
export const fn = () => { };
console.log(456);


// index.js - 执行文件
import a from './a'; // 引入未使用,不保留副作用

import './b'; // 引入无导出,不保留副作用

// 引入使用,保留副作用
import { fn } from './c';
fn();
  • 由此可见,以下场景如使用 sideEffects ,请务必加上标识
    • 立即执行的脚本 - 包括改变 DOM,全局变量
    • css 模块
    • 等等...

Code Splitting

  • 默认情况下,webpack 会将所有引入的模块都打包到一个文件中,这样导致了打包后的文件比较大

  • 例如我们引入 @babel/polyfill,假设 1MB,然后自己业务代码又有 1MB,打包出来的结果就有 2MB,首先就会有两个问题

    • 打包文件过大,加载时间太长
    • 修改一点点代码,用户又要重新访问又要加载 2MB 的文件
  • 我们聪明的朋友就会想,那我把一些代码挂载在 window 上面,然后设置多入口,这不就成了嘛

// polyfill
import '@babel/polyfill';
// lodash
import _ from 'lodash';
window['_'] = _;
// jQuery
import $ from 'jquery';
window['$'] = $;
...

// index.js
console.log('业务代码');
module.exports = {
  entry: {
    polyfill: './src/polyfill.js', // 1mb
    lodash: './src/lodash.js', // 1mb
    jQuery: './src/jQuery.js', // 1mb
    main: './src/index.js', // 1mb
  }
}
  • 当然这不是不可以,不过过于麻烦,不智能

SplitChunksPlugin

  • 以上这都是代码分割的问题,我们需要把代码分成不同的 chunks,而 webpack 本身自带了一个插件做这样的事情 SplitChunksPlugin
  • 最简单的使用就是在 optimization 加上 splitChunks,它会以默认参数执行分割

默认配置

module.exports = {
  optimization: {
    splitChunks: {
      chunks: "async",
      minSize: 30000, // 5.x 为 20000
      minChunks: 1,
      maxAsyncRequests: 5, // 5.x 为 30
      maxInitialRequests: 3, // 5.x 为 30
      automaticNameDelimiter: '~',
      name: true, // 5.x 为 false
      minRemainingSize: 0,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

配置参数(大部分)

chunks
  • 将选择哪些 chunks 进行优化
  • async:异步
  • initial:同步
  • all:同步异步
  • Function:自定义包含哪个 chunks
module.exports = {
  optimization: {
    splitChunks: {
      chunks(chunk) {
        // exclude `my-excluded-chunk`
        return chunk.name !== 'my-excluded-chunk';
      },
    },
  },
};
minSize
  • 大于 minSize 设置的大小才进行分割
  • 单位:字节
maxSize
  • 尝试将大于 maxSize 字节的 chunks 拆分为更小的部分
hidePathInfo
  • 在为按 maxSize 分割的部分创建名称时防止暴露路径信息
minChunks
  • 至少使用了 minChunks 次的模块才进行分割
maxAsyncRequests
  • 最多同时加载的模块数量(并行请求数)
  • 如果超过,将不再进行分割
maxInitialRequests
  • 入口文件,最多同时加载的模块数量(并行请求数)
minRemainingSize
  • webpack 5.x 独有,仅在剩余一个 chunk 时生效,限制拆分后剩余的 chunk 的最小大小,避免了0大小的模块
enforceSizeThreshold
  • 超过阈值将忽略 minRemainingSize maxAsyncRequests maxInitialRequests 进行强制拆分
automaticNameDelimiter
  • chunks 命名连接符,将来源和名称进行连接
  • 例如:vendors~main.js - vendors 缓存组~来源于 main 文件(入口)
name
  • chunks 名称,也可以用在 cacheGroups
  • false:默认名称 - webpack 推荐值
  • true:采用 缓存~来源 方式命名,webpack 5.x 被干掉了
  • Function:自定义名称
  • 注意:使用自定义名称,会根据命名不同生成多个文件(可做到极细分割 chunk)
  • 注意:不同的 chunks 有相同名称时,均会放置在一个共享块内
  • 注意:如果与入口文件命名相同,入口文件将被删除
splitChunks: {
  name(module, chunks, cacheGroupKey) {
    // const absolutePath = module.identifier(); // 模块标识符
    const absolutePath = module.resource; // 资源路径
    const moduleFileName = path.basename(absolutePath, path.extname(absolutePath)); // 文件名
    const allChunksNames = chunks.map((item) => {
      return item.name
    }).join('~'); // 所有 chunk 来源(入口)
    // cacheGroupKey // 缓存组 key
    return `${cacheGroupKey}-${allChunksNames}-${moduleFileName}`;
  }
}
cacheGroups
  • 缓存组,其实相当于一个分组条件,正常情况下满足这些条件则输出为一个 chunksname 的不同可分割为多个)
  • 它可以继承 splitChunks 的其他所有配置,也可以覆盖,但是自己也有特有配置
  • 如果禁用缓存组,设置为 false
splitChunks: {
  cacheGroups: {
    default: false,
  },
},
priority
  • 优先级
  • 一个模块可以符合多个缓存组的条件,将优先选择高的 priority
  • 默认组优先级为 -20,自定义默认组有限制默认为 0
reuseExistingChunk
  • true - 如果引入模块包含已经被分离出来的模块,将会服用,而不会重新生成一个新的
// a
import b from './b'

// index
import a from './a'
import b from './b' // 不会重新生成,服用 a 引入的 b
type
  • webpack 5.x 独有,按模块类型分配缓存组
  • function (type)=>boolean RegExp string
test
  • 控制缓存组选择哪些模块,不写会选择所有模块,就是大家都熟悉的那个 test 匹配
  • function (module, { chunkGraph, moduleGraph }) => boolean RegExp string

Dynamic Imports 动态引入

  • webpack 支持两种方式 - import()require.ensure 动态加载模块
  • 强烈推荐 ECMA 提议的 import() 而非 webpack 独有的 require.ensure(官方也是这么说的),下面只涉及 import()
  • 使用动态引入时,它将会被视为 Code Splitting 分割点 - 请求的模块和子模块会被分割成一个独立的 chunks
  • 注意SplitChunksPlugin[chunks] != 'initial' 时,会被 cacheGroups 等配置影响行为,但绝对会进行分割

简单使用

  • 调用 import() 会返回一个 Promise,接下来就是熟悉的 then 或者 async await
// default 是因为 CommonJS 的兼容
import('lodash').then(({ default: _ }) => {
  console.log(_);
});
  • 注意import() 依赖特性 Promise,如果要兼容旧浏览器记得使用 polyfill

动态表达式

  • import() 可以使用动态语句导入,但是!!不可以只传入一个变量,例如 import(foo)
  • 使用 import() 必须包含一些关于模块所在位置的信息,限制在特定目录
const _module = 'a';
import(`../abc/${a}`).then(({ default: res }) => {
  console.log(res);
})

Magic Comments 魔法注释

  • 在使用 import() 时,可以加入一些神奇的内联注释,webpack 会在打包时根据这些注释做不同的处理

  • webpackChunkName:新 chunks 的名称,占位符 [index][request] 对应的分别是 递增的数字实际解析的文件名,此注释将让我们打包的文件有自定义名称,我们也可以用这个注释将不同的 chunk 打包到同一个 chunks

  • webpackMode:可以指定不同的解析动态导入模式

    • lazy:默认,为每个模块生成一个可延迟加载的 chunk
    • eager:不生成额外的 chunks,不会发出额外的网络请求,但依然由 Promise 返回
    • weak:永远不会执行网络请求,以 Promise 返回,如该模块以其他方式加载,尝试加载模块返回,否则返回 rejected
  • webpackInclude:在使用 动态语句导入 时,解析只有匹配到模块才会打包

  • webpackExclude:在使用 动态语句导入 时,所有匹配到的模块都不会被打包

import(
  /* webpackInclude: /\.json$/ */
  /* webpackExclude: /\.noimport\.json$/ */
  /* webpackChunkName: "my-chunk-name" */
  /* webpackMode: "lazy" */
  `./locale/${language}`
);

Prefetching/Preloading modules

Prefetching
  • SplitChunksPlugin 的默认配置我们可以看到 chunks 参数的默认值是 async 而并非 all,有没有一种可能,webpack 更希望我们通过异步的方式进行代码分割?
  • 同步引入的代码分割成一个文件后,运行时在第二次进入页面时,我们可以借助缓存提高页面访问速度,但我们是不是可以再次优化,将一些需要交互之后才会起作用的非必要代码,进行异步导入,从而提高第一次进入页面的速度呢?这正是 webpack 提倡的
  • 我们可以通过浏览器的 Coverage 功能看到网页代码使用率,快速搜索出网页未用的代码进行优化
  • 但这也产生一个问题,在进行异步加载的过程中,很可能会因为网速问题,导致加载过慢导致交互卡顿;我们可以利用网络空闲时进行加载,从而提高响应速度,我们可以使用 webpackPrefetch 做到这一点
import(/* webpackPrefetch: true */ './popup');
  • 它实际上往 <head> 上添加了 <link rel="prefetch">,让浏览器在闲置时间预取
Preloading
  • Preload 的作用并非是为了不确定使用的资源而使用的,而是当前模块下必须使用的资源
  • 因为动态获取资源对浏览器预加载器并不可见,这严重影响了它们在资源获取队列中的优先级,而 Preload 的作用就是让预加载器知道这个动态请求文件的存在,chrome 甚至会在资源加载后3秒没有被使用时打印一个警告
  • Preload 与 父级文件并行加载
import(/* webpackPreload: true */ './index.scss');
  • 它实际上往 <head> 上添加了 <link rel="preload">
联合举例
// main.js
document.documentElement.onclick = () => {
  import(/* webpackPrefetch: true */ `./a`).then(({ fn }) => {
    setTimeout(() => {
      fn();
    }, 4000);
  })
}

// ./a.js
alert(1)
export const fn = () => {
  import(/* webpackPreload: true */ `./b`).then(res => {
    console.log(res);
  })
}
  • 本地环境,请求完成不超过1s,执行顺序如下
  1. 加载 main.js
  2. main.js 加载完成后;往 <head> 添加 <link rel="prefetch" as="script" href="/a.js"> 进行预取
  3. 点击 document,缓存读取 a.js
  4. <head> 添加 <link charset="utf-8" rel="preload" as="script" href="/b.js">,告知浏览器获取资源,浏览器请求 b.js
  5. alert(1)
  6. b.js 请求过后 3schrome 警告未使用资源
  • 注意a.js 不使用 prefetch 时,a.js b.js 并行加载
  • 注意b.js 不使用 preload 时,a.js 中会先执行 alert 再进行 b.js 的请求

打包分析

  • webpack 进行打包之后,会在控制台简单输出打包信息,但这往往看起来很麻烦,也不够直观,我们可以通过一些打包分析的工具直观详细的看到打包信息,检查打包是否合理

生成 stats.json

  • 很多分析工具都需要我们提供打包时的信息,我们要生成一个 json 文件
  • 加上 --profile --json > stats.json,运行 webpack 会生成包含打包信息文件,我们也可以直接从这个文件看到打包信息
"scripts": {
  "build": "webpack --profile --json > stats.json"
},

工具

缓存(Caching) 与 hash

  • 性能优化的方式中,缓存非常重要,但它也会对我们的及时更新产生影响,往往我们需要手动更新版本号进行处理

  • webpack 给我们内置了 hash,我们可以通过它很好的解决这个问题

  • webpack 在编译时产生的 hash 有三种

    1. hash:跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的 hash 值都会更改,所有文件共用一个 hash
    2. chunkhash:根据不同的入口文件进行依赖文件解析、构建对应的 chunk,生成对应的 hash
    3. contenthash:根据文件内容生成不同的 hash,只有当文件内容发生改变时才会重新生成 hash
  • hashchunkhash 在每次打包后都会生成不同的值,没办法利用于处理缓存问题,而 contenthash 正正合适

module.exports = {
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js',
  },
}
  • [contenthash] 并非只在 output 存在,file-loader miniCssExtractPlugin 等等都可以利用它帮助我们自动解决缓存问题
  • 老版本的 webpack(大概 4 以下) 因为 manifest(用于管理所有模块之间的运行关系) 处理的差异,可能有在内容未改变但 contenthash 改变了的问题,可以通过加上 runtimeChunk 解决
optimization: {
  runtimeChunk: {
    name: 'runtime'
  }
}

Shimming

  • 一些旧的第三方库可能并非遵循现在的模块编写规范,它们可能需要一些全局依赖,又或者this 有特殊指向,而非模块自身,对于这些 “不符合规范的模块”,可以使用 Shimming 对它们进行处理

ProvidePlugin

  • 自动加载模块
  • 简单来说它帮我们把缺少的模块引入补齐(非全局),从而使 library 正常运行
  • 还能暴露某个模块中单个导出值
// index.js
// import $ from 'jquery'
console.log($);

// import lodash from 'lodash';
// console.log(lodash.join(['hello', 'ProvidePlugin'], ''));
console.log(_join(['hello', 'ProvidePlugin'], ''));

// webpack.config.js
const webpack = require('webpack');
module.exports = {
  plugins: [
    new webpack.ProvidePlugin({
      $: 'jquery',
      _join: ['lodash', 'join']
    }),
  ]
}

imports-loader

  • 一些库 this 指向 window,可以使用 imports-loader 改变它
  • 当然这个 loader 也可以干 ProvidePlugin 的活,这里不阐述,有兴趣可以瞄瞄文档
npm install imports-loader -D
module.exports = {
  module: {
    rules: [{
      test: path.resolve('./src', 'index.js'),
      // use: 'imports-loader?this=>window', // webpack 4.x
      use: 'imports-loader?wrapper=window' // webpack 5.x
    }]
  }
}

externals - 外部扩展

  • 它与 Shimming 可以说是相反的,Shimming 是补上 import,而 externals 则是我已经 import 时,需要把他改为 CDN 引入(全局)
  • 防止 import 的模块打包bundle,而是运行时再去外部获取
<script src="https://code.jquery.com/jquery-3.1.0.js"></script>
// index.js
import $ from 'jquery';
console.log($);

// webpack.config.js
module.exports = {
  externals: {
    $: 'jQuery'
  },
}