Skip to content

Webpack

webpack 入门

概念

基本概念

本质上,webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle

从 v4.0.0 开始,webpack 可以不用再引入一个配置文件来打包项目,然而,它仍然有着 高度可配置性,可以很好满足你的需求。

核心概念

  • 入口(entry)
    • 指示 webpack 使用哪个模块来作为内部依赖图的开始,默认是./src/index.js
  • 输出(output)
    • 告诉 webpack 在哪里输出它所创建的 bundle,默认是./dist/main.js
    • 可使用属性pathfilename来指定输出的路径和文件名;
  • loader
    • webpack 只能解析 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力,loader 让 webpack 能够去处理其它类型的文件,并将他们转换为有效的模块,以供应用程序使用,以及被添加到依赖图中;
    • 定义在module对象的rules数组中;
    • 有两个属性
      • test 属性:识别出哪些文件会被转换,支持正则匹配;
      • use 属性,定义出在进行转换的时,应该使用哪个 loader,在官网查看相关 loader 介绍;
const path = require("path");

module.exports = {
  output: {
    filename: "my-first-webpack.bundle.js",
  },
  module: {
    rules: [{ test: /\.txt$/, use: "raw-loader" }],
  },
};
  • 插件(plugin)
    • loader 用于转换某些类型的模块,而插件则可以用于执行范围更广的任务,包括:打包优化,资源管理,注入环境变量;
    • webpack 提供许多开箱可用的插件!查阅 插件列表 获取更多
  • 模式(mode)
    • 指定当前配置文件应用在何种模式,有development, productionnone,默认值为production
  • 浏览器兼容性(browser cpmpatibility)
    • webpack 支持所有符合 ES5 标准 的浏览器(不支持 IE8 及以下版本),如果想要支持旧版本浏览器,在使用这些表达式之前,还需要 提前加载 polyfill
  • 环境(environment)
    • webpack 运行于 Node.js v8.x+ 版本。

起步

webpack 用于编译 JavaScript 模块。一旦完成 安装,你就可以通过 webpack CLIAPI 与其配合交互。如果你还不熟悉 webpack,请阅读 核心概念对比,了解为什么要使用 webpack,而不是社区中的其他工具。

基本安装

全局安装

npm install --global webpack
npm install --global webpack-cli

使用

npx webpack

这样即可将以默认配置./src/index.js为入口,dist/main.js为出口来进行 js 文件打包;

当然,也可以创建webpack.config.js文件来指定配置,使用

npx webapck --config webpack.config.js

来执行指定的 webpack 配置文件;

资源处理

加载 css 文件

npm install --save-dev style-loader css-loader

使用 style-loader 和 css-loader 来处理 css 文件,在 webpack 配置文件中进行配置;

const path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

这里,我们使用了配置文件中的module,并为其配置了一项rulesrules中写入配对并处理 css 文件的 loader 规则,注意的是,处理一种文件可使用多个 loader,loader 在use中被指定,进行文件处理时,是按照该配置项逆序执行相关 loader 的,如上例中,是先执行 css-loader,在执行 style-loader;

前面提到 webpack 默认处理 js 文件,所以要 webpack 能处理 css 文件,那就必须使用 css-loader 将 css 文件转换成 webpack 可处理的 js 代码段(需要注意的是,必须在 js 文件中 import 对应的 css 文件,产生依赖,这样才会被 webpack 打包),至于 style-loader 的话,它的作用是将 css 字符串已<style>标签的形式插入到 index.html 中;

处理 css 文件是如此,同理地,处理其它类型的文件也是同样道理;

加载 images 图像

处理 background 和 icon 之类的图像,我们可以使用 webpack 内置的 asset modules,将这些内容混入我们的系统中,在配置文件中新增 loader 规则;

{
    test: /\.(png|svg|jpg|jpeg|gif)$/i,
    // webpack5新增的资源处理类型
    // asset/resource就是url-loader和file-loader,将文件发送到输出目录,并导出对应的url
    type: 'asset/resource',
},

加载 fonts 字体

与加载 images 图像同样道理,在配置文件中加入以下 loader 规则;

{
    test: /\.(woff|woff2|eot|ttf|otf)$/i,
    type: 'asset/resource',
},

加载数据

可以加载的有用资源还有数据,如 JSON 文件,CSV、TSV 和 XML。类似于 NodeJS,JSON 支持实际上是内置的,也就是说 import Data from './data.json' 默认将正常运行。要导入 CSV、TSV 和 XML,你可以使用 csv-loaderxml-loader。让我们处理加载这三类文件:

npm install --save-dev csv-loader xml-loader

在配置文件加入以下 loader 规则;

  {
    test: /\.(csv|tsv)$/i,
    use: ['csv-loader'],
  },
  {
    test: /\.xml$/i,
    use: ['xml-loader'],
  },

管理输出

多入口

为了处理多个入口文件,需要在配置文件中指定;

const path = require("path");

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

设置 HtmlWebpackPlugin

首先安装插件,该插件的作用是打包后生成对应的 html 文件,并动态引入打包后的 js 文件;

npm install --save-dev html-webpack-plugin

修改配置文件;

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: {
    index: "./src/index.js",
    print: "./src/print.js",
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "管理输出",
    }),
  ],
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
};

清理/dist文件夹

每次进行 webpack 打包时,上一次生成的资源仍在dist,当某些图片或资源在此次打包中没用到,上次打包的时候已经打包到dist目录下了,这就造成了/dist目录文件臃肿、凌乱,要解决这个问题,比较推荐的做法就是每次构建前,先清理dist目录下的文件,再进行生成;

clean-webpack-plugin 是一个流行的清理插件,安装和配置它;

npm install --save-dev clean-webpack-plugin

在配置文件中引入,并实例化;

const { CleanWebpackPlugin } = require("clean-webpack-plugin");

在 plugins 中实例化,再运行打包,即可看到效果;

   plugins: [
    new CleanWebpackPlugin(),
     new HtmlWebpackPlugin({
       title: 'Output Management',
     }),
   ],

开发环境

针对开发环境进行 webpack 配置,首先要在配置文件中加入mode: 'development';

使用 source map

使用 webpack 进行打包时,往往是多个 js 文件打包成一个 bundle.js 文件,打包后的代码不具备可读性,加入了很多的混淆代码,但是在开发过程中,我们有免不了要进行边写边调试,这时候,就需要使用代码映射了,使用代码映射,可以及时定位打包前的代码,然错误代码显示出属于哪个文件、哪一行;

使用source map进行代码映射,配置文件中新增devtool: 'inline-source-map'

使用开发工具

webpack 提供几种可选方式,帮助你在代码发生变化后自动编译代码(以至于不用每次手动输入 npm run build):

使用 watch mode

可以指示 webpack 去“watch”依赖图中所有文件的更改,如果其中一个文件被更新,代码将重新编译;

我们在package.json中添加一个用于启动 webpack watch mode 的 npm scripts:

   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "watch": "webpack --watch",
     "build": "webpack"
   },

如果不想再 watch 触发增量构建后删除index.html文件,可以在CleanWebpackPlugin中配置cleanStaleWebpackAssets: false;

   plugins: [
    new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
     new HtmlWebpackPlugin({
       title: 'Development',
     }),
   ],

这样,在命令行执行npm run watch之后,可以发现,这个命令是一直在运行的,我们修改项目依赖中的文件,并点击保存,可以看到,webpack 在进行自动打包,那我们直接到浏览器中点击刷新,即可看到修改后的内容了;

每次自动打包后,还是要去浏览器进行刷新的,那么我们可以使用webpack-dev-server来实现实时加载,等 webpack 检测到文件变化并打包之后,会自动刷新浏览器;

使用 webpack-dev-server

webpack-dev-server 为你提供了一个简单的 web server,并且具有 live reloading(实时重新加载) 功能。设置如下:

npm install --save-dev webpack-dev-server

在配置文件webpack.config.js中加入以下配置,用作告诉 dev.server 从什么位置查找文件:

  devServer: {
    contentBase: './dist',  // 内容目录
    open: true,  // 设置首次打包自动打开浏览器
  },

我们添加一个可以直接运行 dev server 的 script:

   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "watch": "webpack --watch",
     "start": "webpack serve",
     "build": "webpack"
   },

使用 webpack-dev-middleware

webpack-dev-middleware 是一个封装器(wrapper),它可以把 webpack 处理过的文件发送到一个 server。 webpack-dev-server 在内部使用了它,然而它也可以作为一个单独的 package 来使用,以便根据需求进行更多自定义设置。下面是一个 webpack-dev-middleware 配合 express server 的示例。

首先,安装 expresswebpack-dev-middleware

npm install --save-dev express webpack-dev-middleware

现在,我们需要调整 webpack 配置文件,以确保 middleware(中间件) 功能能够正确启用:

webpack.config.js

   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
   },

我们将会在 server 脚本使用 publicPath,以确保文件资源能够正确地 serve 在 http://localhost:3000 下,稍后我们会指定 port number(端口号)。接下来是设置自定义 express server:

项目根目录下,新建server.js

const express = require("express");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");

const app = express();
const config = require("./webpack.config.js");
const compiler = webpack(config);

// 告知 express 使用 webpack-dev-middleware,
// 以及将 webpack.config.js 配置文件作为基础配置。
app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
  })
);

// 将文件 serve 到 port 3000。
app.listen(3000, function () {
  console.log("Example app listening on port 3000!\n");
});

代码分离

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

常用的代码分离方法有三种:

  • 入口起点:使用 entry 配置手动地分离代码。
  • 防止重复:使用 Entry dependencies 或者 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用来分离代码。

入口起点(entry point)

这是迄今为止最简单直观的分离代码的方式。不过,这种方式手动配置较多,并有一些隐患;

使用这种方式进行代码分离,需要在webpack.config.js配置文件中修改entryoutput;

  entry: {
    index: './src/index.js',
    another: './src/another-module.js',
  },
  // 入口文件中引入要进行分隔的代码
   // 将输出的文件名由单一的文件名改为动态的文件名
   output: {
     // filename: 'main.js',
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },

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

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

防止重复(prevent duplication)

配置 dependOn option 选项,这样可以在多个 chunk 之间共享模块:

  entry: {
    // index: './src/index.js',
    // another: './src/another-module.js',
    index: {
      import: './src/index.js',
      dependOn: 'shared',
    },
    another: {
      import: './src/another-module.js',
      dependOn: 'shared',
    },
    shared: 'lodash',
  },

如果我们要在一个 HTML 页面上使用多个入口时,还需设置 optimization.runtimeChunk: 'single',否则还会遇到这里所述的麻烦。

  optimization: {
    runtimeChunk: 'single',
  },

尽管可以在 webpack 中允许每个页面使用多入口,应尽可能避免使用多入口的入口:entry: { page: ['./analytics', './app'] }。如此,在使用 async 脚本标签时,会有更好的优化以及一致的执行顺序。

splitChunksPlugin

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

   optimization: {
     splitChunks: {
       chunks: 'all',
     },
   },

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

以下是由社区提供,一些对于代码分离很有帮助的 plugin 和 loader:

动态导入(dynamic import)

当涉及到动态代码拆分时,webpack 提供了两个类似的技术。第一种,也是推荐选择的方式是,使用符合 ECMAScript 提案import() 语法 来实现动态导入。第二种,则是 webpack 的遗留功能,使用 webpack 特定的 require.ensure。让我们先尝试使用第一种……

import() 调用会在内部用到 promises。如果在旧版本浏览器中(例如,IE 11)使用 import(),记得使用一个 polyfill 库(例如 es6-promise promise-polyfill),来 shim Promise

在我们开始之前,先从上述示例的配置中移除掉多余的 entryoptimization.splitChunks,因为接下来的演示中并不需要它们:

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',
    },
  }, */
};

我们将更新我们的项目,移除现在未使用的文件:

image-20201201115615740

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

// index.js
function getComponent() {
  const element = document.createElement("div");

  return import("lodash")
    .then(({ default: _ }) => {
      const element = document.createElement("div");

      element.innerHTML = _.join(["Hello", "webpack"], " ");

      return element;
    })
    .catch((error) => "An error occurred while loading the component");
}

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

预获取/预加载模块[prefetch/pre]

在声明 import 时,使用下面的内置指令;

  • prefetch(预获取):将来某些导航下可能需要的资源;
  • preload(预加载):当前导航下可能需要资源;

下面这个 prefetch 的简单示例中,有一个 HomePage 组件,其内部渲染一个 LoginButton 组件,然后在点击后按需加载 LoginModal 组件。

import(/* webpackPrefetch: true */ "./path/to/LoginModal.js");

这会生成 <link rel="prefetch" href="login-modal-chunk.js"> 并追加到页面头部,指示着浏览器在闲置时间预取 login-modal-chunk.js 文件。

只要父 chunk 完成加载,webpack 就会添加 prefetch hint(预取提示)。

与 prefetch 指令相比,preload 指令有许多不同之处:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。
  • preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。
  • 浏览器支持程度不同。

缓存

如果我们在部署新版本时不更改资源的文件名,浏览器可能会认为它没有被更新,就会使用它的缓存版本

此指南的重点在于通过必要的配置,以确保 webpack 编译生成的文件能够被客户端缓存,而在文件内容变化后,能够请求到新的文件。

输出文件的文件名(output filename)

我们可以通过替换 output.filename 中的 substitutions 设置,来定义输出文件的名称。webpack 提供了一种使用称为 substitution(可替换模板字符串) 的方式,通过带括号字符串来模板化文件名。其中,[contenthash] substitution 将根据资源内容创建出唯一 hash。当资源内容发生变化时,[contenthash] 也会发生变化。

    output: {
        filename: '[name].[contenthash].js',
        path: path.resolve(__dirname, 'dist'),
    },

提取引导模板(extracting boilerplate)

将第三方库(library)(例如 lodashreact)提取到单独的 vendor chunk 文件中,是比较推荐的做法,这是因为,它们很少像本地的源代码那样频繁修改。因此通过实现以上步骤,利用 client 的长效缓存机制,命中缓存来消除请求,并减少向 server 获取资源,同时还能保证 client 代码和 server 代码版本一致。 这可以通过使用 SplitChunksPlugin 示例 2 中演示的 SplitChunksPlugin 插件的 cacheGroups 选项来实现。我们在 optimization.splitChunks 添加如下 cacheGroups 参数并构建:

   optimization: {
      runtimeChunk: 'single',
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      },
    },

模块标识符(module identifier)

index.js中添加一个依赖,重新打包,我们可以看到这三个文件的 hash 都变化了。这是因为每个 module.id 会默认地基于解析顺序(resolve order)进行增量。也就是说,当解析顺序发生变化,ID 也会随之改变。因此,简要概括:

  • main bundle 会随着自身的新增内容的修改,而发生变化。
  • vendor bundle 会随着自身的 module.id 的变化,而发生变化。
  • manifest runtime 会因为现在包含一个新模块的引用,而发生变化。

第一个和最后一个都是符合预期的行为,vendor hash 发生变化是我们要修复的。我们将 optimization.moduleIds 设置为 'deterministic'

   optimization: {
      moduleIds: 'deterministic',
      runtimeChunk: 'single',
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      },
    },

这样,改变依赖后,再重新打包,可以看到vender模块(node modules 模块)的 hash 保持一致;

创建 library

基本配置

现在,让我们以某种方式打包这个 library,能够实现以下几个目标:

  • 使用 externals 选项,避免将 lodash 打包到应用程序,而使用者会去加载它。
  • 将 library 的名称设置为 webpack-numbers
  • 将 library 暴露为一个名为 webpackNumbers 的变量。
  • 能够访问其他 Node.js 中的 library。

此外,consumer(使用者) 应该能够通过以下方式访问 library:

  • ES2015 模块。例如 import webpackNumbers from 'webpack-numbers'
  • CommonJS 模块。例如 require('webpack-numbers').
  • 全局变量,在通过 script 标签引入时。

外部化 lodash

如果执行 webpack,你会发现创建了一个体积相当大的文件。如果你查看这个文件,会看到 lodash 也被打包到代码中。在这种场景中,我们更倾向于把 lodash 当作 peerDependency。也就是说,consumer(使用者) 应该已经安装过 lodash 。因此,你就可以放弃控制此外部 library ,而是将控制权让给使用 library 的 consumer。

这可以使用 externals 配置来完成:

webpack.config.js

const path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "webpack-numbers.js",
  },
  externals: {
    lodash: {
      commonjs: "lodash",
      commonjs2: "lodash",
      amd: "lodash",
      root: "_",
    },
  },
};

外部化的限制

对于想要实现从一个依赖中调用多个文件的那些 library,无法通过在 externals 中指定整个 library 的方式,将它们从 bundle 中排除。而是需要逐个或者使用一个正则表达式,来排除它们。

module.exports = {
  //...
  externals: [
    "library/one",
    "library/two",
    // 匹配以 "library/" 开始的所有依赖
    /^library\/.+$/,
  ],
};

暴露 library

对于用法广泛的 library,我们希望它能够兼容不同的环境,例如 CommonJS,AMD,Node.js 或者作为一个全局变量。为了让你的 library 能够在各种使用环境中可用,需要在 output 中添加 library 属性:

const path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "webpack-numbers.js",
    library: "webpackNumbers",
  },
  externals: {
    lodash: {
      commonjs: "lodash",
      commonjs2: "lodash",
      amd: "lodash",
      root: "_",
    },
  },
};

这会将你的 library bundle 暴露为名为 webpackNumbers 的全局变量,consumer 通过此名称来 import。为了让 library 和其他环境兼容,则需要在配置中添加 libraryTarget 属性。这个选项可以控制以多种形式暴露 library。

webpack.config.js

const path = require("path");

module.exports = {
  entry: "./src/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "webpack-numbers.js",
    library: "webpackNumbers",
    libraryTarget: "umd",
  },
  externals: {
    lodash: {
      commonjs: "lodash",
      commonjs2: "lodash",
      amd: "lodash",
      root: "_",
    },
  },
};

有以下几种方式暴露 library:

  • 变量:作为一个全局变量,通过 script 标签来访问(libraryTarget:'var')。
  • this:通过 this 对象访问(libraryTarget:'this')。
  • window:在浏览器中通过 window 对象访问(libraryTarget:'window')。
  • UMD:在 AMD 或 CommonJS require 之后可访问(libraryTarget:'umd')。

环境变量

想要消除 webpack.config.js开发环境生产环境 之间的差异,你可能需要环境变量(environment variable)。

webpack 命令行 环境配置--env 参数,可以允许你传入任意数量的环境变量。而在 webpack.config.js 中可以访问到这些环境变量。例如,--env production--env NODE_ENV=localNODE_ENV 通常约定用于定义环境类型,查看 这里)。

如果设置 env 变量,却没有赋值,--env production 默认表示将 env.production 设置为 true。还有许多其他可以使用的语法。更多详细信息,请查看 webpack CLI 文档。

对于我们的 webpack 配置,有一个必须要修改之处。通常,module.exports 指向配置对象。要使用 env 变量,你必须将 module.exports 转换成一个函数:

webpack.config.js

const path = require("path");

module.exports = (env) => {
  // Use env.<YOUR VARIABLE> here:
  console.log("NODE_ENV: ", env.NODE_ENV); // 'local'
  console.log("Production: ", env.production); // true

  return {
    entry: "./src/index.js",
    output: {
      filename: "bundle.js",
      path: path.resolve(__dirname, "dist"),
    },
  };
};

构建性能

通用环境

无论你是在 开发环境 还是在 生产环境 下运行构建脚本,以下最佳实践都会有所帮助。

更新到最新版本

使用最新的 webpack 版本。我们会经常进行性能优化。webpack 的最新稳定版本是:

latest webpack version

Node.js 更新到最新版本,也有助于提高性能。除此之外,将你的 package 管理工具(例如 npm 或者 yarn)更新到最新版本,也有助于提高性能。较新的版本能够建立更高效的模块树以及提高解析速度。

loader

通过使用 include 字段,仅将 loader 应用在实际需要将其转换的模块:

const path = require("path");

module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        include: path.resolve(__dirname, "src"),
        loader: "babel-loader",
      },
    ],
  },
};

引导(bootstrap)

每个额外的 loader/plugin 都有其启动时间。尽量少地使用工具。

解析

以下步骤可以提高解析速度:

  • 减少 resolve.modules, resolve.extensions, resolve.mainFiles, resolve.descriptionFiles 中条目数量,因为他们会增加文件系统调用的次数。
  • 如果你不使用 symlinks(例如 npm link 或者 yarn link),可以设置 resolve.symlinks: false
  • 如果你使用自定义 resolve plugin 规则,并且没有指定 context 上下文,可以设置 resolve.cacheWithContext: false

DLL

使用 DllPlugin 为更改不频繁的代码生成单独的编译结果。这可以提高应用程序的编译速度,尽管它增加了构建过程的复杂度;

减少编译结果的整体大小,以提高构建性能,尽量保持 chunk 体积小;

  • 使用数量更少/体积更小的 library;
  • 在多页面应用程序中使用splitChunksPlugin
  • 在多页面应用程序中使用splitChunksPlugin,并开启async模式;
  • 移除未引用代码;
  • 只编译你当前正在开发的代码;

worker 池(worker pool)

thread-loader可以将非常消耗资源的 loader 分流给一个 worker pool;

不要使用太多的 worker,因为 Node.js 的 runtime 和 loader 都有启动开销。最小化 worker 和 main process(主进程) 之间的模块传输。进程间通讯(IPC, inter process communication)是非常消耗资源的。

持久化缓存

在 webpack 配置中使用cache选项,使用package.json中的"postinstall"清除缓存目录;

我们支持 yarn PnP v3 yarn 2 berry,来进行持久缓存。

自定义 plugin/loader

对它们进行概要分析,以免在此处引入性能问题;

Progress plugin

Progress plugin从 webpack 中删除,可以缩短构建时间,请注意,Progress plugin可能不会为快速构建提供太多价值,因此,请权衡利弊再使用;

开发环境

以下步骤对于开发环境特别有帮助

增量编译

使用 webpack 的 watch mode(监听模式);而不使用其他工具来 watch 文件和调用 webpack;内置的 watch mode 会记录时间戳并将此信息传递给 compilation 以使缓存失效;

在内存中编译

下面几个工具通过在内存中(而不是写入磁盘)编译和 serve 资源来提高性能:

  • webpack-dev-server
  • webpack-hot-middleware
  • webpack-dev-middleware

stats.toJson 加速

webpack4 默认使用stats.toJson()输出大量数据;除非在增量不走中做必要的统计,否则请避免获取stats对象的部分内容;webpack-dev-server在 v3.1.3 以后的版本,包含一个重要的性能修复,即最小化每个增量构建步骤中,从stats对象获取的数据量;

Devtool

需要注意的是不同的devtool设置,会导致性能差异;

  • "eval"具有最好的性能,但并不能帮助你转译代码;
  • 如果你能接受稍差一些的 map 质量,可以使用cheap-source-map变体配置来提高性能;
  • 使用eval-source-map变体配置进行增量编译;

在大多数情况下,最佳选择是 eval-cheap-module-source-map

避免在生产环境下才会用到的工具

某些 utility,plugin 和 loader 都只用在生产环境,例如,在开发环境下使用TerserPlugin来 minify(压缩)和 mangle(混淆破坏)代码是没有意义的,通常在开发环境下,应该排除以下这些工具:

  • TerserPlugin
  • [fullhash]/[chunkhash]/[contenthash]
  • AggressiveSplittingPlugin
  • ModuleConcatenationPlugin

最小化 entry chunk

webpack 只会在文件系统中输出已经更新的 chunk,某些配置选项(HMR、output.chunkFilename[name]/[chunkhash]/[contenthash][fullhash])来说,除了对已经更新的 chunk 无效之外,对于 entry chunk 也不会生效;

确保在生成 entry chunk 时,尽量减少其体积以提高性能,下面的配置为运行时代码创建了一个额外的 chunk,所以它的生成代价较低:

module.exports = {
  // ...
  optimization: {
    runtimeChunk: true,
  },
};

避免额外的优化步骤

webpack 通过执行额外的算法任务,来优化输出结果的体积和加载性能,这些优化适用于小型代码库,但在大型代码库中却非常耗费性能:

module.exports = {
  // ...
  optimization: {
    removeAvailableModules: false,
    removeEmptyChunks: false,
    splitChunks: false,
  },
};

输出结果不携带路径信息

webpack 会在输出的 bundle 中生成路径信息,然而,在打包数千个模块的项目中,这会导致造成垃圾回收性能压力,在options.output.pathinfo设置中关闭:

module.exports = {
  // ...
  output: {
    pathinfo: false,
  },
};

Node.js 版本 8.9.10-9.11.1

Node.js v8.9.10 - v9.11.1 中的 ES2015 MapSet 实现,存在 性能回退。webpack 大量地使用这些数据结构,因此这次回退也会影响编译时间;之前和之后的 Node.js 版本不受影响;

TypeScript loader

你可以为 loader 传入transpileOnly选项,以缩短使用ts-loader时的构建时间;使用此选项,会关闭类型检查,如果要再次开启类型检查,请使用ForkTsCheckerWebpackOlugin;使用此插件会将检查过程移至单独的进程,可以加快 TypeScript 的类型检查和 ESLint 插入的速度;

module.exports = {
  // ...
  test: /\.tsx?$/,
  use: [
    {
      loader: "ts-loader",
      options: {
        transpileOnly: true,
      },
    },
  ],
};

这是一个关于 ts-loader 完整示例的 Github 仓库。

生产环境

以下步骤对于生产环境特别有帮助;

***要为了很小的性能收益,牺牲应用程序的质量!****注意,在大多数情况下,优化代码质量比构建性能更重要*;

多个 compilation 对象

在创建多个 compilation 时,以下工具可以帮助到你:

  • parallel-webpack:它允许在一个 worker 池中运行 compilation;
  • cache-loader:可以在多个 compilation 之间共享缓存;

Source Maps

source map 相当消耗资源,你真的需要它们?

工具相关问题

下列工具存在某些可能会降低构建性能的问题:

Babel

  • 最小化项目中的 preset/plugin 数量;

TypeScript

  • 在单独的进程中使用fork-ts-checker-webpack-plugin进行类型检查;
  • 配置 loader 跳过类型检查;
  • 使用ts-loader时,设置happyPackMode: true/transpileOnly: true

Sass

  • node-sass中有个来自 Node.js 线程池的阻塞线程的 bug,当使用thread-loader时,需要设置workerParallelJobs: 2;

模块热替换

模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块, 而无需完全刷新;

HMR 不适用于生产环境,这意味着它应当用于开发环境。更多详细信息, 请查看 生产环境 指南。

启动 HMR

此功能可以很大程度提高生产效率。我们要做的就是更新 webpack-dev-server 配置, 然后使用 webpack 内置的 HMR 插件。

    devServer: {
      contentBase: './dist',
      hot: true,
    },

Tree Shaking

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

将文件标记为 side-effect-free(无副作用)

通过 package.json 的 "sideEffects" 属性,来实现这种方式;

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

如果所有代码都不包含 side effect,我们就可以简单地将该属性标记为 false,来告知 webpack,它可以安全地删除未用到的 export。

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

如果你的代码确实有一些副作用,可以改为提供一个数组:

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

注意,所有导入文件都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader 并 import 一个 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除:

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

解释 tree shaking 和sideEffects

sideEffectsusedExports(更多被认为是 tree shaking)是两种不同的优化方式。

sideEffects 更为有效 是因为它允许跳过整个模块/文件和整个文件子树。

usedExports 依赖于 terser 去检测语句中的副作用。它是一个 JavaScript 任务而且没有像 sideEffects 一样简单直接。而且它不能跳转子树/依赖由于细则中说副作用需要被评估。尽管导出函数能运作如常,但 React 框架的高阶函数(HOC)在这种情况下是会出问题的。

压缩输出结果

通过 importexport 语法,我们已经找出需要删除的“未引用代码(dead code)”,然而,不仅仅是要找出,还要在 bundle 中删除它们。为此,我们需要将 mode 配置选项设置为 production

结论

因此,我们学到为了利用 tree shaking 的优势, 你必须...

  • 使用 ES2015 模块语法(即 importexport)。
  • 确保没有编译器将您的 ES2015 模块语法转换为 CommonJS 的(顺带一提,这是现在常用的 @babel/preset-env 的默认行为,详细信息请参阅文档)。
  • 在项目的 package.json 文件中,添加 "sideEffects" 属性。
  • 使用 mode"production" 的配置项以启用更多优化项,包括压缩代码与 tree shaking。

你可以将应用程序想象成一棵树。绿色表示实际用到的 source code(源码) 和 library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

如果你对优化输出很感兴趣,请进入到下个指南,来了解 生产环境 构建的详细细节。

生产环境

在本指南中,我们将深入一些最佳实践和工具,将站点或应用程序构建到生产环境中。

配置

development(开发环境)production(生产环境) 这两个环境下的构建目标存在着巨大差异。在开发环境中,我们需要:强大的 source map 和一个有着 live reloading(实时重新加载) 或 hot module replacement(热模块替换) 能力的 localhost server。而生产环境目标则转移至其他方面,关注点在于压缩 bundle、更轻量的 source map、资源优化等,通过这些优化方式改善加载时间。由于要遵循逻辑分离,我们通常建议为每个环境编写彼此独立的 webpack 配置

虽然,以上我们将生产环境开发环境做了略微区分,但是,请注意,我们还是会遵循不重复原则(Don't repeat yourself - DRY),保留一个 "common(通用)" 配置。为了将这些配置合并在一起,我们将使用一个名为 webpack-merge 的工具。此工具会引用 "common" 配置,因此我们不必再在环境特定(environment-specific)的配置中编写重复代码。

我们先从安装 webpack-merge 开始,并将之前指南中已经成型的那些代码进行分离:

npm install --save-dev webpack-merge

image-20201202151445036

webpack.common.js

const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: {
    app: "./src/index.js",
  },
  plugins: [
    // 对于 CleanWebpackPlugin 的 v2 versions 以下版本,使用 new CleanWebpackPlugin(['dist/*'])
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: "Production",
    }),
  ],
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
};

webpack.dev.js

const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");

module.exports = merge(common, {
  mode: "development",
  devtool: "inline-source-map",
  devServer: {
    contentBase: "./dist",
  },
});

webpack.prod.js

const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");

module.exports = merge(common, {
  mode: "production",
});

现在,在 webpack.common.js 中,我们设置了 entryoutput 配置,并且在其中引入这两个环境公用的全部插件。在 webpack.dev.js 中,我们将 mode 设置为 development,并且为此环境添加了推荐的 devtool(强大的 source map)和简单的 devServer 配置。最后,在 webpack.prod.js 中,我们将 mode 设置为 production,其中会引入之前在 tree shaking 指南中介绍过的 TerserPlugin

注意,在环境特定的配置中使用 merge() 功能,可以很方便地引用 webpack.dev.jswebpack.prod.js 中公用的 common 配置。webpack-merge 工具提供了各种 merge(合并) 高级功能,但是在我们的用例中,无需用到这些功能。

NPM Script

现在,我们把 scripts 重新指向到新配置。让 npm start script 中 webpack-dev-server, 使用 webpack.dev.js, 而让 npm run build script 使用 webpack.prod.js:

"start": "webpack serve --open --config webpack.dev.js",
"build": "webpack --config webpack.prod.js"

指定 mode

许多 library 通过与 process.env.NODE_ENV 环境变量关联,以决定 library 中应该引用哪些内容。例如,当process.env.NODE_ENV 没有被设置为 'production' 时,某些 library 为了使调试变得容易,可能会添加额外的 log(日志记录) 和 test(测试) 功能。并且,在使用 process.env.NODE_ENV === 'production' 时,一些 library 可能针对具体用户的环境,删除或添加一些重要代码,以进行代码执行方面的优化。从 webpack v4 开始, 指定 mode 会自动地配置 DefinePlugin

webpack.prod.js

const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");

module.exports = merge(common, {
  mode: "production",
});

压缩(Minification)

webpack v4+ will minify your code by default in production mode.

注意,虽然生产环境下默认使用 TerserPlugin ,并且也是代码压缩方面比较好的选择,但是还有一些其他可选择项。以下有几个同样很受欢迎的插件:

如果决定尝试一些其他压缩插件,只要确保新插件也会按照 tree shake 指南中所陈述的具有删除未引用代码(dead code)的能力,并将它作为 optimization.minimizer

源码映射(Source Mapping)

我们鼓励你在生产环境中启用 source map,因为它们对 debug(调试源码) 和运行 benchmark tests(基准测试) 很有帮助。虽然有着如此强大的功能,然而还是应该针对生产环境用途,选择一个可以快速构建的推荐配置(更多选项请查看 devtool)。对于本指南,我们将在生产环境中使用 source-map 选项,而不是我们在开发环境中用到的 inline-source-map

webpack.prod.js

const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");

module.exports = merge(common, {
  mode: "production",
  devtool: "source-map",
});

避免在生产中使用 inline-*** eval-***,因为它们会增加 bundle 体积大小,并降低整体性能。

压缩 CSS

将生产环境下的 CSS 进行压缩会非常重要,请查看 在生产环境下压缩 章节。

为了压缩输出文件,请使用类似于 css-minimizer-webpack-plugin 这样的插件。

webpack.config.js

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

module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css",
      chunkFilename: "[id].css",
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"],
      },
    ],
  },
  optimization: {
    minimizer: [
      // For webpack@5 you can use the `...` syntax to extend existing minimizers (i.e. `terser-webpack-plugin`), uncomment the next line
      // `...`
      new CssMinimizerPlugin(),
    ],
  },
};

这将只在生产模式下启用 CSS 压缩优化。如果你需要在开发模式下使用,请设置 optimization.minimize 选项为 true。

CLI 替代选项

以上所述也可以通过命令行实现。例如,--optimize-minimize 标记将在幕后引用 TerserPlugin。和以上描述的 DefinePlugin 实例相同,--define process.env.NODE_ENV="'production'" 也会做同样的事情。而且,webpack -p 将自动地配置上述这两个标记,从而调用需要引入的插件。

虽然这种简写方式很好,但通常我们建议只使用配置方式,因为在这两种方式中,配置方式能够更准确地理解现在正在做的事情。配置方式还为可以让你更加细微地控制这两个插件中的其他选项。

懒加载

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

Shimming 预置依赖

webpack compiler 能够识别遵循 ES2015 模块语法、CommonJS 或 AMD 规范编写的模块。然而,一些 third party(第三方库) 可能会引用一些全局依赖(例如 jQuery 中的 $)。因此这些 library 也可能会创建一些需要导出的全局变量。这些 "broken modules(不符合规范的模块)" 就是 shimming(预置依赖) 发挥作用的地方。

Shimming 预置全局变量

让我们开始第一个 shimming 全局变量的用例;

还记得我们之前用过的 lodash 吗?出于演示目的,例如把这个应用程序中的模块依赖,改为一个全局变量依赖。要实现这些,我们需要使用 ProvidePlugin 插件。

使用 ProvidePlugin 后,能够在 webpack 编译的每个模块中,通过访问一个变量来获取一个 package。如果 webpack 看到模块中用到这个变量,它将在最终 bundle 中引入给定的 package。让我们先移除 lodashimport 语句,改为通过插件提供它:

webpack.config.js

const path = require("path");
const webpack = require("webpack");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
  },
  plugins: [
    new webpack.ProvidePlugin({
      _: "lodash",
    }),
  ],
};

我们本质上所做的,就是告诉 webpack……

如果你遇到了至少一处用到 _ 变量的模块实例,那请你将 lodash package 引入进来,并将其提供给需要用到它的模块。

还可以使用 ProvidePlugin 暴露出某个模块中单个导出,通过配置一个“数组路径”(例如 [module, child, ...children?])实现此功能。所以,我们假想如下,无论 join 方法在何处调用,我们都只会获取到 lodash 中提供的 join 方法。

webpack.config.js

 const path = require('path');
 const webpack = require('webpack');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'main.js',
     path: path.resolve(__dirname, 'dist'),
   },
   plugins: [
     new webpack.ProvidePlugin({
      _: 'lodash',
      join: ['lodash', 'join'],
     }),
   ],
 };

这样就能很好的与 tree shaking 配合,将 lodash library 中的其余没有用到的导出去除。

细粒度 Shimming

一些遗留模块依赖的 this 指向的是 window 对象。在接下来的用例中,调整我们的 index.js

function component() {
  const element = document.createElement("div");

  element.innerHTML = join(["Hello", "webpack"], " ");

  // 假设我们处于 `window` 上下文
  this.alert("Hmmm, this probably isn't a great idea...");

  return element;
}

document.body.appendChild(component());

当模块运行在 CommonJS 上下文中,这将会变成一个问题,也就是说此时的 this 指向的是 module.exports。在这种情况下,你可以通过使用 imports-loader 覆盖 this 指向:

webpack.config.js

const path = require("path");
const webpack = require("webpack");

module.exports = {
  entry: "./src/index.js",
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: require.resolve("./src/index.js"),
        use: "imports-loader?wrapper=window",
      },
    ],
  },
  plugins: [
    new webpack.ProvidePlugin({
      join: ["lodash", "join"],
    }),
  ],
};

全局 Exports

让我们假设,某个 library 创建出一个全局变量,它期望 consumer(使用者) 使用这个变量。为此,我们可以在项目配置中,添加一个小模块来演示说明:

image-20201202173410358

src/globals.js

const file = "blah.txt";
const helpers = {
  test: function () {
    console.log("test something");
  },
  parse: function () {
    console.log("parse something");
  },
};

你可能从来没有在自己的源码中做过这些事情,但是你也许遇到过一个老旧的 library,和上面所展示的代码类似。在这种情况下,我们可以使用 exports-loader,将一个全局变量作为一个普通的模块来导出。例如,为了将 file 导出为 file 以及将 helpers.parse 导出为 parse,做如下调整:

webpack.config.js

 const path = require('path');
 const webpack = require('webpack');

 module.exports = {
   entry: './src/index.js',
   output: {
     filename: 'main.js',
     path: path.resolve(__dirname, 'dist'),
   },
   module: {
     rules: [
       {
         test: require.resolve('./src/index.js'),
         use: 'imports-loader?wrapper=window',
       },
+      {
+        test: require.resolve('./src/globals.js'),
+        use:
+          'exports-loader?type=commonjs&exports[]=file&exports[]=multiple|helpers.parse|parse',
+      },
     ],
   },
   plugins: [
     new webpack.ProvidePlugin({
       join: ['lodash', 'join'],
     }),
   ],
 };

此时,在我们的 entry 入口文件中(即 src/index.js),可以使用 const { file, parse } = require('./globals.js');,可以保证一切将顺利运行。

加载 Polyfills

目前为止我们所讨论的所有内容都是处理那些遗留的 package,让我们进入到第二个话题:polyfill

有很多方法来加载 polyfill。例如,想要引入 babel-polyfill 我们只需如下操作:

npm install --save babel-polyfill

然后,使用 import 将其引入到我们的主 bundle 文件:

src/index.js

+import 'babel-polyfill';
+
 function component() {
   const element = document.createElement('div');

   element.innerHTML = join(['Hello', 'webpack'], ' ');

   // Assume we are in the context of `window`
   this.alert("Hmmm, this probably isn't a great idea...");

   return element;
 }

 document.body.appendChild(component());

注意,我们没有将 import 绑定到某个变量。这是因为 polyfill 直接基于自身执行,并且是在基础代码执行之前,这样通过这些预置,我们就可以假定已经具有某些原生功能。

webpack 插件应用

webpack 打包分析

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

chainwebpack: (config) => {
  config.plugin("bundleAnalysis").use(BundleAnalyzerPlugin);
};