在上一篇文章中分享了关于运用webpack搭建vue项目的经验和总结,但还仅仅停留在只是会用webpack搭建脚手架的阶段,对webpack原理仍是不怎么清楚,再加上各大论坛对webpack原理解析的精品文章较少,要么是一些标题党,通篇教你如何配置webpack,如何优化;要么就是通篇copy源码+简单注解;固然也有大牛写的文章,文章虽好,但晦涩难懂,谁让小弟不才呢。css
种种缘由,决定狠下心研究下webpack的实现原理(真的是难啊)。但我相信,通读此篇,就算是菜鸡,也能对webpack的原理理解透彻。html
好了,闲话很少说,先看看webpack官网对本身的定义,从定义中寻找突破口!let's go~vue
本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序须要的每一个模块,而后将全部这些模块打包成一个或多个 bundle。node
递归
、依赖
、生成一个或多个bundle
什么是递归的构建,什么又是依赖呢?以下图webpack
模块A.js引用模块B.js,模块B.js引用模块C.js,此时A、B、C就构成了依赖关系,那为何要递归的构建呢?请问,webpack配置文件是怎么配置的?git
不论是单entry仍是多entry,配置文件的entry仅仅只有一个或多个入口文件。github
拿上个例子来讲,将A.js设置为entry,此时webpack打包时,就必须把A.js中全部require的模块打包在一块儿(B.js),但此时B.js也有依赖(C.js),这时候就必须递归的进行解析了(若是依赖中还有依赖,那接着递归)。先把C.js与B.js进行打包合并,而后把合并后的代码与A.js合并,打包生成最终的bundle。web
是否是有点头绪了?上面的例子仅仅是最为简单的分析了webpack是如何从entry解析构建依赖模块。下面让咱们从项目中分析下webpack的打包后的代码。npm
项目结构目录以下json
├── 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
在根目录下新建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
尚未实现,因此还作不到解析构建,下面让咱们来实现下打包功能。
上面说了,你要用个人
min-pack
,你的项目根目录下必须有minpack.config.js
配置文件
Compiler.js
接受传入的 minpack.config.js
,获取到配置文件中 entry
对应的值,也就是入口文件,如 ./src/index.js
使用 node 模块中的 fs.readFileSync 读取该模块文件,得到模块文件的源代码
将该模块源代码转换为 AST
语法树。what?什么是AST
语法树?
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
,就是以模块路径为键,该模块源代码为值的对象,以下图
如今 modules
也有了,怎么生成打包代码呢?别忘了,咱们有一份模板 output.ejs
,看看该模板内部:
min-bundle.js
吗?咱们要作的,就是在 Compiler.js
内部,将入口文件路径以及刚刚生成的 modules
对象,使用ejs
模板语法,进行嵌套 嵌套完成后,读取配置文件中的output
路径,经过 fs.writeFileSync
,将output.ejs
中的内容写入到开发者项目中指定的目录内
完成打包!
总结下,基本思路就是
- 递归的查找依赖, 并解析 AST 语法树, 修改全部依赖的 require 为__webpack_require__
- 利用 fs 模块读取全部的修改后的依赖代码
- 将每个模块依赖的相对路径做为键, 该模块代码做为值, 存放到对象中, 用于生成最后的 bundle 文件
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
功能呢?
webpack 可使用 loader 来预处理文件。这容许你打包除 JavaScript 以外的任何静态资源。你可使用 Node.js 来很简单地编写本身的 loader
简单地说,一个loader就是一个js文件,对外暴露一个函数,该函数用来处理模块代码,如
那么如何将loader与咱们本身写好的 Compiler.js
结合呢?
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的加载顺序从右到左,因此调用时也必须倒叙的调用)require
(以前的步骤).....loader
匹配到正确的文件类型,就要调用该loader函数,一个文件有n个loader
匹配到,该文件就会被处理n次,完成后,返回处理后的代码,这也就是为何webpack
打包在 loader
这一层上耗时最多的缘由,只有匹配到,就调用loader
函数处理啊,好累啊,有点写不动了...
plugin可谓是 webpack 生态系统的重要组成部分之一,它同时对外提供了插件接口,可让开发者直接触及到编译过程当中
官方定义:插件可以 钩入(hook) 到在每一个编译(compilation)中触发的全部关键事件
简单理解,插件就是在webpack编译过程的生命周期钩子中,进行编码开发,实现对应功能。也就是你的插件是须要在编译过程当中的哪个周期中执行,就调用对应的钩子函数,在该钩子内部,实现功能
附上webpack编译时compiler的生命周期钩子
疑问: webpack不是打包器吗?为何要有生命周期呢?它又是如何实现生命周期的?
经过上面 Compiler.js
中 loader
的实现,不难看出,webpack
的编译流程就好像一条流水线,每个编译 阶段的就像是一个流水线工人对其进行加工,A加工完交给B,B加工完交给C...每一个工人的职责都是单一的,直到加工完成。
如今我有一个矿泉水加工厂,让咱们看看一瓶水是怎么生产出来的:
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
提供的回调
上面留个个疑问,什么是Compilation,对于 Compiler
和 Compilation
的区别,网上也有不少文章,其实很简单
compiler
对象表示不变的webpack环境,是针对webpack的,包括了options,loaders,plugins等信息,能够理解为 webpack
的实例,也就是咱们本身写的 Compiler
类compilation
对象则是针对随时可变的项目文件,即每一次编译的过程,只要文件有改动,compilation
就会被从新建立。能够经过 compilation.assets
来获取全部须要输出的资源文件,compilation
也能获取到 compiler
对象。到此,webpack原理分析就告一段落了,能读到这里,我相信你对webpack的原理有了更深层次的理解,文章篇幅较多,若有不足之处,还请多多指正。github源码地址webpack源码剖析