webpack工作流程浅析 | 冰岩分享
今天带来前端组同学有关webpack的技术分享
▼
前端开发追求快速与简洁,webpack的出现给这种追求赋予了新的定义。使用webpack让前端开发流程变得高度可控,loader机制可无痛引入社区各种优化开发体验的方案,plugin机制拓宽了代码的工作方式,尽管webpack之前的许多打包工具已经做过诸多尝试,webpack仍以高度顺滑的使用体验脱颖而出。这篇文章给有过webpack使用经验的朋友们初步展示webpack的工作流程,更加细节的东西可以在本文找到相应的切入点之后阅读源码。
webpack流程概览
加载配置文件,加载插件,得到options对象。可以简单地理解为把你的配置文件读取了一遍。
这一步是通过yargs这个模块处理的。
function webpack(options) {// ...compiler = new Compiler();compiler.context = options.context;compiler.options = options;new NodeEnvironmentPlugin().apply(compiler);// 绑定自定义的所有插件到相对应的钩子上if(options.plugins && Array.isArray(options.plugins)) {compiler.apply.apply(compiler, options.plugins);}compiler.applyPlugins("environment");compiler.applyPlugins("after-environment");// 再编译options,主要是注册配置选项对应需要的内部插件compiler.options = new WebpackOptionsApply().process(options, compiler);// ...return compiler;}...// webpack的实际入口是Compiler中的run方法,run一旦执行,就开始了编译和构建流程。其中有几个比较关键的webpack时间节点,这些时间节点被称作“钩子”,后面我们还会介绍。/*** compile开始编译* make从入口点分析模块及依赖模块,创建这些模块对象* build-module 构建模块* after-compile 完成构建* seal 封装构建结果* emit 把各个chunk输出到结果文件* after-emit 完成输出*/
通过run方法正式进入编译流程。
触发compile
这一步将构建出Compilation对象:
compilation对象和compiler一样继承自Tapable。这是一个编译资源生成对象,每当一次新的编译开始都会更新该对象并生成新的编译资源。这个对象具备两个作用,一是负责组织整个打包过程,包含了每个构建环节及输出环节所对应的方法,可以从图中看到比较关键的步骤如addEntry(), _addModuleChain(), buildModule(), seal(), createChunkAssets(),在每一个节点都会触发webpack事件去调用各个插件。二是该对象内部存放着所有module,chunk,生成的asset以及用来生成最后打包文件的template的信息。
编译与构建主流程(make)
在创建module之前,Compiler会触发make,并调用Compilation.addEntry方法,通过options对象的entry字段找到入口js文件,之后在addEntry中调用私有方法_addModuleChain。说了这么多什么什么方法的,其实就是根据你的模块引用,给每个模块找到合适的创建方法,把他们转换成webpack认可的对象保存在内存中。严格来说其实是做了以下两件事情:
根据模块的类型获取对应的模块工厂并创建模块
构建模块
而构建模块是最耗时的一步, 分为三个阶段:
调用各个loader处理模块之间的依赖
比如url-loader,jsx-loader,css-loader等等让我们可以直接在源文件中引用各种资源。webpack调用doBuild(),对每一个require()用对应的loader进行加工,最后生成一个js module
Compilation.prototype._addModuleChain = function process(context, dependency, onModule, callback) {var start = this.profile && +new Date();...// 根据模块的类型获取对应的模块工厂并创建模块var moduleFactory = this.dependencyFactories.get(dependency.constructor);...moduleFactory.create(context, dependency, function(err, module) {var result = this.addModule(module);...this.buildModule(module, function(err) {...// 构建模块,添加依赖模块}.bind(this));}.bind(this));};
调用
acorn解析经loader处理后的源文件生成抽象语法树AST
Parser.prototype.parse = function parse(source, initialState) {var ast;if (!ast) {// acorn以es6的语法进行解析ast = acorn.parse(source, {ranges: true,locations: true,ecmaVersion: 6,sourceType: "module"});}...};
遍历AST,构建该模块所依赖的模块
对于当前模块,会开辟一个依赖模块的数组,在遍历AST时将require()中的模块通过addDependency()添加到数组中,当前模块构建完成后,webpack调用 processModuleDependencies开始递归处理依赖的module,接着就会重复之前的构建步骤。
Compilation.prototype.addModuleDependencies = function(module, dependencies, bail, cacheGroup, recursive, callback) {// 根据依赖数组(dependencies)创建依赖模块对象var factories = [];for (var i = 0; i < dependencies.length; i++) {var factory = _this.dependencyFactories.get(dependencies[i][0].constructor);factories[i] = [factory, dependencies[i]];}...// 与当前模块构建步骤相同}
构建细节
module是webpack构建的核心实体,也是所有module的父类。他有几种不同的子类:NormalModule , MultiModule , ContextModule , DelegatedModule等。在实际构建中,通过build方法来构造。具体的过程如下
// 初始化module信息,如context,id,chunks,dependencies等。NormalModule.prototype.build = function build(options, compilation, resolver, fs, callback) {this.buildTimestamp = new Date().getTime(); // 构建计时this.built = true;return this.doBuild(options, compilation, resolver, fs, function(err) {// 指定模块引用,不经acorn解析if (options.module && options.module.noParse) {if (Array.isArray(options.module.noParse)) {if (options.module.noParse.some(function(regExp) {return typeof regExp === "string" ?this.request.indexOf(regExp) === 0 :regExp.test(this.request);}, this)) {return callback();}} else if (typeof options.module.noParse === "string" ?this.request.indexOf(options.module.noParse) === 0 :options.module.noParse.test(this.request)) {return callback();}}// 由acorn解析生成asttry {this.parser.parse(this._source.source(), {current: this,module: this,compilation: compilation,options: options});} catch (e) {var source = this._source.source();this._source = null;return callback(new ModuleParseError(this, source, e));}return callback();}.bind(this));};
每一个module都会有这样一个构建方法。当然还包括从构建到输出的一系列有关module生命周期的函数,我们通过module父类类图及其子类类图来观察其真实形态(以NormalModule为例)
可见无论构建,处理依赖,封装,都与module密切相关。
打包输出
build完成后,webpack会监听 seal事件调用各种插件对构建后的结果进行封装,要逐次对每个module和chunk进行整理,生成编译后的源码,合并,拆分,形成hash。这一步是我们进行代码优化和功能添加的关键。
Compilation.prototype.seal = function seal(callback) {this.applyPlugins("seal"); // 触发插件的seal事件this.preparedChunks.sort(function(a, b) {if (a.name < b.name) {return -1;}if (a.name > b.name) {return 1;}return 0;});this.preparedChunks.forEach(function(preparedChunk) {var module = preparedChunk.module;var chunk = this.addChunk(preparedChunk.name, module);chunk.initial = chunk.entry = true;// 整理每个Module和chunk,每个chunk对应一个输出文件。chunk.addModule(module);module.addChunk(chunk);}, this);this.applyPluginsAsync("optimize-tree", this.chunks, this.modules, function(err) {if (err) {return callback(err);}... // 触发插件的事件this.createChunkAssets(); // 生成最终assets... // 触发插件的事件}.bind(this));};
生成最终assets
webpack调用Compilation的
createChunkAssets方法生成打包后的代码。这个方法流程如下:
不同的template
通过判断是入口js还是异步加载js来依照不同模板进行封装,入口js会采用webpack事件流的render来触发Template类中的renderChunkModules()(异步加载的js会调用chunkTemplate中的render方法)。webpack中有四种Template的子类,分别是MainTemplate.js,ChunkTemplate.js,ModuleTemplate.js,HotUpdateChunkTemplate.js.前两者已经介绍,ModuleTemplate 是对所有模块进行一个代码生成,HotUpdateChunkTemplate 是对热替换模块的一个处理。
if(chunk.entry) {source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates);} else {source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates);}
模块封装
模块在封装的时候和它在构建时一样,都是调用各模块类中的方法。封装通过调用
module.source()来进行各操作,比如说require()的替换。
MainTemplate.prototype.requireFn = "__webpack_require__";MainTemplate.prototype.render = function(hash, chunk, moduleTemplate, dependencyTemplates) {var buf = [];// 每一个module都有一个moduleId,在最后会替换。buf.push("function " + this.requireFn + "(moduleId) {");buf.push(this.indent(this.applyPluginsWaterfall("require", "", chunk, hash)));buf.push("}");buf.push("");... // 其余封装操作};
生成assets
各模块进行doBlock后,把module的最终代码循环添加到source中。一个 source 对应着一个 asset 对象,该对象保存了单个文件的文件名( name )和最终代码( value )。
输出
最后一步,webpack 调用 Compiler 中的
emitAssets(),按照 output 中的配置项将文件输出到了对应的 path 中,从而 webpack 整个打包过程结束。要注意的是,若想对打包输出的结果进行进一步的处理,则需要在emit触发后对自定义插件进行扩展。
事件钩子&触发时机
「创建」—— webpack在其内部对象上创建各种钩子
「注册」—— 插件将自己的方法挂载到对应钩子上,交给webpack
「调用」—— webpack编译过程中,会适时地触发相应钩子,因此也就触发了插件的方法
Tapable
webpack用来创建钩子的库,compiler和compilation都继承自Tapable,Tapable的主要成员方法有三个:
plugin(name:string,handler:function): 给Tapable实例注册一个自定义插件,等同于观察者模式中的事件监听,也是我们编写Webpack插件需要频繁用到的方法。
apply(...pluginInstances:[]): 传入已有插件并调用其apply方法
applyPlugins(name:string,args:any): 调用对应事件下的所有插件,类似于观察者模式中的事件触发
更详细的TapableAPI
const {SyncHook,SyncBailHook,SyncWaterfallHook,SyncLoopHook,AsyncParallelHook,AsyncParallelBailHook,AsyncSeriesHook,AsyncSeriesBailHook,AsyncSeriesWaterfallHook} = require("tapable");
通过一个例子,我们来了解Tapable创建钩子的方式:
// welcome.jsconst {SyncHook} = require('tapable');module.exports = class Welcome {constructor(words) {this.words = words;this.sayHook = new SyncHook(['words']);}// 进门回家的一系列行为begin() {console.log('开门');console.log('脱鞋');console.log('脱外套');// 打招呼this.sayHook.call(this.words);console.log('关门');}}/*** 在构造函数中创建同步钩子sayhook,用来进行之后的打招呼* begin方法描述了一系列动作,在‘脱外套’和‘关门’之间,触发sayHook并传入参数* 要注意,这里的call方法是Tapable提供的触发钩子的方法,而不是原生js的call方法*/
触发方式很简单:
// run.js
const Welcome = require('./welcome');const welcome = new Welcome('我回来啦!');welcome.begin();/* output:* 开门* 脱鞋* 脱外套* 关门* /
我们希望有不同的打招呼方式,因此额外定义两个模块:
// say.jsmodule.exports = function (welcome) {welcome.sayHook.tap('say', words => {console.log('轻声说:', words);});};// shout.jsmodule.exports = function (welcome) {welcome.sayHook.tap('shout', words => {console.log('出其不意的大喊一声:', words);});};
接下来,修改run模块,给welcome应用shout模块:
// run.jsconst welcome = require('./welcome');const applyShoutPlugin = require('./shout');const welcome = new Welcome('我回来了');applyShoutPlugin(welcome);welcome.begin();/* output:* 开门* 脱鞋* 脱外套* 出其不意的大喊一声: 我回来啦!* 关门* /
以上,我们实现了一个解耦,好比创建了一个‘可插拔’的系统机制,这就是tapable的使用以及插件的原理。
上面的例子中,我们知道:
welcome类是主要的功能类,它包含具体的功能函数(begin函数) + 钩子(sayHook)
say.js & shout.js 是独立的可插入模块。根据需要,可以被自主附加到主流程中。
run.js 模块负责整个流程,插件的插拔在这里处理。
相比之下,webpack的compiler类就相当于上面的Welcome类--它创建了非常多的钩子,这些钩子散落在各个环节等待调用(call)
// Compiler类中的部分钩子this.hooks = {/** @type {SyncBailHook<Compilation>} */shouldEmit: new SyncBailHook(["compilation"]),/** @type {AsyncSeriesHook<Stats>} */done: new AsyncSeriesHook(["stats"]),/** @type {AsyncSeriesHook<>} */eradditionalPass: new AsyncSeriesHook([]),/** @type {AsyncSeriesHook<Compiler>} */beforeRun: new AsyncSeriesHook(["compiler"]),/** @type {AsyncSeriesHook<Compiler>} */run: new AsyncSeriesHook(["compiler"]),/** @type {AsyncSeriesHook<Compilation>} */emit: new AsyncSeriesHook(["compilation"]),……}
我们来看一个webpack插件的基本写法:
// MyPlugin.jsfunction MyPlugin() {}MyPlugin.prototype.apply = function(compiler) {compiler.plugin('emit', function(compilation, callback) {console.log('a ha');//finish its task then callbackcallback();});};
拿它和上面的run.js & shout.js相比较,你发现了什么?
绑定函数的方法不再是welcome.sayHook.tap(),而是变成了compiler.plugin()
整个模块的功能被挂在Myplugin的prototype上
在我们自己构造的插件系统中,在run.js里通过applyShoutPlugin(welcome);这句代码手动注册了Shout插件,而在实际的webpack中,规定每个插件必须有一个apply方法,webpack打包前会调用所有插件的apply方法,插件在该方法中被注册到相应的钩子。
具体步骤从下面的代码段可见一斑:
if (options.plugins && Array.isArray(options.plugins)) {for (const plugin of options.plugins) {plugin.apply(compiler);}}
这将从webpack配置的plugin字段中取出所有插件的实例并调用他们的apply方法,将compiler的实例作为参数传入。当然,这里的apply是我们自定义的,而不是JS原生的apply方法。在webpack源码中,apply与call都并非原生。
可以参考 这篇文章。
其实,上面我们已经提到了,webpack的整个流程像是工厂里面的流水线,compiler对象掌控整个流水线,compilation对象就像流水线上的产品即时的状态,在特定的阶段,compiler对象的钩子被触发,执行插件的回调函数,这些函数往往接收compilation作参数,也就是截取了触发时的流水线上的产品状态。那么,要想搞清楚钩子的效用,我们需要知道两点:
哪些时刻,哪些钩子会被触发
在触发钩子的时刻,compilation对象的状态是什么样的
下面是一部分钩子的触发时机与状态
compiler创建后,启动前触发:
environment
after-enviroment
entry-option
after-plugins
after-resolvers
compiler启动,正式运行前触发:
before-run
run(如果以监听模式运行,则触发watch-run)
创建params对象并触发:
before-compile
创建compilation对象并在这之前触发:
compile
成功创建compilation对象后触发(这是最早能够拿到compilation对象的时间点):
this-compilation
compilation
进入make阶段,调用compilation.addEntry()构建模块树,在完成调用compilation.finish()前触发:
make
构建模块树,compilation做了什么?
使用moduleFactory创建空module
命令module自行构建自身属性,比如依赖的子模块信息(dependency)(module.build)
重复上述操作生成模块树
将模块树记录到chunk中
调用compilation.seal()并触发:
after-compile
seal阶段,compilation做了什么?
配置chunk
将所处模块树深度和引用顺序等信息记录在每个模块上
将所有模块按照引用顺序排序
触发optimize-module-order生命周期并按照排序后的模块顺序给模块编号
使用template对象渲染出chunk的内容source
拿到source后生成asset,添加到assets中
最后拿到assets并生成对应文件,将警告信息&文件大小信息合称为stats状态信息并触发:
done
将stats信息交给webpack主函数输出
■ ■ ■
以上分析参考了了:淘宝前端webpack 、 alienzhou 、 Gloria:Webpack基本架构浅析。
到此,webpack基本的流程我们已经略窥一二,接下来就可以按照自己的理解去阅读源码,或者写几个loader/plugin练练手啦!
作者:兔子
排版:虫
华中科技大学 | 冰岩作坊
---------------------------------------
bingyan_studio
可能是最好的校园互联网团队
想成为你的互联网大学