webpack4源码分析

5,751 阅读13分钟

webpack设计模式

Webpack 源码是一个插件的架构,他的很多功能都是通过诸多的内置插件实现的。Webpack为此专门自己写一个插件系统,叫 Tapable 主要提供了注册和调用插件的功能。

Tapable

tabpable是一个事件发布订阅插件,它支持同步和异步两种;在需要使用的类上继承tabpable,并且该类的构造函数中使用this.hooks添加事件名称。

 this.hooks = {
            accelerate: new SyncHook(["newSpeed"]),
            break: new SyncHook(),
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
        };
订阅

要使用订阅功能,需要先拿到上面说到的类实例,通过实例对象.hooks.break.tap来订阅。

myCar.hooks.break.tap("WarningLampPlugin", () => warningLamp.on());
发布

在需要触发的时机调用this.hooks.accelerate.call就可以触发订阅accelerate的所有监听函数,newSpeed是传入的参数。

setSpeed(newSpeed) {
        this.hooks.accelerate.call(newSpeed);
    }
webpack的插件架构

webpack从配置初始化到build完成定义了一个生命周期,在这个生命周中的每一个阶段定义一些完成不同的功能的含义,webpack的流程就是定义了一个规范,无论是内部插件还是自定义插件只要遵循这个规范就能完成构建;上面提到了webpack是一个插件架构,webpack主要是使用CompilerCompilation类来控制webpack的整个生命周期,定义执行流程;他们都继承了tabpable并且通过tabpable来注册了生命周期中的每一个流程需要触发的事件。webpack内部实现了一堆plugin,这些内部plugin是webpack打包构建过程中的功能实现,订阅感兴趣的事件,在执行流程中调用不同的订阅函数就构成了webpack的完整生命周期。

webpack流程概述

Webpack首先会把配置参数和命令行的参数及默认参数合并,并初始化需要使用的插件和配置插件等执行环境所需要的参数;初始化完成后会调用Compiler的run来真正启动webpack编译构建过程,webpack的构建流程包括compile、make、build、seal、emit阶段,执行完这些阶段就完成了构建过程。

  • 根据我们的webpack配置注册好对应的插件调用 compile.run 进入编译阶段
  • 在编译的第一阶段是 compilation,他会注册好不同类型的module对应的 factory,不然后面碰到了就不知道如何处理了
  • 进入 make 阶段,会从 entry 开始进行两步操作:
  • 第一步是调用 loaders 对模块的原始代码进行编译,转换成标准的JS代码
  • 第二步是调用 acorn 对JS代码进行语法分析,然后收集其中的依赖关系。每个模块都会记录自己的依赖关系,从而形成一颗关系树
  • 最后调用 compilation.seal 进入 render 阶段,根据之前收集的依赖,决定生成多少文件,每个文件的内容是什么

初始化

启动

首先从bin/webpack.js开始调用webpack-cli插件的./bin/cli.js`文件,在cli.js中使用yargs来解析命令行参数并合并配置文件中的参数(options),然后调用lib/webpack.js实例化compiler。

实例化compiler

实例化compiler是在lib/webpack.js中完成的,首先会检查配置参数是否合法;然后根据传入的参数判断是否为数组,若是数组则创建多个compiler,否则创建一个compiler;下面以创建一个compiler来讲述,首先会调用WebpackOptionsDefaulter把传入的参数和默认参数合并得到新的options,创建Compiler,创建读写文件对象和执行注册配置的plugin插件,最后通过WebpackOptionsApply初始化一堆构建需要的内部默认插件。

执行

实例compiler后根据options的watch判断是否启动了watch,如果启动watch了就调用compiler.watch来监控构建文件,否则启动compiler.run来构建文件。

编译构建

接下来正式进入webpack的构建流程,webpack构建流程入口是compiler的run或者watch方法,下面通过run来描述编译过程;在run方法中先执行beforeRun、run钩子函数后进入compile,可以写插件在构建之前来处理一些初始化数据。

在进入构建之前解释两个类

  • Compiler:该类是webpack的神经中枢,一方面所有的配置数据都存储在该实例上,另一方面它是在构建过程中控制整个大体的流程。
  • Compilation:该类是webpack的cto,所有的构建过程中产生的构建数据都存储在该对象上,它掌控着构建过程中每一个细节流程。

compile

在run中先实例化normalModuleFactory等参数,然后调用this.hooks.beforeCompile事件执行一些编译之前需要处理的插件,最后才执行this.hooks.compile事件(比如compile钩子中会执行DllReferencePlugin,在这里注册代理插件);this.hooks.compile执行完后实例化Compilation对象,并调用this.hooks.compilation通知感兴趣的插件,比如在compilation.dependencyFactories中添加依赖工厂类等操作。compile阶段主要是为了进入make阶段做准备,make阶段才是从入口开始递归查找构建模块。

make

make是compilation初始化完成触发的事件,该事件一般情况是通知在WebpackOptionsApply中注册的EntryOptionPlugin插件,在该插件中使用entries参数创建一个单入口(SingleEntryDependency)或者多入口(MultiEntryDependency)依赖,多个入口时在make事件上注册多个相同的监听,并行执行多个入口;然后调用compilation.addEntry(context, dep, name, callback)正式进入make阶段。

addEntry中并没有做任何事,就调用this._addModuleChain方法,在_addModuleChain中根据依赖查找对应的工厂函数,并调用工厂函数的create来生成一个空的MultModule对象,并且把MultModule对象存入compilation的modules中后执行MultModule.build,因为是入口module,所以在build中没处理任何事直接调用了afterBuild;在afterBuild中判断是否有依赖,若是叶子结点直接结束,否则调用processModuleDependencies方法来查找依赖;因为入口传入了一个SingleEntryDependency,所以下面正式讲述从SingleEntryDependency开始的构建。

上面提到入口会创建一个SingleEntryDependency传入,所以上面讲述的afterBuild肯定至少存在一个依赖,processModuleDependencies方法就会被调用;processModuleDependencies根据当前的module.dependencies对象查找该module依赖中所有需要加载的资源和对应的工厂类,并把module和需要加载资源的依赖作为参数传给addModuleDependencies方法;在addModuleDependencies中异步执行所有的资源依赖,在异步中调用依赖的工厂类的create去查找该资源的绝对路径和该资源所依赖所有loader的绝对路径,并且创建对应的module后返回;然后根据该moduel的资源路径作为key判断该资源是否被加载过,若加载过直接把该资源引用指向加载过的module返回;否则调用this.buildModule方法执行module.build加载资源;build完成就得到了loader处理过后的最终module了,然后递归调用afterBuild,直到所有的模块都加载完成后make阶段才结束。

make

DllReferencePlugin

在make阶段webpack会根据模块工厂(normalModuleFactory)的create去实例化module;实例化moduel后触发this.hooks.module事件,若构建配置中注册了DllReferencePlugin插件,DelegatedModuleFactoryPlugin会监听this.hooks.module事件,在该插件里判断该moduel的路径是否在this.options.content中,若存在则创建代理module(DelegatedModule)去覆盖默认module;DelegatedModule对象的delegateData中存放manifest中对应的数据(文件路径和id),所以DelegatedModule对象不会执行bulled,在生成源码时只需要在使用的地方引入对应的id即可。

build

上面在make阶段提到了build,但是没有深入讲解,因为build是在module对象中执行,这节单独说一下build是如何加载和执行loader最后查找该module的依赖后返回的。

在build中会调用doBuild去加载资源,doBuild中会传入资源路径和插件资源去调用loader-runner插件的runLoaders方法去加载和执行loader。执行完成后会返回如下图的result结果,根据返回数据把源码和sourceMap存储在module的_source属性上;doBuild的回调函数中调用Parser类生成AST语法树,并根据AST语法树生成依赖后回调buildModule方法返回compilation类。

result

loader-runner处理流程

runLoaders方法调用iteratePitchingLoaders去递归查找执行有pich属性的loader;若存在多个pitch属性的loader则依次执行所有带pitch属性的loader,执行完后逆向执行所有带pitch属性的normal的normal loader后返回result,没有pitch属性的loader就不会再执行;若loaders中没有pitch属性的loader则逆向执行loader;执行正常loader是在iterateNormalLoaders方法完成的,处理完所有loader后返回result;如下列是loader的执行规则。

Loader执行顺序:

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution
Parser

在Parser类中调用acorn插件生产AST语法树,acorn不在本文的分析范围,有兴趣的可以去阅读一下;Parser中生产AST语法树后调用walkStatements方法分析语法树,根据AST的node的type来递归查找每一个node的类型和执行不同的逻辑,并创建依赖。

ast

MiniCssExtractPlugin

如果在webpack中使用MiniCssExtractPlugin插件把css单独打包成文件,会在样式处理规则中配置MiniCssExtractPlugin.loader,当解析到css文件时,会首先执行MiniCssExtractPlugin的loader中实现的pitch方法,pitch方法会为每一个css模块调用this._compilation.createChildCompiler创建一个childCompiler和childCompilation;childCompiler控制完成该模块的加载和构建后返回。childCompilation中构建的module是CssModule,并且使用type='css/mini-extract'来区分。

css

在seal中MiniCssExtractPlugin会根据module的type='css/mini-extract'的类型来区分是否css样式,进行单独处理,而其他js模版不认识type='css/mini-extract'类型的module也就被过滤掉了,这样就实现了样式分离。

小结

在所有的资源bulid完成后,webpack的make阶段就结束了,make阶段是最耗时的,因为会进行文件路径解析和读文件等IO流操作;make结束后会把所有的编译完成的module存放在compilation的modules数组中,modules中的所有的module会构成一个图。

moule

seal

在所有模块及其依赖模块 build 完成后,webpack 会监听 seal 事件调用各插件对构建后的结果进行封装,要逐次对每个 module 和 chunk 进行整理,生成编译后的源码,合并,拆分,生成 hash 。 同时这是我们在开发时进行代码优化和功能添加的关键环节。

在seal中首先会触发optimizeDependencies类型的一些事件去优化依赖(比如tree shaking就是在这个地方执行的),大家要注意一点是在优化类插件中是不能有异步的;优化完成后根据入口module创建chunk,如果是单入口就只有一个chunk,多入口就有多个chunk;该阶段结束后会根据chunk递归分析查找module中存在的异步导module,并以该module为节点创建一个chunk,和入口创建的chunk区别在于后面调用模版不一样。所有chunk执行完后会触发optimizeModulesoptimizeChunks等优化事件通知感兴趣的插件进行优化处理。所有优化完成后给chunk生成hash然后调用createChunkAssets来根据模版生成源码对象;使用summarizeDependencies把所有解析的文件缓存起来,最后调用插件生成soureMap和最终的数据,下图是seal阶段的流程图。

seal

生成 assets

在封装过程中,webpack 会调用 Compilation 中的 createChunkAssets 方法进行打包后代码的生成。 createChunkAssets 流程如下

create-assets

从上图可以看出不同的chunk处理模版不一样,根据chunk的entry判断是选择mainTemplate(入口文件打包模版)还是chunkTemplate(异步加载js打包模版);选择模版后根据模版的template.getRenderManifest生成manifest对象,该对象中的render方法就是chunk打包封装的入口;mainTemplate和chunkTemplate的唯一区别就是mainTemplate多了wepback执行的bootsrap代码。当调用render时会调用template.renderChunkModules方法,该方法会创建一个ConcatSource容器用来存放chunk的源码,该方法接下来会对当前chunk的module遍历并执行moduleTemplate.render获得每一个module的源码;在moduleTemplate.render中获取源码后会触发插件去封装成wepack需要的代码格式;当所有的module都生成完后放入ConcatSource中返回;并以该chunk的输出文件名称为key存放在Compilation的assets中。

create-assets-code

seal产物

通过seal阶段各种优化和生成最终代码会存放在Compilation的assets属性上,assets是一个对象,以最终输出名称为key存放的输出对象,每一个输出文件对应着一个输出对象,如下图所示。

assets

emit

最后一步,webpack 调用 Compiler 中的 emitAssets() ,按照 output 中的配置项异步将文件输出到了对应的 path 中,从而 webpack 整个打包过程结束。要注意的是,若想对结果进行处理,则需要在 emit 触发后对自定义插件进行扩展。

watch

当配置了watch时webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem(memory-fs 插件) 实例。

监控

当执行watch时会实例化一个Watching对象,监控和构建打包都是Watching实例来控制;在Watching构造函数中设置变化延迟通知时间(默认200),然后调用_go方法;webpack首次构建和后续的文件变化重新构建都是_执行_go方法,在__go方法中调用this.compiler.compile启动编译。webpack构建完成后会触发 _done方法,在 _done方法中调用this.watch方法,传入compilation.fileDependencies和compilation.contextDependencies需要监控的文件夹和目录;在watch中调用this.compiler.watchFileSystem.watch方法正式开始创建监听。

Watchpack

在this.compiler.watchFileSystem.watch中每次会重新创建一个Watchpack实例,创建完成后监控aggregated事件和触发this.watcher.watch(files.concat(missing), dirs.concat(missing), startTime)方法,并且关闭旧的Watchpack实例;在watch中会调用WatcherManager为每一个文件所在目录创建的文件夹创建一个DirectoryWatcher对象,在DirectoryWatcher对象的watch构造函数中调用chokidar插件进行文件夹监听,并且绑定一堆触发事件并返回watcher;Watchpack会给每一个watcher注册一个监听change事件,每当有文件变化时会触发change事件。

在Watchpack插件监听的文件变化后设置一个定时器去延迟触发change事件,解决多次快速修改时频繁触发问题。

触发

当文件变化时NodeWatchFileStstem中的aggregated监听事件根据watcher获取每一个监听文件的最后修改时间,并把该对象存放在this.compiler.fileTimestamps上然后触发 _go方法去构建。

watcher1

在compile中会把this.fileTimestamps赋值给compilation对象,在make阶段从入口开始,递归构建所有module,和首次构建不同的是在compilation.addModule方法会首先去缓存中根据资源路径取出module,然后拿module.buildTimestamp(module最后修改时间)和fileTimestamps中的该文件最后修改时间进行比较,若文件修改时间大于buildTimestamp则重新bulid该module,否则递归查找该module的的依赖。

在webpack构建过程中是文件解析和模块构建比较耗时,所以webpack在build过程中已经把文件绝对路径和module已经缓存起来,在rebuild时只会操作变化的module,这样可以大大提升webpack的rebuild过程。

总结

刚开始读webpack源码时心中的万马奔腾,MMMP数不清的事件名、看不完的内部插件,各种事件之间调过去调过来;~~~就这样吧,~_~