Webpack源码浅析
webpack启动方式
webpack有两种启动方式:
- 通过
webpack-cli
脚手架来启动,即可以在Terminal
终端直接运行;webpack ./debug/index.js --config ./debug/webpack.config.js
- 通过
require('webpack')
引入包的方式执行;其实第一种方式最终还是会用require
的方式来启动webpack,可以查看./bin/webpack.js
文件
webpack编译的起点
从const compiler = webpack(config)
开始
webpack函数源码(./lib/webpack.js
):
const webpack = (options, callback) => {
let compiler = createCompiler(options)
// 如果传入callback函数,则自启动
if(callback){
compiler.run((err, states) => {
compiler.close((err2)=>{
callbacl(err || err2, states)
})
})
}
return compiler
}
webpack函数执行后返回compiler
对象,在webpack中存在两个非常重要的核心对象,分别为compiler
和compilation
,它们在整个编译过程中被广泛使用。
- Compiler类(
./lib/Compiler.js
):webpack的主要引擎,在compiler对象记录了完整的webpack环境信息,在webpack从启动到结束,compiler
只会生成一次。你可以在compiler
对象上读取到webpack config
信息,outputPath
等; - Compilation类(
./lib/Compilation.js
):代表了一次单一的版本构建和生成资源。compilation
编译作业可以多次执行,比如webpack工作在watch
模式下,每次监测到源文件发生变化时,都会重新实例化一个compilation
对象。一个compilation
对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。
两者的区别?
compiler代表的是不变的webpack环境; compilation代表的是一次编译作业,每一次的编译都可能不同;
举个例子:
compiler就像一条手机生产流水线,通上电后它就可以开始工作,等待生产手机的指令; compliation就像是生产一部手机,生产的过程基本一致,但生产的手机可能是小米手机也可能是魅族手机。物料不同,产出也不同。
Compiler
类在函数createCompiler
中实例化(./lib/index.js
):
const createCompiler = options => {
const compiler = new Compiler(options.context)
// 注册所有的自定义插件
if(Array.isArray(options.plugins)){
for(const plugin of options.plugins){
if(typeof plugin === 'function'){
plugin.call(compiler, compiler)
}else{
plugin.apply(compiler)
}
}
}
compiler.hooks.environment.call()
compiler.hooks.afterEnvironment.call()
compiler.options = new WebpackOptionsApply().process(options, compiler) // process中注册所有webpack内置的插件
return compiler
}
Compiler
类实例化后,如果webpack函数接收了回调callback
,则直接执行compiler.run()
方法,那么webpack自动开启编译之旅。如果未指定callback
回调,需要用户自己调用run
方法来启动编译。
process(options, compiler)
WebpackOptionsApply
类的工作就是对webpack options
进行初始化。 打开源码文件lib/WebpackOptionsApply.js
,你会发现前五十行都是各种webpack内置的Plugin
的引入,那么可以猜想process
方法应该是各种各样的new SomePlugin().apply()
的操作,事实就是如此。
compiler.run()
先贴上源码吧(./lib/Compiler.js
):
class Compiler {
constructor(context){
// 所有钩子都是由`Tapable`提供的,不同钩子类型在触发时,调用时序也不同
this.hooks = {
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
done: new AsyncSeriesHook(["stats"]),
// ...
}
}
// ...
run(callback){
const onCompiled = (err, compilation) => {
if(err) return
const stats = new Stats(compilation);
this.hooks.done.callAsync(stats, err => {
if(err) return
callback(err, stats)
this.hooks.afterDone.call(stats)
})
}
this.hooks.beforeRun.callAsync(this, err => {
if(err) return
this.hooks.run.callAsync(this, err => {
if(err) return
this.compile(onCompiled)
})
})
}
}
通读一遍run
函数过程,你会发现它钩住了编译过程的一些阶段,并在相应阶段去调用已经提前注册好的钩子函数(this.hooks.xxxx.call(this)
),效果与React中生命周期函数是一样的。在run
函数中出现的钩子有:beforeRun --> run --> done --> afterDone
。第三方插件可以钩住不同的生命周期,接收compiler
对象,处理不同逻辑。
run
函数钩住了webpack编译的前期和后期的阶段,那么中期最为关键的代码编译过程就交给了this.compile()
来完成了。在this.comille()
中,另一个主角compilation粉墨登场了。
compiler.compile()
compile(callback){
const params = this.newCompilationParams() // 初始化模块工厂对象
this.hooks.beforeCompile.callAsync(params, err => {
this.hooks.compile.call(params)
// compilation记录本次编译作业的环境信息
const compilation = new Compilation(this)
this.hooks.make.callAsync(compilation, err => {
compilation.finish(err => {
compilation.seal(err=>{
this.hooks.afterCompile.callAsync(compilation, err => {
return callback(null, compilation)
})
})
})
})
})
}
compile
函数和run
一样,触发了一系列的钩子函数,在compile
函数中出现的钩子有:beforeCompile --> compile --> make --> afterCompile
。
其中make
就是我们关心的编译过程。但在这里它仅是一个钩子触发,显然真正的编译执行是注册在这个钩子的回调上面。
webpack因为有Tapable
的加持,代码编写非常灵活,node中流行的callback回调机制(说的就是回调地狱),webpack使用的炉火纯青。
this.parser
其实就是JavascriptParser
的实例对象,最终JavascriptParser
会调用第三方包acorn
提供的parse
方法对JS源代码进行语法解析。
const result = this.parser.parse(source)
parse(code, options){
// 调用第三方插件`acorn`解析JS模块
let ast = acorn.parse(code)
// 省略部分代码
if (this.hooks.program.call(ast, comments) === undefined) {
this.detectStrictMode(ast.body)
this.prewalkStatements(ast.body)
this.blockPrewalkStatements(ast.body)
// 这里webpack会遍历一次ast.body,其中会收集这个模块的所有依赖项,最后写入到`module.dependencies`中
this.walkStatements(ast.body)
}
}
有个线上小工具 AST explorer 可以在线将JS代码转换为语法树AST,将解析器选择为acorn
即可。
通常我们会使用一些类似于babel-loader
等 loader 预处理源文件,那么webpack 在这里的parse
具体作用是什么呢?parse
的最大作用就是收集模块依赖关系,比如调试代码中出现的import {is} from 'object-is'
或const xxx = require('XXX')
的模块引入语句,webpack会记录下这些依赖项,记录在module.dependencies
数组中。
compilation.seal()
至此,从入口文件开始,webpack收集完整了该模块的信息和依赖项,接下来就是如何进一步打包封装模块了。
compiler.hooks.emit.callAsync()
在seal执行后,关于模块所有信息以及打包后源码信息都存在内存中,是时候将它们输出为文件了。接下来就是一连串的callback回调,最后我们到达了compiler.emitAssets
方法体中。在compiler.emitAssets
中会先调用this.hooks.emit
生命周期,之后根据webpack config文件的output配置的path属性,将文件输出到指定的文件夹。至此,你就可以在./debug/dist
中查看到调试代码打包后的文件了。
this.hooks.emit.callAsync(compilation, () => {
outputPath = compilation.getPath(this.outputPath, {})
mkdirp(this.outputFileSystem, outputPath, emitFiles)
})
简单总结一下 webpack 编译模块的基本流程:
- 调用
webpack
函数接收config
配置信息,并初始化compiler
,在此期间会apply
所有 webpack 内置的插件; - 调用
compiler.run
进入模块编译阶段; - 每一次新的编译都会实例化一个
compilation
对象,记录本次编译的基本信息; - 进入
make
阶段,即触发compilation.hooks.make
钩子,从entry
为入口: a. 调用合适的loader
对模块源码预处理,转换为标准的JS模块; b. 调用第三方插件acorn
对标准JS模块进行分析,收集模块依赖项。同时也会继续递归每个依赖项,收集依赖项的依赖项信息,不断递归下去;最终会得到一颗依赖树🌲; - 最后调用
compilation.seal
render 模块,整合各个依赖项,最后输出一个或多个chunk
以下为时序图: