前言

本文是第三篇,如果你还没有阅读《Webpack系列-基础篇-2》,建议阅读之后,再继续阅读本文。

那么在上一期,我们介绍了如何配置样式文件的处理、图片的处理、字体的处理,以及一些项目常用相关的知识点和配置。

本文将会介绍如何优化webpack,将从三个大块中展开,1. 优化构建速度(开发时,生产时的打包速度)2. 优化构建结果(文件体积等)3. 优化运行时体验(浏览器打开页面加载资源)

所以将会引入很多webpack配置项,如果文中有任何错误,欢迎在评论区指正,我会尽快修正。

使用最新版本

从 Webpack V3,到 V4,再到最新的 V5 版本,虽然构建功能在不断叠加增强,但性能反而不断优化提升,这得益于 Webpack 开发团队始终重视构建性能,在各个大版本之间不厌其烦地重构核心实现,例如:

  • V3 到 V4 重写 Chunk 依赖逻辑,将原来的父子树状关系调整为 ChunkGroup 表达的有序图关系,提升代码分包效率
  • V4 到 V5 引入 cache 功能,支持将模块、模块关系图、产物等核心要素持久化缓存到硬盘,减少重复工作

因此,开发者应该尽可能保持 Webpack 及 Node、NPM or Yarn 等基础环境的更新,使用最新稳定版本完成构建工作。

对比:删除缓存后初次运行项目

Vue Cli v4.5.7 Webpack v4.0.0Vue Cli v5.0.4 Webpack v5.54.0

优化构建速度

优化 resolve 配置

Webpack 默认提供了一套同时兼容 CMD、AMD、ESM 等模块化方案的资源搜索规则 —— enhanced-resolve,它能将各种模块导入语句准确定位到模块对应的物理资源路径。

参考:https://github.com/webpack/enhanced-resolve

例如:

  • import 'lodash' 这一类引入 npm 包的语句会被 enhanced-resolve 定位到对应包体文件路径 node_modules/lodash/index.js ;
  • import './a' 这类不带文件后缀名的语句则可能被定位到 ./a.js 文件;
  • import '``@/a' 这类化名路径的引用则可能被定位到 $PROJECT_ROOT/src/a.js 文件。

需要注意,这类增强资源搜索体验的特性背后涉及许多 IO 操作,本身可能引起较大的性能消耗,开发者可根据实际情况调整 resolve 配置,缩小资源搜索范围。

优化 resolve.modules 配置

类似于 Node 模块搜索逻辑,当 Webpack 遇到 import 'lodash' 这样的 npm 包导入语句时,会尝试先当前项目的 node_modules 搜索资源,如果找不到则按目录层级尝试逐级向上查找 node_modules 目录,如果依然找不到则最终尝试在全局 node_modules 中搜索。

在一个依赖管理执行的比较良好的业务系统中,我们通常会尽量保持 node_modules 资源的高度内聚,控制在有限的一两个层级上,因此 Webpack 这一逐层查找的逻辑大多数情况下实用性并不高,开发者可以通过修改 resolve.modules 配置项,主动关闭逐层搜索功能,例如:

|

// webpack.config.js
const path = require('path');

module.exports = {
  //...  
 resolve: {
    modules: [path.resolve(__dirname, 'node_modules')],
  },
}

优化 resolve.mainFields 配置

在安装的第三方模块中都会有一个 package.json 文件,用于描述这个模块的属性,其中可以存在多个字段描述入口文件,原因是某些模块可以同时用于多个环境中,针对不同的运行环境需要使用不同的代码。

resolve.mainFields 的默认值和当前的 target 配置有关系,对应的关系如下。

  • target web 或者 webworker 时,值是['browser','module','main']。
  • target 为其他情况时,值是[ 'module','main']。

以 target 等于 web 为例, Webpack 会先采用第三方模块中的 browser 字段去寻找模块的入口文件,如果不存在,就采用 module 字段,以此类推。

resolve.mainFiles 配置项用于定义文件夹默认文件名,例如对于 import './dir' 请求,假设 resolve.mainFiles = ['index', 'home'] ,Webpack 会按依次测试 ./dir/index 与 ./dir/home 文件是否存在。

为了减少搜索步骤,在明确第三方模块的入口文件描述字段时,我们可以将它设置得尽量少,以减少匹配次数。由于大多数第三方模块都采用 main 字段去描述入口文件的位置,所以可以这样配置:

|

  resolve: {
    // 只采用 main 字段作为入口文件的描述字段,以减少搜索步骤
    mainFields: [‘index’,'main'],
  },

优化 resolve.alias 配置

resolve.alias 配置项通过别名来将原导入路径映射成一个新的导入路径。

在实战项目中经常会依赖一些庞大的第三方模块,以 React 库为例,发布出去的 React 库中包含两套代码

  • 一套是采用 CommonJS 规范的模块化代码,这些文件都放在 lib 录下,以 package.json 中指定的入口文件 react.js 为模块的入口
  • 一套是将 React 的所有相关代码打包好的完整代码放到一个单独的文件中, 这些代码没有采用模块化,可以直接执行。其中 dist/react.js 用于开发环境,里面包含检查和警告的代码。dist/react.min.js 用于线上环境,被最小化了。

在默认情况下, Webpack 会从入口文件 ./node_modules/react/react.js 开始递归解析和处理依赖的几十个文件,这会是一个很耗时的操作 通过配置 resolve.alias, 可以让 Webpack 在处理 React 库时,直接使用单独、完整的 react.min.js 文件,从而跳过耗时的递归解析操作.

|

resolve: {
    //使用 alias 将导入 react 的语句换成直接使用单独、完整的 react.min.js 文件,
    //减少耗时的递归解析操作
    alias: {
      react: path.resolve(__dirname, './node_modules/react'),
    },
  },

优化 resolve.extensions 配置

当模块导入语句未携带文件后缀时,如 import './a' ,Webpack 会遍历 resolve.extensions 项定义的后缀名列表,尝试在 './a' 路径追加后缀名,搜索对应物理文件。

在 Webpack 5 中,resolve.extensions 默认值为 ['.js', '.json', '.wasm'] ,这意味着 Webpack 在针对不带后缀名的引入语句时可能需要执行三次判断逻辑才能完成文件搜索,针对这种情况,可行的优化措施包括:

  • 后缀尝试列表要尽可能小,不要将项目中不可能存在的情况写到后缀尝试列表中。(修改 resolve.extensions 配置项,减少匹配次数)
  • 频率出现最高的文件后缀要优先放在最前面,以做到尽快退出寻找过程。
  • 在源码中写导入语句时,要尽可能带上后缀 从而可以避免寻找过程。例如在确定的情况下将 require ( './data ') 写成 require ('./data.json')(代码中尽量补齐文件后缀名)

|

resolve: {
    //尽可能减少后缀尝试的可能性
    extensions: ['.js', '.jsx', '.json', '.css', '.less'],
  }

extensions

externals 配置选项提供了「从输出的 bundle 中排除依赖」的方法,我们可以用这样的方法来剥离不需要改动的一些依赖,大大节省打包构建的时间。

例如,从 CDN 引入 Vue,而不是把它打包:

|

<script src="https://unpkg.com/vue@3/dist/vue.esm-browser.js"></script>

|

externals: {
      vue: 'vue',
}

noParse

有不少 npm 包默认提供了提前打包好,不需要做二次编译的资源版本,例如:

  • Vue 包的 node_modules/vue/dist/vue.runtime.esm.js 文件
  • React 包的 node_modules/react/umd/react.production.min.js 文件

对使用方来说,这些资源版本都是高度独立、内聚的代码片段,没必要重复做依赖解析、代码转译操作,此时可以使用 module.noParse 配置项跳过这些 npm 包,例如:

|

// webpack.config.js
module.exports = {
  //...
  module: {
    noParse: /vue|lodash|react/,
  },
};

配置该属性后,任何匹配该选项的包都会跳过耗时的分析过程,直接打包进 chunk,提升编译速度。

多进程配置

多进程打包是一把双刃剑,如果使用得当,他会大大提高编译速度,如果使用不当,那么编译速度反而会增加

因为thread-loader多线程开启过程需要耗费时间大概为600ms,多线程之间的通信也会消耗时间

一般我们项目中是js编译的时候比较慢,所以多进程打包一般也是和babel-loader一起配置

使用方法

首先,需要安装 Thread-loader 依赖:

|

npm install -D thread-loader

其次,需要将 Thread-loader 配置到 loader 数组首位,确保最先运行,如:

|

module.exports = {
  module: {
    rules: [{
      test: /\.js$/,
      use: [
        'thread-loader',
        'babel-loader',
        'eslint-loader'
      ],
    }, ],
  },
};

最佳实践

理论上,并行确实能够提升系统运行效率,但 Node 单线程架构下,所谓的并行计算都只能依托与派生子进程执行,而创建进程这个动作本身就有不小的消耗 —— 大约 600ms,因此建议读者按实际需求斟酌使用上述多进程方案。

对于小型项目,构建成本可能很低,但引入多进程技术反而导致整体成本增加。

对于大型项目,建议尽量使用 Thread-loader 组件提升 Make 阶段性能。生产环境下还可配合 terser-webpack-plugin 的并行压缩功能,提升整体效率。

缓存配置

webpack4

实际上,Webpack 4 已经内置使用内存实现的临时缓存功能,但必须在 watch 模式下使用,进程退出后立即失效,实用性不高。不过,在 Webpack 4 及之前版本中可以使用一些 loader 自带的缓存功能提升构建性能,例如 babel-loader、eslint-loader、cache-loader 。

开启babel-loader缓存

我们可以通过在babel-loader后面添加cacheDirectory或者是在选项中设置cacheDirectory为true来开启它。

|

    module: { 
        // 用法1
        rules: [{ 
            test: /\.m?js$/, 
            loader: 'babel-loader', 
            options: { 
                cacheDirectory: true, 
            }, 
        }] 
        // 用法2
       rules: [{
            test: /\.m?js$/,
            use: ['babel-loader?cacheDirectory'],
        }]
    }, 

默认情况下,babel-loader 会将缓存内容保存到 node_modules/.cache/babel-loader 目录,用户也可以通过 cacheDirectory = 'dir' 方式设置缓存路径。

开启eslint-loader缓存

如果你使用了eslint,那么也可以为eslint开启缓存,只需设置 cache = true 即可开启,如:

|

 module: { 
        rules: [{ 
            test: /\.js$/, 
            exclude: /node_modules/, 
            loader: 'eslint-loader', 
            options: { 
                cache: true, 
            }, 
        }, ] 
    }, 

默认情况下,当cache设置为true的时候,eslint-loader 会将缓存内容保存到 ./node_modules/.cache/eslint-loader 目录,用户也可以通过 cache = 'dir' 方式设置缓存路径。

使用cache-loader

除 babel-loader、eslint-loader 这类特化 loader 自身携带的缓存功能外,Webpack 4 中还可以使用cache-loader来将性能开销较大的loader运行结果缓存起来,同thread-loader使用方式一样,副作用也一样所以尽量不要使用在一些开销较小的loader上,可能会增加构建时间。使用上只需将 cache-loader 配置在 loader 数组首位,例如:

你需要先安装cache-loader,npm install -D cache-loader

|

module: { 
        rules: [{ 
            test: /\.js$/, 
            use: ['cache-loader', 'babel-loader', 'eslint-loader'] 
        }] 
    }, 

与 Webpack 5 自带的持久化缓存不同,cache-loader 仅 Loader 执行结果有效,缓存范围与深度不如内置的缓存功能,所以性能收益相对较低,但在 Webpack 4 版本下已经不失为一种简单而有效的性能优化手段。

webpack5

持久化缓存算得上是 Webpack 5 最令人振奋的特性之一,它能够将首次构建结果持久化到本地文件系统,在下次执行构建时跳过一系列解析、链接、编译等非常消耗性能的操作,直接复用 module、chunk 的构建结果。

使用持久化缓存后,构建性能有巨大提升! 使用极其简单:只需要配置cache.type = 'filesystem'

|

cache: { 
        type: 'filesystem' 
    }, 

优化构建结果

构建结果分析

webpack

webpack-bundle-analyzer 扫描 bundle 并构建其内部内容的可视化。使用此可视化来查找大的或不必要的依赖项。

安装 webpack-bundle-analyzer

|

npm install webpack-bundle-analyzer --save-dev

配置

方式一: 作为插件使用

|

// 分析包内容
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
  plugins: [
    // 开启 BundleAnalyzerPlugin
    new BundleAnalyzerPlugin(),
  ],
};

一般运行在生产版本中,该插件将在浏览器中打开统计信息结果页面。

方式二:在线使用

WEBPACK VISUALIZER 分析工具

打开 chrisbateman.github.io/webpack-vis… ,上传 stats.json 既可看到分析结果。

webpack bundle optimize helper 分析工具

打开 webpack.jakoblind.no/optimize/ ,上传 stats.json 既可看到分析结果及优化建议:

Vue Cli

不需要安装插件,默认已集成。

只需要在build命令后面添加参数。

  • |

    vue-cli-service build --report --report-json

| |
| -- |

  • --report--report-json 会根据构建统计生成报告,它会帮助你分析包中包含的模块们的大小。

运行后会在 打包目录输出 report.html 和 report.json

参考:https://cli.vuejs.org/zh/guide/cli-service.html#vue-cli-service-build

优化步骤

  • 看看有没有什么重复的模块,或者没有用的模块被打包到了最终的代码中
  • 按需加载组件库
  • 使用动态 import 优化路由

压缩JS

webpack 4

安装压缩插件 https://webpack.js.org/plugins/terser-webpack-plugin/#uglify-js

|

npm install terser-webpack-plugin --save-dev

然后将插件添加到你的 webpack 配置中,例如:

|

const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
    optimization: {
        minimize: true,
        minimizer: [new TerserPlugin()],
    },
};

webpack5

已经自带,但当你需要自定义配置时,仍需要安装插件。

|

const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
        optimization: {
            minimize: true, // 可省略,默认最优配置:生产环境,压缩 true。开发环境,不压缩 false
            minimizer: [
                 new TerserPlugin({
                    parallel: true, // 可省略,默认开启并行
                    terserOptions: {
                        toplevel: true, // 最高级别,删除无用代码
                        ie8: true,
                        safari10: true,
                    }
                })
            }
        }

清除无用css

webpack5中默认会进行tree shaking,只要是满足模块化与mode=production 就能剔除掉被没有被使用到的代码

css中的tree shaking 使用purgecss-webpack-plugin插件,具体配置如下:

|

const PurgeCSSPlugin = require('purgecss-webpack-plugin')
const PATHS = {
    src: path.join(__dirname, 'src')
}
module.exports = {
    plugin: [
new PurgeCSSPlugin({
            paths: glob.sync(`${PATHS.src}/**/*`, {
                nodir: true
            }),
        })
]
}

优化运行时体验

入口点分割(多页打包)

有时候,我们项目里面的很多页面都是不相关的,可以看作为多个应用,每个应用都可以独立部署,这时候我们就需要实现 多页面应用程序。

比如像阿里云:

文档、购物车、ICP备案、控制台、登录这些模块可能都是不同团队去写的代码,但却要集成在一个应用中,这时候就需要多页应用程序。

我们在了解了单页应用程序的基础上,可以得出,一个页面的应用程序需要一个入口文件+对应的html模板。那么我们多页应用程序,就需要多个html,多个js,并且可以一一对应上。

建立多页应用程序,首选需要创建相应的文件结构,这里我创建了一个pages文件夹,其中放置了俩个文件夹,分别是不同的应用程序。比如在这里我的test1中放置的是react18,而在test2中放置的是vue3。它们是不同技术栈的。

并且我在src目录下,将index.js修改为了vue应用程序,当作了我们的主应用。

将基础的测试文件添加完成后,我们需要修改 webpack 的配置。

打开 webpack.common.js 文件,配置如下:

|

const path = require('path')
const glob = require('glob')
const globAll = require('glob-all')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const isProd = process.env.NODE_ENV === 'production'
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
const TerserPlugin = require("terser-webpack-plugin");
// const HardSourceWebpackPlugin = require('hard-source-webpack-plugin')
const PurgecssPlugin = require('purgecss-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
const PATHS = {
  src: path.join(__dirname, 'src'),
  public: path.join(__dirname, 'public'),
}



function setMPA () {

  const files = glob.sync('./src/pages/**/index.js')
  const entry = {
    index: './src/index.js'
  }
  const html = [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'public/index.html'),
      filename: `index.html`,
      chunks: ['vendor', 'manifest', 'index'],
      inject: true,
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeRedundantAttributes: true,
        useShortDoctype: true,
        removeEmptyAttributes: true,
        removeStyleLinkTypeAttributes: true,
        keepClosingSlash: true,
        minifyJS: true,
        minifyCSS: true,
        minifyURLs: true,
      },
    })
  ]
  files.forEach(file => {
    const ret = file.match(/^\.\/src\/pages\/(\S*)\/index\.js$/)
    if (ret) {
      entry[ret[1]] = file;
      html.push(new HtmlWebpackPlugin({
        template: path.join(__dirname, 'public/index.html'),
        filename: `${ret[1]}.html`,
        chunks: ['vendor', 'manifest', ret[1]],
        inject: true,
        minify: {
          removeComments: true,
          collapseWhitespace: true,
          removeRedundantAttributes: true,
          useShortDoctype: true,
          removeEmptyAttributes: true,
          removeStyleLinkTypeAttributes: true,
          keepClosingSlash: true,
          minifyJS: true,
          minifyCSS: true,
          minifyURLs: true,
        },
      }))
    }
  })

  return { entry, html }
}
const { entry, html } = setMPA()
module.exports = {
  mode: isProd ? 'production' : 'development',
  devtool: 'eval-cheap-module-source-map',
  entry: entry,
  // output: {
  //   path: path.resolve(__dirname, 'dist'), //必须是绝对路径
  //   // 输出文件目录(将来所有资源输出的公共目录,包括css和静态文件等等) path: path.resolve(__dirname, "dist"), //默认 // 入口文件名称(指定名称+目录) filename: "[name].js", // 默认 // 所有资源引入公共路径前缀,一般用于生产环境,小心使用 (例如,你最终编译出来的代码部署不是在根目录下,例如:https://www.xxxx.com/my-app/ 那么 publicPath需要设置为 ‘/my-app/’)
  //   // publicPath: '/test',
  //   filename: 'bundle.[hash].js',
  //   /* 非入口文件chunk的名称。所谓非入口即import动态导入形成的chunk或者optimization中的splitChunks提取的公共chunk 它支持和 filename 一致的内置变量 */
  //   chunkFilename: '[contenthash:10].chunk.js',
  //   clean: true, // 打包前清空输出目录,相当于clean-webpack-plugin插件的作用,webpack5新增。
  //   /* 当用 Webpack 去构建一个可以被其他模块导入使用的库时需要用到library */
  //   // library: {
  //   //   name: '[name]', //整个库向外暴露的变量名
  //   //   type: 'window', //库暴露的方式
  //   // },
  //   // assetModuleFilename: 'images/[hash][ext][query]'
  // },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        // 注意:如果项目源码中没有 jsx 文件就不要写 /\.jsx?$/,提升正则表达式性能
        test: /\.jsx?$/, // 这告诉 webpack 编译器(compiler) 如下信息:
        // “嘿,webpack 编译器,当你碰到「在 require()/import 语句中被解析为 '.js' 的路径时,在你对它打包之前,先 use(使用) babel-loader 转换一下。”
        // babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
        // 'thread-loader',
        use: ['babel-loader?cacheDirectory'],
        // 排除 node_modules 目录下的文件
        // node_modules 目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换
        exclude: /node_modules/,
      },
      {
        test: /\.(le|c)ss$/,
        use: [
          process.env.NODE_ENV !== 'production'
            ? 'vue-style-loader'
            : MiniCssExtractPlugin.loader,
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: [
                  [
                    'postcss-preset-env',
                    {
                      // 其他选项
                    },
                  ],
                ],
              },
            },
          },
          'less-loader',
        ],
        exclude: /node_modules/,
      },

      {
        test: /\.(png|svg|jpg|jpeg|gif)$/i,
        type: 'asset/resource',
        generator: {
          filename: 'assets/images/[hash][ext][query]',
        },
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/i,
        type: 'asset/resource',
        generator: {
          filename: 'assets/fonts/[hash][ext][query]',
        },
      },
    ],
  },
  optimization: {
    minimizer: [
      // 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`),将下一行取消注释
      // `...`,
      new CssMinimizerPlugin(),
      new TerserPlugin({
        parallel: true, // 可省略,默认开启并行
        terserOptions: {
          toplevel: true, // 最高级别,删除无用代码
          ie8: true,
          safari10: true,
        }
      })
    ],
    splitChunks: {
      // chunks: 'async',
      // minSize: 20000,
      // minRemainingSize: 0,
      // minChunks: 1,
      // maxAsyncRequests: 30,
      // maxInitialRequests: 30,
      // enforceSizeThreshold: 50000,
      cacheGroups: {
        default: {
          name: 'common',
          chunks: 'initial',
          minChunks: 2  //模块被引用2次以上的才抽离
          // priority: -20,
          // reuseExistingChunk: true,
        },
        vendors: {  //拆分第三方库(通过npm|yarn安装的库)
          test: /[\\/]node_modules[\\/]/,
          name: 'vendor',
          chunks: 'initial',
          priority: -10
        }
      },
    },

  },
  plugins: [
    ...html,
    new VueLoaderPlugin(),
    new MiniCssExtractPlugin({
      filename: 'css/[name].[hash].css',
      //个人习惯将css文件放在单独目录下
      //publicPath:'../' //如果你的output的publicPath配置的是 './' 这种相对路径,那么如果将css文件放在单独目录下,记得在这里指定一下publicPath
    }),
    // @ts-ignore
    new PurgecssPlugin({
      paths: globAll.sync([
        // path.join(__dirname, './src/**/*'),
        path.join(__dirname, '/public/index.html'),
        // path.join(__dirname, './src/**/*.vue'),
        // path.join(__dirname, './src/**/*.js'),
        `${PATHS.src}/*`
      ], {
        nodir: true
      }),
      safelist: ['body']
    }),
    // new HardSourceWebpackPlugin(),
    // new BundleAnalyzerPlugin(),
  ],
  resolve: {
    alias: {
      '@scr': path.resolve(__dirname, './src'),
    },
    // modules: [path.resolve(__dirname, 'node_modules')],
    // 只采用 main 字段作为入口文件的描述字段,以减少搜索步骤
    // mainFields: ['main'],
    //尽可能减少后缀尝试的可能性
    // extensions: ['.js', '.jsx', '.json', '.css', '.less'],
  },
}

主要修改的点为: 入口文件,以及 new HtmlWebpackPlugin 相关配置,以及vue、react相关运行环境。

核心思想很简单,我们通过配置多个 入口文件来获得多个 js 文件,然后将多个 html 文件引入对应的 js 和 css 即可。由于 css 文件是在 js 文件中引入的,所以如果想让 html 文件引入对应的 js,指定对应的入口 js 文件 key 即可。

这里因为我们的多页应用程序,entry的配置和new htmlwebpackplugin的配置非常相似,我们这里使用了glob来将他们的入口文件遍历获取到,这样我们就可以在后续添加项目的时候,不需要再编辑entry和new htmlwebpackplugin的配置了。

后续只需要按照固定的文件路径形式去pages下新建应用即可。

splitChunks 分包配置

开发多页应用的时候,如果不对webpack打包进行优化,当某个模块被多个入口模块引用时,它就会被打包多次(在最终打包出来的某几个文件里,它们都会有一份相同的代码)。当项目业务越来越复杂,打包出来的代码会非常冗余,文件体积会非常庞大。大体积文件会增加编译时间,影响开发效率;如果直接上线,还会拉长请求和加载时长,影响网站体验。作为一个追求极致体验的攻城狮,是不能忍的。所以在多页应用中优化打包尤为必要。那么如何优化webpack打包呢?

概念

在一切开始前,有必要先理清一下这三个概念:

  • module: 模块,在webpack眼里,任何可以被导入导出的文件都是一个模块。
  • chunk: chunk是webpack拆分出来的:

    • 每个入口文件都是一个chunk
    • 通过 import、require 引入的代码也是
    • 通过 splitChunks 拆分出来的代码也是
  • bundle: webpack打包出来的文件,也可以理解为就是对chunk编译压缩打包等处理后的产出。

问题分析

首先,简单分析下,我们刚才提到的打包问题:

  • 核心问题就是:多页应用打包后代码冗余,文件体积大。
  • 究其原因就是:相同模块在不同入口之间没有得到复用,bundle之间比较独立。

弄明白了问题的原因,那么大致的解决思路也就出来了:

  • 我们在打包的时候,应该把不同入口之间,共同引用的模块,抽离出来,放到一个公共模块中。这样不管这个模块被多少个入口引用,都只会在最终打包结果中出现一次。————解决代码冗余。
  • 另外,当我们把这些共同引用的模块都堆在一个模块中,这个文件可能异常巨大,也是不利于网络请求和页面加载的。所以我们需要把这个公共模块再按照一定规则进一步拆分成几个模块文件。————减小文件体积。
  • 至于如何拆分,方式因人而异,因项目而异。我个人的拆分原则是:

    • 业务代码和第三方库分离打包,实现代码分割;
    • 业务代码中的公共业务模块提取打包到一个模块;
    • 第三方库最好也不要全部打包到一个文件中,因为第三方库加起来通常会很大,我会把一些特别大的库分别独立打包,剩下的加起来如果还很大,就把它按照一定大小切割成若干模块。

说起来可能不够直接,这里直接上图:

代码逻辑很简单,test1应用中引用了lodash模块,test2应用中也引用了lodash模块。

如果不引用模块,它们的大小是这样的:

可以看到,当引用了lodash之后,我们test1和test2应用的入口文件大小均有增加。

意味着同样的代码被打包了两遍,test1应用和test2应用均有一份lodash的代码。

要想解决这个问题,我们需要配置splitChunks:

|

module.exports = {
    optimization: {
        splitChunks: {
            cacheGroups: {
                default: {
                    name: 'common',
                    chunks: 'initial',
                    minChunks: 2 //模块被引用2次以上的才抽离
                    // priority: -20,
                    // reuseExistingChunk: true,
                },
                vendors: { //拆分第三方库(通过npm|yarn安装的库)
                    test: /[\\/]node_modules[\\/]/,
                    name: 'vendor',
                    chunks: 'initial',
                    priority: -10
                }
            }
        }
    }
}

下面我们对上诉的配置进行解释:

  1. cacheGroups

    1. cacheGroups是splitChunks配置的核心,对代码的拆分规则全在cacheGroups缓存组里配置。
    2. 缓存组的每一个属性都是一个配置规则,我这里给他的default属性进行了配置,属性名可以不叫default可以自己定。
    3. 属性的值是一个对象,里面放的我们对一个代码拆分规则的描述。
  2. name

    1. 提取出来的公共模块将会以这个来命名,可以不配置,如果不配置,就会生成默认的文件名,大致格式是index~a.js这样的。
  3. chunks

    1. 指定哪些类型的chunk参与拆分,值可以是string可以是函数。如果是string,可以是这个三个值之一:all, async, initial,all 代表所有模块,async代表只管异步加载的, initial代表初始化时就能获取的模块。如果是函数,则可以根据chunk参数的name等属性进行更细致的筛选。
  4. minChunks:

    1. 它控制的是每个模块什么时候被抽离出去:当模块被不同entry引用的次数大于等于这个配置值时,才会被抽离出去。
    2. 它的默认值是1。也就是任何模块都会被抽离出去(入口模块其实也会被webpack引入一次)。

值得注意的是大家可以看到,我在cacheGroups中添加了vendors属性(属性名可以自己取,只要不跟缓存组下其他定义过的属性同名就行,否则后面的拆分规则会把前面的配置覆盖掉)。

它的作用是将第三方库进行拆分,也就是在node_modules中的。它里面的配置项解释:

  1. minSize

    1. minSize设置的是生成文件的最小大小,单位是字节。如果一个模块符合之前所说的拆分规则,但是如果提取出来最后生成文件大小比minSize要小,那它仍然不会被提取出来。这个属性可以在每个缓存组属性中设置,也可以在splitChunks属性中设置,这样在每个缓存组都会继承这个配置。这里由于我的demo中文件非常小,为了演示效果,我把minSize设置为30字节,好让公共模块可以被提取出来,正常项目中不用设这么小。
  2. priority

    1. priority属性的值为数字,可以为负数。作用是当缓存组中设置有多个拆分规则,而某个模块同时符合好几个规则的时候,则需要通过优先级属性priority来决定使用哪个拆分规则。优先级高者执行。我这里给业务代码组设置的优先级为-20,给第三方库组设置的优先级为-10,这样当一个第三方库被引用超过2次的时候,就不会打包到业务模块里了。
  3. test

    1. test属性用于进一步控制缓存组选择的模块,与chunks属性的作用有一点像,但是维度不一样。test的值可以是一个正则表达式,也可以是一个函数。它可以匹配模块的绝对资源路径或chunk名称,匹配chunk名称时,将选择chunk中的所有模块。我这里用了一个正则/[\\/]node_modules[\\/]/来匹配第三方模块的绝对路径,因为通过npm或者yarn安装的模块,都会存放在node_modules目录下。

经过一顿操作之后,我们再次打包:

可以发现我们test1和test2应用的入口文件减少了非常多,然后在下面可以看到多了一个vender.js和common.js文件。

至此,我们就成功实现了抽离公共模块、业务代码剥离。

代码懒加载

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

示例

我们有些时候会需要当点击某个按钮的时候触发一段逻辑,而不点击按钮永远不会用到这个逻辑,这时候我们可以使用延迟加载。

这里有一段代码,当它被触发的时候,才会获取特定的逻辑执行。

|

 const openSum = () => {
   import(/* webpackChunkName: "sum" */ './sum.js').then(({ default: sum }) => {
      sum(1,2);
   })
 }

框架

许多框架和类库对于如何用它们自己的方式来实现(懒加载)都有自己的建议。这里有一些例子:

prefetch 与 preload

preload 与prefetch 的区别

  • preload 是一个声明式 fetch,可以 强制浏览器在不阻塞 document 的 onload 事件的情况下请求资源 。 preload 顾名思义就是一种预加载的方式,它通过声明向浏览器声明一个需要提交加载的资源,当资源真正被使用的时候立即执行,就无需等待网络的消耗。
  • prefetch 告诉浏览器 这个资源将来可能需要 ,但是什么时间加载这个资源是由浏览器来决定的。 若能预测到用户的行为,比如懒加载,点击到其它页面等则相当于提前预加载了需要的资源。

在webpack中的使用方式

单页面应用由于页面过多,可能会导致代码体积过大,从而使得首页打开速度过慢。所以 切分代码,优化首屏打开速度尤为重要

但是所有的技术手段都不是完美的。当我们切割代码后,首屏的js文件体积减少了好多。但是也有一个突出的问题:

那就是当跳转其他页面的时候,需要下载相应页面的js文件,这就导致体验极其不好,每一次点击访问新页面都要等待js文件下载,然后再去请求接口获取数据。频繁出现loading动画的体验真的不好

所以如果我们在进入首页后,在浏览器的空闲时间提前下好用户可能会点击页面的js文件,这样首屏的js文件大小得到了控制,而且再点击新页面的时候,相关的js文件已经下载好了,就不再会出现loading动画。

动态引入js文件,实现code-splitting,减少首屏打开时间

按引入情况加载,只需添加注释即可

  • 代码分割注释:/webpackChunkName: 'mp-supports'/
  • prefetch注释:/ webpackPrefetch: true /

|

// 代码分割 相同的ChunkName将会被打包到一起
import(/* webpackChunkName: "theme-os-beta" */ `./pages/task-page/index.vue`)

// 这将导致<link rel="prefetch" href="login-modal-chunk.js">被附加到页面的头部,这将指示浏览器在空闲时间预取login-modal-chunk.js文件。
import(/* webpackPrefetch: true */ './path/to/LoginModal.js');

// 当一个使用ChartingLibrary的页面被请求时,这个图表库块也会通过<link rel="preload">被请求。
import(/* webpackPreload: true */ 'ChartingLibrary');

更多的,可以查看 webpack 注释黑魔法:https://webpack.js.org/api/module-methods/#magic-comments

总结

在当下,webpack是稳坐前端构建工具界扛把子之位,它确实是一款优秀的构建工具,它不仅拥有强大的扩展能力,还拥有一群优秀的开发者,拥有活跃的社区,给它提供源源不断的生命力,这些都是webpack最核心的竞争力。

在未来,随着ESModule规范的普及,或许我们将不再需要工具来进行模块打包,webpack也有可能被替代,但是我相信构建工具不会退出舞台,而只会做越来越多的事情,因为工程化的理念已深入人心。

但是,学习webpack这类构建工具并不是我们的最终目的,因为归根结底他们只不过是一个工具而已,是工具就会被更新被取代,而如何利用工具来解放生产力才应该是前端工程化的最终目的。前端工程化更重要的是一种思想,而不是特指某个特定的工具,他们仅是手段而已。在工作中要有意识将一些复杂或耗时的工

作交给程序来做,并且可以将用到的工具和方法分享给其他人,如此不仅可以提高自己和团队的开发效率,还能提升自己的影响力,升职加薪,迎娶白富美... 想想是不是觉得有点小心机呢?

下期预告

在下一期,可能会给大家带来:《webpack实现 vue cli(精简版)》

参考资料

最后修改:2023 年 03 月 24 日
如果觉得我的文章对你有用,请随意赞赏