Webpack启动流程
下面我们根据codewhy大佬整理的webpack启动流程图,按照顺序去看webpack的启动流程:
首先我们会使用npm run build进行打包,本质上是找到了package.json里的命令(其实这个命令就相当于npx webpack ……)。
它会找到node_modules/bin目录下的webpack.js文件并执行。
//package.json
"scripts": {
"build": "webpack --config ./config/webpack.common.js --env production",
"serve": "webpack serve --config ./config/webpack.common.js --env development"
},
我们可以看到在webpack.js文件里看到很多代码,
其实它主要做两件事:
- 一个是判断当前项目有没有安装webpack-cli,如果没有会在终端一步步的指引安装。
- 另一个事,就是用户安装了webpack-cli的情况下执行runcli()。(我们可以看到runcli这个函数)
//node_modules/bin/webpack
const runCli = cli => {
//一步步的路径查找
const path = require("path");
const pkgPath = require.resolve(`${cli.package}/package.json`);
const pkg = require(pkgPath);
//最终找到webpack-cli/bin下cli文件的代码,并执行
require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));
};
上边的runcli函数会找到webpack-cli/bin下的cli.js文件,cli.js文件跟上边的webpack.js一样是去做两件事。
- 一个是去判断webpack是否安装,如果没有安装会在终端指引安装。
- 另一个事,就是继续执行又封装了一层的runcli函数。
我们直接看runlic函数本质上在做什么事:我们可以看到它是去声明了一个WebpackCLI类的实例对象,并执行了run方法。
const runCLI = async (args, originalModuleCompile) => {
// 创建一个webpackCli实例。
const cli = new WebpackCLI();
cli._originalModuleCompile = originalModuleCompile;
//执行实例方法run
await cli.run(args);
};
这个run方法核心:调用webpack库的webpack(config,callback)方法进行打包编译
//在run方法一步步找到createCompiler方法下的webpack调用
this.webpack(
config.options,
callback
? (error, stats) => {
if (error && this.isValidationError(error)) {
this.logger.error(error.message);
process.exit(2);
}
callback(error, stats);
}
: callback
);
小结
我们看到上边webpack-cli很多的代码,一层一层地去找发现最后的核心代码就是对webpack的调用。
我们甚至可以删掉webpack-cli的很多额外代码,只保留核心代码。也是可以使用webpack进行打包的。
尝试自己封装一个webpack-cli
我们模仿webpack-cli的核心代码,自己写一个build.js文件。
使用命令node build.js(node环境下跑我们的js代码)。这样就可以完成webpack打包了。
核心代码就两步:
- 调用webpack(config)获取compiler。
- 调用compiler.run()打包编译。
```javascript
1.//build.js
2.const webpack = require(‘webpack’);
3.const config = require(“./config/webpack.common”)({
- production: true
5.});
6. 7.//看完后续源码我们就会发现,其实webpack(config,callback)相当于这里的webpack(config).run 8.const compiler = webpack(config); - 10.compiler.run((err, stats) => {
- if (err) {
- console.error(err);
- } else {
- console.log(stats);
- }
16.}); ```
Webpack-cli本质:
- 合并我们所有的配置options;
- 调用webpack库的webpack(config,callback)方法进行打包编译
接下来我们就可以去看webpack库的源码,更深入地学习webpack(config,callback)方法是怎么实现打包编译的。
Webpack源码阅读
接下来我们来到webpack的github仓库将webpack库的源码下载到本地,然后就开始我们的源码阅读之路。
https://github.com/webpack/webpack
接下来我们会带着问题看每一步的源码,更方便理清webpack每一步都要做什么事情。
入口webpack函数
入口webpack函数做了什么事?
根据源码我们得出答案:
create()拿到compiler并且返回compiler对象。
最终调用compiler.run()方法进行打包编译
// lib/webpack.js
// 核心:
//1. create()创建compiler对象 ,最终返回结果都是compiler对象
//2. 调用compiler.run方法进行打包编译
if (callback) {
try {
//create拿到compiler对象
const { compiler, watch, watchOptions } = create();
if (watch) {
compiler.watch(watchOptions, callback);
} else {
// 如果有callback,帮我们调用compiler.run方法()
compiler.run((err, stats) => {
compiler.close(err2 => {
callback(err || err2, stats);
});
});
}
// 最终都会返回compiler对象
return compiler;
} catch (err) {
process.nextTick(() => callback(err));
return null;
}
} else {
//create拿到compiler对象
const { compiler, watch } = create();
if (watch) {
util.deprecate(...)();
}
// 没有callback最终也会返回compiler对象,由webpack-cli帮我们执行compiler.run()
return compiler;
}
创建compiler对象
create()函数做了什么事?
根据源码我们得出答案:create函数核心就是从createCompiler函数拿到compiler对象
// lib/webpack.js
// 核心:从createCompiler拿到compiler对象返回出去
const create = () => {
validateSchema(webpackOptionsSchema, options);
// 1.定义compiler
let compiler;
let watch = false;
let watchOptions;
if (Array.isArray(options)) {
// 2.如果是数组,创建多个compiler(一般只有一个options,不会走这里)
compiler = createMultiCompiler(options, options);
watch = options.some(options => options.watch);
watchOptions = options.map(options => options.watchOptions || {});
} else {
// 2.创建compiler
compiler = createCompiler(options);
watch = options.watch;
watchOptions = options.watchOptions || {};
}
return { compiler, watch, watchOptions };
};
createCompiler函数做了什么事?
根据源码我们得出答案:核心是做了四件事
- 根据我们的options配置,new一个compiler对象
- 将所有的plugin注册到compiler
- 调用了两个hook钩子(environment和afterEnvironment)
处理除了plugins外的其他属性(比如entry、output、devtool)
// lib/webpack.js const createCompiler = rawOptions => { const options = getNormalizedWebpackOptions(rawOptions); applyWebpackOptionsBaseDefaults(options); // 1.通过new创建Compiler对象(compiler绑定这我们的options配置) const compiler = new Compiler(options.context); compiler.options = options; new NodeEnvironmentPlugin({ infrastructureLogging: options.infrastructureLogging }).apply(compiler); // 2.注册所有的plugin插件到compiler if (Array.isArray(options.plugins)) { for (const plugin of options.plugins) { //使用call或apply,将plugin插件注册到compiler上 if (typeof plugin === "function") { // 如果plugin是一个函数, 调用call方法完成注册 // 第一个参数是this绑定值, 第二个是传入的参数 plugin.call(compiler, compiler); } else { // 如果plugin是一个对象, 调用apply方法完成注册 plugin.apply(compiler); } } } applyWebpackOptionsDefaults(options); // 3.调用钩子environment和afterEnvironment的call() // tapable compiler.hooks.environment.call(); compiler.hooks.afterEnvironment.call(); // 4. WebpackOptionsApply().process作用:处理config文件中除了plugins的其他属性(比如entry/output/devtool等等) new WebpackOptionsApply().process(options, compiler); compiler.hooks.initialize.call(); return compiler; };
createCompiler做了四件事,其实这四件事都是核心。需要我们一个个地去理解每一件事里边都做了什么。所以我们又得思考如下几个问题:
- 我们在Compiler类上创建compiler实例,Compiler类都做了什么?
- 我们通过apply()注册了所有的plugins插件到compiler对象上,那plugins又是在哪个阶段执行?
- 调用的钩子hook是干嘛用的?
- WebpackOptionsApply().process对Plugins外的其他配置做了处理,这些处理是什么?
Ok,我们接下来继续带着问题看源码
根据源码我们得出答案:一进入Compiler类,在其constrouct函数上,就绑定了一大堆的hooks。
这些hooks其实可以理解为compiler的生命周期,我们把plugins绑定在compiler对象上,然后监听所有的hooks,监听到每个hook阶段就去执行这个阶段上的plugins。
// lib/Compiler.js
lass Compiler {
constructor(context) {
// 在constructor上,this.hooks初始化了一系列的hooks
this.hooks = Object.freeze({
initialize: new SyncHook([]),
shouldEmit: new SyncBailHook(["compilation"]),
done: new AsyncSeriesHook(["stats"]),
afterDone: new SyncHook(["stats"]),
additionalPass: new AsyncSeriesHook([]),
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
emit: new AsyncSeriesHook(["compilation"]),
assetEmitted: new AsyncSeriesHook(["file", "info"]),
afterEmit: new AsyncSeriesHook(["compilation"]),
......
......
WebpackOptionsApply().process核心:
- 根据环境判断,将需要的内置的Plugin进行导入
- 将除了plugins外的所有配置转化为plugin插件,并注册到compiler上(entry、output、devServer等等)
根据源码我们得出答案:WebpackOptionsApply().process将Plugins外的其他配置统统转化为plugins插件,并且注册到compiler上。
// lib/WebpackOptionsApply.js
process(options, compiler) {
// 核心:做了两件事
// 1. 根据环境判断,将需要的内置的Plugin进行导入
// 2. 将除了plugins外的所有配置转化为plugin插件,并注册到compiler上(entry、output、devServer等等)
// (这些Plugin后续是通过tapable来实现钩子的监听, 并进行自己的处理逻辑)
// 1. 根据环境判断,将需要的内置的Plugin进行导入
// 比如这里是NodeTarget时需要的Plugin
if (options.externalsPresets.node) {
const NodeTargetPlugin = require("./node/NodeTargetPlugin");
new NodeTargetPlugin().apply(compiler);
}
// 比如这里是Electron环境时需要的Plugin
if (options.externalsPresets.electronMain) {
//@ts-expect-error https://github.com/microsoft/TypeScript/issues/41697
const ElectronTargetPlugin = require("./electron/ElectronTargetPlugin");
new ElectronTargetPlugin("main").apply(compiler);
}
......
......
......
// 2. 将除了plugins外的所有配置转化为plugin插件(entry、output、devServer等等)
// 使用new ...Plugin().apply(compiler)将其他配置转化为plugins插件,并注册到compiler上
// 入口处理的插件是EntryOptionPlugin
new EntryOptionPlugin().apply(compiler);
new RuntimePlugin().apply(compiler);
在上边看完了compiler对象的创建都做了什么事,更好地理解了compiler对象里都有什么。但是最终打包编译的还是compiler里的run方法。接下来我们一起看看run方法都有什么内容。
compiler.run方法中执行的hook
Compiler在constrouct构造函数时,绑定了很多的hooks。这些hooks都在什么时候执行?
Webpack会使用“this.hooks.beforeRun.callAsync()”这种方式执行hooks。
根据源码我们得出答案:在run方法中执行的hooks顺序为:beforeRun- run - beforeCompiler - compile - make - finishMake - afterCompiler - done
// lib/Compiler.js
const run = () => {
// 先执行一系列的hooks:this.hooks.beforeRun.callAsync()就是执行hooks
// beforeRun hook
this.hooks.beforeRun.callAsync(this, err => {
if (err) return finalCallback(err);
////run hook
this.hooks.run.callAsync(this, err => {
if (err) return finalCallback(err);
this.readRecords(err => {
if (err) return finalCallback(err);
// 进入compile函数,接着看执行了哪些hooks
this.compile(onCompiled);
});
});
});
};
接着上边的代码,进入compile函数中看执行了哪些hooks
// lib/Compiler.js
compile(callback) {
// new创建一个compilation实例
const compilation = this.newCompilation(params);
const logger = compilation.getLogger("webpack.Compiler");
//beforeCompile hook
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);
// make hook
this.hooks.make.callAsync(compilation, err => {
if (err) return callback(err);
//finishMake hook
this.hooks.finishMake.callAsync(compilation, err => {
if (err) return callback(err);
// finishMake hook
this.hooks.afterCompile.callAsync(compilation, err => {
if (err) return callback(err);
return callback(null, compilation);
Compilation对Module的处理
我们所有的Module在哪个hook阶段执行呢?
在前面我们知道了各个hooks的执行顺序。而且我们之前就在源码里看到了在创建compiler对象时,我们所有的Module,都通过WebpackOptionsApply().process转化成了plugins,绑定在了compiler上了。
这是目前我们知道的。我们要想知道所有的Module在哪个阶段开始执行,就应该去看entry入口转化后的plugins,被绑定在哪个hook上。
那我们接下来就去WebpackOptionsApply().process这里边找关于entry被绑定在哪个hook。
于是就按照这个路径EntryOptionPlugin.apply -> EntryPlugin.apply找到了我们的答案。
根据源码得到答案:使用hooks.make.tapAsync将entry绑定在compiler的make hook上。
所以说我们的Module是在make hook上开始执行。
//EntryPlugin.js
apply(compiler) {
//核心:使用hooks.make.tapAsync将entry绑定在compiler的make hook上
compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => {
const { entry, options, context } = this;
// 创建依赖
const dep = EntryPlugin.createDependency(entry, options);
// 调用了compilation的addEntry方法!!!
compilation.addEntry(context, dep, options, err => {
callback(err);
});
});
}
Compilation对Module做了什么处理?
在上边问题中,我们找到了在make hook里绑定entry,并且其回调函数里调用了compilation.addEntry。
继续按照这个思路阅读源码,就能找到新的答案了。
来到compilation.js里,通过阅读源码,下边内容就是Compilation对module的处理。
- addEntry
- -> _addEntryItem
- -> addModuleTree(将所有模块加入到模块树里)
- -> handleModuleCreation(对模块进行一些处理然后传给下一步)
- -> addModule(把模块添加到模块队列中,然后由_addModule对模块队列中模块保存到Compilation里)
- -> buildModule(把模块添加到构建队列中,由_buildModule将队列里的每个模块添加后续操作)
- -> module.needBuild(判断是否需要构建)
- -> module.build(模块开始构建)
module的build阶段
从上个问题的结果中,我们知道了模块的构建就交给了module处理。我们直接来到NormalModule.js下的build方法。开始解决我们下一个问题。
Module是怎么完成build构建的?
我们看到build方法,发现其核心是使用了doBuild方法。所以我们直接看核心doBuild方法。
在doBuild中,调用loader-runner库中拿到runLoader方法,也就是使用loader去处理我们的module然后拿到一个结果。然后去执行processResult方法。
在processResult方法里做了很多处理结果的事情,最终将结果存到_source里,然后执行doBuild的回调。
//NormalModule.js
doBuild(options, compilation, resolver, fs, callback) {
hooks.beforeLoaders.call(this.loaders, this, loaderContext);
// 使用loader-runner库的runLoaders方法,使用loader处理resource资源,然后把结果传给processResult
runLoaders(
{
processResource: (loaderContext, resource, callback) => {
...
}
},
//runLoaders的回调:将结果传给processResult方法处理
(err, result) => {
if (!result) {
processResult(
err || new Error("No result from loader-runner processing"),
null
);
}
}
// 处理结果的函数
const processResult = (err, result) => {
//继续处理结果
......
//结果保存到_source中
this._source = this.createSource(
......
);
this._ast = ......
// 最终执行回调,这里的回调是doBuild的callback
return callback();
};
}
我们回到build方法中找到doBuild的回调函数,直接看其核心:
调用JavaScriptParse来处理ast树(或_source),目的就是看一下当前模块有没有对其他模块存在依赖(JavaScriptParse本质上是acorn这个JavaScript解析器)
// doBuild的回调函数的核心代码
try {
// 使用JavaScriptParser看当前模块有没有依赖
result = this.parser.parse(this._ast || this._source.source(), {
current: this,
module: this,
compilation: compilation,
options: options
});
} catch (e) {
handleParseError(e);
return;
}
handleParseResult(result);
如果当前模块存在依赖模块,就接着执行handleParseResult,它会回到Compilation里的_buildModule方法将新的依赖模块放到构建模块队列里,重新执行构建操作。
输出asset阶段
在前面我们看到了module模块及其相关的依赖被build构建解析好了,之后开始进行回调callback,我们一直向上回溯,回到了compiler里最开始的compile方法中。
//compilation.js的compile方法
compilation.finish(err => {
if (err) return callback(err);
//执行compilation.seal,seal在finishMake与afterCompile两个hook之间
compilation.seal(err => {
if (err) return callback(err);
//开始执行afterCompile hook
this.hooks.afterCompile.callAsync(compilation, err => {
if (err) return callback(err);
//继续执行回调
return callback(null, compilation);
});
});
});
接下来我们思考两个问题:
Compilation的seal封存具体做了什么?
seal封存后,开始执行afterCompile hook,afterCompile hook的回调做了什么?
我们可以看下源码中seal中的关键代码: 可以看到seal方法里会对之前解析好的所有模块进行处理(optimize优化相关的配置在这里开始执行)。然后进入代码生成阶段,生成好的代码交给createChunkAssets方法处理。
//compilation.js
seal(callback) {
......
// 1.这里遍历所有模块modules,加入到ChunkGraph
for (const module of this.modules) {
ChunkGraph.setChunkGraphForModule(module, chunkGraph);
}
......
// 2.执行关于optimize的优化配置的hook
this.hooks.optimizeChunkModules.callAsync(
//处理了很多optimize配置
// 3.代码生成的阶段
this.codeGeneration(err => {
......
// 4.创建chunkAssets资源
this.createChunkAssets(err => {}
}
)
}
我们来到源码看createChunkAssets里的核心代码:原来createChunkAssets会统一的将所有的chunks生成的代码使用manifest对象的render集中处理后,交给emitAsset方法。
最终由emitAsset方法实现最终想要实现的目的:将所有要打包输出的资源都放到compilation的assets内存里,准备输出。(compilation.seal封存的最终目的)(至此,我们解决了第一个问题。)
//compilation.js
createChunkAssets(){
// 1. 创建了一个manifest对象, 将chunks都放到对象里,
//目的是要使用manifest对象的render方法
manifest = this.getRenderManifest({})
//2. 将所有chunks通过render输出到source
source = fileManifest.render();
//3. source中有_source就是要输出的内容,最终将source传给emitAsset
this.emitAsset(file, source, assetInfo);
}
emitAsset(file, source, assetInfo = {}){
......
//核心:将source存到compilation的assets里
this.assets[file] = source;
}
然后我们再去看afterCompile hook的回调函数具体是执行了什么。根据callback回调一直追溯,找到了Compiler类里run的onComiled -> Compiler.emitAssets。
这里看一下emitAssets做了什么?
emitAssets(compilation, callback) {
// 将asset的资源写入到指定的输出目录
this.hooks.emit.callAsync(compilation, err => {
if (err) return callback(err);
outputPath = compilation.getPath(this.outputPath, {});
mkdirp(this.outputFileSystem, outputPath, emitFiles);
});
}
Compiler.emitAssets将所有的assets资源输出文件夹。emitAssets执行完后,就会在其回调函数里执行done hook。(现在我们也知道了第二个问题的答案了)
至此,整个打包编译的流程就走完了。
Compiler和Compilation的区别
最后补充一个面试常见问题:compiler与compilation的区别
Compiler:
- 在webpack构建的之初就会创建的一个对象, 并且在webpack的整个生命周期都会存在(before - run - beforeCompiler - compile - make - finishMake - afterCompiler - done);
- Compiler对象在webpack的配置发送变化,重新进行npm run build时才会重新创建;
Compilation
- 到准备编译模块(比如main.js), 才会创建Compilation对象,主要是存在于 compile - make 阶段主要使用的对象;
- Compilation则是代码发生改变后就会重新创建。