Webpack 知识点小结

5,232 阅读13分钟

本文首发于公众号【龙猫研习社】mp.weixin.qq.com/s/Nx04pM5gA…

最近给团队系统性地培训了 Webpack,本文简单地罗列出相关知识点,以便大家随时翻阅巩固。

工具对比

很多人对 Webpack、Babel、gulp(grunt)、rollup、parcel 等工具总是傻傻分不清楚,这里简单陈述下自己的看法:

  • Webpack:打包工具,将各式各样的资源转换并打包成能够被浏览器识别的静态资源;
  • Babel:代码转译工具,即将最新的 JavaScript 转译为能够被广大浏览器兼容的低级 JavaScript 语法;
  • gulp(grunt):基于流水线(一个任务一个任务的执行)的工具,侧重于构建过程的管理,适用于小型项目;
  • rollup:功能与 Webpack 类似,侧重于 library 的构建,适用于类库项目;
  • parcel:功能与 Webpack 类似,强调零配置,适用于需要快速启动或没有太多自定义构建逻辑的项目。

Loader

Webpack 仅能解析 JavaScript 模块,如果要处理 CSS、图片等其它资源,就需要使用 Loader 先将其转换成 JavaScript 代码,然后将转换后的 JavaScript 代码交给 Webpack 进行解析,Loader 的配置如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

上例中,如果业务代码引用了文件后缀为 css 的资源,Webpack 会在 node_modules 目录下加载名为 style-loadercss-loaderjs 文件或 package 中的 main 文件。当然,也可通过以下方式来指定相关 Loader 的路径:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [path.resolve('path/to/style-loader.js'), path.resolve('path/to/css-loader.js')],
      },
    ],
  },
};

module.exports = {
  resolveLoader: {
    alias: {
      'style-loader': path.resolve('path/to/style-loader.js'),
      'css-loader': path.resolve('path/to/css-loader.js'),
    },
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

module.exports = {
  resolveLoader: {
    modules: [
      'node_modules',
      path.resolve(__dirname, 'loaders'),
    ]
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

Loader 的使用非常简单,不过需要注意以下几点:

  • Loader 的调用顺序采用后进先出的方式,即最先声明的 Loader 会被最后调用,最后声明的会被第一个调用(比如上例的调用顺序为: css-loaderstyle-loader);
  • 最先被调用的 Loader(比如上例中的 css-loader),它会得到原始的资源内容,其它位置的 Loader 将得到上一个 Loader 处理后的结果;
  • 最后被调用的 Loader(比如上例中的 style-loader),它应该输出 JavaScript 代码,其它位置的 Loader 往往通过 callback 的形式将其处理后的结果传递给下一个 Loader。

其中后两点是实现自己的 Loader 时需要特别注意的,比如下面的例子:

// css-loader.js
module.exports = function (source) {
  const callback = this.async();
  processSource(source, (err, result) => {
    if (err) {
      callback(err);
    } else {
      callback(null, result);
    }
  })
};

// style-loader.js
module.exports = function (source) {
  return `export default ${JSON.stringify(source)}`;
};

上例中:

  • 因为 css-loader 是第一个被调用的,所以它的参数 source 是最原始的资源内容,也由于它不是最后一个执行的,故它通过 callback 的形式将处理后的结果传递给下一个 Loader;
  • 因为 style-loader 是最后一个被调用的,所以它的参数 source 是上一个 Loader 的处理结果(即 css-loader 的处理结果),并且它的返回值是纯粹的 JavaScript 代码。

在实现自己的 Loader 时,如需使用外部资源(比如读取外部文件),此时需要对其进行声明,以便 Webpack 不对其进行缓存,并避免在 watch 模式下造成无法重新编译的问题,比如下面的例子:

module.exports = function (source) {
  const { filePath } = this.getOptions();
  this.addDependency(filePath);
  // 读取文件并处理 source
};

上例中,对资源的处理依赖于指定的文件,所以在代码中加入了 this.addDependency(filePath) 对该依赖进行声明,以避免上文陈述的问题。

Plugin

如果说 Loader 解决了 Webpack 只能处理 JavaScript 模块的问题,那么 Plugin 为 Webpack 进行高效、个性化打包提供了可能(比如公共依赖提取、控制模块的输出模式等)。比如下面的例子:

// ./plugins/remove-comment-plugin.js
const webpack = require('webpack');
const decomment = require('decomment');

module.exports = class {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap('RemoveCommentPlugin', (compilation) => {
      compilation.hooks.processAssets.tapAsync({
        name: 'RemoveCommentPlugin',
        stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
        additionalAssets: true,
      }, (_, callback) => {
        const bundleName = compilation.getAssetPath(compilation.outputOptions.filename, {
          hash: compilation.hash,
        });
        compilation.updateAsset(bundleName, source => {
          return new webpack.sources.RawSource(
            decomment(source.source())
          );
        });
        callback();
      });
    });
  }
}

// webpack.config.js
const RemoveCommentPlugin = require('./plugins/remove-comment-plugin');

module.exports = {
  plugins: [
    new RemoveCommentPlugin(),
  ],
};

上例主要实现了一个删除生成的 bundle 中代码注释的 Plugin,通过代码可知,Webpack 的 Plugin 主要是对 CompilerCompilation 相关钩子的运用,相关内容此处不做过多声明,只是需要注意以下几点:

  • Plugin 的调用顺序与 Loader 相反,即最先声明的最先调用,最后声明的最后调用;
  • Compiler 包含了 Webpack 的所有配置项和 Plugin 相关的调用函数,开发者可随意获得想要的配置,并根据相应的配置实现自己的 Plugin;
  • Compilation 包含了 modules、chunks、cache、assets 等动态资源集合,开发可在合适的时机对其进行修改以满足自己的业务需求;
  • Compiler 在 Webpack 的整个执行环境中是唯一且不变的,而 Compilation 在不同的编译阶段,其产生的资源集合是不同的。

Runtime

查看 Webpack 打包后的代码,会发现 Webpack 插入了 __webpack_modules____webpack_module_cache____webpack_require__ 等一些支持业务代码能够正确、高效运行的代码,我们将这些由 Webpack 动态插入的代码称之为运行时,该知识点的详细分析,请参见笔者的另一篇文章 Webpack Runtime 小析,此处不再阐述。

DevServer & HMR

现代化的前端开发体验中,代码变更后浏览器在维持当前页面状态的同时自动完成代码的更新,这早已成为众多开发工具链中的标配,庆幸的是 Webpack 为提供了相应的机制 DevServer & HMR,该知识点的详细分析,请参见笔者的另一篇文章 Webpack DevServer & HMR 小析,此处不再阐述。

SourceMap

出于用户体验的考虑,开发者往往会通过代码压缩合并等方式对代码进行优化,这应该没什么问题,但随着前端项目代码规模的不断增加,这种经过处理的代码难以调试,出现问题时开发者难以快速定位到具体的源代码,为解决这些问题,各浏览器相继支持 SourceMap,关于 SourceMap 的详细介绍可参见阮一峰老师的 JavaScript Source Map 详解,此处不做过多阐述,本文仅介绍在 Webpack 中的使用:

module.exports = {
  devtool: 'source-map',
};

如上所示,在配置文件中,只需设置 devtool 的值即可开启 SourceMap 的功能,该属性的常用可选值及差异对比如下表所示:

devtool初次构建速度再次构建速度是否适合生产环境代码质量
(none)fastestfastest
evalfastfastest转换后的代码
eval-cheap-source-mapokfast转换后的代码(只包含行信息)
eval-cheap-module-source-mapslowfast源代码(只包含行信息)
eval-source-mapslowestok源代码
cheap-source-mapokslow转换后代码(只包含行信息)
cheap-module-source-mapslowslow源代码(只包含行信息)
inline-cheap-source-mapokslow转换后的代码(只包含行信息)
inline-cheap-module-source-mapslowslow源代码(只包含行信息)
source-mapslowestslowest源代码
inline-source-mapslowestslowest源代码
hidden-source-mapslowestslowest源代码
nosources-source-mapslowestslowest源代码(只包含行、列信息)

结合上述表格,在实际运用中,可以根据打包速度、打包地文件大小、调试友好性、保密性等需求来灵活选择 devtool 的值。

优化

Webpack 自身会根据 mode 配置项的值做一些优化(比如 Scope Hoisting、代码分隔、Tree Shaking 等),除此之外,开发者亦可通过一些措施进行更进一步优化,本节我们就对其进行简短介绍。

Scope Hoisting

Scope Hoisting 也叫作用域提升,它的作用是将某模块的作用域提升到引用该模块的模块的顶部,以减少生成的代码体积,提高代码的运行效率。该特性是默认开启的,具体配置项如下:

module.exports = {
  optimization: {
    concatenateModules: true, // 默认值为 true
  },
};

为了更直观感受开启该特性前后的差异,我们看下面的例子:

// src/utils.js
export const NAME = "tom";

// src/index.js
import { NAME } from './utils';
console.log(NAME);

未开启该特性后的打包结果:

/***/ "./src/utils.js":
/*!**********************!*\
  !*** ./src/utils.js ***!
  \**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "NAME": () => (/* binding */ NAME)
/* harmony export */ });
const NAME = "tom";
/***/ })

(() => {
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils */ "./src/utils.js");
console.log(_utils__WEBPACK_IMPORTED_MODULE_0__.NAME)
})();

开启该特性后的打包结果:

/*!**********************************!*\
  !*** ./src/index.js + 1 modules ***!
  \**********************************/
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);

;// CONCATENATED MODULE: ./src/utils.js
const NAME = "tom";

;// CONCATENATED MODULE: ./src/index.js

console.log(NAME)
/******/ })()
;

通过对比可知,开启该特性后,模块 src/utils.js 中的 NAME 被提取到了模块 src/index.js 的顶部,并且删除了一些不必要的方法调用。不过要注意的是所引用的模块必须符合 ESModule 规范,为了使该特性最大化地发挥价值,可加上以下配置项:

module.exports = {
  resolve: {
    mainFields: ['module', 'main'],
  },
};

该配置项的作用是从 npm 包中导入模块时,此选项将决定在 package.json 中使用哪个字段导入模块,具体详情参见 resolve.mainFields

Tree Shaking

Tree Shaking 主要用于清除符合 ESModule 规范、在程序上下文环境中未被引用的模块导出代码,以减少生成的代码体积,该知识点的详细分析,请参见笔者的另一篇文章 Webpack Tree Shaking 使用小结,此处不再阐述。

Code Splitting

Code Splitting 也叫代码分隔,它的目的是将资源模块按照指定的规则打包到不同的 bundle 中,以便解决单一 bundle 过大而导致页面响应缓慢的问题。该特性常用于多页面应用,使用规则如下:

  • 一个页面对应一个 bundle(即一个页面指定一个不同的入口文件);
  • 公共逻辑抽取到单独的 bundle 中。

相关配置如下所示:

module.exports = {
  entry: {
    index: './src/index.js',
    about: './src/about.js'
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0
    }
  }
};

上面的配置中,通过指定多个 entry 的方式将不同页面的代码分别打包到不同的 bundle 中去,并通过 optimization.splitChunks 配置用来提取公共逻辑,该配置的详情参见 optimization.splitChunks,本文只对 chunks 的取值进行简短介绍:

  • async:默认值,即将动态加载的模块生成一个单独的 bundle;

  • initialall 均按照以下条件拆分 chunk:

    • 新的 chunk 可被共享,或模块来自于 node_modules;
    • 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积);
    • 当按需加载 chunk 时,并行请求的最大数量小于或等于 30;
    • 当加载初始化页面时,并发请求的最大数量小于或等于 30。
  • initialall 的差异:

    • 如果模块 A 既被动态加载了,也被同步加载了,在 initial 下,该模块会被提取两次,分别打包到相应的 bundle 中;
    • all 下仅提取一次,打包到同一个 bundle 中。

Dynamic Import

除了 Code Splitting,也可通过 Dynamic Import 来优化代码体积,通过该特性,可在应用运行过程中,只加载需要的资源模块,以减少应用启动时需要加载的资源体积大小,提高应用的响应速度,并节省网络流量。

Webpack 会将动态加载的模块抽取到单独的 bundle 中,无需开发者做过多的干预,比如:

// utils.js
export function doSomething() {
  console.log('doSomething');
}

// index.js
console.log('Hello World');
import('./utils').then(({ doSomething }) => {
  doSomething();
});

执行 npx webpack 命令后查看 dist 目录:

dist
├── 1.js
└── main.js

其中 1.js 就是 Webpack 为 utils.js 单独生成的 bundle,默认情况下生成的文件名是不确定的,可通过以下方式指定具体文件名:

import(/* webpackChunkName: 'utils'*/ './utils').then(({ doSomething }) => {
  doSomething();
});

再次执行 npx webpack 命令后查看 dist 目录,此刻 1.js 变成了指定的文件名 utils.js

dist
├── utils.js
└── main.js

动态链接

在有点规模的项目中,经常会遇到很多复用性很高的公共模块很少发生变化,但每次业务代码的变更都需要对这些库进行打包,这样便造成了系统资源与时间的浪费,为了将这些较少发生变更且复用性较高的模块在没有发生变化时永远只打包一次,Webpack 为我们提供了动态链接的特性。

该特性涉及到 DLLPlugin 和 DllReferencePlugin,使用方法如下所示:

// webpack.dll.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
  entry: {
    vue: ['vue', 'vue-router', 'vuex'],
  },
  output: {
    filename: '[name].dll.js',
    path: path.join(__dirname, 'dist/dll'),
    library: '[name]_dll_[hash]',
  },
  plugins: [
    new webpack.DllPlugin({
      name: '[name]_dll_[hash]',
      path: path.join(__dirname, 'dist/dll', '[name].manifest.json'),
    }),
  ],
};

上述配置主要用于构建公共库 vue 及其配置信息,执行 npx webpack --config webpack.dll.config.js 命令,在 dist/dll目录下会生成 vue.manifest.json 文件,该文件记录了公共库 vue 的构建元数据,得到配置文件后,便可在业务代码中使用:

// webpack.config.js
const webpack = require('webpack');
module.exports = {
  plugins: [
    new webpack.DllReferencePlugin({
      manifest: require('./dist/dll/vue.manifest.json'),
    }),
  ],
};

这样,在对项目进行打包时,在公共库没有发生变更的情况下,我们仅需要对业务代码进行打包,从而提升构建速度。

其它

除了上述的优化措施外,还有以下几种常用措施:

  • 因为 Webpack 会根据 mode 选项的值自动启用不同的 Plugin,所以在项目中养成不同环境使用不同的配置文件;
  • 使用 happypack、thread-loader、webpack-parallel-uglify-plugin、uglifyjs-webpack-plugin、terser-webpack-plugin 等工具来进行并行解析与压缩,以缩短构建时间;
  • 对图片资源进行压缩与合并;
  • 使用缓存等。

执行流程

Webpack 的执行可分为初始化、构建、打包三个主要阶段,各个阶段的说明如下文所述。

初始化

Webpack 的执行始于 lib/webpack.js 中的 createCompiler 函数,该函数主要完成:

  • 根据用户指定的 options 进行运行参数及 Compiler 实例的初始化;
  • 执行用户指定的 pluginapply 方法;
  • 触发 compiler.hooks.environmentcompiler.hooks.afterEnvironment 钩子;
  • 触发 WebpackOptionsApply 中的 process 方法,根据运行参数配置来触发不同的内置 pluginapply 方法;
  • 触发 compiler.hooks.initialize 钩子;
  • 返回上文创建的 Compiler 实例。

上述过程会触发以下钩子:

  • compiler.hooks.environment
  • compiler.hooks.afterEnvironment
  • compiler.hooks.entryOption
  • compiler.hooks.afterPlugins
  • compiler.hooks.afterResolvers
  • compiler.hooks.initialize

其中 compiler.hooks.entryOptioncompiler.hooks.afterPluginscompiler.hooks.afterResolversWebpackOptionsApplyprocess 方法中触发。

得到 Compiler 实例后,可调用该实例的 watchrun 方法,开启 Webpack 的执行流程。

其中 watch 会触发以下钩子:

  • compiler.hooks.watchRun

run 会触发以下钩子:

  • compiler.hooks.beforeRun
  • compiler.hooks.run

compiler.hooks.watchRuncompiler.hooks.run 回调中,Webpack 会调用 Compiler 实例的 compile 方法,该方法主要用于生成 Compilation 实例,然后触发 compiler.hooks.make 钩子进入构建阶段,该过程会触发以下钩子:

  • compiler.hooks.normalModuleFactory
  • compiler.hooks.contextModuleFactory
  • compiler.hooks.beforeCompile
  • compiler.hooks.compile
  • compiler.hooks.thisCompilation
  • compiler.hooks.compilation

构建

完成了运行参数的设置、创建了 Compiler 以及 Compilation 实例后,Webpack 便进入构建阶段,该阶段主要对 module 进行编译解析,并根据 module 之间的依赖关系生成模块依赖图(即 ModuleDependcyGraph),大概流程如下:

  • 将 module(起始为入口 module)转译成 AST,并根据 AST 信息收集 module 的依赖列表;
  • 将收集到的依赖列表添加到模块依赖图(即 ModuleDependcyGraph)中;
  • 遍历 module 的依赖,重复上述步骤,直到没有新的依赖模块需要解析。

该阶段会触发以下钩子:

  • compiler.hooks.make
  • compiler.hooks.finishMake

打包

完成了 module 依赖解析及模块依赖图(即 ModuleDependcyGraph)的构建,Webpack 将调用 Compilation 实例的 seal 以及 Compiler 实例的 emitAssetsemitRecords 方法来进行最终的打包操作,该阶段的大概流程为:

  • 根据规则生成 chunks,并构建 ChunkGraph;
  • 收集运行时依赖,注入运行时代码,并生成最终的 JS 代码;
  • 触发 Compilation 中的钩子函数对前面生成的 JS 代码进行优化(比如分包、压缩等);
  • 将最终代码写入到指定的目录文件中(实现在 Compiler 实例的 emitAssetsemitRecords 中)。

该阶段会触发 compiler.hooks.afterCompile 及后续钩子。

总结

本文对工作中常用的 Webpack 知识点进行了简短总结,没有涉及的还需大家自行实践总结,毕竟纸上得来终觉浅,绝知此事要躬行,最后祝大家快乐编码每一天。