We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
相信有不少小伙伴在膜拜别人的文章时,经常性的会被“断点调试”这四个字劝退,毕竟很多看源码写文章的作者都是大佬,默认断点必备技能,一笔带过,环境没搭起来,怎么跟着作者遨游源码世界呢?
今天,让我们手把手,环境没搭好,我们就不看这源码了好不好!!!
git clone https://github.com/webpack/webpack.git cd webpack # 切换到最新的webpack 4的某个线上版本(现在已经有5了) git checkout 45ecebc
debug
# 新建debug目录 mkdir debug cd debug # 新建4个文件 touch index.js module.js start-debug.js webpack.config.js # index和module 是要跑起来的文件 # webpack.config 是配置文件,跟我们日常使用的一样 # start-debug 调用webpack的入口文件,启动编译 # npm初始化debug目录,装一个插件 npm init -y yarn add clean-webpack-plugin # 退出外层,接下来的操作都在外面执行 cd ..
// debug/start-debug.js // 看根目录的package.json的main字段,可知入口在lib/webpack.js const webpack = require('../lib/webpack'); const config = require('./webpack.config'); // compiler是webpack的启动入口,直接调用即可 const compiler = webpack(config); compiler.run();
// debug/webpack.config.js const path = require('path'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = { // 选择调试开发环境 mode: 'development', devtool: 'source-map', entry: './index.js', // 在debug目录下会生成一个dist目录,打包完成后会生成一个main.js文件 output: { path: path.join(__dirname, './dist'), }, plugins: [new CleanWebpackPlugin()], };
// debug/index.js import { mudoleName } from './module'; console.log('hi, webpack, this is ' + mudoleName); // debug/module.js export const mudoleName = 'module';
webpack-cli
# 如果已经全局安装过,忽略这一步 yarn global add webpack webpack-cli # 在webpack根目录下执行 webpack debug/index.js --config debug/webpack.config.js
如果以上配置正确,应该会成功在debug/dist目录下打包出一个main.js文件,然后把里面的代码直接粘贴到浏览器控制台运行,应该能正确打印出hi, webpack, this is module
debug/dist
main.js
hi, webpack, this is module
上面步骤通过后,我们来配置一下vscode的断点调试环境,用本地的webpack源码来跑编译,而不是上面全局安装的webpack-cli
打开vscode的调试界面,增加一个launch.json配置文件,选择Node.js环境,然后在program中选择webpack启动入口start-debug.js,可以顺便改个名字
launch.json
Node.js
program
start-debug.js
配置完后,进webapck的lib目录内随便打几个断点,点击开始按钮
然后就会出现上面的断点工具栏(跟chrome的断点是不是很像?) 进入第一个断点
想了解更多可看具体vscode官方文档 Debugging in Visual Studio Code
好了,万事俱备只欠东风,接下来让我们同步断点一步步来看webpack的工作流程吧
啰嗦一句,看代码之前,还是希望读者们有使用过webpack的经验,并且大致理解webpack的原理,不然有可能会雾里看花 对于这部分同学,这里有两道开胃菜 【webpack进阶系列】手撸一个mini-webpack(一) : 分析收集依赖 【webpack进阶系列】手撸一个mini-webpack(二) : 打包依赖代码
大纲
一起从const compiler = webpack(config)开始
const compiler = webpack(config)
Webpack的起点在lib/webpack.js,除了导出webpack,还通过挂载对象属性的方式导出了很多内置插件
lib/webpack.js
webpack
这里有个看源码的原则,我们只关注核心流程,只看核心代码,多余的信息暂且无视或者跳过,这是看源码的一种方法吧,你不可能一下子接受太多的细节信息,应该先把核心脉络弄清楚,建立起对整个库的宏观认知,对作者的主要思路弄清楚后,再回头慢慢品尝旁路分支和一些细枝末节
进入lib/webpack.js,核心代码如下
// lib/webpack.js const webpack = (options, callback) => { compiler = createCompiler(options); // 调用compiler.run()手动启动编译,自动编译的代码这里省略了 return compiler; }; module.exports = webpack;
再回想我们上面start-debug.js写的启动代码,如何启动webpack是不是秒懂?
// debug/start-debug.js const webpack = require('../lib/index'); const config = require('./webpack.config'); // compiler是webpack的启动入口,直接调用即可 const compiler = webpack(config); compiler.run();
compiler在webpack中是一个非常重要的概念,这里先把概念铺出来,后面会慢慢深入 * compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。 * compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用
compiler
options
loader
plugin
compilation
我们先看下createCompiler的实现
createCompiler
// lib/webpack.js const createCompiler = options => { const compiler = new Compiler(options.context); compiler.options = options; // options.plugins也就是用户自定义的外部插件,都在这里注册 // 自定义写插件时,为何要定义一个apply方法,其实就是在这里被调用了 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); return compiler; };
compiler是Compiler实例化而来,用户自定义的webpack.config配置通过options属性挂在compiler上,我们日常使用的plugins原来都是在这里注册的,如果自定义的插件是一个方法,就直接被call调用,如果插件是以类的方式编写,需要提供一个apply方法以供调用,同时会把compiler当做参数传入
Compiler
webpack.config
call
apply
调用钩子:compiler.hooks.environment.call()是一种钩子调用的方式,也是接下来我们会碰的最多的一种用法,而这种钩子注册于调用的方式来源于Tapable(含义,水龙头),Tapable是 Eventemitter 的升级版本,包含了 同步/异步 发布订阅模型, 在它的异步模型里又分为 串行/并行,Webpack的整个骨架基于Tapable,Webpack 可以认为是一种基于事件流的编程范例,内部的工作流程都是基于 插件 机制串接起来
compiler.hooks.environment.call()
Tapable
再往下看WebpackOptionsApply().process(options, compiler)
WebpackOptionsApply().process(options, compiler)
进入WebpackOptionsApply去看process方法的实现
WebpackOptionsApply
process
// lib/WebpackOptionsApply.js class WebpackOptionsApply extends OptionsApply { // ... constructor() {} process(options, compiler){ // 处理配置中的参数,调对应的plugin // if (options.param) new XXXPlugin().apply(compiler) if(options.target) { new XXXPlugin().apply(compiler) } // 或者直接初始化某些必备plugin // new XXXPlugin().apply(compiler) ... new JavascriptModulesPlugin().apply(compiler); // 调用/注册某些钩子 compiler.hooks.afterPlugins.call(compiler); compiler.hooks.afterResolvers.call(compiler); return options; } }
拿到参数后,new很多的plugin,并apply他们
new
我们知道,webpack插件其实就是提供了apply方法的类编写一个插件 | webpack,这个 apply 方法在安装插件时,会被 compiler 调用一次。apply 方法可以接收一个 compiler 对象的引用,从而可以在回调函数中访问到 compiler 对象
用户自定义的插件在上面createCompiler的时候就已经全部注册完成了,这里的全部是webpack内置的插件,它们的职责其实是一样的,都是勾住compiler.hooks上的一个生命周期(或者说webpack的事件钩子),一旦进入该生命周期(或者说注册的事件被触发),插件上定义的callback事件也会被触发,执行插件的功能
compiler.hooks
再次回到上面说的Webpack 可以认为是一种基于事件流的编程范例,内部的工作流程都是基于 **插件** 机制串接起来,是不是好理解一点了
Webpack 可以认为是一种基于事件流的编程范例,内部的工作流程都是基于 **插件** 机制串接起来
通过注册用户自定义以及webpack内置的插件,在合适的钩子事件触发后执行插件的功能,而插件可以通过compiler和compilation拿到完整的 webpack 环境配置以及编译过程的状态,那么通过插件,我们就几乎可以控制整个编译过程的任一节点,做任意的事情。
好了好了,扯远了 以上process流程完成了用户配置的初始化,把对应的插件都注册好了,webpack将所有它关心的hook消息都注册完成,就等着后续编译过程中一一触发
compiler是实例化了一个Compiler类,启动webpack编译是通过compiler.run()方法,我们去类上面找一下这个方法
compiler.run()
// lib/Compiler.js run(callback) { const onCompiled = (err, compilation) => { if (this.hooks.shouldEmit.call(compilation) === false) { this.hooks.done.callAsync(stats, err => {...}); } this.emitAssets(compilation, err => { if (compilation.hooks.needAdditionalPass.call()) { this.hooks.done.callAsync(stats, err => { this.compile(onCompiled); }); } this.emitRecords(err => { this.hooks.done.callAsync(stats, err => {...}); }); }); }; this.hooks.beforeRun.callAsync(this, err => { this.hooks.run.callAsync(this, err => { this.compile(onCompiled); }); }); }
先看后面那段,在beforeRun和run两个钩子中应该是先做完一些前置预备工作(beforeRun中绑定读取文件的对象,run中处理缓存的模块,减少编译的模块,加速编译速度),然后进入Compiler.compile()编译环节
beforeRun
run
Compiler.compile()
// beforeRun -> run -> this.compile(onCompiled); this.hooks.beforeRun.callAsync(this, err => { this.hooks.run.callAsync(this, err => { this.compile(onCompiled); }); });
Compiler.compile()编译完成后调用上面的onCompiled回调,看shouldEmit和done这些钩子的字面意思,应该将编译后的输出结果生成文件
onCompiled
shouldEmit
done
const onCompiled = (err, compilation) => { // 编译失败,直接done if (this.hooks.shouldEmit.call(compilation) === false) { this.hooks.done.callAsync(stats, err => {...}); } // 编译成功,emitAssets生成文件,然后done, 如果有递归,可以继续 this.emitAssets(compilation, err => { if (compilation.hooks.needAdditionalPass.call()) { this.hooks.done.callAsync(stats, err => { this.compile(onCompiled); }); } this.emitRecords(err => { this.hooks.done.callAsync(stats, err => {...}); }); }); };
虽然上面的代码已经够精简了,但还是还有点复杂,而且有点callback hell的意思,我们再捋一捋顺序,本质上是这么一条链路 hooks.beforeRun -> hooks.run -> this.compile(onCompiled); -> hooks.done -> hooks.afterDone
hooks.beforeRun
hooks.run
this.compile(onCompiled);
hooks.done
hooks.afterDone
this.compiler(onCompiled)编译过程稍等一下,先看其他4个钩子,beforeRun -> run -> doned -> afterDone
this.compiler(onCompiled)
beforeRun -> run -> doned -> afterDone
字面来看,run方法勾住了编译了整个阶段的前期和后期,在编译过程中的不同阶段,处处都有compiler对象的存在,触发了不同的生命周期钩子,那么前面根据钩子注册的插件的回调也会被相应调用
举个例子,我随便搜了个beforeRun的钩子,发现一个叫NodeEnvironmentPlugin的内置插件,勾住了beforeRun,
NodeEnvironmentPlugin
compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => { if (compiler.inputFileSystem === inputFileSystem) { inputFileSystem.purge(); } });
那么,在触发beforeRun钩子时,也即运行到this.hooks.beforeRun.callAsync时,NodeEnvironmentPlugin插件的下面这段回调逻辑就会执行,这里也就是上面说的beforeRun中绑定读取文件的对象
this.hooks.beforeRun.callAsync
beforeRun中绑定读取文件的对象
if (compiler.inputFileSystem === inputFileSystem) { inputFileSystem.purge(); }
以上可以理解,所有注册了beforeRun、run、doned、afterDone钩子的插件,都会在compiler.run()这个过程中被一一触发调用
beforeRun、run、doned、afterDone
捋完compiler的钩子,我们再看看核心的this.compiler(onCompiled)
终于要到compilation要出场了,再po一次概念 * compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。 * compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用
先看精简版代码
// lib/Compiler.js // callback:compiler.run()传入的onCompiled方法 compile(callback) { const params = this.newCompilationParams(); this.hooks.beforeCompile.callAsync(params, err => { this.hooks.compile.call(params); const compilation = this.newCompilation(params); this.hooks.make.callAsync(compilation, err => { compilation.finish(err => { compilation.seal(err => { this.hooks.afterCompile.callAsync(compilation, err => { return callback(null, compilation); }); }); }); }); }); }
同样是一个回调层次比较深的方法,仿照上面的分析方法抽一下钩子出来看看,beforeCompile -> compile -> make -> afterCompile
beforeCompile -> compile -> make -> afterCompile
在make钩子之间实例化了一个compilation对象,实例化之前通过newCompilationParams实例化了两个核心的工厂对象normalModuleFactory和contextModuleFactory,用来创建normalModule和contextModule实例,然后将整个模块工厂对象传入 compiler和 compilation中,在compilation中能够拿到当前的模块类型,最常用的就是normalModule
make
newCompilationParams
normalModuleFactory
contextModuleFactory
normalModule
contextModule
真正的编译构建过程注册在make这个钩子的回调上,终于到你了啊,老哥
虽然知道了是make这个钩子,但它是在哪里被哪个插件注册的呢?
我们写过业务代码都知道,发布订阅模式虽然好用,但是它的代码可以到处飞,任何地方都可能出现订阅代码,而基于tapable的webpack同样有这个特点,我们反向查找一下看看,它是用callAsync触发的,先试试hooks.make.tapAsync,如果不行,就试试tapPromise
callAsync
hooks.make.tapAsync
tapPromise
一共也没几个选项,根据插件的名字,其实很容易就能区分出来,就是SingleEntryPlugin 和 MultiEntryPlugin这两插件
SingleEntryPlugin
MultiEntryPlugin
其他几个插件的名字明显不符合要求,什么自动预加载、DLL、动态入口、预加载、测试子编译进程失败,只有动态入口DynamicEntryPlugin有个干扰性,大不了都看一下嘛
DynamicEntryPlugin
go go go,进lib/SingleEntryPlugin.js看一下
lib/SingleEntryPlugin.js
找到了插件,那么在哪里调用的呢? 再反向查一下哪里new XXXPlugin,搜一发new SingleEntryPlugin,再用一下排除法,原来是个叫EntryOptionPlugin的插件,果然是同时调用了SingleEntryPlugin、MultiEntryPlugin两个插件,上面的猜测没错
new XXXPlugin
new SingleEntryPlugin
EntryOptionPlugin
SingleEntryPlugin、MultiEntryPlugin
等一下,这里是不是有点似曾相识,原来是对配置的entry字段根据不同类型进行分发,分别调用SingleEntryPlugin、MultiEntryPlugin、DynamicEntryPlugin等插件
entry
SingleEntryPlugin、MultiEntryPlugin、DynamicEntryPlugin
// entry是字符串、数组、对象等类型时的处理,默认入口是main if (typeof entry === "string" || Array.isArray(entry)) { itemToPlugin(context, entry, "main").apply(compiler); } else if (typeof entry === "object") { for (const name of Object.keys(entry)) { itemToPlugin(context, entry[name], name).apply(compiler); } // entry是函数时,实例化一个动态入口插件 } else if (typeof entry === "function") { new DynamicEntryPlugin(context, entry).apply(compiler); }
再往下找调用处,搜new EntryOptionPlugin,nice,只有一个结果
new EntryOptionPlugin
咦,回到了WebpackOptionsApply.process()方法里,这不就是上面第2点的用户配置初始化吗,bingo,看来上面的判断没错,WebpackOptionsApply.process里已经把所有的插件都注册好了,make就在这里等你触发呢
WebpackOptionsApply.process()
WebpackOptionsApply.process
整体梳理一下,围绕make钩子的核心关系,如下图
现在我们再回到最初注册make的地方,先看看lib/SingleEntryPlugin.js注册了什么回调
// lib/SingleEntryPlugin.js compiler.hooks.make.tapAsync( "SingleEntryPlugin", (compilation, callback) => { const { entry, name, context } = this; const dep = SingleEntryPlugin.createDependency(entry, name); compilation.addEntry(context, dep, name, callback); } );
再看看lib/MultiEntryPlugin.js
lib/MultiEntryPlugin.js
// /lib/MultiEntryPlugin.js compiler.hooks.make.tapAsync( "MultiEntryPlugin", (compilation, callback) => { const { context, entries, name } = this; const dep = MultiEntryPlugin.createDependency(entries, name); compilation.addEntry(context, dep, name, callback); } );
两份代码类似,重点在compilation.addEntry,继续往下看
compilation.addEntry
从compilation.addEntry开始,这里的方法嵌套比较深,而且参数有可能就是关键方法,直接读不是容易理解,我们先把调用链理出来 addEntry -> _addModuleChain -> onModule(module) -> buildModule -> module.build
addEntry -> _addModuleChain -> onModule(module) -> buildModule -> module.build
_addModuleChain,构建依赖核心方法,同时保存模块与模块之间的依赖
_addModuleChain
const Dep = /** @type {DepConstructor} */ (dependency.constructor); const moduleFactory = this.dependencyFactories.get(Dep); // ... moduleFactory.create() // ... onModule(module);
moduleFactory是当前模块类型的创造工厂
moduleFactory
moduleFactory.create()生成新模块
moduleFactory.create()
onModule(module)最终调用了_addModuleChain的第三个参数,将入口模块添加到compilation.entries中
onModule(module)
compilation.entries
这里虽然方法名写的很清楚,但是要理解的话,可能要认真读一读几个方法的参数是怎么传递的,才能知道整个调用的链路,比如onModule整个方法其实是传进来的参数,并不是在compilation里直接定义的方法属性
onModule
module => { this.entries.push(module); },
再往下走,buildModule -> this.hooks.buildModule.call(module); 给出一个可以对module进行操作的hook,即允许自定义插件勾住这个地方对模块进行操作
buildModule
this.hooks.buildModule.call(module);
module.build自行创建模块,找到build整个方法需要根据类的继承一直往上查找,最终会走到NormalModule的doBuild里面,然后runLoaders选择合适的loader把所有的resource全部转换为标准JS模块(webpack只认识JS,对css、vue、react、jpg等等文件全都不认识,需要loader把它们转成标准JS模块)
module.build
NormalModule
doBuild
runLoaders
resource
经过build -> doBuild拿到所有模块后,开始编译生成AST
build
// lib/NormalModule.js build(options, compilation, resolver, fs, callback) { // ...定义很多内部变量 return this.doBuild(options, compilation, resolver, fs, err => { // ... try { const result = this.parser.parse( this._ast || this._source.source(), { current: this, module: this, compilation: compilation, options: options }, (err, result) => { if (err) { handleParseError(err); } else { handleParseResult(result); } } ); if (result !== undefined) { // parse is sync handleParseResult(result); } } catch (e) { handleParseError(e); } }); }
其中const result = this.parser.parse()方法,本质调用链是new JavascriptModulesPlugin() -> new Parser() -> Parse.parse() -> acorn.parse ,用第三方包acorn提供的parse方法对JS源代码进行语法解析生成AST
const result = this.parser.parse()
new JavascriptModulesPlugin()
new Parser()
Parse.parse()
acorn.parse
acorn
parse
其实生成AST最大的作用的是收集模块依赖关系,比如代码中出现了以下语法的话,都可以统一处理收集,试想通过写正则表达式或者其他ifelse条件来处理这部分依赖的话,头都要炸了
// ES6 module import Module from 'XX-npm-module'; // commonjs const Module = require('XX-npm-module') // AMD/CMD require(Module, callback)
本段生成module相关内容可参考详细版本 【webpack进阶系列】构建module流程
至此,webpack已经收集完整了从入口开始所有模块的信息和依赖项,那么接下来就是如何封装打包代码了 -> compilation.seal
compilation.seal
回到前面的compiler.compile()
compiler.compile()
compilation完成构建过程后,接下来进入compilation.seal钩子,点进去瞧一眼,可怕。。。大量的性能优化插件都在此被调用,简化起来就是生成chunk资源
// lib/Compilation.js seal() { // ...many optimize plugins being called buildChunkGraph() // ...many optimize plugins being called this.createHash(); // ... this.createModuleAssets(); // ... this.createChunkAssets(); // ... }
this.createChunkAssets()会调用emitAsset,即将生成最终js并输出到output的path,在这里我们打个断点,会看到第二个参数 source._source的内容已经初具雏形,基本就是我们打包完成后所看见的内容了
this.createChunkAssets()
emitAsset
source._source
然后会调用emit钩子,根据配置文件的output.path属性,将文件输出到指定的文件夹,然后就可以查看最后的打包文件了
emit
output.path
this.hooks.emit.callAsync(compilation, err => { if (err) return callback(err); outputPath = compilation.getPath(this.outputPath); this.outputFileSystem.mkdirp(outputPath, emitFiles); });
下面以一张完美的gif图来收个尾,看一看打包文件在最后一步从无到有的神圣一刻
这里只是一条主线,我们剔除了很多的细节,只是为了搞清楚webpack是怎么跑起来到生成打包文件的
举个例子,假如我们以watch的模式运行webpack,其实走的不是run任务,而是watch-run任务
watch-run
然后还有tapable、模块工厂、Parse如何生成AST、seal过程如何生成chunk、如何调用模板生成最终代码,性能优化插件是怎么处理的等等,还有很多方面的东西值得我们去研究学习,后面笔者有时间的话会一块块写文章进行分析讲解,敬请期待。
然后再啰嗦一句,为何我们要花时间去看源码了解webapck的原理,做这些看起来没啥业务价值,甚至有点【枯燥】的事情?
个人看法,前端变化快是公认的,但是技术这东西,应该是万变不离其宗的,比如我们现在在研究webpack4,但其实webpack5已经出来了,源码里已经在写webpack6了,那么我们要怎么跟得上,怎么避免’学不动’?其实无论它发展到6、7、8都好,本质上webpack就是tapable模型扩展开来的插件架构,把核心的几个钩子串起来的流程弄清楚后,再怎么变也只是一些API和一些细节,使用起来或者说掌握webpack的使用会更加得心应手,当然我的意思不是说你看过源码懂原理就一定能把webpack用的很好,实践还是很吃项目的,但你懂了就能很快上手基于webpack生态的前端工程化,而且有举一反三,开发插件的能力。
掌握技术的核心底层原理,不能立刻产生效果,能让我们学的更快,走的更远,这绝对是稳赚不赔的投资。
Webpack author Tobias Koppers: How Webpack works - YouTubewebpack作者亲自给你讲webpack原理,还要啥自行车 Understanding webpack from the inside out - YouTube Webpack源码解读:理清编译主流程 - 掘金 从Webpack源码探究打包流程,萌新也能看懂~ - 掘金
The text was updated successfully, but these errors were encountered:
前辈好厉害,看懂了一部分流程,后面越看越懵,自己断点弄的头都快晕了,再好好吸收一下,点个赞👍
Sorry, something went wrong.
👍
./debug/index.js
No branches or pull requests
目录
一、如何用vscode断点调试webpack
相信有不少小伙伴在膜拜别人的文章时,经常性的会被“断点调试”这四个字劝退,毕竟很多看源码写文章的作者都是大佬,默认断点必备技能,一笔带过,环境没搭起来,怎么跟着作者遨游源码世界呢?
今天,让我们手把手,环境没搭好,我们就不看这源码了好不好!!!
debug
目录用于调试webpack-cli
脚手架跑一下以上配置,看能否能正常运行,OK了我们再开始debug如果以上配置正确,应该会成功在
debug/dist
目录下打包出一个main.js
文件,然后把里面的代码直接粘贴到浏览器控制台运行,应该能正确打印出hi, webpack, this is module
上面步骤通过后,我们来配置一下vscode的断点调试环境,用本地的webpack源码来跑编译,而不是上面全局安装的webpack-cli
打开vscode的调试界面,增加一个
launch.json
配置文件,选择Node.js
环境,然后在program
中选择webpack启动入口start-debug.js
,可以顺便改个名字配置完后,进webapck的lib目录内随便打几个断点,点击开始按钮
然后就会出现上面的断点工具栏(跟chrome的断点是不是很像?)
进入第一个断点
想了解更多可看具体vscode官方文档 Debugging in Visual Studio Code
好了,万事俱备只欠东风,接下来让我们同步断点一步步来看webpack的工作流程吧
二、webpack源码探索
大纲
1、webpack入口
一起从
const compiler = webpack(config)
开始Webpack的起点在
lib/webpack.js
,除了导出webpack
,还通过挂载对象属性的方式导出了很多内置插件这里有个看源码的原则,我们只关注核心流程,只看核心代码,多余的信息暂且无视或者跳过,这是看源码的一种方法吧,你不可能一下子接受太多的细节信息,应该先把核心脉络弄清楚,建立起对整个库的宏观认知,对作者的主要思路弄清楚后,再回头慢慢品尝旁路分支和一些细枝末节
进入
lib/webpack.js
,核心代码如下再回想我们上面
start-debug.js
写的启动代码,如何启动webpack是不是秒懂?compiler
在webpack中是一个非常重要的概念,这里先把概念铺出来,后面会慢慢深入*
compiler
对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括options
,loader
和plugin
。当在 webpack 环境中应用一个插件时,插件将收到此compiler
对象的引用。可以使用它来访问 webpack 的主环境。*
compilation
对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用我们先看下
createCompiler
的实现compiler
是Compiler
实例化而来,用户自定义的webpack.config
配置通过options
属性挂在compiler
上,我们日常使用的plugins原来都是在这里注册的,如果自定义的插件是一个方法,就直接被call
调用,如果插件是以类的方式编写,需要提供一个apply
方法以供调用,同时会把compiler
当做参数传入调用钩子:
compiler.hooks.environment.call()
是一种钩子调用的方式,也是接下来我们会碰的最多的一种用法,而这种钩子注册于调用的方式来源于Tapable
(含义,水龙头),Tapable是 Eventemitter 的升级版本,包含了 同步/异步 发布订阅模型, 在它的异步模型里又分为 串行/并行,Webpack的整个骨架基于Tapable,Webpack 可以认为是一种基于事件流的编程范例,内部的工作流程都是基于 插件 机制串接起来再往下看
WebpackOptionsApply().process(options, compiler)
2、WebpackOptionsApply.process:处理用户自定义的webpack配置
进入
WebpackOptionsApply
去看process
方法的实现拿到参数后,
new
很多的plugin
,并apply
他们我们知道,webpack插件其实就是提供了
apply
方法的类编写一个插件 | webpack,这个apply
方法在安装插件时,会被compiler
调用一次。apply
方法可以接收一个compiler
对象的引用,从而可以在回调函数中访问到compiler
对象用户自定义的插件在上面
createCompiler
的时候就已经全部注册完成了,这里的全部是webpack内置的插件,它们的职责其实是一样的,都是勾住compiler.hooks
上的一个生命周期(或者说webpack的事件钩子),一旦进入该生命周期(或者说注册的事件被触发),插件上定义的callback事件也会被触发,执行插件的功能再次回到上面说的
Webpack 可以认为是一种基于事件流的编程范例,内部的工作流程都是基于 **插件** 机制串接起来
,是不是好理解一点了通过注册用户自定义以及webpack内置的插件,在合适的钩子事件触发后执行插件的功能,而插件可以通过
compiler
和compilation
拿到完整的 webpack 环境配置以及编译过程的状态,那么通过插件,我们就几乎可以控制整个编译过程的任一节点,做任意的事情。好了好了,扯远了
以上
process
流程完成了用户配置的初始化,把对应的插件都注册好了,webpack将所有它关心的hook消息都注册完成,就等着后续编译过程中一一触发3、compiler.run()
compiler
是实例化了一个Compiler
类,启动webpack编译是通过compiler.run()
方法,我们去类上面找一下这个方法先看后面那段,在
beforeRun
和run
两个钩子中应该是先做完一些前置预备工作(beforeRun
中绑定读取文件的对象,run
中处理缓存的模块,减少编译的模块,加速编译速度),然后进入Compiler.compile()
编译环节Compiler.compile()
编译完成后调用上面的onCompiled
回调,看shouldEmit
和done
这些钩子的字面意思,应该将编译后的输出结果生成文件虽然上面的代码已经够精简了,但还是还有点复杂,而且有点callback hell的意思,我们再捋一捋顺序,本质上是这么一条链路
hooks.beforeRun
->hooks.run
->this.compile(onCompiled);
->hooks.done
->hooks.afterDone
this.compiler(onCompiled)
编译过程稍等一下,先看其他4个钩子,beforeRun -> run -> doned -> afterDone
字面来看,
run
方法勾住了编译了整个阶段的前期和后期,在编译过程中的不同阶段,处处都有compiler
对象的存在,触发了不同的生命周期钩子,那么前面根据钩子注册的插件的回调也会被相应调用举个例子,我随便搜了个
beforeRun
的钩子,发现一个叫NodeEnvironmentPlugin
的内置插件,勾住了beforeRun
,那么,在触发
beforeRun
钩子时,也即运行到this.hooks.beforeRun.callAsync
时,NodeEnvironmentPlugin
插件的下面这段回调逻辑就会执行,这里也就是上面说的beforeRun中绑定读取文件的对象
以上可以理解,所有注册了
beforeRun、run、doned、afterDone
钩子的插件,都会在compiler.run()
这个过程中被一一触发调用捋完
compiler
的钩子,我们再看看核心的this.compiler(onCompiled)
终于要到
compilation
要出场了,再po一次概念*
compiler
对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括options
,loader
和plugin
。当在 webpack 环境中应用一个插件时,插件将收到此compiler
对象的引用。可以使用它来访问 webpack 的主环境。*
compilation
对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用4、compiler.compile()
先看精简版代码
同样是一个回调层次比较深的方法,仿照上面的分析方法抽一下钩子出来看看,
beforeCompile -> compile -> make -> afterCompile
在
make
钩子之间实例化了一个compilation
对象,实例化之前通过newCompilationParams
实例化了两个核心的工厂对象normalModuleFactory
和contextModuleFactory
,用来创建normalModule
和contextModule
实例,然后将整个模块工厂对象传入compiler
和compilation
中,在compilation
中能够拿到当前的模块类型,最常用的就是normalModule
真正的编译构建过程注册在
make
这个钩子的回调上,终于到你了啊,老哥虽然知道了是
make
这个钩子,但它是在哪里被哪个插件注册的呢?我们写过业务代码都知道,发布订阅模式虽然好用,但是它的代码可以到处飞,任何地方都可能出现订阅代码,而基于tapable的webpack同样有这个特点,我们反向查找一下看看,它是用
callAsync
触发的,先试试hooks.make.tapAsync
,如果不行,就试试tapPromise
一共也没几个选项,根据插件的名字,其实很容易就能区分出来,就是
SingleEntryPlugin
和MultiEntryPlugin
这两插件go go go,进
lib/SingleEntryPlugin.js
看一下找到了插件,那么在哪里调用的呢?
再反向查一下哪里
new XXXPlugin
,搜一发new SingleEntryPlugin
,再用一下排除法,原来是个叫EntryOptionPlugin
的插件,果然是同时调用了SingleEntryPlugin、MultiEntryPlugin
两个插件,上面的猜测没错等一下,这里是不是有点似曾相识,原来是对配置的
entry
字段根据不同类型进行分发,分别调用SingleEntryPlugin、MultiEntryPlugin、DynamicEntryPlugin
等插件再往下找调用处,搜
new EntryOptionPlugin
,nice,只有一个结果咦,回到了
WebpackOptionsApply.process()
方法里,这不就是上面第2点的用户配置初始化吗,bingo,看来上面的判断没错,WebpackOptionsApply.process
里已经把所有的插件都注册好了,make
就在这里等你触发呢整体梳理一下,围绕
make
钩子的核心关系,如下图现在我们再回到最初注册
make
的地方,先看看lib/SingleEntryPlugin.js
注册了什么回调再看看
lib/MultiEntryPlugin.js
两份代码类似,重点在
compilation.addEntry
,继续往下看5、添加模块依赖+构建
从
compilation.addEntry
开始,这里的方法嵌套比较深,而且参数有可能就是关键方法,直接读不是容易理解,我们先把调用链理出来addEntry -> _addModuleChain -> onModule(module) -> buildModule -> module.build
_addModuleChain
,构建依赖核心方法,同时保存模块与模块之间的依赖moduleFactory
是当前模块类型的创造工厂moduleFactory.create()
生成新模块onModule(module)
最终调用了_addModuleChain
的第三个参数,将入口模块添加到compilation.entries
中再往下走,
buildModule
->this.hooks.buildModule.call(module);
给出一个可以对module进行操作的hook,即允许自定义插件勾住这个地方对模块进行操作module.build
自行创建模块,找到build整个方法需要根据类的继承一直往上查找,最终会走到NormalModule
的doBuild
里面,然后runLoaders
选择合适的loader把所有的resource
全部转换为标准JS模块(webpack只认识JS,对css、vue、react、jpg等等文件全都不认识,需要loader把它们转成标准JS模块)经过
build
->doBuild
拿到所有模块后,开始编译生成AST其中
const result = this.parser.parse()
方法,本质调用链是new JavascriptModulesPlugin()
->new Parser()
->Parse.parse()
->acorn.parse
,用第三方包acorn
提供的parse
方法对JS源代码进行语法解析生成AST其实生成AST最大的作用的是收集模块依赖关系,比如代码中出现了以下语法的话,都可以统一处理收集,试想通过写正则表达式或者其他ifelse条件来处理这部分依赖的话,头都要炸了
至此,webpack已经收集完整了从入口开始所有模块的信息和依赖项,那么接下来就是如何封装打包代码了 ->
compilation.seal
6、封装seal+打包输出
回到前面的
compiler.compile()
compilation
完成构建过程后,接下来进入compilation.seal
钩子,点进去瞧一眼,可怕。。。大量的性能优化插件都在此被调用,简化起来就是生成chunk资源this.createChunkAssets()
会调用emitAsset
,即将生成最终js并输出到output的path,在这里我们打个断点,会看到第二个参数source._source
的内容已经初具雏形,基本就是我们打包完成后所看见的内容了然后会调用
emit
钩子,根据配置文件的output.path
属性,将文件输出到指定的文件夹,然后就可以查看最后的打包文件了下面以一张完美的gif图来收个尾,看一看打包文件在最后一步从无到有的神圣一刻
三、核心流程总结
这里只是一条主线,我们剔除了很多的细节,只是为了搞清楚webpack是怎么跑起来到生成打包文件的
举个例子,假如我们以watch的模式运行webpack,其实走的不是
run
任务,而是watch-run
任务然后还有tapable、模块工厂、Parse如何生成AST、seal过程如何生成chunk、如何调用模板生成最终代码,性能优化插件是怎么处理的等等,还有很多方面的东西值得我们去研究学习,后面笔者有时间的话会一块块写文章进行分析讲解,敬请期待。
然后再啰嗦一句,为何我们要花时间去看源码了解webapck的原理,做这些看起来没啥业务价值,甚至有点【枯燥】的事情?
个人看法,前端变化快是公认的,但是技术这东西,应该是万变不离其宗的,比如我们现在在研究webpack4,但其实webpack5已经出来了,源码里已经在写webpack6了,那么我们要怎么跟得上,怎么避免’学不动’?其实无论它发展到6、7、8都好,本质上webpack就是tapable模型扩展开来的插件架构,把核心的几个钩子串起来的流程弄清楚后,再怎么变也只是一些API和一些细节,使用起来或者说掌握webpack的使用会更加得心应手,当然我的意思不是说你看过源码懂原理就一定能把webpack用的很好,实践还是很吃项目的,但你懂了就能很快上手基于webpack生态的前端工程化,而且有举一反三,开发插件的能力。
掌握技术的核心底层原理,不能立刻产生效果,能让我们学的更快,走的更远,这绝对是稳赚不赔的投资。
参考
Webpack author Tobias Koppers: How Webpack works - YouTubewebpack作者亲自给你讲webpack原理,还要啥自行车
Understanding webpack from the inside out - YouTube
Webpack源码解读:理清编译主流程 - 掘金
从Webpack源码探究打包流程,萌新也能看懂~ - 掘金
The text was updated successfully, but these errors were encountered: