全方位详解 webpack 优化策略,从此摆脱脚手架工程师称号!

585 阅读10分钟

webpack构建原理

在正式开始前,我们先简单看一看 webpack 的打包构建过程:

  1. 从入口开始分析文件间的依赖关系;
  2. 调用 loader 对文件进行转换编译;
  3. 编译结束,根据依赖关系生成 chunk;
  4. 将编译完成的内容写入文件系统。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。

而针对 webpack 打包构建的优化,其实就是分析整个构建过程中可能存在的耗时操作、性能瓶颈等,再通过修改配置、使用工具(插件、loader)来提高速度,优化产出

下面我们就来一起看看有哪些具体的优化手段吧!

下列相关代码、插件、配置等均以 webpack4 为例。另外所有插件、loader 的链接都是可点击的,方便小伙伴们快速跳转~

构建速度优化

工具

工欲善其事,必先利其器。

想要优化性能,我们就需要知道性能瓶颈在哪里。这里先给大家介绍一个构建速度分析工具:

SpeedMeasureWebpackPlugin

作用: 用于分析项目打包构建速度;

代码配置:

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin")

const smp = new SpeedMeasurePlugin({
  // 输出文件格式化的方式
  outputFormat: "humanVerbose",
  // 输出最近两次构建速度的对比文件
  compareLoadersBuild: {
    filePath: "./buildInfo.json",
  }
})
// 调用 wrap 方法 将 webpack 配置传入
const webpackConfig = smp.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()],
})

结果对比:

下图是引入 speed-measure-webpack-plugin 后,两次构建结果的分析对比;通过对比结果,我们就能很直观的看出究竟是哪些操作影响了构建速度:

image.png

开启多线程打包

针对 speed-measure-webpack-plugin 分析中,较为耗时的几个 loader ,我们可以借用插件开启多线程打包。

HappyPack

在 webpack 3 版本下最为常用的多线程打包插件,但目前已停止维护,因此不做过多介绍。

我们来简单看一下它的使用方法:

// 使用 5 个线程池
const happyThreadPool = HappyPack.ThreadPool({ size: 5 })
module.exports = {
  ...
  plugins:[
    new HappyPack({
      id: 'eslintLoader', // 通过 id 标识作用的loader
      loaders: [
        {
          loader:'eslint-loader'
        }
      ],
      threadPool: happyThreadPool, // 线程池配置
    })
  ],
  module: {
    rules: [
        {
          test: /.(js|vue)$/,
          loader: 'happypack/loader?id=eslintLoader', // 这里的 id=xxx 需要与上面声明的一致
          enforce: 'pre',
          include: [resolve('src'), resolve('test')]
        }
    ]
  },
}

thread-loader

webpack4 以后我们可以使用  thread-loader 来达到多线程打包的目的。

使用方法:将 thread-loader 放在需要使用多线程的 loader 之前;

// 如果有 watch 的需求,可以把 poolTimeout 设定为 Infinity 让 pool 一直存在增加编译效率
const jsWorkerPool = {
  poolTimeout: Infinity
};

const cssWorkerPool = {
  workerParallelJobs: 2,
  poolTimeout: Infinity
};
// 通过预热线程池 提高线程启动的速度
threadLoader.warmup(jsWorkerPool, ['babel-loader']);
threadLoader.warmup(cssWorkerPool, ['css-loader', 'postcss-loader']);
rules: [
    ...
    {
      test: /.js$/,
      include: [
        path.resolve(__dirname, '../src')
      ],
      use: [
        {
          loader: 'thread-loader',
          options: jsWorkerPool
        },
        {
          loader: 'babel-loader'
        }
      ]
    }
]

注意:开启多线程以及线程之间的通讯都会额外耗费时间,因此需要根据项目实际情况去使用,如果是体量比较小的项目使用后反而会降低打包速度。

合理利用缓存

针对一些耗费性能的 loader,可以通过使用缓存来提高其编译速度:

cache-loader

使用方法:在一些性能开销较大的 loader 之前添加此 loader,将结果缓存到磁盘里;

module.exports = {
  module: {
    rules: [
      {
        test: /.ext$/,
        use: ['cache-loader', ...loaders],
        include: path.resolve('src'),
      },
    ],
  },
};

babel-loader

babel-loader 往往也是造成性能瓶颈的一大原因。

它有一个 cacheDirectory 选项,默认值为 false;当设置为 true 时,指定的目录将用来缓存 loader 的执行结果;之后的 webpack 构建,将会尝试读取缓存。

使用方法

rules: [
    ...
    {
      test: /.js$/,
      use: [
        loader: 'babel-loader?cacheDirectory=true'
      ]
    }
]

HardSourceWebpackPlugin

hardSourceWebpackPlugin 插件,通过第一次正常构建,并将结果写入缓存,二次构建时直接从缓存中读取编译结果,从而极大提高二次构建的速度;

使用方法

const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');

module.exports = {
  // ......
  plugins: [
    new HardSourceWebpackPlugin()
  ]
}

首次构建: 写入缓存,构建时间 49ms

image.png

二次构建: 构建时间从 49ms => 3ms,有了大幅度的提升:

image.png

踩坑注意:

该插件目前已停止维护,偶发报错 Could not freeze 'xxxx'

image.png

如果在 webpack4 以下的版本中使用且出现这个报错,可以通过删除缓存目录(node_modules/.cache/hard-source) 解决。

webpack5 中提供了开箱即用的缓存机制,无需再额外使用该插件。

使用动态链接库

动态链接库的原理是把一些不常更新且构建时间较长的模块抽离,打包到一个个单独的动态链接库中去;当需要导入的模块存在于动态链接库中时,这个模块不会再次打包,而是直接去动态链接库中获取。

配置 dll 打包的过程较为繁琐,我们可以通过 autodll-webpack-plugin 来完成:

AutoDllWebpackPlugin

自动生成动态链接库。

const path = require('path');
const AutoDllPlugin = require('autodll-webpack-plugin');

module.exports = {
    ...
    plugins: [
        new AutoDllPlugin({
            filename: '[name].dll.js',
            context: path.resolve(__dirname, '../'), // 必须和 package.json 的同级目录
            entry: {
                // 配置要打包的文件
                vendor: [
                     'vue',
                     'vue-router',
                     'vuex',
                     'moment',
                     'lodash', 
                     'echarts'
                   ]
            }
        })
    ]
}

未使用动态链接库打包结果:

image.png

使用动态链接库打包结果:

image.png

可以看到,对于打包构建速度的提升并不明显,这是因为 webpack4 对于这些模块的打包性能已经足够优秀了,因此收效甚微。

vue-cli 在早前的一次版本更新中,也将原有的 dll option 去除了,具体可参照 issues

缩小文件搜索范围

  • 优化 loader 的 include/exclude

    使用 include 去命中需要被 loader 转换的文件,缩小范围,还可以使用 cacheDirectory 开启 babel-loader 的缓存: loader: 'babel-loader?cacheDirectory'

  • 优化 resolve.modules 配置

    指明存放第三方模块的绝对路径,从而减少查找的层级。

  • 优化 resolve.mainFields 配置

    配置第三方模块的入口文件,减少搜索步骤。

  • 优化 resolve.alias 配置

    通过别名,把导入路径映射为完整的文件路径,可以减少 webpack 递归解析和处理的过程,但要注意,可能会影响 tree shaking

  • 优化 resolve.extensions 配置

    文件后缀尝试列表,将尽量高频的后缀放在前面,不可能出现的后缀可以剔除。

  • 优化 module.noParse 配置

    忽略对没采用模块化的文件的递归解析处理。

产出代码的优化

介绍完了如何去提高 webpack 的打包构建速度,接下来我们把重点放在如何优化 webpack 打包构建后的代码上

工具

首先还是给大家介绍一个打包产物的分析工具:

WebpackBundleAnalyzer

作用: WebpackBundleAnalyzer 插件能够用来分析 webpack 打包后的体积大小,并生成相关 html 文件报告。

使用方法:

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

可视化结果分析:

通过生成的报告(如下图),我们能够直观的分析打包后的文件信息,找出不合理的模块(如模块重复打包、常用/不常用模块被打包到一起等)

image.png

知道了哪些模块的打包是不合理的,就能够针对性的去优化啦!

而一套合理的分包规则,不仅能够缩减打包后代码的体积,配合前端缓存去使用还能更多的命中缓存,提高网页性能,可谓一举多得!

具体优化策略,我们接着往下看:

SplitChunksPlugin

之前我们说到 webpack 会分析依赖,最终将文件打包成一个个 chunk, SplitChunksPlugin 就是用来告诉 webpack 应该遵循怎样的规则去生成 chunk。

SplitChunksPlugin 支持开箱即用,有一套默认拆分 chunks 的规则:

  • 新的 chunk 可以被共享(至少使用了1次以上),或者模块来自于 node_modules 文件夹;
  • 新的 chunk 体积大于 30kb(在进行 min+gz 之前的体积);
  • 当按需加载 chunks 时,并行请求的最大数量小于或等于 5;
  • 当加载初始化页面时,并行请求的最大数量小于或等于 3;

当 chunk 满足以上默认规则时,将会被拆分出来。

合理分包

下面以我们的 vue 项目举例:

splitChunks: {
      chunks: "all", // 同时处理同步和异步引入的模块
      // 分包策略:
      cacheGroups: {
        // 1、vue 全家桶等基础类库,所有页面通用且升级频率不高,将这些单独打包
        libs: {
          name: "libs",
          test: /[\/]node_modules[\/]/,
          priority: 10, // 权重
          chunks: "initial"
        },
        // 2、我们自己的UI库单独打包(体积较大,并且可能需要经常更新)
        myUI: {
          name: "myUI",
          priority: 20,
          test: /[\/]node_modules[\/]@myUI[\/]/
        },
        // 3、将echart单独打包
        echarts: {
          name: "echarts",
          priority: 20,
          test: /[\/]node_modules[\/]echarts[\/]/
        },
        // 4、将 src 目录下共享4次以上的模块打包到commons,
        commons: {
          name: "comomns",
          test: path.resolve("src"),
          minChunks: 4, // 模块至少应被4个chunk所共享才进行分割
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }

分包策略

这里给大家提供一份分包策略,大家在实际进行分包操作时可以参考:

类型共用率使用频率更新频率例子
基础类库vue/react、vuex/mobx、xx-router、axios 等
UI 组件库Element-UI/Ant Design 等
必要组件/函数Nav/Header/Footer 组件、路由定义、权限验证、全局 State 、全局配置等
非必要组件/函数封装的 Select/Radio 组件、utils 函数 等 (必要和非必要组件可合并)
低频组件富文本、Mardown-Editor、Echarts、Dropzone 等
业务代码业务组件、业务模块、业务页面 等

分包策略来自文章 手摸手,带你用合理的姿势使用webpack4(下)

需要注意的是:项目的分包不能一味追求缩减包体积,要注意平衡包颗粒度与 HTTP 请求带来的消耗,针对自身项目多加尝试,找到较为合适的分包方式。

减少第三方库的体积

TreeShaking

Tree Shaking 能够帮助我们去除一些未使用的死代码,比如一些引入后未使用的模块、第三方库等。

Tree Shaking 依赖于 ES6 的模块语法(import export),因为 ES6 的模块语法是静态的,这使得 webpack 在打包过程中能够标记哪些模块没有被使用到,最终在 bundle 中删除它们。

使用 TreeShaking 正确姿势

  • 正确的引入模块

使用导入具体的模块来代替全部导入。

// 全部导入 (不支持 tree-shaking)
import _ from 'lodash';
// 具名导入(支持 tree-shaking)
import { debounce } from 'lodash';
// 直接导入具体的模块 (支持 tree-shaking)
import debounce from 'lodash/lib/debounce';
  • 使用支持 TreeShaking 的包

以 lodash 为例,官方有使用 common.js 语法的 lodash ,以及使用ES6模块语法导出的 lodash-es 版本,我们可以选用 lodash-es 版本。

这样一来,webpack 就能够通过这一特性帮助我们剔除死代码了。

忽略不需要的内容

以项目中的 moment.js 文件为例,其中的 locale.js 占据了很大一部分体积,而这个文件中包含的是世界各个国家的时区信息;

image.png

如果项目中未使用到这些内容,我们可以通过 IgnorePlugin 使用正则匹配忽略内容,或使用 NormalModuleReplacementPlugin 来将这些内容替换为空。

使用 IgnorePlugin

//webpack.config.js
module.exports = {
    //...
    plugins: [
        new webpack.IgnorePlugin(/^./locale$/, /moment$/)
    ]
}

还可以直接使用替代的库 day.js,它与 Moment.js 的 API 设计保持完全一致,但是大小只有 2kb。

MiniCssExtractPlugin

在 webpack4 中,我们可以使用 MiniCssExtractPlugin 来将我们的CSS 提取到单独的文件中(webpack3 使用 ExtractTextWebpackPlugin),该插件会为每个包含 CSS 的 JS 文件创建一个 CSS 文件。

作用:

  • 支持 CSS 文件按需加载;
  • JS 代码与 CSS 代码相互独立,互不影响缓存。

使用方法:

const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  plugins: [new MiniCssExtractPlugin({
      // 为了达到 JS 代码与 CSS 代码缓存互不影响的目的,这里需要使用 contenthash
      filename: utils.assetsPath('css/[name].[contenthash].css'), 
      chunkFilename: utils.assetsPath('css/[id].[contenthash].css'),
      ignoreOrder: true
  })],
  module: {
    rules: [
      {
        test: /.css$/i,
        use: [MiniCssExtractPlugin.loader, "css-loader"], // 在 css-loader 之后使用
      },
    ],
  },
};

压缩代码

TerserWebpackPlugin

该插件在 webpack5 内置,webpack4 版本仍需额外安装。

作用: 进行 js 代码压缩,从而缩小包体积。

使用方法:

const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin()],
  },
};

CssMinimizerWebpackPlugin

作用: 搭配 MiniCssExtractPlugin 来实现优化和压缩 CSS 代码。

使用方法:

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

module.exports = {
  module: {
    rules: [
      {
        test: /.s?css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
      },
    ],
  },
  optimization: {
    minimizer: [
      new CssMinimizerPlugin(),
    ],
  },
  plugins: [new MiniCssExtractPlugin()],
};

更多优化选择

  • 通过 url-loader 将小图片转为 base64,减少 HTTP 请求;
  • 配置 externals,通过 CDN 引入第三方库;
  • 使用 ImageMinimizerWebpackPlugin 压缩图片尺寸;
  • ……

总结

以上就是 webpack 相关优化的全部内容啦!

说的再多都是纸上谈兵,各位小伙伴不妨在自己的项目上动手改造试试;毕竟实践出真知,相信上手完毕你的 简历又能多水一点 理解又会进一步加深!