webpack4 深入了解

990 阅读32分钟

编译打包相关知识

  • webpack 基本工作流程

    webpack 提供一个方法 webpack(options, callback) 用于 编译打包源文件

    webpack 方法 在执行过程中, 经历的 主要流程 如下:

    1. 数据校验,判断传入的 配置项 - configOption数据格式 是否符合 webpack 要求,不符合会直接抛错

    2. 将用户传入的 配置项 - configOption 标准化, 根据 工作模式(development / production)entryoptimizationresolveoutput 等添加 相应的默认属性

      例如: 如果 configOption未指定 entry 属性webpack 会向 configOption 添加 entry 属性默认值'./src/index'

    3. 构建一个 编译器 - compiler, 用于 实际的编译打包

    4. 遍历 configOption 中的 plugins 属性, 给 compiler 安装 用户指定插件

    5. 根据 webpack工作模式(development/ production) 以及 options配置项, 安装 相应的插件

    6. 执行 编译器 - compilerrun 方法, 开始编译打包

  • 编译器 - compiler

    编译器 - compilerwebpack 编译打包的核心

    webpack 会在 工作过程中 构建一个 compiler, 然后执行 compiler.run 方法进行 编译打包。 整个 编译打包基本过程 如下:

    1. 构建一个 compilation params 对象, 用于 构建 compilation 对象。

      compilation params 对象, 可以理解为 编译所需参数params 对象 中包含一个属性: normalModuleFactory, 对应的 属性值 是一个 对象, 可以理解为 模块工厂。 这个 对象 提供了一个 create 方法, 执行这个方法, 可以生成一个 module 对象

    2. 使用 compilation params 对象, 构建一个 compilation 对象。

      一个 compilation 对象,可以理解为 一个 编译过程compilation 直接负责整个编译打包过程

    3. compilation 开始 编译打包 工作。

      整个 编译打包工作流程 如下:

      1. 构建模块依赖图

        compilation 会以 入口文件(entry)起点, 找到 整个项目所需要的所有文件,为 每一个文件 都生成相应的 module 对象, 并 建立模块之间的依赖关系, 生成一个 模块依赖图

      2. 模块依赖图 中的 模块 分离生成 chunk

      3. 将生成的 chunks 转化为 bundles 并输出到 指定位置(output)

    整个 编译打包 过程, 简单来讲, 就是 webpack 先构建一个 编译器 - compiler, 然后 compiler 根据一个 参数 - params, 生成一个 compilationcompilation入口文件起点,构建一个 模块依赖图, 将 模块依赖图 分离成 chunks, 然后 将 chunks 转化为 bundles 并输出到指定位置

  • 模块依赖图

    使用 webpack 编译打包源文件 时,每一个 源文件 都可以当做一个 module 来处理,这个 源文件 可以是 .js 文件.css 文件.json 文件 以及 .png 文件 等等。

    源文件 之间的 相互引用,使得 对应的 module 之间存在 依赖关系。如果一个 module A 使用的时候需要先引入 module B, 那么 module A 就依赖于 module B

    webpack 会以 入口文件起点,找到 入口文件的依赖文件 以及 依赖文件的依赖文件直到最后一个依赖文件没有依赖文件为止。 在这个过程中,会为 每一个文件 创建一个 module 对象, 然后 建立 module 对象之前的依赖关系, 最后形成一个 模块依赖图

    生成 模块依赖图 经历的 过程 大致如下:

    1. 解析(resolve) entry 配置项 提供的 入口文件路径,以获取入口文件在磁盘中的位置(绝对路径)

      解析过程中,会同时得到 处理文件内容需要的 loaders(eslint-loader、babel-loader)解析(parse)文件内容需要的 parser构建最后输出内容需要的 generator

    2. 构建一个 module 对象module 对象userRequestloadersparsergenerator 属性分别指向步骤一生成的 文件绝对路径处理文件内容需要的loaders解析文件内容需要的parser构建最后输出内容需要的 generator

    3. 读取 源文件的内容,使用收集的 loaders 处理 源文件的内容

      如: 使用 babel-loader 处理 js 内容, 使用 sass-loadercss-loader 等处理 css 内容

      loader 处理以后的 源文件内容, 会保存在 module 对象_source 属性中。

    4. 使用 parser 解析 loader 处理过的文件内容获取当前文件所依赖的文件 的 请求路径

      parser 会将 文件内容 解析为 ast - 抽象语法树对象, 从 ast 中可以获取到 依赖文件的请求路径(一般为相对路径)

      分析 ast 的时候, 会为每一个 依赖文件 生成一个 dep 对象dep 对象request 属性 的值为 依赖文件的请求路径module 属性的值为 依赖对文件对应的 module,此时为 null

      文件中 依赖的静态文件对应的 dep 对象 会收集到 当前文件 对应的 moduledependencies 列表中,依赖的需要懒加载的文件对应的 dep 对象 会收集到 moduleblocks 列表中, 依赖的全局变量 会收集到 modulevariables 列表中。

    5. 解析 dependenciesblocksvariablesdep 对象 中的 请求路径,获取 依赖文件在磁盘中的位置 以及 解析依赖文件内容的loaders、parser、generator,创建 依赖文件的 module 对象, 并为 dep 对象module属性 赋值。

      这样, 通过 模块dependenciesblocksvariables,便可建立 模块之间的依赖关系

    6. 重复步骤2到步骤5, 直到模块的 dependencies、blocks 列表中的值为空为止

    综上, 一个 模块依赖图 便生成,然后用于 打包分离生成chunk

  • 模块

    module 是构成 模块依赖图关键,在使用 webpack编译打包源文件 时, 每一个 源文件 都对应一个 module 对象module 对象 会中包含 源文件的请求路径绝对路径依赖文件文件输出 等信息。

    一个 模块 的生成,要经历 resolvecreatebuild 三个阶段,即 解析文件请求路径生成 module 对象使用 loader 处理文件内容和使用 parser 解析文件内容

    • resolve

      源文件 构建一个 module 对象,首先要做的就是 解析源文件请求路径, 获 取源文件在本地磁盘的位置(绝对路径)

      源文件请求路径 可以是 相对路径绝对路径模块路径别名路径(resolve.alias)webpack 会根据 配置项 中的 context(基础目录)resolve 来解析 文件的请求路径

      文件的请求路径解析完成 以后, 会使用 解析生成的绝对路径校验文件是否存在。如果 不存在抛出 file no exist 异常

      如果 允许文件不需要扩展名(resolve.enforceExtension : false)请求路径没有扩展名,解析时会根据 配置项提供的自动解析的扩展(resolve.extensions - 默认值为[".wasm", ".mjs", ".js", ".json"]), 依次补全绝对路径,然后 校验文件是否存在。 当 所有的扩展使用以后文件还是不存在抛出 file no exist 异常

      文件的 绝对路径生成 以后, 会根据 配置项 - modules 中提供的 rules, 收集 处理源文件 需要的 loader。 依据 loader名称, 解析 loader绝对路径, 如: babel-loader 解析以后的 绝对路径 如下:

      // babel-loader 的绝对路径
      D:\study\demo\webpack\webpack-4-demo\node_modules\_babel-loader@7.1.5@babel-loader\lib\index.js
      

      此外, 还会生成一个 解析器 - parser, 用于 解析源文件内容,一个 生成器 - generator,构建 module 对象 对应的 输出内容(js代码字符串)

      综上, 在 resolve 阶段, webpack 会找到 源文件的绝对路径(在本地磁盘中的位置)处理源文件内容需要的 loaders 的绝对路径(在本地磁盘中的位置)解析源文件内容需要的 parser、以及 构建输出内容的 generator, 然后使用这些信息构建一个 module 对象

    • create

      create 阶段, webpack 会根据 resolve 阶段 返回的 源文件绝对路径loadersparsergenerator 等信息生成一个 module 对象

      生成的 module 对象 会添加到一个 缓存-cache 中,防止 相同文件的重复 resolve、build

      另外,module 对象 还会被添加到 compilation 对象modules 列表中(compilation.modules 会在构建 chunks 的时候使用)。

    • build

      build 阶段webpack 做了 两件事情: 使用 resolve 阶段收集的 loaders 处理源文件内容, 然后 使用 parser 解析 loader 处理以后的源文件内容

      具体的 build 流程 如下:

      1. 根据 收集的 loaders 的绝对路径, 通过 require(path) 的方式, 获取 每一个 loader 提供的 方法

      2. 根据 源文件绝对路径读取源文件的内容(是一个字符串)

      3. 使用 步骤一loader 提供的方法源文件的内容 进行 预处理

        如: 使用 css-loader 处理 css 内容字符串babel-loader 处理 js 内容字符串vue-loader 处理 .vue 文件内容字符串

        loader 返回的都是 js 格式的内容字符串

      4. 使用 parser 解析 步骤三 返回的 内容字符串

        parser 会将 输入的代码内容字符串 解析为一个 ast 对象ast 对象 中包含一系列 节点- nodenode 节点类型 为: ImportDeclarationVariableDeclarationSwitchStatementIfStatementWhileStatementForOfStatementExportDefaultDeclaration 等, 分别对应文件内容中的 import 声明语句变量声明语句switch语句if语句while语句for-ofexport 声明语句, 如:

        import {func} from 'util.1.js'
        
        // 对应的 ast对象 节点
        {
            "type": "ImportDeclaration", // import 申明
            "specifiers": [
                {
                    "type": "ImportDefaultSpecifier",
                    "local": {
                        "type": "Identifier",
                        "name": "func"
                    }
                }
            ],
            "source": {
                "type": "Literal",
                "value": "util.1.js",
                "raw": "'util.1.js'"
            }
        }
        
        

        webpack 会遍历 ast 对象 中的 节点, 如果节点与文件的输入语句相关, 那么会创建一个 dep 对象dep 对象request 属性 会从 ast 对象节点source.raw 获取。

        如果 节点对应的语句中使用了全局变量, 也会创建一个 dep对象

        如果是 普通依赖dep 对象 会添加到 module 对象dependencies 列表中; 如果是 懒加载依赖dep 对象 会添加到 module 对象blocks 列表中; 如果是 全局变量dep 对象 会添加到 module 对象variables 列表中。

      5. 处理模块的依赖

        遍历模块的 dependenciesblocksvariables 列表, 根据列表中 dep 对象重复步骤一到步骤五, 生成 依赖模块, 并 处理依赖模块的依赖, 直到模块的 dependenciesblocksvariables 列表为 为止。

    这样, 经过 resolvecreatebuild 阶段, 一个模块就 构建 完毕。

  • chunk

    概述

    在构建 模块依赖图 阶段, 每个源文件对应的 module 对象, 都会被 收集compilation 对象modules 列表 中。 等 模块依赖图构建完成 以后, webpack 会使用 compilation.modules 中的 module 对象 进行 代码分离 来生成 chunks, 每个 chunk 包含各自对应的 module 对象

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

    分类

    代码分离 生成的 chunk, 根据 包含模块的特性 以及 chunk 的用途,可以分为如下几类(个人意见):

    • initial chunk

      入口文件 对应的 module 所在的 chunk 称之为 initial chunk

    • async chunk

      懒加载文件 对应的 module 所在的 chunk 称之为 async chunk

    • runtime chunk

      客户端 负责 安装chunk安装module加载lazy chunkchunk

      runtime chunk 可以通过 optimization.runtimeChunk: trueinitial chunk 中分离出来。

    • normal chunk

      通过 optimization.splitChunks 策略 分离生成的 chunk 都可以称之为 normal chunk

    不同的 chunk,在 客户端加载顺序不同runtime chunk 加载顺序最前, 然后 initial chunk、normal chunk 次之, async chunk 最后normal chunk 加载顺序不固定, 可能先于 initial chunk, 也可能后于 initial chunk, 得看具体情况。

    split chunks

    compilation.modules 中收集的 所有 module 分离成 chunks, 要经历三个阶段: 构建 initial chunk分离 async chunk使用 optimization.splitChunks 策略进行优化形成 normal chunk

    1. 构建 initial chunk

      根据 入口文件(main.js) 对应的 模块(main module), webpack 会创建一个 chunk 对象。 这个 chunk 对象 会用 入口文件的名称 来命名,一般为 'main', 因此 chunk 也称之为 main chunkinitial chunk 会将 main module 收集到 _modules 列表中。

      如果是 多页面应用, 有 多个入口文件, 会生成 多个 initial chunk

    2. 以 main module 为起点,遍历 模块依赖图,分离 async chunk

      模块依赖图 中, 每一个 module 依赖的 normal modules 都会收集到 dependencies 列表中, 依赖的 全局变量 会收集到 variables 列表 中, 依赖的 async modules 会收集到 blocks 列表 中。

      分离 的时候, 会 依次遍历各个模块dependenciesvariablesblocks列表。

      具体的 分离过程 如下:

      1. 遍历 main moduledependenciesvariables 列表, 将 dependenciesvariables 中收集的 module对象 添加到 mian chunk_modules 列表中。

        继续遍历 dependenciesvariables 列表中的 module 对象dependenciesvariables 列表, 直到 module 对象dependenciesvariables 为止。 将 遍历过程中遇到到所有 module 都添加到 initial chunk_modules 列表 中。

      2. 遍历 main moduleblocks 列表, 为 blocks 中收集的 module,创建一个 新的 chunk, 即 async chunk。 将 block 列表 中的 async module,分别添加到对应的 async chunk_modules 列表 中。

      3. async chunk 中, 以 async module起点,遍历 async moduledependenciesvariables 列表, 将 dependenciesvariables 中收集的 modules 添加到 async chunk_modules 列表 中。

        继续遍历 dependenciesvariables 列表中的 module 对象dependenciesvariables 列表, 直到 module 对象dependenciesvariables 为止。 将 遍历过程中遇到到所有module 都添加到 async chunk_modules 列表中。

      4. 遍历 async moduleblocks 列表, 为 blocks 中收集的 module,创建一个 新的 async chunk, 将 block 列表 中的 async module,分别添加到对应的 async chunk_modules 列表中。

      5. 重复 步骤3步骤4,直到 blocks 为止。

      经过上述过程, 我们可以得到一个 initial chunk 和 多个 async chunk

      如果一个 chunk A(initial chunk 或者 async chunk) 中可分离出一个 chunkB(async chunk), 那么 chunkAchunkBparentchunkBchunkAchild。 一个 chunk 可以有 多个 parent, 也可以有 多个 child

      async chunk 分离以后, 会进行 优化。 如果 async chunk(child) 中收集的 moduleparent chunk 中已经存在, 那么移除 async chunk(child) 中对应的 modules

    3. optimization.splitChunks 优化

      initial chunksasync chunks 生成以后, 我们还需要对它们做一些 优化。 比如, 将第三方库(如vue、react)对应的 module 分离多个 chunk 中公共 module 分离 等。

      通过 optimization.splitChunks.cacheGroups 配置项, 我们可以将上述 moduleinitial chunksasync chunks 分离出来。

      webpack4optimization.splitChunks.cacheGroups 提供了 默认值, 如下:

      {
          ...
          optimization: {
              splitChunks: {
                  cacheGroups: {
                      // 将被至少2个chunk共享的module分离成新的chunk
                      default: {
                          automaticNamePrefix: '',
                          reuseExistingChunk: true,
                          minChunks: 2,
                          priority: -20
                      },
                      // 将引用的第三方库分离成新的chunk
                      vendors: {
                          automaticNamePrefix: 'vendors',
                          test: /[\\/]node_modules[\\/]/,
                          priority: -10
                      }
                  }
              }
          }
      }
      

      通过上述配置, 我们就可以将 第三方库多个chunk共享的module 分离成 新的 chunk。这些 新的 chunk,可以称为 normal chunk。( optimization.splitChunks.cacheGroups 的用法详见 官网 - splitChunksPlugin )。

      另外, optimization.splitChunks 还提供了一些 配置项,对 代码分离 进行了 限制。具体的 限制 如下:

      • maxAsyncRequests

        按需加载 时的 最大允许并行请求数production 模式 下为 默认值5developmen t模式 下为 默认值Infinity(不做限制)

        如果不满足, 代码分离失败

      • maxInitialRequests

        入口点处最大并行请求数production模式 下为 3development模式 下为 Infinity(不做限制)

        如果不满足, 代码分离失败

      • minSize

        指定生成块的最小大小, 以字节为单位production 模式 下默认 30KB.

        如果不满足, 代码分离失败

      • maxSize

        webpack 会尝试将大于 maxSize 的块拆分成 更小的部分

      • minChunks

        代码分离前必须共享模块的最小块数

      • chunks

        表明可以选择哪些 chunks 中的 modules 进行分离。

      上述配置项的具体用法及说明详见: 官网 - splitChunksPluginwebpack4 常用配置项使用整理

    综上, 我们便可以将 源文件 根据我们的实际需要分离为多个 chunks

  • 构建 bundle

    chunks 构建完成以后,接下来要做的是根据 chunk 中收集的 modules, 构建可输出的 bundle 文件

    构建过程如下:

    1. 根据 chunk 的类型, 获取对应的 template

      webpack 提供了两种 templatemainTemplatechunkTemplate

      template 用于构建 chunk 对应的 输出内容

      在前面 分离 chunk 的部分, 我们了解到 initial chunk 中可以分离出 runtime chunkruntime chunk 在应用中是 第一个加载 的,用于 安装所有的 chunks 以及 chunk 中 modules

      initial chunk, 如果 没有分离出 runtime chunk, 使用 mainTemplate 构建 输出文件内容; 如果 分离了 runtime chunk, 使用 chunkTemplate 构建 输出文件内容

      async chunknormal chunk 使用 chunkTemplate 构建 输出文件内容

    2. 根据 output.filename 构建 bundle 的文件名

    3. 遍历 chunk 收集的 modules, 构建每个 module 对应的 输出内容

      在这个阶段, 会用到 构建 module 阶段 生成的 生成器 - generator

      generator 会 从 module对象_source 属性中获取 经 loader 处理以后的源文件的内容字符串, 然后根据 module 对象dependenciesblocksvariables 中存储的 dep 对象替换源文件内容字符串中的 引入、输出语句、全局变量语句

      // 源文件 - example1.js, 是一个懒加载文件
      import {func3} from '../utils/util.3'
      
      export const example1 = () => {
          func3()
          alert('example1.1.9')
      }
      
      // 输出文件
      {
      
      /***/ "./src/examples/example.1.js":
      /***/ (function(module, __webpack_exports__, __webpack_require__) {
      
              "use strict";
              __webpack_require__.r(__webpack_exports__);
              /* harmony export (binding) */
              __webpack_require__.d(__webpack_exports__, "example1", function() { return example1; });
              /* harmony import */ 
              var _utils_util_3__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/utils/util.3.js");
              
              var example1 = function example1() {
                  Object(_utils_util_3__WEBPACK_IMPORTED_MODULE_0__[/* func3 */ "a"])();
                  alert('example1.1.9');
              };
          
          /***/ })
      
      }
      
    4. 利用步骤3返回的 模块输出内容,构建 chunk 对应 bundle

      步骤三中 example1 对应的 chunk 生成的 bundle 如下:

      (window["webpackJsonp"] = window["webpackJsonp"] || []).push([["example1"],{
      
      /***/ "./src/examples/example.1.js":
      /***/ (function(module, __webpack_exports__, __webpack_require__) {
      
              "use strict";
              __webpack_require__.r(__webpack_exports__);
              /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "example1", function() { return example1; });
              /* harmony import */ var _utils_util_3__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/utils/util.3.js");
              
              var example1 = function example1() {
                  Object(_utils_util_3__WEBPACK_IMPORTED_MODULE_0__[/* func3 */ "a"])();
                  alert('example1.1.9');
              };
      
      /***/ })
      
      }]);
      

    通过上述过程, 一个 chunk 对应的 bundle 文件 便可生成, 最后利用 node提供的文件输出功能 便可 将生成的bundle文件输出到 output 配置项指定的位置

    另外, runtime chunk 对应的 bundle 文件 格式如下:

    // 用于安装chunk和chunk中的module
    (function(modules) { // webpackBootstrap
        // install a JSONP callback for chunk loading
        // 安装 chunk
        function webpackJsonpCallback(data) {
     		...
     	};
     	
     	...
     	
     	// 安装chunk中的每一个module, 获取 module 的输出
     	// The require function
     	function __webpack_require__(moduleId) {
     	    ...
     	}
     	
     	...
    })([]);
    
  • hash

    webpack编译打包 过程中, 会根据 output 配置项 提供的 hashFunction(默认md4)hashDigest(默认hex)hashDigestLength(hash前缀长度, 默认20) 等生成 hashhash 有几种类型, 如下:

    • module hash

      每一个 module标识,根据 module 对应的 源文件的内容、模块ID 等信息生成, 长度为 32

      修改 源文件的内容模块ID, 都会导致 module hash 值的变化。

    • module renderedHash

      根据 hashDigestLength, 截取 module hash前20位

    • chunk hash

      每一个 chunk 的标识, 根据 chunk的namechunk 中收集的所有 module 对象的 module hash 等信息生成, 长度为 32

      只要修改 chun某一个 module 对象对应的源文件的内容、文件名、位置, 都会导致 chunk hash 的 变化。

    • chunk renderedHash

      根据 hashDigestLength, 截取 module hash前20位

      output.filename = '[name].[chunkhash].js' 中使用的 chunkhash 就是 chunk renderedHash

    • chunk contentHash

      chunkcontentHash 是一个 对象, 里面有 两个 属性:css/mini-extractjavascript, 属性值是 hash 值

      chunk.contentHash["css/mini-extract"] 的值为 chunk 中所有 css 内容 生成的 hash 值的 前20位

      chunk.contentHash.javascript 属性值是 chunk 中所有 js 内容 生成的 hash 值的 前20位

      修改 chunk 中的 css 内容chunk.contentHash["css/mini-extract"] 的值 会变化chunk.contentHash.javascript 的值 不会变化

      同理, 修改 chunk中 js 内容chunk.contentHash.javascript 的值 会变化chunk.contentHash["css/mini-extract"] 的值 不会变化

    • compilation fullhash

      每一个 compilation标识, 根据 compilation 生成的所有 chunk 对象的 chunk hash 信息生成, 长度为 32

      修改任意一个源文件, 都会导致 compilation fullhash 变化

    • compilation hash

      根据 hashDigestLength, 截取 compilation fullhash前20位

      output.filename = '[name].[hash].js' 中使用的 hash 就是 compilation hash。 即 所有的 bundle 文件名中的 hash相同

未完待续...

开发模式相关知识

  • 基础知识

    通过设置配置项 mode: "development",我们可以将 webpack 的工作模式设置为 开发模式

    开发模式 使用到的 三个关键包webpackwebpack-dev-serverwebpack-dev-middleware,各自的功能如下:

    • webpack-dev-server

      基于 express 构建一个 dev-server

      devServer 配置项 用于指引 wepack-dev-server 的行为。

    • webpack-dev-middleware

      返回一个 middleware

    • webpack

      构建一个 watching 对象, 对 源文件 编译打包以后 缓存到内存 中, 并对 源文件进行监听。如果 源文件发生变化重新编译打包

  • 开发模式工作流程

    开发模式 下, webpack 的工作流程如下:

    1. 使用 webpack 构建一个编译器 compiler

    2. 使用 webpack-dev-server 创建一个 server,监听 host:port;

    3. compiler 开始工作

      compile 会将 源文件 编译打包并输入到内存 中,将编译打包过程中收集的 moduleschunks 缓存到 compiler.records 对象中,供下一次编译打包使用。

    4. 浏览器 通过 url 链接访问应用,建立 websocket 连接;

    5. 修改 源文件

    6. 服务端 监听到 源文件 发生变化, compiler 重新开始工作, 重新 构建依赖图,并将 修改后的文件打包成一个 新的 chunk 文件

      编译打包 的时候, 大量的时间花在构建 模块依赖图上(大量的读文件操作)开发模式 下,修改了哪个文件,就重新读某个文件,然后重新解析,未改变的文件 直接从 内存(compile.records) 中读取 上一次编译打包 生成的 module,这样 重新构建 依赖图 的时间会小的多。

      判断缓存中的 module 是否可用的过程:模块 上一次 build 成功以后会有一个时间: buildTimestamp;如果 源文件 发生修改,修改以后的文件 会得到一个 修改时间: filestamp。 如果 filestamp > buildTimestamp, 则 模块需要重新build, 如果 小于, 则 不需要 build, 直接使用缓存

    7. 服务端通过 websocket 连接, 通知浏览器更新

      如果不是 热更新 - hmr浏览器重新加载(刷新页面) 的方式来 更新应用

      如果是 热更新 - hmr,浏览器会通过 动态添加script元素 的方式,请求 更新后的 chunk 文件,然后 更新浏览器中缓存的 module

  • 模块热替换 - HRM

    模块热替换,即 浏览器页面无需刷新,便可 自动加载更新以后的源文件

    若想触发 HRM 功能,需满足三个条件:

    • hot 配置项 的值为 true

      devServer.hot = true;

    • 启用 inline 模式

      devServer.inline = true;

    • 必须 显示 声明 module.hot.accept('url', callback), 否则只能 刷新页面

    HMR 的工作流程如下:

    1. 浏览器 构建 webSocket 对象, 注册 message 事件;

    2. 服务端 监听到文件发生变化, 生成更新以后的 chunk 文件, chunk 文件中包含更新的 modules,然后通过 webSocket 通知 浏览器 更新;

    3. 浏览器 构建的 webSocket 对象触发 message 事件,会收到一个 hash 值和一个 ‘ok’ 信息, 然后通过 动态添加 script 元素, 加载 新的 chunk 文件

    4. 根据 module id应用缓存(installedModuled) 中 找到之前 缓存的 module。 然后以找到的 module 为基础, 递归遍历 module.parent 属性, 查找定义 module.hot.acceptparent module

      如果没有找到, 则 hmr 不起作用, 只能通过 重新加载页面 来显示更新。 在 递归过程 中, 我们会把遇到的 module id 存储起来。

    5. 找到定义 module.hot.acceptparent module 之后, 根据第四步收集的 module id, 将 installedModules 中将对应的 module 清除, 然后根据 module.hot.accept(url, callback) 中的 url, 重新 安装关联的modules

    6. 执行我们注册的 callback

  • inline 模式 & iframe 模式

    通过配置 devServer.inline, 我们可以设置启用 inline 模式 或者 iframe 模式

    inline 默认值为 true, 即为 inline 模式。 如果 inline 的值为 false, 则为 iframe 模式

    inline 模式iframe 模式的原理相同, 都是 浏览器服务器 之间建立 websocket 连接。当 源文件 发生变化时,通知浏览器进行更新

    inline 模式 下, 浏览器端建立 websocket 连接的逻辑代码 会和我们的 应用代码 打包到一起, 会增加编译时间。

    iframe 模式 下,浏览器端 建立 websocket 连接的逻辑代码 和 我们的 应用代码 是分开的。 当我们通过 localhost:port/webpack-dev-server/ 访问应用时, 服务端会返回一个 html 页面。html 页面中会包含一个 live.bundle.js 的文件, 负责 建立与服务器之间的 websocket 连接, 并 动态建立一个 iframe 用于加载实际应用。当源文件发生变化时, 页面不刷新, 会通知 iframe 重新更新。

    HRM 是应用在 inline 模式下的,iframe 模式下无法启用 HMR

未完待续...

loader 相关知识

  • loader的本质

    webpack 中的 loader 本质上是一个 函数 - function函数输入值 可以是 源文件中源代码字符串内容, 也可以是 格式化以后的文件请求路径,还可以是 上一个 loader 返回结果返回值处理以后生成的 js 代码字符串

  • css-loader

    css-loader 用于处理 css 格式 的内容, 即 css-loader输入值css内容字符串(如: ".class1 { height: 100px; }")。 这个 输入值, 可以 直接从 .css 文件 中直接获取, 也可以是 上一个 loader 提供(如 sass-loader 处理 .scss 文件,返回的 css 内容字符串 作为 css-loader 的 输入值)

    css-loader输出值 是一段 js代码字符串。执行这一段 js 代码,会返回 css 模块 对应的 module 对象module.exports 是一个 数组, 存储着 css 模块 及 子css模块idcss内容字符串

    // 返回一个数组, 用于收集 css module对象
    exports = module.exports = require("../../node_modules/_css-loader@3.1.0@css-loader/dist/runtime/api.js")(false);
    // 收集css中引入的子css module对象
    exports.i(require("-!../../node_modules/_css-loader@3.1.0@css-loader/dist/cjs.js!./style.1.css"), "");
    // 收集 css module对象
    exports.push([module.id, ".egoo9WLFwSoCC0hGalRN3 {\r\n    width: 100px;\r\n    height: 100px;\r\n    background-color: aqua;\r\n}", ""]);
    // 如果css-loader的modules属性为true, 会将类名、id名等转化为唯一名称
    // locals 收集 css类名、id名 和 生成的唯一名称
    exports.locals = {
        // class 为类型名, 对应的值为 css-loader根据class生成的唯一名称
    	"class": "egoo9WLFwSoCC0hGalRN3"
    };
    

    必须使用 css-loader 处理 css 内容字符串, 将 css内容 转化为 js代码字符串, 否则 webpack 会报错

    原因: webpack 在构建 依赖关系图 时, 通过 acornParser 解析模块源代码字符串,从中 收集依赖的子模块acornParser 只能 处理js代码, 如果 输入值css代码 或者 其他,会 抛出异常

    使用 css-loader 的时候, 可以通过传入一个 配置项-options, 来控制 css-loader 的行为, 具体使用详见 官网

  • style-loader

    style-loader 会通过 动态添加 style标签 到文档 head 的方式, 使 css样式 生效。

    style-loader输入值 是一个 内联 css-loader 的文件请求路径输出值 是一段 js 代码字符串

    输入值格式 如下:

    // 内联 css-loader 的文件请求路径
    !!../../node_modules/_css-loader@3.2.0@css-loader/dist/cjs.js!./style.css"
    

    输出值(代码字符串)格式 如下:

    // 获取经过css-loader处理的css内容
    // content 是一个数组, 存储着样式内容
    "var content = require("!!../../node_modules/_css-loader@3.2.0@css-loader/dist/cjs.js!./style.css");
    
    if(typeof content === 'string') content = [[module.id, content, '']];
    
    ...
    // 获取addStyles方法, 然后将content中的css样式通过添加style的方式添加到head中
    // 返回一个update方法, 用于热更新
    var update = require("!../../node_modules/_style-loader@0.23.1@style-loader/lib/addStyles.js")(content, options);
    
    if(content.locals) module.exports = content.locals;
    // 热更新代码
    if(module.hot) {
    	module.hot.accept("!!../../node_modules/_css-loader@3.2.0@css-loader/dist/cjs.js!./style.css", function() {
    		var newContent = require("!!../../node_modules/_css-loader@3.2.0@css-loader/dist/cjs.js!./style.css");
    		
    		...
            // 使用update方法进行热更新, 使用新的css内容更新style标签中原来的样式内容
    		update(newContent);
    	});
    }"
    

    项目运行 时, 会 执行上述的代码,将 css样式 添加到 文档 中,步骤如下:

    1. 执行 css-loader 返回的 js代码, 得到一个 数组对象 - contentcontent 中存储着 css样式内容字符串

    2. 获取 style-loader 提供的 addStyle 方法, 将 content 中的 css样式内容 添加到文档的head 中。

      addStyle 方法执行过程中, 会遍历 content数组。 针对 content 中的 每一个元素,都会通过 document.createElement('style') 方式创建一个 style 元素 添加到 head中, 然后利用 元素中css样式内容字符串 通过 document.createTextNode(css) 方法创建一个 文本节点, 再将这个 文本节点 通过 style.appendChild(textNode) 的方式添加到 style 标签 中。

    开发模式 下,style-loader 输出的代码 中会包含 热更新 逻辑(hmr: true)。 如果我们修改了 源文件中的样式内容浏览器端 会获取到 修改以后的css样式内容, 然后通过 textNode = document.createTextNode(newCss)style.appendChild(textNode) 的方式 更新样式

    使用 style-loader 的时候, 可以通过传入一个 配置项-options, 来控制 style-loader 的行为, 具体使用详见 官网

    style-loader 必须需配合 css-loader 使用,无法单独使用,否则会 报错, 对应的 配置项 如下:

    rules: [{
        test: /.css$/,
        loaders: ['style-loader', 'css-loader']
    }]
    

    在使用 webpack 处理 css内容 时, style-loader 先工作, css-loader 后工作。

  • sass-loader

    sass-loader 可以将 sass/scss 样式代码 通过 编译 生成 css 样式代码

    sass-loader输入值sass/scss 样式代码字符串输出值css样式代码字符串

    sass-loader输出值 一般作为 css-loader输入值 使用。

    webpack打包过程 中, 如果未使用 sass-loader 处理, 最后 输出的样式内容sass/scss 格式浏览器无法识别

    sass-loader 必须配合 css-loader 处理, 否则没有意义, 对应的配置项如下:

    rules: [{
        test: /.scss$/,
        loaders: ['style-loader', 'css-loader', 'sass-loader']
    }]
    

    在使用 webpack 在处理 scss/sass 内容 时, style-loader 先工作, sass-loader 后工作, 最后 css-loader 工作。

  • less-loader

    工作模式和sass-loader相同

未完待续...

plugin 相关知识

  • miniCssExtractPlugin

    miniCssExtractPlugin 可以将项目中使用到的所有 css 内容 打包合并,生成一个新的 .css 文件, 具体使用详见 官网

    使用 miniCssExtractPlugin 时, 需要构建一个 plugin 实例, 添加到 webpack 配置项plugins 列表 中。 构建 实例 的时候, 可以传入一个 配置项配置项 中的属性为: filenamechunkFilenamemoduleFilenameignoreOrder

    其中, filename 用于指定从 initial chunk 中分离出的 .css 文件 对应的 文件名, 默认值 为 '[name].css'。 chunkFilename 用于指定从 非 initial chunk(async chunk、normal chunk等) 中分离出的 .css 文件 对应的 文件名默认值 为 '[id].[contenthash].css'。moduleFilename用户自定义函数, 用于 动态生成 filenameignoreOrder 指示 是否忽略引用的 .css 文件 之间的顺序, 默认为 false

    miniCssExtractPlugin 工作时,会将 chunk 中的 css modules 分离出来生成一个 .css 文件。 极端情况下, 有多少个 chunk, 就会生成多少个 .css 文件。 此时, 我们可以先通过 optimization.splitChunkschunk 中的 css modules 分离出来生成一个 新的 chunk, 然后再根据这个 chunk 生成 .css 文件, 这样最后只会生成 一个 .css 文件

    .css 文件 的命名规则一般为 '[id].[contentHash].css'。 从 chunk 中分离 .css 文件 生成 文件名 时, id 对应 chunk.id, contentHash 对应 chunk.contentHash["css/mini-extract"]

    不要使用 [id].[chunkhash].css

    miniCssExtractPlugin 的工作过程主要是 两个部分:

    1. 通过 miniCssExtractPlugin.loadercss 内容 全部转化为 css module 添加到 模块依赖图 中。

      在处理过程中, 会构建一个 child compiler

    2. 利用 chunk 中的 css modules, 生成 bundle, 并输出到 指定位置

未完待续...

optimization 相关知识

  • tree shaking

    概述

    tree shaking 可以将 未使用的代码(dead code)打包代码删除优化打包代码的体积

    使用 tree shaking 前提 : 模块的输入和输入,必须使用ES2015模块语法,即 import / export

    原因: tree shaking 是对代码的静态分析,在编译阶段就需要确认模块的使用情况ES2015模块语法编译时加载支持静态分析,使得 编译时就能确定模块的依赖关系,以及输入和输出的变量

    require 是运行时加载, 在编译阶段无法知道模块的内部使用情况

    配置说明

    我们可以通过设定 optimization.sideEffects 的值来决定 是否开启 tree shaking。如果 optimization.sideEffects 的值为 ture启用 tree shaking; 如果值为 false不启用 tree shaking

    production 模式下, optimization.sideEffects 的值为 true,默认 开启 tree shaking

    optimization.sideEffects 的值为 true 时,webpack 会安装 SideEffectsFlagPlugin 来帮助我们实现 tree shaking

    每个 模块生成 以后,都会通过 SideEffectsFlagPlugin 添加一个 标志属性: sideEffectFree。 这个 标志属性 代表 tree shaking 是否可以作用于模块如果为 true, 代表 当前模块 可是使用 tree shaking,没有副作用;如果为 false,代表 当前模块 不可以使用 tree shaking

    项目根目录 - package.json 中的 sideEffects 属性的值 会影响 模块 sideEffectFree 属性的值:

    • 没有 sideEffects 属性(即值为undefined) 或者 sideEffects 的值为 true, 声明使用 tree shaking 会有 副作用。此时 所有模块 sideEffectFree 属性值会置为 false不可使用 tree shaking

    • sideEffects 的值为 false,声明使用 tree shaking 不会有 副作用。此时 所有模块 sideEffectFree 的属性值为 true,可以使用 tree shaking没有副作用

    • sideEffects 的值为 字符串 或者 数组,声明 满足条件的模块使用 tree shaking 会有副作用。 如果 模块的请求路径匹配 sideEffects 的值,则 sideEffectFree 的属性值为 false不可使用tree shaking; 如果 不匹配,则 sideEffectFree 的属性值为 true可以使用 tree shaking

    另外,module 配置项rule.sideEffects 的值也会影响 模块sideEffectFree 属性的值:

    • 没有sideEffects属性模块 sideEffectFree 的值受项目根目录 - package.json 中的 sideEffects 属性值 的影响;

    • sideEffects的值为true对匹配规则的模块使用tree shaking有副作用模块 sideEffectFree 的值会置为 false不可使用tree shaking

    • sideEffects 的值为 false对匹配规则的模块使用 tree shaking 不会有副作用模块 sideEffectFree 的属性值置为 true可以使用 tree shaking

    module 配置项 中 rule.sideEffects 的 优先级 会高于 项目根目录 - package.json 中的 sideEffects

    分类

    tree shaking 可以分为两类: 整体 tree shaking局部 tree shaking

    整体 tree shaking,即 如果模块只被引用但没有使用,当前模块会从打包代码中删除

    局部 tree shaking, 即 一个模块有多个 export,未使用的 export 会从打包代码中删除

    模块 sideEffectFree 属性会在 打包阶段 起作用。

    打包阶段,会将 compilation 收集的modules 分离成 chunks每一个chunk 都通过 _modules 属性 来收集 属于它的 module。如果 modulesideEffectFree 属性值为 true,且 module 的输出完全没有被使用, 那么该 module不会添加到 _modules 列表中,最后 输出的打包文件中不会包含该module,这样就达到了 tree shaking 的目的。

    实现 局部 tree shaking,还需要将配置项 optimization.usedExportsoptimization.minimize 的值设置为 trueproduction 模式下, 这两个配置项的值默认为true。在 打包阶段,会为 每一个module 对象 添加一个 usedExports 属性, 该属性是一个 数组数组元素是模块被使用的 输出(export) 的名称。 如果 模块的输出未被使用, 则 usedExports 的值为 nullwebpack最后的构建输出文件代码 时, 会根据 moduleusedExports 中的值, 确定已使用的export, 如:

    // 最后的输出代码
    (function(module, __webpack_exports__, __webpack_require__) {
    // cube 有被使用, 则 module 的 usedExports 的值为 ["cube"]
    // 确定已使用的 export 为 cube, square 未被使用
    /* unused harmony export square */
    /* harmony export (immutable) */ __webpack_exports__["a"] = cube;
    function square(x) {
      return x * x;
    }
    
    function cube(x) {
      return x * x * x;
    }
    

    输出文件代码 生成以后, webpack 会根据 optimization.minimize: true 启用 TerserPlugin(Uglify)输出文件进行压缩、混淆未使用的代码(如square)会被删除, 达到 tree shaking 的目的。

webpack 打包分析、优化

webpack 打包分析

分析 webpack打包速度、打包体积 常用手段:

  • 使用 webpack 内置的 stats

    颗粒度比较粗,只能知道 构建时间打包文件体积无法知道哪个阶段耗时长、具体哪个模块体积大

  • 使用 speed-measure-webpack-plugin

    颗粒度比较细, 可用于分析 打包总耗时 以及 每个插件loader耗时情况

  • 使用 webpack-bundle-analyzer

    可视化 分析 各个bundle的体积各个bundle中包含的module及体积, 通过分析可进行 针对性的优化

webpack 打包优化

webpack 打包可以从 两个方面 进行 优化 - 打包速度打包文件体积

  • 打包速度优化

    可以使用以下措施 优化打包速度:

    1. 使用 高版本webpacknode提升打包速度;

    2. 多进程/多实例构建

      使用 happypack 或者 thread-loader(不要滥用)。

      module: {
          rules: [{
              test: /\.js$/,
              loaders: [{
                  loader: 'thread-loader',
                  options: {
                      workers: 3
                  }
              }, 'babel-loader']
          }]
      }
      
    3. 多进程并行压缩代码

      通过 optimization.minimizer 来配置, 使用 teser-webpack-plugin 进行 多线程并行压缩代码

      optimization: {
          minimizer: [new TerserWebpackPlugin({
              parallel: 4
          })],
          ...
      }
      

      不要滥用,需根据实际情况酌情使用,否则会起到反效果。

    4. 预编译资源文件(第三方库、业务库等)

      使用 DllPlugin 提前将 第三方类库业务库 打包成 一个文件一 个mainfest.json 文件, 然后通过 DllReferencePlugin 使用 预编译生成的打包文件

    5. 充分利用 缓存 提升 二次构建速度

      使用缓存的一些思路:

      • babel-loader 开启缓存

      • terser-webpack-plugin 开启缓存

      • 使用 cache-loader

      开启缓存后,上次编译产生的文件 会保存在 node_modules 目录 下的 .cache 文件夹中,供下次编译使用。

    6. 缩小构建目标

      • 缩小 loader 的使用范围 - module 配置项中使用 excludeinclude

      • 减少文件的 搜索范围

        • 优化 resolve.modules, 只在根目录下的 node_modules 下寻找 npm 包,减少模块搜索层级;

        • 优化 resolve.mainFileds, 设置为 main, 在npm包package.json 文件中 通过 main 字段 查找入口文件;

        • 合理使用 alias

  • 打包文件体积优化

    可以使用以下措施 优化打包文件体积

    1. 使用 懒加载 以及 合理的代码分离策略(optimization.splitChunks);

    2. tree shaking 删除 无用 js、css 代码

      • 开启 tree shaking, 将 未使用的依赖模块bundle 中删除;

      • 设置 optimization.minimize: true, 将 模块中未使用的js代码 删除;

      • 使用 purgecss-webpack-plugin, 将模块中 未使用的css代码 删除。

        需配合 mini-css-extract-plugin 使用

    3. 通过 image-webpack-loader 压缩图片

    4. 使用 动态 polyfill 服务

      不使用 babel-polyfill,使用 polyfill-service。根据 user agent,返回 浏览器 对应的 polyfill

其他

wepack 魔法注释

通过 import() 的方式 动态导入模块 时,我们可以添加如下注释:

  • webpackChunkName

    指定 lazy chunkname

  • webpackPrefetch

    import(/* webpackChunkName: "example3", webpackPrefetch: true */ './examples/example.3')
    

    预提取懒加载chunk,具体实现方式如下:

    <link rel="prefetch" as="script" href="example3.js">
    

    浏览器通常会在 空闲状态 取得这些资源,在取得资源之后搁在 HTTP缓存 以便于实现将来的请求。

  • webpackPreload

    import(/* webpackChunkName: "example3", webpackPreload: true */ './examples/example.3')
    

    预加载懒加载chunk,具体实现方式如下:

    <link rel="preload" as="script" href="example3.js">
    

    preload 的块与 父块 并行加载

  • webpackInclude

    正则表达式匹配到的模块可以打包

  • webpackExclude

    正则表达式匹配到的模块不可以打包

  • webpackMode

    可指定不同的模式 解析动态导入

    默认为 lazy, 分离 lazy chunk; 值为 eager, 不分离 chunk

打包文件中模块的使用

webpack 提供了两个全局变量: modulesinstalledModules。其中,modules 负责收集各个模块的安装函数, 即初始化函数;installedModules 负责收集的是已经初始化的 module

安装 chunks, 收集 chunk 中各个 module 的 install function(初始化函数)。使用 module 的时候,如果 module 未初始化(installedModules 中没有),执行 modules 中对应的 install function, 初始化 module(添加到 installedModules 中)。