周其仁:停止改革,我们将面临三大麻烦

抛开立场观点不谈,且看周小平写一句话能犯多少语病

罗马尼亚的声明:小事件隐藏着大趋势——黑暗中的风:坚持做对的事相信未来的结果

布林肯突访乌克兰,为何选择去吃麦当劳?

中国不再是美国第一大进口国,贸易战殃及纺织业? 美国进一步延长352项中国商品的关税豁免期

生成图片,分享到微信朋友圈

自由微信安卓APP发布,立即下载! | 提交文章网址
查看原文

​webpack工作流程浅析 | 冰岩分享

冰岩作坊 2022-04-23

今天带来前端组同学有关webpack的技术分享


前端开发追求快速与简洁,webpack的出现给这种追求赋予了新的定义。使用webpack让前端开发流程变得高度可控,loader机制可无痛引入社区各种优化开发体验的方案,plugin机制拓宽了代码的工作方式,尽管webpack之前的许多打包工具已经做过诸多尝试,webpack仍以高度顺滑的使用体验脱颖而出。这篇文章给有过webpack使用经验的朋友们初步展示webpack的工作流程,更加细节的东西可以在本文找到相应的切入点之后阅读源码。


webpack流程概览



前置工作

加载配置文件,加载插件,得到options对象。可以简单地理解为把你的配置文件读取了一遍。

这一步是通过yargs这个模块处理的。


构建webpack对象



  1. function webpack(options) {

  2. // ...

  3. compiler = new Compiler();

  4. compiler.context = options.context;

  5. compiler.options = options;

  6. new NodeEnvironmentPlugin().apply(compiler);

  7. // 绑定自定义的所有插件到相对应的钩子上

  8. if(options.plugins && Array.isArray(options.plugins)) {

  9. compiler.apply.apply(compiler, options.plugins);

  10. }

  11. compiler.applyPlugins("environment");

  12. compiler.applyPlugins("after-environment");

  13. // 再编译options,主要是注册配置选项对应需要的内部插件

  14. compiler.options = new WebpackOptionsApply().process(options, compiler);


  15. // ...

  16. return compiler;

  17. }

  18. ...

  19. // webpack的实际入口是Compiler中的run方法,run一旦执行,就开始了编译和构建流程。其中有几个比较关键的webpack时间节点,这些时间节点被称作“钩子”,后面我们还会介绍。

  20. /**

  21. * compile开始编译

  22. * make从入口点分析模块及依赖模块,创建这些模块对象

  23. * build-module 构建模块

  24. * after-compile 完成构建

  25. * seal 封装构建结果

  26. * emit 把各个chunk输出到结果文件

  27. * after-emit 完成输出

  28. */


run()

通过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认可的对象保存在内存中。严格来说其实是做了以下两件事情:

  1. 根据模块的类型获取对应的模块工厂并创建模块

  2. 构建模块

而构建模块是最耗时的一步, 分为三个阶段:

  • 调用各个loader处理模块之间的依赖

比如url-loaderjsx-loadercss-loader等等让我们可以直接在源文件中引用各种资源。webpack调用doBuild(),对每一个require()用对应的loader进行加工,最后生成一个js module

  1. Compilation.prototype._addModuleChain = function process(context, dependency, onModule, callback) {

  2. var start = this.profile && +new Date();

  3. ...

  4. // 根据模块的类型获取对应的模块工厂并创建模块

  5. var moduleFactory = this.dependencyFactories.get(dependency.constructor);

  6. ...

  7. moduleFactory.create(context, dependency, function(err, module) {

  8. var result = this.addModule(module);

  9. ...

  10. this.buildModule(module, function(err) {

  11. ...

  12. // 构建模块,添加依赖模块

  13. }.bind(this));

  14. }.bind(this));

  15. };

  • 调用acorn解析经loader处理后的源文件生成抽象语法树AST

  1. Parser.prototype.parse = function parse(source, initialState) {

  2. var ast;

  3. if (!ast) {

  4. // acorn以es6的语法进行解析

  5. ast = acorn.parse(source, {

  6. ranges: true,

  7. locations: true,

  8. ecmaVersion: 6,

  9. sourceType: "module"

  10. });

  11. }

  12. ...

  13. };


  • 遍历AST,构建该模块所依赖的模块

对于当前模块,会开辟一个依赖模块的数组,在遍历AST时将require()中的模块通过addDependency()添加到数组中,当前模块构建完成后,webpack调用 processModuleDependencies开始递归处理依赖的module,接着就会重复之前的构建步骤。

  1. Compilation.prototype.addModuleDependencies = function(module, dependencies, bail, cacheGroup, recursive, callback) {

  2. // 根据依赖数组(dependencies)创建依赖模块对象

  3. var factories = [];

  4. for (var i = 0; i < dependencies.length; i++) {

  5. var factory = _this.dependencyFactories.get(dependencies[i][0].constructor);

  6. factories[i] = [factory, dependencies[i]];

  7. }

  8. ...

  9. // 与当前模块构建步骤相同

  10. }


构建细节

module是webpack构建的核心实体,也是所有module的父类。他有几种不同的子类:NormalModule , MultiModule , ContextModule , DelegatedModule等。在实际构建中,通过build方法来构造。具体的过程如下

  1. // 初始化module信息,如context,id,chunks,dependencies等。

  2. NormalModule.prototype.build = function build(options, compilation, resolver, fs, callback) {

  3. this.buildTimestamp = new Date().getTime(); // 构建计时

  4. this.built = true;

  5. return this.doBuild(options, compilation, resolver, fs, function(err) {

  6. // 指定模块引用,不经acorn解析

  7. if (options.module && options.module.noParse) {

  8. if (Array.isArray(options.module.noParse)) {

  9. if (options.module.noParse.some(function(regExp) {

  10. return typeof regExp === "string" ?

  11. this.request.indexOf(regExp) === 0 :

  12. regExp.test(this.request);

  13. }, this)) {

  14. return callback();

  15. }

  16. } else if (typeof options.module.noParse === "string" ?

  17. this.request.indexOf(options.module.noParse) === 0 :

  18. options.module.noParse.test(this.request)) {

  19. return callback();

  20. }

  21. }

  22. // 由acorn解析生成ast

  23. try {

  24. this.parser.parse(this._source.source(), {

  25. current: this,

  26. module: this,

  27. compilation: compilation,

  28. options: options

  29. });

  30. } catch (e) {

  31. var source = this._source.source();

  32. this._source = null;

  33. return callback(new ModuleParseError(this, source, e));

  34. }

  35. return callback();

  36. }.bind(this));

  37. };

每一个module都会有这样一个构建方法。当然还包括从构建到输出的一系列有关module生命周期的函数,我们通过module父类类图及其子类类图来观察其真实形态(以NormalModule为例)

可见无论构建,处理依赖,封装,都与module密切相关。

打包输出

build完成后,webpack会监听 seal事件调用各种插件对构建后的结果进行封装,要逐次对每个module和chunk进行整理,生成编译后的源码,合并,拆分,形成hash。这一步是我们进行代码优化和功能添加的关键。

  1. Compilation.prototype.seal = function seal(callback) {

  2. this.applyPlugins("seal"); // 触发插件的seal事件

  3. this.preparedChunks.sort(function(a, b) {

  4. if (a.name < b.name) {

  5. return -1;

  6. }

  7. if (a.name > b.name) {

  8. return 1;

  9. }

  10. return 0;

  11. });

  12. this.preparedChunks.forEach(function(preparedChunk) {

  13. var module = preparedChunk.module;

  14. var chunk = this.addChunk(preparedChunk.name, module);

  15. chunk.initial = chunk.entry = true;

  16. // 整理每个Module和chunk,每个chunk对应一个输出文件。

  17. chunk.addModule(module);

  18. module.addChunk(chunk);

  19. }, this);

  20. this.applyPluginsAsync("optimize-tree", this.chunks, this.modules, function(err) {

  21. if (err) {

  22. return callback(err);

  23. }

  24. ... // 触发插件的事件

  25. this.createChunkAssets(); // 生成最终assets

  26. ... // 触发插件的事件

  27. }.bind(this));

  28. };


  • 生成最终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 是对热替换模块的一个处理。

  1. if(chunk.entry) {

  2. source = this.mainTemplate.render(this.hash, chunk, this.moduleTemplate, this.dependencyTemplates);

  3. } else {

  4. source = this.chunkTemplate.render(chunk, this.moduleTemplate, this.dependencyTemplates);

  5. }


    • 模块封装    

      模块在封装的时候和它在构建时一样,都是调用各模块类中的方法。封装通过调用module.source()来进行各操作,比如说require()的替换。

  1. MainTemplate.prototype.requireFn = "__webpack_require__";

  2. MainTemplate.prototype.render = function(hash, chunk, moduleTemplate, dependencyTemplates) {

  3. var buf = [];

  4. // 每一个module都有一个moduleId,在最后会替换。

  5. buf.push("function " + this.requireFn + "(moduleId) {");

  6. buf.push(this.indent(this.applyPluginsWaterfall("require", "", chunk, hash)));

  7. buf.push("}");

  8. buf.push("");

  9. ... // 其余封装操作

  10. };


    • 生成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

  1. const {

  2. SyncHook,

  3. SyncBailHook,

  4. SyncWaterfallHook,

  5. SyncLoopHook,

  6. AsyncParallelHook,

  7. AsyncParallelBailHook,

  8. AsyncSeriesHook,

  9. AsyncSeriesBailHook,

  10. AsyncSeriesWaterfallHook

  11. } = require("tapable");

通过一个例子,我们来了解Tapable创建钩子的方式:

  1. // welcome.js

  2. const {SyncHook} = require('tapable');


  3. module.exports = class Welcome {

  4. constructor(words) {

  5. this.words = words;

  6. this.sayHook = new SyncHook(['words']);

  7. }


  8. // 进门回家的一系列行为

  9. begin() {

  10. console.log('开门');

  11. console.log('脱鞋');

  12. console.log('脱外套');

  13. // 打招呼

  14. this.sayHook.call(this.words);

  15. console.log('关门');

  16. }

  17. }

  18. /**

  19. * 在构造函数中创建同步钩子sayhook,用来进行之后的打招呼

  20. * begin方法描述了一系列动作,在‘脱外套’和‘关门’之间,触发sayHook并传入参数

  21. * 要注意,这里的call方法是Tapable提供的触发钩子的方法,而不是原生js的call方法

  22. */

触发方式很简单:

  1. // run.js

  2. const Welcome = require('./welcome');

  3. const welcome = new Welcome('我回来啦!');

  4. welcome.begin();


  5. /* output:

  6. * 开门

  7. * 脱鞋

  8. * 脱外套

  9. * 关门

  10. * /

我们希望有不同的打招呼方式,因此额外定义两个模块:

  1. // say.js

  2. module.exports = function (welcome) {

  3. welcome.sayHook.tap('say', words => {

  4. console.log('轻声说:', words);

  5. });

  6. };


  7. // shout.js

  8. module.exports = function (welcome) {

  9. welcome.sayHook.tap('shout', words => {

  10. console.log('出其不意的大喊一声:', words);

  11. });

  12. };

接下来,修改run模块,给welcome应用shout模块:

  1. // run.js

  2. const welcome = require('./welcome');

  3. const applyShoutPlugin = require('./shout');

  4. const welcome = new Welcome('我回来了');

  5. applyShoutPlugin(welcome);

  6. welcome.begin();

  7. /* output:

  8. * 开门

  9. * 脱鞋

  10. * 脱外套

  11. * 出其不意的大喊一声: 我回来啦!

  12. * 关门

  13. * /

以上,我们实现了一个解耦,好比创建了一个‘可插拔’的系统机制,这就是tapable的使用以及插件的原理。

上面的例子中,我们知道:

  • welcome类是主要的功能类,它包含具体的功能函数(begin函数) + 钩子(sayHook)

  • say.js & shout.js 是独立的可插入模块。根据需要,可以被自主附加到主流程中。

  • run.js 模块负责整个流程,插件的插拔在这里处理。

相比之下,webpack的compiler类就相当于上面的Welcome类--它创建了非常多的钩子,这些钩子散落在各个环节等待调用(call)

  1. // Compiler类中的部分钩子


  2. this.hooks = {

  3. /** @type {SyncBailHook<Compilation>} */

  4. shouldEmit: new SyncBailHook(["compilation"]),

  5. /** @type {AsyncSeriesHook<Stats>} */

  6. done: new AsyncSeriesHook(["stats"]),

  7. /** @type {AsyncSeriesHook<>} */er

  8. additionalPass: new AsyncSeriesHook([]),

  9. /** @type {AsyncSeriesHook<Compiler>} */

  10. beforeRun: new AsyncSeriesHook(["compiler"]),

  11. /** @type {AsyncSeriesHook<Compiler>} */

  12. run: new AsyncSeriesHook(["compiler"]),

  13. /** @type {AsyncSeriesHook<Compilation>} */

  14. emit: new AsyncSeriesHook(["compilation"]),

  15. ……

  16. }

我们来看一个webpack插件的基本写法:

  1. // MyPlugin.js

  2. function MyPlugin() {}

  3. MyPlugin.prototype.apply = function(compiler) {

  4. compiler.plugin('emit', function(compilation, callback) {

  5. console.log('a ha');

  6. //finish its task then callback

  7. callback();

  8. });

  9. };

拿它和上面的run.js & shout.js相比较,你发现了什么?

  • 绑定函数的方法不再是welcome.sayHook.tap(),而是变成了compiler.plugin()

  • 整个模块的功能被挂在Myplugin的prototype上

在我们自己构造的插件系统中,在run.js里通过applyShoutPlugin(welcome);这句代码手动注册了Shout插件,而在实际的webpack中,规定每个插件必须有一个apply方法,webpack打包前会调用所有插件的apply方法,插件在该方法中被注册到相应的钩子。

具体步骤从下面的代码段可见一斑:

  1. if (options.plugins && Array.isArray(options.plugins)) {

  2. for (const plugin of options.plugins) {

  3. plugin.apply(compiler);

  4. }

  5. }

这将从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做了什么?

  1. 使用moduleFactory创建空module

  2. 命令module自行构建自身属性,比如依赖的子模块信息(dependency)(module.build)

  3. 重复上述操作生成模块树

  4. 将模块树记录到chunk中

  • 调用compilation.seal()并触发:

    • after-compile    

    seal阶段,compilation做了什么

  1. 配置chunk

  2. 将所处模块树深度和引用顺序等信息记录在每个模块上

  3. 将所有模块按照引用顺序排序

  4. 触发optimize-module-order生命周期并按照排序后的模块顺序给模块编号

  5. 使用template对象渲染出chunk的内容source

  6. 拿到source后生成asset,添加到assets中

  • 最后拿到assets并生成对应文件,将警告信息&文件大小信息合称为stats状态信息并触发:

    • done

  • 将stats信息交给webpack主函数输出

  • ■ ■ ■

    以上分析参考了了:淘宝前端webpack 、 alienzhou 、 Gloria:Webpack基本架构浅析。

    到此,webpack基本的流程我们已经略窥一二,接下来就可以按照自己的理解去阅读源码,或者写几个loader/plugin练练手啦!


    作者:兔子

    排版:虫


    华中科技大学 | 冰岩作坊


    ---------------------------------------


    bingyan_studio


    可能是最好的校园互联网团队

    想成为你的互联网大学

    文章有问题?点此查看未经处理的缓存