文章由来
在上一篇文章中分享了关于运用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.js
,ticket.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文件的代码作为值,一一存入到对象中作为参数的。那么问题来了,它内部是怎么操作的?如果配置了loader
和plugin
,又是如何处理模块内的js代码呢?
下面让我们实操一下,实现一个属于自己的迷你webpack,深刻的体会webpack的打包原理,loader原理和插件原理。
项目准备工作
新建两个项目,一个项目是min-pack的主程序,你可以理解为webpack,发布到npm后,供开发者通过 npm install min-pack
后使用;另一个项目是开发者自己的项目,也就是说,你要用min-pack
,得先有自己的程序代码啊,不然你让min-pack
打包谁?
min-pack的实现
准备工作
-
首先新建一个目录,命名为min-pack
-
在根目录下新建
lib
目录,目录内新建Compiler.js
,该js用来实现解析打包,稍后会详细解读 -
在根目录下,新建
template
目录,目录内新建output.ejs
,使用ejs
模板来生成打包代码 -
在该项目下新建
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 -
在该项目中的
package.json
中配置bin
脚本"bin": { "min-pack": "./bin/min-pack.js" } // 这样配置完后,在开发者自己的项目中,就可以使用 `min-pack` 来进行打包了。
-
通过
npm link
链接到全局包中,供本地测试使用。测试完成后再发布到npm上,供第三方开发者使用
完成了上述操作,你就可以在另一个项目中,也就是开发者要打包的项目里运行 min-pack
了。但现在的Compiler.js
还没有实现,所以还做不到解析构建,下面让我们来实现下打包功能。
Compiler类
Compiler.js要做什么?
上面说了,你要用我的
min-pack
,你的项目根目录下必须有minpack.config.js
配置文件
-
Compiler.js
接受传入的minpack.config.js
,获取到配置文件中entry
对应的值,也就是入口文件,如./src/index.js
-
使用 node 模块中的 fs.readFileSync 读取该模块文件,获得模块文件的源代码
-
将该模块源代码转换为
AST
语法树。what?什么是AST
语法树?- 其实UglifyJS或babel转换代码,实际的背后就是在对JavaScript的抽象语法树进行操作。
- 可以先简单的理解,
AST
语法树,就是为了让我们更高效,更简洁的对JavaScript代码进行操作。因为在下面第 4 步中会将模块源代码中require
替换成__webpack_require__
,怎么替换?难道你让我写正则?或是操作字符串?那就太Low了吧
-
将源代码中的
require
,全部替换成__webpack_require__
(为什么?)-
因为浏览器环境并不识别
require
语法。你可能就要问了,我项目中所有的依赖都是使用import A from 'xx'
来导入模块,使用export const xx = 1
或exports default {...}
来导出模块的,没使用require
啊。那么请问,你是不是使用babel
来处理js的,babel
内部会把你的import
转换为require
,把export
和export default
转换为exports
。如下图 -
再回忆下最开始我们分析
webpack
打包出的min-bundle.js
时,可以发现,该js内部把我们项目中的入口文件的及其所有依赖内部的require()
全部替换成了__webpack_require__
,然后自己实现了__webpack_require__
,该函数内部定义了module
对象,对象内部有exports: {}
,所以,你可以使用exports或module.exports来导出模块了,使用__webpack_require__
来导入模块。
-
-
将模块文件的
require()
中的参数,也就是模块文件的依赖模块路径,存入数组中,暂且将该数组命名为dependencies
-
将模块文件的相对路径,也就是
./src/xxx.js
作为键,处理后的源代码作为值,存储到一个对象中,暂且把该对象定义为modules
。- 为什么要存入对象中?又得回忆下上面分析的
min-bundle.js
了。它内部是一个自调用函数,该函数的参数就是刚刚定义的modules
对象,函数体内通过__webpack_require__
递归的调用modules
对象中的每一个键对应的值,也就是该键对应的源代码。
- 为什么要存入对象中?又得回忆下上面分析的
-
第一个模块文件解析完毕,如果该模块有依赖文件,就要开始解析它的依赖模块了,怎么解析呢?第 5 步骤中,将依赖模块路径存入到了
dependencies
数组中,ok,遍历这个数组,递归的开始上面第 2 步,直到最后一个模块没有依赖模块,完成递归。 -
此时
modules
,就是以模块路径为键,该模块源代码为值的对象,如下图 -
现在
熟不熟悉?这不就类似于我们最开始分析的webpack打包生成的modules
也有了,怎么生成打包代码呢?别忘了,我们有一份模板output.ejs
,看看该模板内部:min-bundle.js
吗?我们要做的,就是在Compiler.js
内部,将入口文件路径以及刚刚生成的modules
对象,使用ejs
模板语法,进行嵌套 -
嵌套完成后,读取配置文件中的
output
路径,通过fs.writeFileSync
,将output.ejs
中的内容写入到开发者项目中指定的目录内 -
完成打包!
总结下,基本思路就是
- 递归的查找依赖, 并解析 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版,读到这,你可能忽略了 loader
和 plugin
的存在,也可能有一些疑问,如何在自己写的 min-pack
中加入类似于 webpack
中的loader
和 plugin
功能呢?
什么是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.js
中depAnalyse
函数内部,读取到模块文件的源代码,此时将模块代码作为参数,倒序迭代调用所有loader函数(loader的加载顺序从右到左,所以调用时也必须倒叙的调用) - 最后返回处理后的代码,进行 AST 语法树解析,替换
require
(之前的步骤)..... - 所以,一旦
loader
匹配到正确的文件类型,就要调用该loader函数,一个文件有n个loader
匹配到,该文件就会被处理n次,完成后,返回处理后的代码,这也就是为什么webpack
打包在loader
这一层上耗时最多的原因,只有匹配到,就调用loader
函数处理
啊,好累啊,有点写不动了...
什么是plugin
plugin可谓是 webpack 生态系统的重要组成部分之一,它同时对外提供了插件接口,可以让开发者直接触及到编译过程中
官方定义:插件能够 钩入(hook) 到在每个编译(compilation)中触发的所有关键事件
简单理解,插件就是在webpack编译过程的生命周期钩子中,进行编码开发,实现对应功能。也就是你的插件是需要在编译过程中的哪一个周期中执行,就调用对应的钩子函数,在该钩子内部,实现功能
附上webpack编译时compiler的生命周期钩子
疑问: webpack不是打包器吗?为什么要有生命周期呢?它又是如何实现生命周期的?
通过上面 Compiler.js
中 loader
的实现,不难看出,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
这个库,实现一个插件。
实现生命周期并发布事件
-
首先安装
tapable
,如何使用tapable
?传送门 -
然后在
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个生命周期钩子,那在什么时候发布呢?
-
发布生命周期钩子
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) }
-
ok,我们的生命周期有了,也在指定的阶段发布了相应的事件了,接下来干嘛?写插件啊!终于能写一个属于自己的插件了。
- 但是由于是我们自己实现的迷你版的
webpack
,所以并没有Compilation
对象,嗯?第一次听说,什么是Compilation
?稍后解释。 - 所以,我们的插件只能是编写
helloWorld
级别的,那就将他暂时命名为HelloWorldPlugins
吧
- 但是由于是我们自己实现的迷你版的
实现 HelloWorldPlugins
插件
怎么写一个webpack插件呢? 官方定义:
webpack 插件由以下组成:
- 一个 JavaScript 命名函数。
- 在插件函数的 prototype 上定义一个 apply 方法。
- 指定一个绑定到 webpack 自身的事件钩子。
- 处理 webpack 内部实例的特定数据。
- 功能完成后调用 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,对于 Compiler
和 Compilation
的区别,网上也有很多文章,其实很简单
compiler
对象表示不变的webpack环境,是针对webpack的,包括了options,loaders,plugins等信息,可以理解为webpack
的实例,也就是我们自己写的Compiler
类compilation
对象则是针对随时可变的项目文件,即每一次编译的过程,只要文件有改动,compilation
就会被重新创建。可以通过compilation.assets
来获取所有需要输出的资源文件,compilation
也能获取到compiler
对象。
总结
到此,webpack原理分析就告一段落了,能读到这里,我相信你对webpack的原理有了更深层次的理解,文章篇幅较多,如有不足之处,还请多多指正。github源码地址webpack源码剖析