不再一知半解,这次真的让你理解透彻webpack打包原理,小白读不懂?不存在的!

3,987 阅读21分钟

文章由来

在上一篇文章中分享了关于运用webpack搭建vue项目的经验和总结,但还仅仅停留在只是会用webpack搭建脚手架的阶段,对webpack原理还是不怎么清楚,再加上各大论坛对webpack原理解析的精品文章较少,要么是一些标题党,通篇教你如何配置webpack,如何优化;要么就是通篇copy源码+简单注解;当然也有大牛写的文章,文章虽好,但晦涩难懂,谁让小弟不才呢。

种种原因,决定狠下心研究下webpack的实现原理(真的是难啊)。但我相信,通读此篇,就算是菜鸡,也能对webpack的原理理解透彻。

好了,闲话不多说,先看看webpack官网对自己的定义,从定义中寻找突破口!let's go~

本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle

关键字 递归依赖生成一个或多个bundle

什么是递归的构建,什么又是依赖呢?如下图

模块A.js引用模块B.js,模块B.js引用模块C.js,此时A、B、C就构成了依赖关系,那为什么要递归的构建呢?请问,webpack配置文件是怎么配置的?

不管是单entry还是多entry,配置文件的entry仅仅只有一个或多个入口文件。

拿上个例子来说,将A.js设置为entry,此时webpack打包时,就必须把A.js中所有require的模块打包在一起(B.js),但此时B.js也有依赖(C.js),这时候就必须递归的进行解析了(如果依赖中还有依赖,那接着递归)。先把C.js与B.js进行打包合并,然后把合并后的代码与A.js合并,打包生成最终的bundle。

是不是有点头绪了?上面的例子仅仅是最为简单的分析了webpack是如何从entry解析构建依赖模块。下面让我们从项目中分析下webpack的打包后的代码。

项目结构目录如下

├── node_modules
├── src
│   ├── index.js
│   ├── ticket.js
│   ├── price.js
├── webpack.config.js

webpack配置项。

const path = require('path')
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'min-bundle.js',
    path: path.join(__dirname, 'dist')
  },
}

模块代码

分析webpack打包的bundle文件

三个模块文件,index.js为webpack的入口文件,它依赖了ticket.jsticket.js依赖了price.js。我们希望webpack打包生成的min-bundle.js运行后,能够log出 “迪斯尼门票甩卖,门票价格为299人民币/人”,执行打包,果不其然。那么问题来了,webpack怎么做到的呢?

这就得看生成的min-bundle.js了,为了更容易理解,将无关代码尽可能删除后,主要代码如下:

(卧槽,这都是啥?说好了不贴源码呢?不要急,咱们慢慢分析)

发现了吗?生成的bundle.js其实就是一个自调用函数,参数是一个对象,为当前项目中的入口文件和其依赖模块,即./src/index.js,./src/ticket.js,./src/price.js是一个函数,就是对应每个模块内的代码,使用eval来执行内部代码。自调用函数中,函数体内有一个__webpack_require__函数。下面开始逐步分析:

第一次执行

自调用函数中直接return __webpack_require__('./src/index.js'),于是开始执行__webpack_require__函数,__webpack_require__函数的参数moduleId,在第一次执行时就是项目的入口文件,即./src/index.js。进入函数体内看看?发现有个 module 对象

const module = {
    i: moduleId,
    exports: {}
}
该module对象的主要作用是,为每一个模块提供一个单独的module对象,module对象内还有一个exports对象
这就使得每个模块都可以使用module.exports和exports来对外暴露

接下来开始执行下面这行代码:modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)。一脸懵逼不要紧,跟上节奏,下面一步步解释每行代码的作用

  • modules是什么?就是自调用函数中传入的参数,也就是上图中红框内的对象

  • 那么moduleId呢?在第一次执行阶段为'./src/index.js'

  • 所以代码就变成了:modules['./src/index.js'],什么意思? 这一步,就是执行modules对象内,键为'./src/index.js'的函数,怎么执行呢?this指向指给谁呢? ...call(module.exports, module, module.exports, __webpack_require__)中,有四个参数

    arg1:`module.exports`,明确this指向(因为每个模块都会有各自的module对象)
    arg2:`module`对象,使得模块内可以通过module.exports来对外暴露
    arg3:`module.exports`,使得模块内可以通过exports来对外暴露
    arg4:`__webpack_require__`函数,为什么?既然要执行modules对象内所有的键对应的函数
           那函数内使用`__webpack_require__()`来进一步添加依赖,这个函数从哪来呢?
           就是从这传进来的,也就是用来递归的调用`__webpack_require__`
    
  • 开始执行 modules对象内键为'./src/index.js'的函数

    function(module, exports, __webpack_require__) {
       eval(`const ticket = __webpack_require__("./src/ticket.js");console.log('迪斯尼门票甩卖,' + ticket);`)
    }
    
  • 发现该函数调用了__webpack_require__("./src/ticket.js"),那岂不是又要走一遍上面的流程? 没错,因为index.js依赖了ticket.js

第二次执行

此时__webpack_require__(moduleId)的实参就变成'./src/ticket.js',仍然重复上面的步骤,当执行modules['./src/ticket.js'].call()时,就要执行 modules 对象中键为'./src/ticket.js'的函数了

function(module, exports, __webpack_require__) {
    eval(`const price = __webpack_require__("./src/price.js");module.exports = '门票价格为' + price.content;`)
}

发现该函数又依赖了price.js,没招啊,接着递归呗

第三次执行

此时__webpack_require__(moduleId)的实参就变成'./src/price.js',仍然重复上面的步骤,执行modules 对象中键为'./src/price.js'的函数

function(module, exports, __webpack_require__) {
    eval(`module.exports = {content: '299人民币/人'};`)
}

price.js中没有依赖项,于是直接返回{content: '299人民币/人'}

完事了?当然没有

price.js是执行完了,ticket.js还等着呢,于是开始赋值

未递归执行price.js时
eval(`
    const price = __webpack_require__("./src/price.js");
    module.exports = '门票价格为' + price.content;
`)
递归执行price.js后
eval(`
    const price = {content: '299人民币/人'}
    module.exports = '门票价格为' + '299人民币/人'
`)

别急啊老弟,ticket.js是执行完了,index.js还等着呢

未递归执行ticket.js时
eval(`
    const ticket = __webpack_require__("./src/ticket.js");
    console.log('迪斯尼门票甩卖,' + ticket);
`)
递归执行ticket.js后
eval(`
    const ticket = '门票价格为299人民币/人'
    console.log('迪斯尼门票甩卖,' + '门票价格为299人民币/人');
`)

此时,所有依赖模块解析完成,回到最初自调用函数的代码, return __webpack_require__("./src/index.js")

此时的__webpack_require__("./src/index.js")已经有了结果,即

'迪斯尼门票甩卖,门票价格为299人民币/人'

直接return,大功告成!可喜可贺!

阶段总结:webpack将每个js文件的名称作为,该js文件的代码作为,一一存入到对象中作为参数。然后自己实现了一个__webpack_require__函数,通过该函数,递归导入依赖关系。

到这里,分析webpack打包后的bundle.js就告一段落了。上面说了,webpack是把每个js文件的名称作为,该js文件的代码作为,一一存入到对象中作为参数的。那么问题来了,它内部是怎么操作的?如果配置了loaderplugin,又是如何处理模块内的js代码呢?

下面让我们实操一下,实现一个属于自己的迷你webpack,深刻的体会webpack的打包原理,loader原理和插件原理。

项目准备工作

新建两个项目,一个项目是min-pack的主程序,你可以理解为webpack,发布到npm后,供开发者通过 npm install min-pack 后使用;另一个项目是开发者自己的项目,也就是说,你要用min-pack,得先有自己的程序代码啊,不然你让min-pack打包谁?

min-pack的实现

准备工作

  1. 首先新建一个目录,命名为min-pack

  2. 在根目录下新建lib目录,目录内新建Compiler.js,该js用来实现解析打包,稍后会详细解读

  3. 在根目录下,新建template目录,目录内新建output.ejs,使用ejs模板来生成打包代码

  4. 在该项目下新建bin目录,将打包工具主程序放入其中

    #!/usr/bin/env node
    const path = require('path')
    
    // minpack.config.js 为开发者自己的项目下的配置文件,webpack4默认是0配置的
    // 我们这里就不做那么复杂了,直接指定配置文件为 minpack.config.js
    // 也就是说,你要用我的 min-pack,你项目的根目录下就必须有 minpack.config.js 配置文件
    // 注意: path.resolve 可以来解析开发者工作目录下的 minpack.config.js
    const config = require(path.resolve('minpack.config.js'))
    
    // 引入打包的主程序代码 Compiler.js
    const Compiler = require('../lib/Compiler')
    // 将配置文件传入Compiler中,并执行start方法,开始打包
    new Compiler(config).start()
    

    注意:主程序的顶部应当有:#!/usr/bin/env node标识,指定程序执行环境为node

  5. 在该项目中的package.json中配置bin脚本

    "bin": {
         "min-pack": "./bin/min-pack.js"
     }
     // 这样配置完后,在开发者自己的项目中,就可以使用 `min-pack` 来进行打包了。
    
  6. 通过npm link链接到全局包中,供本地测试使用。测试完成后再发布到npm上,供第三方开发者使用

完成了上述操作,你就可以在另一个项目中,也就是开发者要打包的项目里运行 min-pack 了。但现在的Compiler.js还没有实现,所以还做不到解析构建,下面让我们来实现下打包功能。

Compiler类

Compiler.js要做什么?

上面说了,你要用我的 min-pack,你的项目根目录下必须有 minpack.config.js 配置文件

  1. Compiler.js 接受传入的 minpack.config.js,获取到配置文件中 entry 对应的值,也就是入口文件,如 ./src/index.js

  2. 使用 node 模块中的 fs.readFileSync 读取该模块文件,获得模块文件的源代码

  3. 将该模块源代码转换为 AST 语法树。what?什么是AST 语法树?

    • 其实UglifyJS或babel转换代码,实际的背后就是在对JavaScript的抽象语法树进行操作。
    • 可以先简单的理解,AST语法树,就是为了让我们更高效,更简洁的对JavaScript代码进行操作。因为在下面第 4 步中会将模块源代码中require替换成 __webpack_require__,怎么替换?难道你让我写正则?或是操作字符串?那就太Low了吧
  4. 将源代码中的 require,全部替换成 __webpack_require__ (为什么?)

    • 因为浏览器环境并不识别require语法。你可能就要问了,我项目中所有的依赖都是使用 import A from 'xx'来导入模块,使用 export const xx = 1exports default {...} 来导出模块的,没使用 require啊。那么请问,你是不是使用 babel 来处理js的,babel 内部会把你的 import 转换为 require,把 exportexport default 转换为 exports。如下图

    • 再回忆下最开始我们分析 webpack 打包出的 min-bundle.js 时,可以发现,该js内部把我们项目中的入口文件的及其所有依赖内部的require() 全部替换成了 __webpack_require__,然后自己实现了 __webpack_require__,该函数内部定义了 module 对象,对象内部有 exports: {},所以,你可以使用exports或module.exports来导出模块了,使用 __webpack_require__ 来导入模块。

  5. 将模块文件的 require() 中的参数,也就是模块文件的依赖模块路径,存入数组中,暂且将该数组命名为 dependencies

  6. 将模块文件的相对路径,也就是 ./src/xxx.js 作为,处理后的源代码作为,存储到一个对象中,暂且把该对象定义为 modules

    • 为什么要存入对象中?又得回忆下上面分析的 min-bundle.js 了。它内部是一个自调用函数,该函数的参数就是刚刚定义的 modules 对象,函数体内通过 __webpack_require__ 递归的调用 modules 对象中的每一个对应的,也就是该对应的源代码。
  7. 第一个模块文件解析完毕,如果该模块有依赖文件,就要开始解析它的依赖模块了,怎么解析呢?第 5 步骤中,将依赖模块路径存入到了 dependencies 数组中,ok,遍历这个数组,递归的开始上面第 2 步,直到最后一个模块没有依赖模块,完成递归。

  8. 此时 modules ,就是以模块路径为键该模块源代码为值的对象,如下图

  9. 现在 modules 也有了,怎么生成打包代码呢?别忘了,我们有一份模板 output.ejs,看看该模板内部:

    熟不熟悉?这不就类似于我们最开始分析的webpack打包生成的 min-bundle.js吗?我们要做的,就是在 Compiler.js 内部,将入口文件路径以及刚刚生成的 modules 对象,使用ejs模板语法,进行嵌套

  10. 嵌套完成后,读取配置文件中的output路径,通过 fs.writeFileSync,将output.ejs中的内容写入到开发者项目中指定的目录内

  11. 完成打包!

总结下,基本思路就是

  • 递归的查找依赖, 并解析 AST 语法树, 修改所有依赖的 require 为__webpack_require__
  • 利用 fs 模块读取所有的修改后的依赖代码
  • 将每一个模块依赖的相对路径作为键, 该模块代码作为值, 存放到对象中, 用于生成最后的 bundle 文件

Compiler类的实现

const path = require('path')
const fs = require('fs')
const ejs = require('ejs')
// 解析AST语法树
const parser = require('@babel/parser')
// 维护整个AST 树状态,负责替换,删除和添加节点
const traverse = require('@babel/traverse').default
// 将AST转换为代码
const generator = require('@babel/generator').default

class Compiler {
  constructor(config) {
    this.config = config
    this.entry = config.entry
    // root: 执行 min-pack 指令的目录的绝对路径
    this.root = process.cwd()
    this.modules = {}
  }
  
  /**
   * 打包依赖分析
   * @param {Object} modulePath 当前模块的绝对路径
   */
  depAnalyse(modulePath, relativePath) {

    let self = this

    // 1. 读取模块文件的代码
    let source = fs.readFileSync(modulePath, 'utf-8')

    // 2. 声明依赖数组, 存储当前模块的所有依赖
    let dependencies = []
    
    // 3. 将当前模块代码转为AST语法
    let ast = parser.parse(source)

    // 4. 修改 AST 语法树
    traverse(ast, {
      CallExpression(p) {
        
        if(p.node.callee.name === 'require') {

          p.node.callee.name = '__webpack_require__'
          
          // 提取并处理require()中传入的文件路径
          p.node.arguments[0].value = './' + path.join('src', p.node.arguments[0].value))
          
          // 处理路径中的反斜杠 \
          p.node.arguments[0].value = p.node.arguments[0].value.replace(/\\+/g, '/')
          
          // 将处理好的当前模块路径存入dependencies数组中,用于递归调用 depAnalyse 函数
          dependencies.push(p.node.arguments[0].value)
        }
      }
    })

    // 5. 将处理好的 AST 语法树转为程序代码
    let resultSourceCode = generator(ast).code

    // 6. 获取 执行打包指令目录的绝对路径 与 当前模块的绝对路径的 相对路径
    let modulePathRelative = this.replaceSlash('./' + path.relative(this.root, modulePath))
    
    // 7. 将 6 中获取到的相对路径为键, 当前模块AST处理后的代码为值, 存储至 this.modules
    this.modules[modulePathRelative] = resultSourceCode

    dependencies.forEach(dep => {
      return this.depAnalyse(path.resolve(this.root, dep), dep)
    })

  }

  /**
   * 将生成的 this.modules 与获取模板字符串进行拼接
   */
  emitFile() {
    const templatePath = path.join(__dirname, '../template/output.ejs')
    // 读取模板文件
    let template = fs.readFileSync(templatePath, 'utf-8')
    // 进行模板渲染
    let result = ejs.render(template, {
      entry: this.entry,
      modules: this.modules
    })

    // 读取执行打包的配置文件中的output, 将生成好的 result 写入配置output指定文件中
    let outputPath = path.join(this.config.output.path, this.config.output.filename)

    fs.writeFileSync(outputPath, result)

  }

  start() {
    // 1. 依赖分析
    this.depAnalyse(path.resolve(this.root, this.entry), this.entry
    // 2. 生成最终的打包后的代码
    this.emitFile()
  }
  
}

module.exports = Compiler

上面就是Compiler的代码实现,完成了该步骤,意味着你的项目代码就可以通过 min-pack 进行打包了,赶紧动手尝试一下吧~

当然,这个仅仅是超级无敌迷你的webpack版,读到这,你可能忽略了 loaderplugin的存在,也可能有一些疑问,如何在自己写的 min-pack中加入类似于 webpack中的loaderplugin功能呢?

什么是loader

webpack 可以使用 loader 来预处理文件。这允许你打包除 JavaScript 之外的任何静态资源。你可以使用 Node.js 来很简单地编写自己的 loader

简单地说,一个loader就是一个js文件,对外暴露一个函数,该函数用来处理模块代码,如

上图中,就是一个既简单的loader,该loader接受一个参数,这个参数就是模块的源代码,函数体内对源代码一系列进行操作

那么如何将loader与我们自己写好的 Compiler.js 结合呢?

min-pack中添加loader的功能

  • 既然你需要loader来处理你的代码,就必须有这个loader,而且你的 minpack.config.js 也必须配置相应的 rules,注意哦,rules 中 use 的值,可能是字符串,可能是对象,也可能是数组。
    module: {
        rules: [
          {
            test: /\.js$/,
            use: [
              './loaders/loader1.js',
              './loaders/loader2.js',
              './loaders/loader3.js'
            ]
          }
        ]
      }
    
  • Compiler.js 中,读取配置文件中的 rules
  • 因为 Compiler.jsdepAnalyse 函数内部,读取到模块文件的源代码,此时将模块代码作为参数,倒序迭代调用所有loader函数(loader的加载顺序从右到左,所以调用时也必须倒叙的调用)
  • 最后返回处理后的代码,进行 AST 语法树解析,替换 require (之前的步骤).....
  • 所以,一旦loader匹配到正确的文件类型,就要调用该loader函数,一个文件有n个loader匹配到,该文件就会被处理n次,完成后,返回处理后的代码,这也就是为什么webpack打包在 loader 这一层上耗时最多的原因,只有匹配到,就调用loader函数处理

啊,好累啊,有点写不动了...

什么是plugin

plugin可谓是 webpack 生态系统的重要组成部分之一,它同时对外提供了插件接口,可以让开发者直接触及到编译过程中

官方定义:插件能够 钩入(hook) 到在每个编译(compilation)中触发的所有关键事件

简单理解,插件就是在webpack编译过程的生命周期钩子中,进行编码开发,实现对应功能。也就是你的插件是需要在编译过程中的哪一个周期中执行,就调用对应的钩子函数,在该钩子内部,实现功能

附上webpack编译时compiler的生命周期钩子

疑问: webpack不是打包器吗?为什么要有生命周期呢?它又是如何实现生命周期的?

通过上面 Compiler.jsloader 的实现,不难看出,webpack 的编译流程就好像一条流水线,每一个编译 阶段的就像是一个流水线工人对其进行加工,A加工完交给B,B加工完交给C...每个工人的职责都是单一的,直到加工完成。

现在我有一个矿泉水加工厂,让我们看看一瓶水是怎么生产出来的:

  • 首先,储存原水,没有水,你让我加工啥?类似于编写webpack.config.js,没有配置文件,你让我打包啥?(当然webpack4牛逼,默认不配置)
  • 多层过滤器,类似于 loader,每一瓶水都要经过过滤器过滤
  • 紫外线杀菌,类似于 JS css 代码压缩(uglifyjs, mini-css-extract-plugin)。咦,这不就是插件吗?
  • 装瓶并粘贴广告,类似于 html-webpack-plugin插件,将 bundle.js 自动引入生产的html中。咦,这不也是插件吗?
  • 加工完成!

现在有个问题,加工矿泉水的机器,是怎么知道什么时候杀菌,什么时候装瓶,什么时候贴广告呢? 同理 webpack

其实,webpack内部,通过 Tapable 这个小型 library ,有了它就可以通过事件流的形式,将各个生成线串联起来,其核心原理采用了发布订阅者的模式。Tapable提供了一系列同步和异步钩子,webpack 使用这些钩子,定义自己的生命周期。webpack 在运行过程中,在不同阶段,发布相应的事件,插件内部只需要订阅你需要使用的事件,webpack编译到了该阶段时,会去执行你插件中订阅事件的回调函数。

一脸懵逼?没关系,让我们接着回到上一个例子中

  • 在储存原水时,安排一个工人张三,一旦开始加工,广播:“开始加工啦!!!”(发布)
  • 在开始过滤水阶段,安排一个工人李四,一旦开始过滤,广播:“开始过滤啦!!!”(发布)
  • 过滤完成后,安排一个工人王五,一旦过滤完成,广播:“过滤结束啦!!!”(发布)
  • 在加工完成时,安排一个工人赵六,一旦加工完成,广播:“加工完成啦!!!”(发布)
  • 那么问题来了,我有两个步骤,杀菌和装瓶并粘贴广告,这两个到底该放到什么时候呢?我能在加工前杀菌吗?张三就说了,你走开,我还没开始呢!那我能在过滤前杀菌吗?李四又不让了。
  • 所以,在杀菌这一步,我就必须在内部告诉机器,你要给我在王五广播完“过滤结束后,开始杀菌”,这叫什么?这就是在订阅王五的广播,一旦到了王五广播的时候,我就要杀菌!
  • 同理,装瓶并粘贴广告这一步也同样,它也要订阅王五的广播,在王五广播后,装瓶。
    • 不对吧?我没杀菌就装瓶?黑商!
    • 其实,杀菌机器和装瓶机器,是谁按顺序放置的?当然是制造商,所以,这就需要人为的操控了
    • 同样,webpack 中的插件,也会按照顺序执行,我的代码先经过A插件处理,处理完后把处理后的代码交给B插件。那插件顺序谁写的?当然是你咯,所以,在使用插件时,必须知道每个插件是做什么的,然后按顺序调用插件。

是不是对插件的运行机制有所了解了?别急,让我们在自己实现的 min-pack 中利用 Tapable 这个库,实现一个插件。

实现生命周期并发布事件

  1. 首先安装 tapable,如何使用 tapable传送门

  2. 然后在 Compiler 类中,定义生命周期

    class Compiler {
      constructor(config) {
        this.config = config
        this.entry = config.entry
        // root: 执行 min-pack 指令的目录的绝对路径
        this.root = process.cwd()
        this.hooks = {
          start: new SyncHook(), // min-pack开始编译钩子
          compile: new SyncHook(["relativePath"]), // 编译中的钩子 可以知道当前编译的模块名
          afterCompile: new SyncHook(), // 全部编译完成钩子
          emit: new SyncHook(["filename"]), // 开始打包bundle.js钩子
          afterEmit: new SyncHook(["outputPath"]), // 打包bundle.js结束钩子
          done: new SyncHook() // min-pack编译结束钩子
        }
        this.modules = {}
      }
    }
    

    上面,我们定义了6个生命周期钩子,那在什么时候发布呢?

  3. 发布生命周期钩子

    start() {
        // 整体编译开始钩子(start)
        this.hooks.start.call()
        
        // 正在编译钩子(compile)
        this.hooks.compile.call() 
        
        // 主编译函数 开始编译
        this.depAnalyse(path.resolve(this.root, this.entry), this.entry)
        
        // 编译结束钩子(afterCompile)
        this.hooks.afterCompile.call()
        
        // 整体编译完成钩子(done)
        this.hooks.done.call()
      }
    

    在 函数内,发布 emit 和 afterEmit 钩子,具体代码在上面讲解过,此处省略部分代码

    emitFile() {
        // ......此处省略代码
        
        // 开始打包bundle.js钩子(emit)
        this.hooks.emit.call(this.config.output.filename)
        
        // fs 写入文件(生成bundle.js)
        fs.writeFileSync(outputPath, result)
        
        // 打包bundle.js结束钩子(afterEmit)
        this.hooks.afterEmit.call(outputPath)
      }
    
  4. ok,我们的生命周期有了,也在指定的阶段发布了相应的事件了,接下来干嘛?写插件啊!终于能写一个属于自己的插件了。

    • 但是由于是我们自己实现的迷你版的 webpack,所以并没有 Compilation 对象,嗯?第一次听说,什么是 Compilation?稍后解释。
    • 所以,我们的插件只能是编写 helloWorld 级别的,那就将他暂时命名为 HelloWorldPlugins

实现 HelloWorldPlugins 插件

怎么写一个webpack插件呢? 官方定义:

webpack 插件由以下组成:

  1. 一个 JavaScript 命名函数。
  2. 在插件函数的 prototype 上定义一个 apply 方法。
  3. 指定一个绑定到 webpack 自身的事件钩子。
  4. 处理 webpack 内部实例的特定数据。
  5. 功能完成后调用 webpack 提供的回调。

补充下第3条,并不一定只能是一个,当你的插件中需要在不同阶段做不同操作时,也可以绑定多个事件钩子,只不过不推荐罢了,最好一个插件单独做一个功能。

看代码~

module.exports = class HelloWorldPlugins {
  // apply方法
  apply(compiler) {
   // 指定一个(这个插件中为多个)绑定到 webpack 自身的事件钩子。
   // 订阅 start 钩子
    compiler.hooks.start.tap('HelloWorldPlugin', () => {
      console.log('webpack开始编译')
    });
    
    // 订阅 compile 钩子
    compiler.hooks.compile.tap('HelloWorldPlugin', () => {
      console.log('编译中')
    });
    
    // 订阅 afterCompile 钩子
    compiler.hooks.afterCompile.tap('HelloWorldPlugin', () => {
      console.log('webpack编译结束')
    });
    
    // 订阅 emit 钩子
    compiler.hooks.emit.tap('HelloWorldPlugin', (filename) => {
      console.log('开始打包文件,文件名为: ', filename)
    });
    
    // 订阅 afterEmit 钩子
    compiler.hooks.afterEmit.tap('HelloWorldPlugin', (path) => {
      console.log('文件打包结束,打包后文件路径为: ', path)
    });
    
    // 订阅 done 钩子
    compiler.hooks.done.tap('HelloWorldPlugin', () => {
      console.log('webpack打包结束')
    })
  }
}

运行后看看日志:

到此,我们的 HelloWorldPlugins 插件就写完了,因为没有 Compilation 对象,所以并不能做什么炫酷的功能,旨在理解webpack插件的运行原理即可。其实要写一个真正的webpack插件也很简单

一个函数->调用apply方法->订阅事件钩子->写你的程序代码->调用 webpack 提供的回调

Compiler 和 Compilation

上面留个个疑问,什么是Compilation,对于 CompilerCompilation 的区别,网上也有很多文章,其实很简单

  • compiler 对象表示不变的webpack环境,是针对webpack的,包括了options,loaders,plugins等信息,可以理解为 webpack 的实例,也就是我们自己写的 Compiler
  • compilation 对象则是针对随时可变的项目文件,即每一次编译的过程,只要文件有改动,compilation 就会被重新创建。可以通过 compilation.assets 来获取所有需要输出的资源文件,compilation 也能获取到 compiler 对象。

总结

到此,webpack原理分析就告一段落了,能读到这里,我相信你对webpack的原理有了更深层次的理解,文章篇幅较多,如有不足之处,还请多多指正。github源码地址webpack源码剖析