Webpack原理及相关配置学习笔记

最近读完了居玉皓老师的《Webpack实战——入门、进阶与调优》,记下了这篇笔记。

Webpack简介

什么是Webpack?

Webpack是一个开源的模块打包工具,其最核心的功能是解决模块之间的依赖,把各个模块按照特定的规则和顺序组织在一起,最终合并为一个或一些JS文件。这个过程就叫做模块打包。

可以把Webpack理解为一个模块处理工厂。我们把源代码交给Webpack,由它去进行加工、拼装处理,产出最终的资源文件,等待送往用户。

为什么需要Webpack?

开发一个简单的Web应用,其实只需要浏览器和一个简单的编辑器就可以了。最早的Web应用就是这么开发的,因为需求很简单。当应用的规模大了之后,就必须借助一定的工具,否则人工维护代码的成本将逐渐变得难以承受。这个工具就是模块化。

在设计程序结构时,把所有代码都堆在一起是一种糟糕的做法。更好的组织方式是按照特定的功能把代码拆分为多个代码段,每个代码段实现一个特定的目的。可以进行独立的设计、开发和测试,最终通过接口组合在一起。这就是基本的模块化思想。

大多数程序设计语言(比如C、Java),开发者都可以直接使用模块进行开发。工程中的各个模块在经过编译、链接等过程之后会被整合成单一的可执行文件并交由系统运行。

然而JavaScript之父Brendan Eich最初设计这门语言时只是将它定位成一个小型的脚本语言,用来实现网页上一些简单的动态特性,没有考虑到用它实现今天这样复杂的场景。所以在过去的很长一段时间里,Javascript都没有模块这一概念。如果工程中有多个JS文件,只能通过script标签将它们一个个插入页面中。

从2009年开始,Javascript社区开始对模块化进行不断的尝试,如CommonJS等。在2015年,ES6正式定义了Javascript模块标准,使这门语言在诞生了20年后终于有了模块这一概念。

ES6模块标准目前已经得到了大多数现代浏览器的支持,但实际应用方面还需要等待一段时间,主要有以下原因:

  1. 无法使用代码分片(code splitting)和删除死代码(tree shaking)。
  2. 大多数npm模块还是CommonJS的形式,而浏览器并不支持其语法,因此这些包没有办法直接拿来用。根本原因是CommonJS模块规范最初是为Node.JS设计,而浏览器缺少module、exports、require、global这四个Node.JS环境的变量。
  3. 仍然需要考虑个别浏览器及平台的兼容性问题。

为了使我们的工程在使用模块化的同时也能正常运行在浏览器中,就需要使用模块化打包工具。它的任务是解决模块间的依赖,使打包后的结果能运行在浏览器上。目前社区中比较流行的模块打包工具有Webpack、Vite、Parcel、Rollup等。

对比同类模块打包工具,Webpack具有以下几点优势:

  1. Webpack支持多种模块标准,包括AMD、CommonJS及最新的ES6模块,其它的工具大多只能支持一到两种。对于同时使用多种模块标准的工程,它会帮我们处理好不同模块之间的依赖关系。
  2. Webpack有完备的代码分片解决方案。可以分割打包后的资源,首屏只加载必要的部分,将不太重要的功能放到后面动态加载,提升首页渲染速度。
  3. Webpack可以处理各种类型的资源。除了JavaScript以外,Webpack还可以处理样式、模板甚至图片。
  4. Webpack拥有强大的社区支持。

模块打包原理简介

为了对Webpack有大体的认识,先对模块打包原理做个简单介绍。

先看一个简单的打包结果(bundle)的例子:

//立即执行匿名函数
(function(modules) {
    //模块缓存
    var installedModules = {};
    // 实现require
    function __webpack_require_(moduleId){
        ...
    }
    //执行入口模块的加载
    return __webpack_require__(__webpack_require__.s = 0);
}) ({
    //modules: 以key-value的形式存储所有被打包的模块
    "0": function(module, exports, __webpack_require__) {
        //打包入口
        module.exports = __webpack_require__("3qiv");
    },
    "3qiv": function(module, exports, __webpack_require__) {
        //index.js内容
    },
    "jkzz": function(module, exports) {
        //被index.js引用的内容
    }
});
​

从上面的bundle中可以清晰地展示出它是如何将具有依赖关系的模块串联在一起的。上面的bundle分为以下几个部分:

  • 最外层匿名函数。它用来包裹整个bundle,构成自身的作用域。
  • installedModules 对象。每个模块只在第一次被加载的时候执行,之后其导出值就被存储到这个对象里面,当再次被加载的时候webpack会直接从这里取值,而不会重新执行该模块。
  • __webpack_require__函数。对模块加载的实现,在浏览器中可以通过调用__webpack_require__(module_id)来完成模块导入。
  • modules对象。工程中所有产生了依赖关系的模块都会以key-value的形式放在这里。key可以理解为一个模块的id,由数字或者一个很短的hash字符串构成;value则是由一个匿名函数包裹的模块实体,匿名函数的参数赋予了每个模块导出和导入的能力。

接下来我们看看bundle是如何在浏览器中执行的:

  1. 在最外层匿名函数中初始化浏览器执行环境,包括定义installedModules对象、__webpack_require__函数等,为模块的加载和执行做一些准备工作。
  2. 加载入口模块。每个bundle都有且只有一个入口模块,在上面的示例中,index.js是入口模块,在浏览器中会从它开始执行。
  3. 执行模块代码。如果执行到了module.exports则记录下模块的导出值;如果中间遇到__webpack_require__函数,则会暂时交出执行权,进入__webpack_require__函数体内进行加载其它模块的逻辑。
  4. __webpack_require__中判断即将加载的模块是否存在于installedModules中。如果存在则直接取值,否则返回第三步,执行该模块的代码来获取导出值。
  5. 所有依赖的模块都已执行完毕,最后执行权又回到入口模块。当入口模块的代码执行完毕,也就意味着整个bundle运行结束。

不难看出,第3步和第4步是一个递归的过程。Webpack为每个模块创造了一个可以导出和导入模块的环境,但本质上并没有修改代码的执行逻辑,因此代码执行的顺序和模块加载的顺序是一致的,这也是Webpack模块打包的奥秘。

资源的输入与输出

如果将Webpack模块打包看作工厂的产品组装,资源的输入输出就是定义产品的原材料从哪里来,组装后的产品送到哪里去。

配置资源入口

在一切流程的最开始,我们需要指定一个或多个入口(entry),也就是告诉Webpack具体从源码目录的哪个文件开始打包。Webpack会从入口文件开始检索,并将具有依赖关系的模块生成一棵依赖树,最终得到一个chunk。我们一般将这个chunk得到的打包产物称为bundle。

Webpack通过context和entry这两个配置项来共同决定入口文件的路径。配置入口时,我们实际上做了两件事。

  • 确定入口模块位置,告诉Webpack从哪里开始进行打包。
  • 定义chunk name。如果工程只有一个入口,那么默认其chunk name为main;如果工程有多个入口,我们需要为每个入口定义chunk name,来作为该chunk的唯一标识。

Webpack的默认配置文件为webpack.config.js,我们在工程根目录下创建webpack.config.js,并添加以下代码:

const path = require("path");
module.exports = {
    context: path.join(__dirname, "./src/scripts"),
    entry: "./index.js",
};

配置context的主要目的是让entry的编写更加简洁。context可以省略,默认值为当前工程的根目录。

配置资源出口

所有与出口相关的配置都集中在output对象里。大多数使用频率并不高,最常用的是以下几种:

output: {
    filename: "bundle.js",
    path: path.join(__dirname, "assets"),
    publicPath: "/dist/",
}

filename

filename的作用是控制输出资源的文件名,形式为字符串,可以不仅仅是bundle的名字,还可以是一个相对路径,即便路径中的目录不存在,Webpack会在输出资源时创建该目录。在多入口的场景,我们需要为对应产生的每个bundle指定不同的名字。Webpack支持一种类似模板语言的形式动态地生成文件名。如:

filename: "[name].js",

在资源输出时,上面配置的filename中的name会被替换为chunk name。共有以下几种模板变量可以用于filename的配置:

  • [contenthash],指代当前chunk单一内容的hash。
  • [chunkhash],指代当前chunk内容的hash。
  • [id],指代当前chunk的id。
  • [name],指代当前chunk的name。

上述变量一般有如下两种作用:

  1. 当有多个chunk存在时对不同的chunk进行区分。
  2. 实现客户端缓存。[contenthash]和[chunkhash]都与chunk内容直接相关,在filename中使用了这些变量后,当chunk的内容改变时,资源文件名也会随之改变,从而使用户在下一次请求资源文件时会立即下载最新版本而不会使用本地缓存。

path

path可以指定资源输出的位置,要求值必须为绝对路径。在Webpack4以前的版本中,打包资源会默认存储在工程根目录。而在Webpack4之后,output.path已经默认为dist目录,除非我们需要更改它,否则不需要单独配置。

publicPath

publicpath与path非常容易混淆。从功能上来说,path用来指定资源的输出位置,publicPath则用来指定资源的请求位置。

输出位置:打包完成后资源产生的目录,一般将其指定为工程中的dist目录。

请求位置:由JS或CSS所请求的间接资源路径。页面的资源分为两种,一种是由html页面直接请求的,比如script标签加载的JS;另一种是由JS或CSS发起请求的间接资源,如图片、字体、异步加载的JS等。publicPath的作用就是指定这部分间接资源的请求位置。

预处理器(loader)

一个web工程通常会包含HTML、JS、CSS、图片、字体等多种静态资源。对于Webpack来说,这些静态资源都是模块,我们可以像加载一个JS文件一样去加载它们。通过Webpack“一切皆模块”的思想,我们可以将模块的特性应用到每一种静态资源上,从而设计和实现出更加健壮的系统。

loader概述

loader是Webpack中的一个核心概念,我们可以将其理解为一个代码转换的工具。loader赋予了Webpack可处理不同资源类型的能力,极大丰富了其可扩展性。每个loader本质上都是一个函数,可以表示为以下形式:

output = loader(input)

这里的input可能是工程源文件的字符串,也可能是上一个loader转化后的结果,output则包括了转化后的代码、source-map和AST对象。如果这是最后一个loader,结果将直接被送到Webpack进行后续处理,否则将作为下一个loader的输入向后传递。

举个例子,当我们使用babel-loader将ES6+的代码转化为ES5时,上面的公式如下:

ES5 = babel-loader(ES6+)

loader可以是链式的。我们可以对一种资源设置多个loader,第一个loader的输入是文件源码,之后所有loader的输入都为上一个loader的输出。则公式表达为以下形式:

output = loaderA(loaderB(loaderC(input)))

例如在工程中编译SCSS时,我们可能需要如下loader:

Style标签 = style-loader(css-loader(sass-loader(SCSS)))

loader的配置

Webpack本身只认识JavaScript,对于其它类型的资源,我们必须预先定义一个或多个loader对其进行转译,输出为Webpack能够接收的形式再继续进行,因此loader实际上做的是一个预处理的工作。

假设我们要处理CSS,首先按照Webpack“一切皆模块”的思想,从一个JS文件加载一个CSS文件。工程中没有loader,Webpack无法处理CSS语法,直接打包会报错。

将css-loader引入到工程中,并做如下配置:

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

loader相关的配置都在module对象中,其中module.rules代表了模块的处理规则。其中最重要的两项配置是test和use。

  • test可以接收一个正则表达式或者一个元素为正则表达式的数组,只有正则匹配上的模块才会使用这条规则。
  • use可接收一个数组,数组包含该规则所使用的loader。在只有一个loader时也可以将其简化为一个字符串。

很多时候,在处理某一类资源时需要使用多个loader:

use: ["style-loader", "css-loader"],

我们把style-loader加到了css-loader前面,这是因为Webpack在打包时是按照数组从后往前的顺序将资源交给loader处理的,因此要把最后生效的放在前面。

下面介绍下其它场景下loader的相关配置:

exclude 与 include

exclude与include用于排除和包含指定目录下的模块。

exclude: /node_modules/,
include: /src/,

当exclude与include同时存在时,exclude具有更高优先级。

resource 与 issuer

resource 与 issuer用于更加精确地确定模块规则的作用范围。在Webpack中,我们认为被加载模块是resource,而加载者是issuer。

issuer: {
    test: /.js$/,
    exclude: /node_modules/,
}

enforce

enforce用来指定一个loader的种类,只接收pre或post两种字符串类型的值。

Webpack中的loader按照执行顺序可以分为pre、inline、normal、post四种类型,直接定义的loader属于normal类型,inline形式官方已经不推荐使用,而pre和post则需要使用enforce来指定。

enfore: "pre",

enfore值为pre表示该loader将在所有loader之前执行,为post则表示该loader将在所有loader之后执行。

代码分片

实现高性能应用的重要的一点就是尽可能地让用户每次只加载必要的资源,对于优先级不太高的资源则采用延迟加载等技术渐进式获取,这样可以保证页面的首屏速度。

通过入口划分代码

在Webpack中每个入口都将生成一个对应的资源文件,通过入口的配置我们可以进行一些简单有效的代码拆分。对于多页面应用来说,我们也可以利用入口划分的方式拆分代码。比如,为每个页面创建一个入口,并放入只涉及该页面的代码,这样每个页面会生成一个对应的JavaScript文件,并添加到每个页面的HTML中。但这样通过手工的方式去配置和提取公共模块可能会比较复杂,可以使用Webpack专门提供的插件来解决这个问题。

optimization.SplitChunks

optimization.SplitChunks是Webpack4为了改进CommonsChunkPlugin而重新设计和实现的代码分片特性。它不仅比CommonChunkPlugin功能更强大,还简单易用。

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

all表示SplitChunks将会对所有的chunks生效(默认情况下,SplitChunks只对异步chunks生效,并且不需要配置)。

使用CommonChunkPlugin的时候,我们大多通过配置项将特定入口中的特定模块提取出来,也就是更贴近命令式的方式。而在使用SplitChunks时,我们只需要设置一些提取条件,如提取的模式、提取模块的体积等,当某些模块达到这些条件后就会自动被提取出来。SplitChunks的使用更像是声明式的。

以下是SplitChunks的默认配置:

  • 提取后的chunk可被共享或者来自node_modules目录。这一条很容易理解,被多次引用或处于node_modules中的模块更倾向于是通用模块,比较适合被提取出来。
  • 提取后的 JavaScript chunk 体积大于20KB(压缩和gzip之前),CSS chunk 体积大于50KB。这个也比较容易理解,如果提取后的资源体积太小,那么带来的优化效果也比较一般。
  • 在按需加载过程中,并行请求的资源最大值小于等于30。按需加载指的是,通过动态插入script标签的方式加载脚本。我们一般不希望同时加载过多的资源,因为每一个请求都要带来建立和释放链接的成本,因此提取规则只在并行请求不多的时候生效。
  • 在首次加载时,并行请求的资源数最大值小于等于30。和上一条类似,但因为页面首次加载时往往对性能要求更高,我们可将它手动设置为更低。
splitChunks: {
    chunks: "async",
    minSize: 20000,
    minRemainingSize: 0,
    minChunks: 1,
    maxAsyncRequests: 30,
    maxInitialRequests: 30,
    enforeSizeThreshold: 50000,
    cacheGroups: {
        vendors: {
            test: /[\/]node_modules[\/]/,
            priority: -10,
        },
        default: {
            minChunks: 2,
            priority: -20,
            reuseExistingChunk: true,
        },
    },
},

匹配模式chunks有三个可选值,async即只提取异步chunk,initial只对入口chunk生效,all则同时开启两种模式。

cacheGroups可以理解成分离chunks时的规则。默认情况下有两种规则——vendors和default。vendors用于提取所有node_modules中符合条件的模块,default则作用于被多次引用的模块。我们可以对这些规则进行增加或修改,如果想要禁用某种规则,也可以直接将其置为false。当一个模块同时符合多个cacheGroups时,则根据其中的priority配置项确定优先级。

生产环境配置

环境配置的封装

生产环境的配置与开发环境有所不同,比如要设置模式、环境变量,为文件名添加chunkhash作为版本号等。为了让Webpack针对不同的环境采用不同的配置,一般有两种方式:

  • 采用相同配置文件。在构建时将当前所属环境作为一个变量传进去,然后在webpack.config.js中通过各种条件来决定具体使用哪个配置。
  • 为不同的环境创建各自的配置文件。构建时通过–config参数指定打包时使用的配置文件。两个文件中重复的配置可以单独提取出来放在一个文件中,例如webpack.common.config.js。然后将另外两个配置文件引用该文件,再加上自身配置即可。也可以用专门用来做Webpack配置合并的webpack-merge工具,对繁杂的配置进行管理。

开启production模式

webpack4中加了一个mode配置项,可以让开发者通过它来切换打包模式。

mode: "production",

production意味着当前处于生产环境模式,Webpack会自动添加许多适用于生产环境的配置项。

环境变量

我们通常要为生产环境和本地环境添加不同的环境变量,在Webpack中可以使用DefinePlugin进行设置。

plugins: [
    new webpack.DefinePlugin({
        ENV: JSON.stringify("production"),
    })
],

上面的配置通过DefinePlugin设置了ENV环境变量。许多框架和库都采用process.env.NODE_ENV作为一个区别开发环境和生产环境的变量。如果启用了mode: “production”,则Webpack已经设置好了 process.env.NODE_ENV,不需要再人为添加了。

source-map

source-map指的是将编译、打包、压缩后的代码映射回源代码的过程。经过Webpack打包压缩后的代码基本上已经不具备可读性。有了source-map,再加上浏览器调试工具,可以很方便地在代码抛出错误时,回溯它的调用栈。

source-map原理

Webpack对于工程源代码每一步的处理都可能改变代码的位置、结构,甚至所处文件,因此每一步都要生成对应的source-map。若我们启用了devtool配置项,source-map就会跟随源代码一步步被传递,直到生成最后的map文件。这个文件的名称默认就是打包后的文件名加上.map,比如bundle.js.map。

在生成映射文件的同时,bundle文件中会追加一具注释来标识map文件的位置。当我们打开浏览器工具时,map文件会同时被加载,这时浏览器会使用它来对打包后的bundle文件进行解析,分析出源代码的目录结构和内容。

source-map配置

JavaScript的source-map配置很简单,只要在webpack.config.js中添加devtool即可。

devtool: "source-map",

对于CSS、SCSS、LESS来说,我们则需要添加额外的source-map配置项。

{
    loader: "css-loader",
    options: {
      sourceMap: true,  
    },
}

开启source-map后,打开浏览器的开发者工具,在Sources选项卡下即可找到解析后的工程源码。

资源压缩

在将资源发布到线上环境前,我们通常都会进行代码压缩,或者叫uglify,意思是移除多余的空格、换行、及执行不到的代码,缩短变量名,在执行结果不变的前提下将代码替换为更短的形式。一般正常的代码在uglify之后整体体积都会显著缩小。同时,uglify之后的代码将基本上不可读,在一定程度上提升了代码的安全性。

压缩JavaScript

terser是一种常用的压缩JavaScript文件的工具。官方在Webpack4中默认使用了terser的插件terser-webpack-plugin。Webpack4之后的压缩配置被移到了config.optimization.minimize。

optimization: {
    minimize: true,
},

自定义terser-webpack-plugin插件配置

optimization: {
    //覆盖默认的 minimizer
    minimizer: [
        new TerserPlugin({
            /* your config */
            test: /.js(?.*)?$/i,
            exclude: //excludes/,
        })
    ],
},

压缩 CSS

压缩CSS文件的前提是使用mini-css-extract-plugin将样式提取出来,接着使用optimize-css-assets-webpack-plugin来进行压缩。

缓存

缓存是指重复利用浏览器已经获取过的资源。合理地使用缓存是提升客户端性能的一个关键因素。浏览器会在资源过期前一直使用本地缓存进行响应。当开发者想要对代码进行Bug修复,并希望立即更新到所有用户的浏览器上,此时最好的办法是更改资源的URL,迫使所有客户端都去下载最新的资源。

资源hash

一个常用的方法是在每次打包的过程中对资源的内容计算一次hash,并作为版本号存放在文件名中,每当代码发生变化时相应的hash也会变化。我们通常使用chunkhash来作为文件版本号,因为它会为每一个Chunk单独计算一个hash。

output: {
    filename: "bundle@[chunkhash].js",
},

输出动态HTML

资源名的改变也意味着HTML中引用路径的改变。每次更改后都要手动地去维护它是很困难的,理想的情况是在打包结束后自动把最新的资源名同步过去。使用html-webpack-plugin可以帮我们做到这一点。

plugins: [
    new HtmlWebpackPlugin({
        template: "./template.html",
    })
],

html-webpack-plugin会自动将我们打包出来的资源名放入生成的index.html中,这样就不必手动更新资源URL了。传入一个已有的HTML模板,可以让我们放入很多个性化内容。

bundle体积监控和分析

为了保证良好的用户体验,我们可以对打包输出的bundle体积进行持续监控,以防止不必要的冗余模块被添加进来。

VS Code中有一个插件Import Cost可以帮助我们对引入模块的大小进行实时监测。还有一个很有用的工具是webpack-bundle-analyzer,它可以生成一张bundle的模块组成结构图,帮助我们分析一个bundle的构成。

我们还需要自动化地对资源体积进行监控,可以使用bundlesize这个工具包做到这一点。它会根据我们配置的资源路径和最大体积验证最终的bundle是否超限。

打包优化

优化Webpack配置,可以让打包的速度更快,输出的资源更小。软件工程领域有一条经验——不要过早优化,在项目的初期不要看到任何优化点就拿来加到项目中,这样不但会增加复杂度,优化的效果也不会太理想。一般是当项目发展到一定规模后,性能问题会随之而来,这时再去分析然后对症下药,才有可能达到理想的效果。

HappyPack

HappyPack是一个通过多线程来提升Webpack打包速度的工具。打包过程中有一项非常耗时的工作就是使用loader对各种资源进行转译处理,如babel-loader,ts-loader。Webpack是单线程的,假设一个模块依赖于其它几个模块,则Webpack必须对这些模块逐个转译。这些转译任务彼此之间没有任何依赖关系,却必须串行地执行。HappyPack以此为切入点,它的核心特性是可以开启多个线程,并行地对不同模块进行转译,充分利用本地计算资源来提升打包速度。

HappyPack适用于转译任务比较重的工程,把类似babel-loader和ts-loader等loader迁移到HappyPack后,一般都可以收到不错的效果,而对于其它如sass-loader等本身消耗时间并不太多的工程则效果一般。

缩小打包作用域

从宏观角度看,提升性能的方法无非两种:增加资源或者缩小范围。增加资源指用更多的计算能力来缩短执行任务的时间。缩小范围针对任务本身,去掉冗余的流程,尽量不做重复性的工作。

exclude和include

exclude和include,在配置loader时一般都会加上它们。对于JS来说,一般要把node_modules目录排除掉。

noParse

对于有些库,我们希望Webpack完全不要去进行解析,即不希望应用任何loader规则,库的内部也不会有对其它模块的依赖,例如lodash。那么这时可以使用noParse实现。

module: {
    noParse: /lodash/,
}

上面的配置会忽略所有文件名中包含lodash的模块,这些模块仍然会被打包进资源文件,只不过Webpack不会对其进行任何解析。

IgnorePlugin

IgnorePlugin可以完全排除一些模块,被排除的模块即使被引用了也不会打包进资源文件中。

IgnorePlugin对于排除一些库相关文件非常有用。对于一些由库产生的额外资源,我们其实不会用到又无法去掉,因为引用的语句处于库文件的内部。比如,Moment.js是一个日期处理相关的库,为了做本地化它会加载很多语言包,占很大的体积,但我们一般用不到其它地区的语言包,这时就可以用IgnorePlugin来去掉。

plugins: [
    new webpack.IgnorePlugin({
        resourceRegExp: /^./locale$/, // 匹配资源文件
        contextRegExp: /moment$/, //匹配检索目录
    })
],

缓存

使用缓存也可以有效减少Webpack的重复工作,进而提升打包效率。我们可以令Webpack将已经进行过预编译的文件内容保存到一个特定的目录中。当下一次接收到打包指令时,可以去查看源文件是否有改动,如果没有改动则直接使用缓存即可,中间的各种预编译步骤都可以跳过。

DllPlugin

DllPlugin对于第三方模块或者一些不常变化的模块,可以将它们预先编译和打包,然后在项目实际构建过程中直接取用即可。

DllPlugin和代码分片有点类似,都可以用来提取公共模块,但本质上有一些区别。代码分片的思路是设置一些规则并在打包的过程中根据这些规则提取模块;DllPlugin则是将vendor完全拆出来,定义一整套自己的Webpack配置并独立打包,在实际工程构建时就不再对它进行任何处理,直接取用即可。因此,理论上来说,DllPlugin会比代码分片在打包速度上更胜一筹。

去除死代码

ES6 Module依赖关系的构建是在代码编译时而非运行时。基于这项特性Webpack提供了去除死代码(tree shaking)功能,它可以在打包过程中帮助我们检测工程中有没有没被引用过的模块,这部分代码将永远无法被执行到,因此也被称为“死代码”。Webpack会对这部分代码进行标记,并在资源压缩时将它们从最终的bundle中去掉。正常开发模式下这些代码仍然存在,在生产环境的压缩那一步会被移除掉。去除死代码只能对ES6 Module生效。

如果我们在工程中使用了babel-loader,那么一定要通过配置来禁用它的模块依赖解析。因为如果由babel-loader来做依赖解析,Webpack接收到的就都是转化过的CommonJS形式的模块,无法对死代码进行去除。

开发环境调优

热模块替换

早期开发时需要改代码然后刷新网页查看结果,后来一些开发框架和工具提供了更加便捷的方式,检测到代码改动就会自动重新构建,然后触发网页刷新。这种一般称为live reload。Webpack更进了一步,可以让代码在网页不刷新的前提下得到最新的改动,甚至可以让我们不需要重新发起请求就能看到更新后的结果,这就是热模块替换(Hot Module Replacement,HMR)功能。

开启HMR

HMR需要手动开启,并且有一些必要条件。

首先我们要确保项目是基于webpack-dev-server或者webpack-dev-middle进行开发的,Webpack本身的命令行并不支持HMR。

plugins: [
    new webpack.HotModuleReplacementPlugin()
],
devServer: {
    hot: true,
},

HMR原理

在本地开发环境下,浏览器是客户端,webpack-dev-server(WDS)相当于我们的服务端。HMR的核心就是客户端从服务端拉取更新后的资源(准确地说,HMR拉取的不是整个资源文件,而是chunk diff,即chunk需要更新的部分)。

WDS与浏览器之间维护了一个websocket,当本地资源发生变化时,WDS会向浏览器推送更新事件,并带上这次构建的hash,让客户端与上一次资源进行对比。通过对比hash可以防止冗余更新的出现。在客户端已经知道新的构建结果和当前有所差别后,它会向WDS发起一个请求来获取更改文件的列表,即哪些模块有了改动。通常这个请求的名字为[hash].hot-update.json。客户端再借助这些信息继续向WDS获取该chunk的增量更新。

客户端获取到了chunk的更新后,后续不再是Webpack的工作,但是它提供了相关的API,开发者可以使用这些API进行针对自身场景的处理。像react-hot-loader和vue-loader也都是借助这些API来实现的HMR。

Webpack打包机制

控制台在执行了Webpack打包命令后项目中的源代码得到了处理,其中可能有一些语法转译,配置好的插件会协同工作,最后生成一系列静态资源并放置到了一个目录下。宏观来看Webpack是一个函数,输入是一个个有依赖关系的模块,输出是静态资源:

assets = webpack(modules)

Webpack有两种构建模式,build模式和watch模式(也称独立构建和持续构建)。build模式的步骤包括从开始到加载缓存、打包、输出资源、展示结果、保存缓存,最后退出进程。build模式和watch模式的区别仅在于,watch模式会在文件系统中为源文件添加上watcher。当监听到文件改动时,Webpack会重新加载缓存、打包资源并执行后续的过程。

准备工作

在开工之前,Webpack会先进行一次配置项的检查。我们也可以单独运行webpack configtest命令来进行该检查。配置项可以通过命令行获取,也可以通过配置文件获取,还可以同时使用这两种方式获取。当命令行和配置文件中有相同的配置项时,命令行中的优先级更高。

缓存加载

Webpack还有一个重要的预先工作就是加载缓存。Webpack5中的缓存有两种,一种是内存中的,一种是文件系统中的。在build模式下,仅可以使用文件系统缓存。因为每次构建结束后,Webpack使用的内存都会被释放掉,而文件系统缓存是可以一直存在的。使用文件系统缓存的好处就在于,即便是新打开一个命令行重新开始编译,Webpack也能找到之前的缓存,从而加快构建速度。

在加载缓存的过程中,最重要的环节是验证其是否有效。内存缓存主要由Webpack内部进行管理,文件系统缓存在大多数情况下需要开发者介入。如果不清楚风险的情况下开启了文件系统缓存,会导致一些缓存验证相关的问题。

Webpack提供了几种管理缓存的方法,分别是:

  • 打包依赖(Build Dependencies)
  • 缓存名称(Cache Name)
  • 缓存版本(Cache Version)

先看看打包依赖,在Webpack5的配置中有一个cache.buildDependencies配置项,它可以为缓存添加上额外的文件或目录依赖。当检测到cache.buildDependencies中的文件或目录发生改动时,Webpack会令现有的缓存失效。

cache: {
    type: "filesystem",
    buildDependencies: {
        importantDependency: ["important-folder/"]
    },
},

当传入一个文件时,不仅所指定的文件本身会成为缓存的依赖,也包括其通过require()语句依赖的文件。

在启动文件系统缓存后,Webpack会把缓存存放在一个特定的目录。实际工作中,我们经常需要对同一份代码在不同模式下打包,模式间的切换会导致缓存的互相覆盖,这会增加很多额外的工作量。因此Webpack默认会使用${config.name}-${config.mode}这样的目录形式来存放缓存。

最后说一下缓存版本,它对应的配置是cache.version。有些情况下,我们的缓存不仅仅依赖于一些特定的文件或目录,有些可能来自于数据库、命令行参数或者其它任何地方。这种时候,我们也可以通过各种方式获取到这些依赖,然后直接作用到缓存版本。cache.version配置项只支持传入一个字符串,当我们传入一个新值时,Webpack就不会使用旧的缓存了。

cache: {
    type: "filesystem",
    version: "version-string",
},

模块打包

Webpack在模块打包阶段会引入一个个不同的角色,分别对应Webpack仓库中的类。在打包运行阶段,会生成这些类的实例并令它们一起协同工作。

Compiler

Compiler是Webpack内部处于核心地位的一个类。当我们执行打包命令时,Webpack会创建一个Compiler实例。它相当于Webpack内外连接的桥梁,会接收外部传进来的配置项,同时也向外暴露诸如run、watch等重要的方法。若第三方插件需要侵入Webpack的打包流程做任何事情,都要首先经过Compiler。

注意,无论是build模式还是watch模式,都仅仅会创建一个Compiler实例,这也是我们在watch模式下修改Webpack的配置时新的配置不会生效的原因。只有停下控制台的进程重新启动Webpack,这些配置才会随着新的Compiler实例初始化而生效。

在Webpack内部,Compiler控制着总任务流,并把这些任务分配给其它模块来做。这里的其它模块大多是插件(Plugin)。Compiler与插件的协作模式并不是Compiler直接调用这些插件,而是由Compiler暴露出工作流程中的Hook,让插件监听这些Hook,并在适当的时机完成相应的工作。通过Hook来进行流程管理的方式实际上贯穿了整个Webpack内部的实现。它使每一个模块更便于单独管理,并且是可替代的,这也给Webpack整体的架构带来的更强的灵活性和可维护性。

Compilation

Compilation也是Webpack中处于核心地位的一个类。与Compiler类似于总指挥管的角色相比,Compilation则类似于总管的角色,管理着更为底层的任务,比如创建依赖关系图,单个模块的处理以及模板渲染等,每一个具体的步骤都是由Compilation来控制的。

Compilation的工作模式与Compiler类似,也提供了非常多的Hook让其它模块监听和处理更为细小的事务。Compiler中有一个名为compilation的Hook,其触发的时机是compilation创建后。假设我们现在编写一个新的Webpack插件去监听compilation中的Hook,则必须先监听Compiler中的compilation这个Hook,然后从中获取到compilation对象,才能进行后续的处理。

Resolver

初始化Compiler和Compilation实例是打包流程的开始,下一步需要构建依赖关系图。

Webpack会首先拿到入口路径,然后尝试找到对应的入口文件。这个寻找的过程就是由Resolver来实现的。不仅仅是入口文件,由入口文件所获取到的依赖关系都需要Resolver来找到实际的文件路径。Resolver找到文件后,将会返回一个对象,里面包含了resolve行为的所有信息,包括源代码中的引用路径、最后实际找到的文件路径及其上下文等。然而从Resolver得来的信息并不包含源代码,实际内容会在模块工厂中获取到。

Module Factory

Module Factory(模块工厂)的主要作用是产出模块,它的工作模式也类似于一个函数。接收的是Resolver提供的resolve信息,返回一个模块对象,其中包含源代码。另外Module Factory也参与了模块级别的流程调度,它暴露出了很多Hook,以便对单个模块进行处理。

Parser

从Module Factory中得到的源代码是各种各样的,我们必须让它变成Webpack能理解的形式,这就是Parser(解析器)的工作。Parser接收由Module Factory产出的模块对象,通过Webpack配置的模块处理规则,让不同类型的源代码经过loader的处理最终都变成JavaScript。

在将源代码转译为JavaScript之后,Parser还需要将JavaScript转化为抽象语法树(AST,Abstract Syntax Tree),并进一步寻找文件依赖。

解析为AST后,Parser会进一步分析AST,从中获取到依赖关系后,再通过Resolver、Module Factory、Parser这一整个流程获取依赖的具体内容,最终生成一个dependencies数组,用于记录依赖的模块。

模板渲染

在由入口index.js开始找到的所有模块都处理完毕后,Webpack会把这些模块封装在一起,并生成一个chunk。当然,也可能会有一些特殊处理,比如异步加载的模块可能被分到单独的chunk,或者某些模块匹配到splitChunksPlugin规则后又生成了一个chunk,这些chunk都等待着被转化为最后的代码,Webpack实现这最后一步的方法就是模板渲染。

打包出的目标代码可能是这样:

//......
var __webpack_exports__ = __webpack_require__("./src/index.js");

实际上是由这样的模板渲染来的:

//......
var __webpack_exports__ = __webpack_require__(${moduleIdExpr});

对于不同类型的模块以及模块间的依赖关系,Webpack内部都有相应的模板。根据依赖关系图以及前面各步骤所得到的模块信息,Webpack组织和拼装模块,再把模块实际相关的内容填进去,就“渲染”出了我们所看到的目标代码。

深入Webpack插件

Webpack整体架构的实现依靠它的插件系统。Compiler、Compilation作为调度者管理着构建的流程,同时暴露一些Hook,然后由各个负责不同职责的插件来监听这些Hook,并完成具体的工作。

Tapable

Tapable是整个Webpack插件系统的核心,它的意思是“可以被监听的”。Tapable在Webpack中是一个类,所有由这个类所产生的实例都是可以被监听的。而所有Webpack中的插件,甚至包括Compiler和Compilation,都继承了Tapable类。(在Webpack5中为了扩展内部实现,已经不再直接继承Tapable,但依然使用着相同的API。)

下面是一个Webpack插件的最简单实现:

class MySyncWebpackPlugin {
    apply(compiler) {
        compiler.hooks.afterResolvers.tap("MySyncWebpackPlugin", (compiler) => {
            console.log("[compiler] >", compiler);
        });
    }
}

上面的代码展示了Webpack插件的基本结构。首先它是一个类,并且拥有一个apply方法。如果将这个插件由Webpack的plugins配置项传入,那么在Webpack运行的开始阶段就会执行该apply方法。而插件将会从这个apply方法上得到Compiler实例,来监听特定的Hook。

以下是所有Hook的类型以及允许的监听方法:

Hook类型 监听方法
SyncHook tap
SyncBailHook tap
SyncWaterfallHook tap
AsyncSeriesHook tap/tapAsyns/tapPromise
AsyncSeriesWaterfallHook tap/tapAsyns/tapPromise
AsyncSeriesBailHook tap/tapAsyns/tapPromise
AsyncParallelHook tap/tapAsyns/tapPromise

使用tap方法时,回调函数仅可以包含由hook提供的参数,且只允许执行同步逻辑。

tapAsync方法允许执行异步逻辑,最后通过参数中的callback函数结束插件的执行。

tapPromise基本上只是tapAsync的另一种写法。

插件的协同模式

对于绝大多数的Hook来说,同一时刻只允许有一个插件在工作,在上一个插件被触发而未执行完其逻辑之前,下一个插件不会被触发。即使插件允许被异步执行,也还是会有“阻塞性”的。

Webpack中所有构建任务基本都要靠插件完成,如果每个插件都可以随时操作Compiler和Compilation的实例对象,很容易造成不可预知的相互影响。而如果插件执行顺序固定,整个流程的管理会相对容易。

插件引入的顺序就是其执行的顺序。不过也有特殊情况,当hook的类型为AsyncParallelHook时,不同的插件可以并行执行任务。

下面再来看看Bail、Waterfall、Series这几个关键字。

Bail关键字表示当一个Hook有多个插件监听时,一旦有一个插件返回了非undefined的值,则该Hook提前终止,后面的插件不会再继续执行。

Waterfall关键字代表前一个插件的结果将作为后一个插件的输入。这种类型的Hook适用于需要多个插件按照流水线的方式来工作的情景。通过这种方式可以将某个值不断传递下去,并且每一个插件都允许去修改它。

Series关键字总是和Async一起出现,意思是逐个地执行异步任务。监听AsyncSeriesHook类型Hook的插件既可以使用tap执行同步方法,也可以使用tapAsync或tapPromise来执行异步方法。由于不同插件逐个被触发,这么做还可以保证流程相对可控,适用的场景最广。

原文链接:https://juejin.cn/post/7359084330121035788 作者:Tinyhealer

(0)
上一篇 2024年4月19日 上午10:59
下一篇 2024年4月19日 上午11:04

相关推荐

发表回复

登录后才能评论