webpack构建性能优化看这一篇就够了

1,649 阅读9分钟

前言

现目前,前端开发使用打包工具更多的还是 webpack,再加上不管是通过 vue-cli、vite、create-react-app 来生成 Vue 项目还是 React 项目,这些脚手架都已经将 webpack 打包工具集成进去了。这对于我们开发者来说省去很多时间去单独配置 webpack,但是有时候也需要去额外配置一些工具,使得集成的 webpack 更加便捷快速,所以这次就给你们来点狠货,分享一些 webpack 的优化技巧。

无论是日常开发还是面试,都应该掌握一些 webpack 的优化技巧,总之你来看了这篇文章是不会吃亏的,反而会让你在今后的工作中得心应手。

性能分析

俗话说:工欲善其事,必先利其器。

要想进行 Webpack 的性能优化,先要知道性能问题出现在哪?所以我们需要对 webpack 的构建进行分析。

依赖分析

使用 webpack 编译源码时,用户可以生成一个包含模块统计信息的 JSON 文件。这些统计信息可以用来分析应用中的依赖关系图,从而优化 webpack 的编译速度。

使用方法

该文件通常由以下 CLI 命令生成:

npx webpack --profile --json=compilation-stats.json

--json=compilation-stats.json 标志告诉 webpack 生成一个包含依赖关系图和其他各种构建信息的 compilation-stats.json 文件。

通常情况下,--profile 标志也会被添加,这样的话每个 module objects 都会增加一个 profile 部分,它包含了特定模块的统计信息。

速度分析

speed-measure-webpack-plugin 插件可以测量各个插件和 loader 所花费的时间,使用之后,构建时,会得到类似下面这样的信息:

preview.png

对比前后的信息,来确定优化的效果。

使用方法

安装 npm 包

npm install --save-dev speed-measure-webpack-plugin

修改 webpack 配置如下:

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

const smp = new SpeedMeasurePlugin();

modile.exports = smp.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()],
});

打包体积分析

webpack-bundle-analyzer 包可以帮助了解捆绑包中的真实内容,找出哪些模块构成了其最大尺寸,查找错误到达那里的模块,它可以得到下图的关于 bundle 的信息:

93f72404-b338-11e6-92d4-9a365550a701.gif

以便我们对 bundle 进行体积优化。

使用方法

安装 npm 包

# NPM
npm install --save-dev webpack-bundle-analyzer
# Yarn
yarn add -D webpack-bundle-analyzer

修改 webpack 配置如下:

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

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

构建阶段优化

多线程构建

由于 Javascript 是单线程的,webpack 在构建上默认也是单线程的。

多线程可以提高程序的效率,在 webpack 中,就可以使用 thread-loader 来启用多线程的加载器。

安装

npm i thread-loader -D

配置

{
    test: /.js$/,
    use: [
        'thread-loader',
        'babel-loader'
    ]
}

注意

使用时,需将此 loader 放置在其他 loader 之前。放置在此 loader 之后的 loader 会在一个独立的 worker 池中运行。

在 worker 池中运行的 loader 是受到限制的。例如:

  • 这些 loader 不能生成新的文件。
  • 这些 loader 不能使用自定义的 loader API(也就是说,不能通过插件来自定义)。
  • 这些 loader 无法获取 webpack 的配置。
  • 每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。

请仅在耗时的操作中使用此 loader!

预热

为了防止启动工作线程时出现高延迟,可以预热工作线程池。

这将引导池中的最大辅助角色数,并将指定的模块加载到节点.js 模块缓存中。

const threadLoader = require('thread-loader');

threadLoader.warmup(
  {
    // pool options, like passed to loader options
    // must match loader options to boot the correct pool
  },
  [
    // modules to load
    // can be any module, i. e.
    'babel-loader',
    'babel-preset-es2015',
    'sass-loader',
  ]
);

减少编译的模块

DllPlugin

它的核心思想是将项目依赖的框架等模块「单独构建打包」,与普通构建流程区分开。

事先把常用但又构建时间长的代码提前打包好(例如 react、react-dom),取个名字叫 dll。后面再打包的时候就跳过原来的未打包代码,直接用 dll。这样一来,构建时间就会缩短,提高 webpack 打包速度。

配置

两个配置文件

webpack.dll.config.js

module.exports = {
  entry: {
    vendor: ['react', 'react-dom'],
  },
  output: {
    filename: '[name].dll.js',
    path: path.join(__dirname, 'dll'),
    publicPath: '/dll',
    library: '[name]_dll',
  },
  plugins: [
    new webpack.DllPlugin({
      context: __dirname,
      name: '[name]_dll',
      path: path.join(__dirname, 'dll' + '/[name]_manifest.json'),
    }),
  ],
}

webpack.DllPlugin 生成 manifest.json 文件,供 DllReferencePlugin 指向依赖模块位置, 将公共模块 react、react-dom 抽离到项目中 dll 文件下

webpack.app.config.js

plugins: [
  new webpack.DllReferencePlugin({
    context: __dirname,
    manifest: require('./dll/vendor_manifest.json'),
  }),
],

webpack.DllReferencePlugin 引用 manifest.json 文件,寻找依赖模块

IgnorePlugin

有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块

典型的例子是 moment 这个包,一般情况下在构建时会自动引入其 locale 目录下的多国语言包

Webpack 提供的 IgnorePlugin ,即可在「构建模块时」直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积。

new webpack.IgnorePlugin({
  resourceRegExp: /^\.\/locale$/,
  contextRegExp: /moment$/,
});
  • resourceRegExp
    • 指定需要剔除的文件(夹)
  • contextRegExp (可选)
    • 特定目录 任何以 'moment' 结尾的目录中匹配 './locale' 的任何 require 语句都将被忽略

除了 moment 包以外,其他一些带有「国际化模块」的依赖包,都可以应用这一优化方式。

按需引入类库模块

「减少执行模块的方式是按需引入」,一般适用于「工具类库」性质的依赖包的优化

典型例子是 lodash 依赖包

优化方式

  • 定向引入
    • 效果最佳的方式是在「导入声明时只导入依赖包内的特定模块」
  • 使用插件
    • babel-plugin-lodash
    • babel-plugin-import

适用于 antd,antd-mobil,lodash

{
  "plugins": [["import",{
    "libraryName": "lodash",
    "libraryDirectory": "",
    "camel2DashComponentName": false,  // default: true
    }]]
}

注意点

Tree Shaking,这一特性也能减少产物包的体积,但是 Tree Shaking 需要相应导入的依赖包使用 ES6 模块化,而 lodash 还是基于 CommonJS ,需要替换为 lodash-es 才能生效

Tree Shaking 是在优化阶段生效,Tree Shaking 并不能减少模块编译阶段的构建时间。

提升单个模块构建的速度

include/exclude

webpack loader 中配置 include/exclude,是常用的优化特定模块构建速度的方式之一

  • include 的用途是只对符合条件的模块使用指定 Loader 进行转换处理
  • exclude 则相反,不对特定条件的模块使用该 Loader

例如不使用 babel-loader 处理 node_modules 中的模块 使用范例

module.exports = {
   ......
   module: {
    rules: [
      {
        test: /\.js$/,
        include: /src/
        exclude: /node_modules/,
        use: ['babel-loader'],
      },
    ],
  },
}

注意点

通过 include/exclude 排除的模块,并非不进行编译,而是使用 Webpack 「默认的 js 模块编译器进行编译」

在一个 loader 中的 include 与 exclude 配置存在冲突的情况下,优先使用 exclude 的配置,而忽略冲突的 include 部分的配置

noParse

Webpack 配置中的 module.noParse 则是在 include/exclude 的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间

使用范例

module.exports = {
   ......
   module: {
    noParse: /jquery|lodash/,
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader'],
      },
    ],
  },
}

TypeScript 编译优化

在 Webpack 中使用 ts-loader 编译 TS 时,由于 ts-loader 默认在「编译前进行类型检查,因此编译时间往往比较慢」

通过加上配置项 transpileOnly: true,可以在编译时忽略类型检查

module.exports = {
   ......
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: {
          loader: 'ts-loader',
          options: {
            transpileOnly: true,
          },
        },
      },
    ],
  },
}

减少文件搜索范围

Webpack 中的 resolve 配置制定的是在「构建时指定查找模块文件的规则」,合理的配置能减少文件的查找时间

  • resolve.modules 指定查找模块的目录范围
  • resolve.extensions 指定查找模块的文件类型范围
  • resolve.mainFields 指定查找模块的 package.json 中主文件的属性名
  • resolve.symlinks 指定在查找模块时是否处理软连接

打包阶段优化

压缩

js 压缩

webpack 默认使用 TerserWebpackPlugin 插件压缩 JavaScript。

webpack v5 开箱即带有最新版本的 terser-webpack-plugin。如果你使用的是 webpack v5 或更高版本,同时希望自定义配置,那么仍需要安装 terser-webpack-plugin。如果使用 webpack v4,则必须安装 terser-webpack-plugin v4 的版本。

首先,你需要安装 terser-webpack-plugin:

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

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

webpack.config.js

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

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

接下来,按照你习惯的方式运行 webpack。

css 压缩

可以使用mini-css-extract-plugin进行 css 提取

MiniCssExtractPlugin,它支持缓存和多进程,「默认开启多进程」,使用了 MiniCssExtractPlugin 过后,样式就被提取到单独的 CSS 文件中了,「样式文件并没有被压缩」。Webpack 「内置的压缩插件仅仅是针对 JS 文件的压缩,其他资源文件的压缩都需要额外的插件」。

可以使用 css-minimizer-webpack-plugin 进行 css 压缩,这个插件并不是配置在 plugins 数组中的,而是添加到了 optimization 对象中的 minimizer 属性中。示例如下:

// ./webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
module.exports = {
  optimization: {
    minimizer: [new CssMinimizerPlugin()],
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
  plugins: [new MiniCssExtractPlugin()],
};

代码分离

代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。

多入口分包

这是迄今为止最简单直观的分离代码的方式。不过,这种方式手动配置较多,并有一些隐患,我们将会解决这些问题。

使用方式

先来看看如何从 main bundle 中分离 another module(另一个模块):

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
  |- another-module.js
|- /node_modules

another-module.js

import _ from "lodash";

console.log(_.join(["Another", "module", "loaded!"], " "));

webpack.config.js

const path = require("path");

module.exports = {
  mode: "development",
  entry: {
    index: "./src/index.js",
    another: "./src/another-module.js",
  },
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
};

这将生成如下构建结果:

...
[webpack-cli] Compilation finished
asset index.bundle.js 553 KiB [emitted] (name: index)
asset another.bundle.js 553 KiB [emitted] (name: another)
runtime modules 2.49 KiB 12 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 245 ms

正如前面提到的,这种方式存在一些隐患:

  • 如果入口 chunk 之间包含一些重复的模块,那些重复模块都会被引入到各个 bundle 中。
  • 这种方法不够灵活,并且不能动态地将核心应用程序逻辑中的代码拆分出来。

防止重复

配置 dependOn option 选项

这样可以在多个 chunk 之间共享模块:

webpack.config.js

const path = require("path");

module.exports = {
  mode: "development",
  entry: {
    index: {
      import: "./src/index.js",
      dependOn: "shared",
    },
    another: {
      import: "./src/another-module.js",
      dependOn: "shared",
    },
    shared: "lodash",
  },
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  optimization: {
    runtimeChunk: "single",
  },
};

构建结果如下:

...
[webpack-cli] Compilation finished
asset shared.bundle.js 549 KiB [compared for emit] (name: shared)
asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime)
asset index.bundle.js 1.77 KiB [compared for emit] (name: index)
asset another.bundle.js 1.65 KiB [compared for emit] (name: another)
Entrypoint index 1.77 KiB = index.bundle.js
Entrypoint another 1.65 KiB = another.bundle.js
Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB
runtime modules 3.76 KiB 7 modules
cacheable modules 530 KiB
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./src/index.js 257 bytes [built] [code generated]
webpack 5.4.0 compiled successfully in 249 ms

由上可知,除了生成 shared.bundle.js,index.bundle.js 和 another.bundle.js 之外,还生成了一个 runtime.bundle.js 文件 (为了解决这个问题)。

SplitChunksPlugin

SplitChunksPlugin 插件可以将公共的依赖模块提取到已有的入口 chunk 中,或者提取到一个新生成的 chunk。让我们使用这个插件,将之前的示例中重复的 lodash 模块去除:

webpack.config.js

const path = require("path");

module.exports = {
  mode: "development",
  entry: {
    index: "./src/index.js",
    another: "./src/another-module.js",
  },
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  optimization: {
    splitChunks: {
      chunks: "all",
    },
  },
};

使用 optimization.splitChunks 配置选项之后,现在应该可以看出,index.bundle.js 和 another.bundle.js 中已经移除了重复的依赖模块。需要注意的是,插件将 lodash 分离到单独的 chunk,并且将其从 main bundle 中移除,减轻了大小。执行 npm run build 查看效果:

...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 8.92 KiB [compared for emit] (name: index)
asset another.bundle.js 8.8 KiB [compared for emit] (name: another)
Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB
Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB
runtime modules 7.64 KiB 14 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 241 ms

动态导入

按需加载,指的是「在应用运行过程中,需要某个资源模块时,才去加载这个模块」。

  • 极大地「降低了应用启动时需要加载的资源体积」
  • 提高了应用的「响应速度」
  • 节省了「带宽和流量」

当涉及到动态代码拆分时,webpack 推荐使用符合 ECMAScript 提案 的 import() 语法 来实现动态导入

我们不再使用 statically import(静态导入) lodash,而是通过 dynamic import(动态导入) 来分离出一个 chunk:

src/index.js

async function getComponent() {
  const element = document.createElement("div");
  const { default: _ } = await import("lodash");
  element.innerHTML = _.join(["Hello", "webpack"], " ");
  return element;
}

getComponent().then((component) => {
  document.body.appendChild(component);
});

由于 import() 会返回一个 promise,因此它可以和 async 函数一起使用。之所以需要 default,是因为 webpack 4 在导入 CommonJS 模块时,将不再解析为 module.exports 的值,而是为 CommonJS 模块创建一个 artificial namespace 对象,详见webpack 4: import() and CommonJs

执行 webpack,查看 lodash 分离到一个单独的 bundle:

...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 13.5 KiB [compared for emit] (name: index)
runtime modules 7.37 KiB 11 modules
cacheable modules 530 KiB
  ./src/index.js 434 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.4.0 compiled successfully in 268 ms

Tree Shaking

tree shaking 是指移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。

配置方式

webpack.config.js

webpack.config.js;

const path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  mode: "development",
  optimization: {
    usedExports: true,
  },
};

side effect(副作用)

"side effect(副作用)" 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。

在有没有明确 side effect 的项目中,Tree Shaking 不会删除未使用的代码

我们需要明确指定项目的 side effect,可通过 package.json 的 "sideEffects" 属性进行配置,详见将文件标记为 side-effect-free

{
  "name": "your-project",
  "sideEffects": false
}

Polyfill

通常我们在项目中会使用 babel 来将很多 es6 中的 API 进行转换成 es5,但是还是有很多新特性没法进行完全转换,比如 promise、async await、map、set 等语法,那么我们就需要通过额外的 polyfill(垫片)来实现语法编译上的支持。

一般处理方式:babel-polyfill.js

引入

<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.js"></script>

然后就 es6、es7 特性随便写了,但缺点是,babel-polyfill 包含所有补丁,不管浏览器是否支持,也不管你的项目是否有用到,都全量引了,babel-polyfill 打包后体积:88.49k

动态补丁 polyfill.io

polyfill-service 只给用户返回需要的 polyfill,由 polyfill.io 社区维护,部分国内奇葩浏览器 UA 可能无法识别(但可以使用降级返回所需全部 polyfill)

原理:

访问页面,发送请求,识别 User Agent,然后下发不同的 Polyfill

使用方式:

<script src="https://polyfill.io/v3/polyfill.min.js"></script>

source-map

当代码出现 bug 时,source-map 可以快速定位到源代码的位置,但是这个文件很大。所以为了平衡性能和准确性,在开发模式下生成准确(但更大)的 source-map;在生产模式下生成更小(但不那么准确)的源映射。

开发模式:

module.exports = {
    mode: 'development',
    devtool: 'eval-cheap-module-source-map'
}

生产方式:

module.exports = {
    mode: 'production',
    devtool: 'nosources-source-map'
}

缓存优化

Cache

webpack5 的 cache 配置会缓存生成的 webpack 模块和 chunk,来改善构建速度。cache 会在开发 模式被设置成 type: 'memory' 而且在 生产 模式 中被禁用

webpack.config.js

module.exports = {
  //...
  cache: "memory", // memory | filesystem
};

Cache-loader

cache-loader 允许缓存以下 loaders 到(默认)磁盘或数据库。

使用

安装 cache-loader:

npm install --save-dev cache-loader

在一些性能开销较大的 loader 之前添加 cache-loader,以便将结果缓存到磁盘里。

webpack.config.js

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

注意

保存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader。

babel-loader 缓存

只需设置 cacheDirectory = true 即可开启 babel-loader 持久化缓存功能,例如:

module.exports = {
    // ...
    module: {
        rules: [{
            test: /\.m?js$/,
            loader: 'babel-loader',
            options: {
                cacheDirectory: true,
            },
        }]
    },
    // ...
};