3. webpack构建整体流程的组织:webpack -> Compiler -> Compilation

2022-11-16 17:27:00 浏览数 (1)

通过一个demo带你深入进入webpack@4.46.0源码的世界,分析构建原理,专栏地址,共有十篇。

  • 1. 从构建前后产物对比分析webpack做了些什么?
  • 2. webpack构建的基石: tapable@1.1.3源码分析
  • 3. webpack构建整体流程的组织:webpack -> Compiler -> Compilation
  • 4. 创建模块实例,为模块解析准备
  • 5. 路径解析:enhanced-resolve@4.5.0源码分析
  • 6. 模块构建之loader执行:loader-runner@2.4.0源码分析
  • 7. 模块构建之解析_source获取dependencies
  • 8. 从dependency graph 到 chunk graph
  • 9. 从chunk到最终的文件内容到最后的文件输出?
  • 10. webpack中涉及了哪些设计模式呢?

第一节对比构建前后的内容时,执行了yarn build-simple,会经过webpack注册的命令行命令,该命令的主要工作是读取输入参数以及读取config内容后合并生成最终的options,而后进入到webpack的入口文件即webpack/lib/webpack.js执行webpack(options, callback)

下面从webpack(options, callback)方法开始分析整个构建流程。

入口文件:webpack.js

代码语言:javascript复制
const webpackOptionsSchema = require("../schemas/WebpackOptions.json");

const webpack = (options, callback) => {
    // 1. 选项验证
    const webpackOptionsValidationErrors = validateSchema(webpackOptionsSchema, options);
    // 配置校验异常 ...

    let compiler;
    if (Array.isArray(options)) { /*...*/ } else if (typeof options === "object") {
        // 2. 设置默认选项
        options = new WebpackOptionsDefaulter().process(options);

        // 3. 创建compiler对象
        compiler = new Compiler(options.context);
        //... 构造日志对象
        
        // 4. 遍历用户提供的插件,并进行注册
        for (const plugin of options.plugins) {
            if (typeof plugin === "function") { // 支持函数
                plugin.call(compiler, compiler);
            } else {
                // 通常是给提供apply方法进行注册
                plugin.apply(compiler);
            }
        }
        // ... hooks.environment/afterEnvironment 等

        // 5. 注册内置插件、注册resolverFactory相关的钩子
        compiler.options = new WebpackOptionsApply().process(options, compiler);
    } else //... 异常

    if (callback) {
        // ... watch mode

        // 6. 开始构建
        compiler.run(callback);
    }
    return compiler;
};

主要有以下几个步骤:

  1. options验证,调用validateSchema()进行options的验证,该方法内部调用ajv库。webpack官网提供了具体的配置,用户参考这些配置来定制功能,在运行时webpack基于内置的JSON Schema(schemas/WebpackOptions.json)文件使用ajv库来对用户提供的options进行校验。
  2. 设置默认选项。webpack会在用户提供的配置的基础上,补充其他未配置的选项并设置默认值,部分默认值可能会区分环境,比如会根据mode的差异设置不同的优化策略(如压缩),又或者根据target即构建目标平台的不同设置相应平台合理的默认值。
  3. 创建compiler对象。看名字也可以猜得出和核心对象,构建的主体流程由其构建,整个构建过程中只会有一个实例。
  4. 遍历用户提供的插件(plugins),并进行注册。支持两种形式,有apply方法的对象或者插件本身就是一个函数。通常会在插件中注册构建过程中部分环节的hooks来参与构建流程。
  5. 注册内置插件、注册resolverFactory相关的钩子。除了用户提供了插件外,webpack自身很多功能也是基于插件体系来参与构建,因此很多的内置插件同样需要进行注册。
  6. compiler.run() 启动本次构建流程。

下面挑几个关键步骤说下

设置默认配置

代码语言:javascript复制
new WebpackOptionsDefaulter().process(options);

如果不提供mode(枚举值有哪些)选项,mode默认会被按照production解释

代码语言:javascript复制
// options.mode = undefined / false等假值,等价于production
const isProductionLikeMode = options => {
    return options.mode === "production" || !options.mode;
};
  • options.optimization:构建优化相关的配置

标题

none

development

porduction

配置

差异点

基准

namedChunks、nameModules

concatenateModules、minimize、usedExports、...

显然上述这些配置我们并没有在配置文件中提供,经过WebpackOptionsDefaulter的处理,添加了很多默认配置选项。

optimization中的大多数属性都会决定是否需要注册优化插件,false值则不注册,true则会注册,显然producton mode下会注册很多优化插件。(具体可以看WebpackOptionsApply.js的逻辑,会根据options中的值(target/mode/devtool/optimization等等)来注册插件)

production mode下做了很多优化,不利于我们分析主流程。因此demo中设置modenone,可以在调试过程中避过很多优化细节更专注的分析主流程。

内置插件的注册,先关注EntryOptionPlugin

代码语言:javascript复制
new WebpackOptionsApply().process(options, compiler);

WebpackOptionsApply文件中会根据提供的处理后的options进行内置插件的注册。这里我们先重点关注EntryOptionPlugin

代码语言:javascript复制
// WebpackOptionsApply.js
process(options, compiler) {
    //...
    new EntryOptionPlugin().apply(compiler);
    compiler.hooks.entryOption.call(options.context, options.entry);
    //...
}

显示调用apply()进行插件的注册,随即触发hooks.entryOption,而后进入EntryOptionPlugin中注册该钩子的的回调中。

代码语言:javascript复制
// EntryOptionPlugin.js
const SingleEntryPlugin = require("./SingleEntryPlugin");
const MultiEntryPlugin = require("./MultiEntryPlugin");
const DynamicEntryPlugin = require("./DynamicEntryPlugin");

const itemToPlugin = (context, item, name) => {
   if (Array.isArray(item)) { /*...*/ }
   return new SingleEntryPlugin(context, item, name);
};

module.exports = class EntryOptionPlugin {
   apply(compiler) {
      compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
         if (typeof entry === "string" || Array.isArray(entry)) { /*...*/ } 
         else if (typeof entry === "object") {
            for (const name of Object.keys(entry)) {
               itemToPlugin(context, entry[name], name).apply(compiler);
            }
         } else if (typeof entry === "function") { /*...*/ }
         return true;
      });
   }
};

options.entry支持多种形式:字符串、字符串数组、对象、函数,显然这里的作用是为了根据entry的类型(使用typeof判断)动态的进行XxxEntryPlugin插件的注册。

我们demo中的配置如下,

代码语言:javascript复制
// webpack.config.simple.js
entry: {
    chunkMain: './src/simple/main.js',
},

这里会注册SingleEntryPlugin即执行apply方法,看到apply实际只是注册两个hooks,记住这里的两个钩子后面会再说到。

  • hooks.compilation
  • hooks.make
代码语言:javascript复制
// SingleEntryPlugin.js
apply(compiler) {
  compiler.hooks.compilation.tap("SingleEntryPlugin", 
      (compilation, { normalModuleFactory }) => { /*...*/ }
  );

  compiler.hooks.make.tapAsync("SingleEntryPlugin", 
      (compilation, callback) => { /*...*/ }
  );
} 

小结

compiler.run进入到Compiler.js文件中

Compiler.js:compiler.run()

代码语言:javascript复制
run(callback) {
   const onCompiled = (err, compilation) => {
      // hooks.shouldEmit
      this.emitAssets(compilation, err => { /*...*/ });
   };

   // hooks.beforeRun -> hooks.run -> compile
   this.compile(onCompiled);
}

compile方法会生成最终需要输出的所有文件路径和内容但是并不会输出到文件系统,文件的输出交给emitAssets方法

代码语言:javascript复制
compile(callback) {
    // 创建normalModuleFactory、contextModuleFactory
   const params = this.newCompilationParams(); 
   
   this.hooks.beforeCompile.callAsync(params, err => {
      // hooks.compile
      // 1. 构造compilation对象、触发hooks.compilation、hooks.thisCompilation
      // 注意params
      const compilation = this.newCompilation(params);

       // 2. 触发构建 -> dependency graph
      this.hooks.make.callAsync(compilation, err => {
         compilation.finish(err => {
         
             // 3. dependency graph -> chunk graph -> 优化
             // -> 生成输出的文件内容(内容替换、添加运行时等)
            compilation.seal(err => {
               this.hooks.afterCompile.callAsync(compilation, err => {
                  return callback(null, compilation);
               });
            });
         });
      });
   });
}

主要步骤如下:

创建Compilation实例,触发hooks.thisCompilation/compilation钩子,大多数插件都会监听这两个钩子,webpack中的hooks都是作为某个对象(Compiler/Compilation/Parser等等)的属性存在,webpack使用hooks提供流水线机制让各插件可以参与到生产流程中。因此想要参与构建流程中首先就需要拿到相对应对象,比如这里的compilation对象就可以通过compiler.hooks.thisCompilation/compilation获取,拿到compilation后就可以继续监听compilation中提供的各种hooks从而参与compilation中的流程,实际上这也webpack插件的主要用法,先获取关键对象,再监听对应的钩子,通常这里的对象存在着父子关系,就像Compiler和Compilation。 比如SplitChunksPlugin。

代码语言:javascript复制
// SplitChunksPlugin.js
apply(compiler) {
   compiler.hooks.thisCompilation.tap("SplitChunksPlugin", compilation => {
        //...
      compilation.hooks.optimizeChunksAdvanced.tap(...,(...) => {
          //...
      })          
    }
}

在SingleEntryPlugin中有监听hooks.compilation,在dependencyFactories中设置XxxDependency类型到具体模块工厂-对象的映射,因为后期需要基于dependency关联的模块工厂来常见模块实例,即存在这样一层关系:XxxDependency -> XxxModuleFactory -> XxxModule

代码语言:javascript复制
// SingleEntryPlugin.js -> hooks.compilation
compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory);

为什么需要这么做❓❓❓因为XxxDependency是动态添加的,没必要一开始将所有的映射关系都保存下来。

hooks.make:非常关键的hook,前面SingleEntryPlugin中有注册该钩子

代码语言:javascript复制
// SingleEntryPlugin.js -> hooks.make
(compilation, callback) => {
   const { entry, name, context } = this;

   // static createDependency: return new SingleEntryDependency(entry);
   const dep = SingleEntryPlugin.createDependency(entry, name);
   compilation.addEntry(context, dep, name, callback);
}   

先是调用静态方法createDependency创建SingleEntryDependency实例,然后调用compilation.addEntry发起构建,从entry开始收集依赖生成依赖图,最终得到所有的模块。

compilation.seal:在模块基础上构造chunk,生成最终需要输出的文件及其内容。

小结 - 给出了单次构建的大框架

Compilation.js:compilation.addEntry

代码语言:javascript复制
addEntry(context, entry, name, callback) {
   this.hooks.addEntry.call(entry, name);

   const slot = {
      name: name,
      module: null // 入口模块哦
   }; 

    // 关键: _preparedEntrypoints 的作用
   this._preparedEntrypoints.push(slot);
   // ...
   this._addModuleChain(context, entry, module => { this.entries.push(module); }, 
      (err, module) => {
         if (module) {
             // 保存到_preparedEntrypoints中的slot中,buildChunkGraph中会用到
            slot.module = module;
         } else //...
         return callback(null, module);
      }
   );
}

参数解释

context:node命令行当前的工作路径,在WebpackOptionsDefaulter中设置默认值时调用process.cwd()获取

entrySingleEntryDependency,继承自ModuleDependency,有request和userRequest属性,初始时两个值一致,当前案例为:'./src/simple/main.js'

name:当options.entry为对象时,name指向对象的key,上面的requst指向对应的value。这里是chunkMain

代码语言:javascript复制
entry: {
    chunkMain: "./src/simple/main.js"
}

逻辑分析

_preparedEntrypoints用来保存入口模块的信息:名称和构建后的模块实例(如NormalModule实例)

该方法的主要作用是当前依赖关联的模块构建完成后保存该模块实例到_preparedEntrypoints中,入口模块非常重要,是dependency graph的起始点,也是后面buildChunkGraph初步构造chunk graph的重要依据。

下面看下入口模块的构建方法:_addModuleChain

_addModuleChain -> moduleFactory.create -> buildModule

代码语言:javascript复制
_addModuleChain(context, dependency, onModule, callback){
    // dependencyFactories存储了依赖和对应工厂的关系
    const Dep = (dependency.constructor);
    const moduleFactory = this.dependencyFactories.get(Dep);

    this.semaphore.acquire(() => {
        // 创建当前模块实例、获取该模块的loaders,resource信息,并创建NormalModule实例
        moduleFactory.create(/*...*/, (err, module) => {
            const addModuleResult = this.addModule(module);
            module = addModuleResult.module;            
            onModule(module); // 添加到 this.entries中
            // ...
            // 模块是有由依赖生成的,构造完模块后,关联起来
            dependency.module = module;

            const afterBuild = () => {
                // 如果存在依赖,则继续对依赖的模块进行构建
                if (addModuleResult.dependencies) {
                    this.processModuleDependencies(module, err => { /*...*/ })
                } else { /*...*/ }
            }
            if (addModuleResult.build) {
                // 开始构建当前模块
                this.buildModule(module, false, null, null, err => {
                    this.semaphore.release();
                    afterBuild();
                });
            } else { /*...*/ }
        })
    })
}

this.semaphore用来做并发控制,有兴趣的同学可以自己看下,有可能作为面试题出现,哈哈。

SingleEntryPlugin中的hooks.compilation的回调中通过给dependencyFactories设置SingleEntryDependency关联的工厂normalModuleFactory;这里通过设计模式之工厂模式来进行模块实例的构造,实际模块的构造需要很多准备工作是一个非常复杂的工作,这里通过工厂来将很多前置工作处理完然后再创建实例,做到了关注点分离并且模块实例的职责单一

normalModuleFactory.create只是创建了NormalModule实例,并将该资源关联的resourceloaders等信息交给该模块实例,模块的实际构建发生在this.buildModule中,然后会去调用module.build来获取该资源的实际内容(_source)和其依赖(depenencies)。

看到在该模块的回调中的afterBuild方法,调用processModuleDependencies,如果该模块有依赖depenencies,需要对这些依赖资源同样做一次构建,这也是webpack的核心目标,从entry收集到依赖链上的所有资源,让所有的资源都参与到构建流程中。

compilation.buildModule -> normalModule.build

代码语言:javascript复制
buildModule(module, optional, origin, dependencies, thisCallback) {
    // 1. 通过_buildingModules(结构key: module, value: 回调列表) 存储当前模块构建完成的回调
    // 并判断是否正在进行构建,如果正则构建,则将回调添加到该模块的回调列表中
    // 2. hooks.buildModule
    
    // 3. 开始模块的构建(如normalModule.build)
    module.build(this.options, this,
        this.resolverFactory.get("normal", module.resolveOptions),
        this.inputFileSystem,
        error => {
            // 1. 模块构建过程中产生的错误和警告收集到compilation对象中            
            // 2. 触发构建成功或失败后的钩子:hooks.failedModule、hooks.succeedModule
        }
    );
}

开始模块的实际构建(如normalModule.build):执行loaders获取_source,收集dependencies等信息。

buildModule -> afterBuild -> processModuleDependencies

代码语言:javascript复制
processModuleDependencies(module, callback) {
   const dependencies = new Map();

   const addDependency = dep => {
      const resourceIdent = dep.getResourceIdentifier();
      if (resourceIdent) {
         const factory = this.dependencyFactories.get(dep.constructor);
         let innerMap = dependencies.get(factory);
         if (innerMap === undefined) {
            dependencies.set(factory, (innerMap = new Map()));
         }
         let list = innerMap.get(resourceIdent);
         if (list === undefined) innerMap.set(resourceIdent, (list = []));
         list.push(dep);
      }
   };

   const addDependenciesBlock = block => {
      if (block.dependencies) {
         iterationOfArrayCallback(block.dependencies, addDependency);
      }
      if (block.blocks) {
          // 收集该block的依赖
         iterationOfArrayCallback(block.blocks, addDependenciesBlock);
      }
      if (block.variables) {
         iterationBlockVariable(block.variables, addDependency);
      }
   };

   try {
      addDependenciesBlock(module);
   } catch (e) //...

   const sortedDependencies = [];

   for (const pair1 of dependencies) {
      for (const pair2 of pair1[1]) {
         sortedDependencies.push({
            factory: pair1[0],
            dependencies: pair2[1]
         });
      }
   }

   this.addModuleDependencies(module, sortedDependencies, this.bail, null, true, callback);
}

主要步骤:

  1. 将module.build收集的依赖进行过滤(dep.getResourceIdentifier()有返回值)和分类,存储到sortedDependencies
  2. 调用addModuleDependencies构建依赖(就像_addModuleChain处理SingleEntryDependency一样)

DependenciesBlock的三个属性:dependencies、blocks、variables

看到addDependenciesBlock方法中有三个分支分别是module.dependencies、module.blocks、module.variables,这三个属性继承自DependenciesBlock,分别是不同的类型

代码语言:javascript复制
// class NormalModule extends Module
// class Module extends DependenciesBlock

class DependenciesBlock {
   constructor() {
      this.dependencies = []; // 类型: Dependency
      this.blocks = [];       // 类型: AsyncDependenciesBlock
      this.variables = [];    // 类型: DependenciesBlockVariable
   } 
      
    // 关键: block是 AsyncDependenciesBlock类型
    addBlock(block) { 
       this.blocks.push(block);
       block.parent = this;
    }

    //...
}   

DependenciesBlock可以理解就是用来提供记录当前模块的依赖情况的,但是依赖本身分为这三个大类。

1. 遍历dependencies: Dependency类型

遍历dependencies,进入addDependency方法,先列举一部分Dependency子类继承结构

进入addDependency看到,会调用getResourceIdentifier方法,getResourceIdentifier来自Dependency,在其所有子类中只有ContextDependency、ModuleDependency对该方法进行了重写

代码语言:javascript复制
// ModuleDependency
getResourceIdentifier() {
  return `module${this.request}`;
}

// ContextDependency
getResourceIdentifier() {
   return (/*...*/);
}

当前模块(./src/simple/main.js)经过module.build得到的dependencies如下:

由此这里有些dependency会被过滤掉,比如HarmonyInitDependency继承自NullDependency的getResourceIdentifier返回假值。

processModuleDependencies函数作用域变量dependencies的结构如下,这个结构实际上按照fatory、resourceIdent分类的作用,后面的得到的sortedDependencies就是按照这个进行分类的.

代码语言:javascript复制
{ xxxfactory: {xxxresourceIdent: [xxxdependency,...]} } // Map
2. 遍历blocks: AsyncDependenciesBlock类型

该类我理解是一个分离点,用于分离异步模块,其本身同Module一样都继承自DependenciesBlock,同样包含blocks、dependencies、variables属性。

当前demo这里产生的结果,blocks保存了ImportDependenciesBlock对象,该对象是因为使用import(/*...*/ './b')而收集的,其本身可以理解是一个中间对象,也可以认为是一种标记表明异步引用一个模块,具体异步模块的信息作为该block的dependencies等属性中存储,如下面的ImportDependency

看到这里递归调用addDependenciesBlock用来收集异步依赖,如这里的ImportDependency

代码语言:javascript复制
// 注意:addDependenciesBlock
iterationOfArrayCallback(block.blocks, addDependenciesBlock);
3. 遍历variables: DependenciesBlockVariable类型

ariables,用的场景并不多,源码中搜索关键词addVariable(DependenciesBlock类中的方法)

variable name

插件

__webpack_amd_options__

AMDPlugin

__resourceQuery

ConstPlugin

举个例子看下用法,在之前的demo上添加如下代码片段:

代码语言:javascript复制
// main.js 中添加如下代码
console.log('__webpack_amd_options__',__webpack_amd_options__)

// a.js 添加如下代码
console.log('__resourceQuery', __resourceQuery)


// webpack.config.simple.js 添加如下配置项
amd: {
    jQuery: true,
},

构建产物中会进行变量注入,如下

代码语言:javascript复制
// main.js 会多出如下代码
/* WEBPACK VAR INJECTION */(function(__webpack_amd_options__) {
        // main.js构建后代码
/* WEBPACK VAR INJECTION */}.call(this, {"jQuery":true}))


// a.js 会多出如下代码
/* WEBPACK VAR INJECTION */(function(__resourceQuery) {
    // a.js构建后代码
/* WEBPACK VAR INJECTION */}.call(this, "?c=d"))

除了上述两种变量外,还有其他插件(如NodeStuffPlugin、CommonJsStuffPlugin、ProvidePlugin等)、也调用了addVariable方法,有兴趣的同学可以自己深入研究下。由于此类用法并不常见并且在webpack5中取消了这个用法,后面不会对该属性进行深入解释。


三种属性存储的依赖添加完成后,进入addModuleDependencies

addModuleDependencies

代码语言:javascript复制
// dependencies: [{ factory、dependencies }]
addModuleDependencies(module, dependencies, bail, cacheGroup, recursive, callback) {
    asyncLib.forEach(dependencies,(item, callback) => {
            const dependencies = item.dependencies;
            
            this.semaphore.acquire(() => {
                const factory = item.factory; 
                // 同构造入口模块(_addModuleChain)一样的逻辑
                // 调用工厂的create创建模块实例
                factory.create(/*...*/, (err, dependentModule) => {
                       // 构建当前模块
                       this.buildModule(/*...*/, err => {
                           // 构造模块的依赖
                           // afterBuild -> processModuleDependencies
                       })
                    }
                );
            });
        },
        err => { /*...*/ }
    );
}

核心逻辑和构造入口模块时是相同的

遍历dependencies -> factory.create -> buildModule -> module.build

小结

从options.entry开始到其依赖链上的解析是由dependency驱动,先有dependency再有模块,比如在这里构建从SingleEntryDependency开始的,然后创建该依赖关联的模块,创建依赖后再解析该模块的依赖,再从依赖到模块,因此是现有依赖再有模块。你也可以这么理解:假设存在一个哑节点(解决链表问题时通常会引入这样的概念: dummy node)即一个哑模块,而该模块的依赖有SingleEntryDependency,而后在继续往下。两种理解的区别在于起点是依赖还是模块。

总结

从命令行到构建入口文件webpack.js到Compiler.js和Compilation.js,三个核心类确定了整个构建的主要框架。

遗留的问题有

  1. 深入normalModuleFactory.create 和 normalModule.build
  2. 深入compilation.seal

在后面章节深入介绍这两个问题。

思考:

  1. MultiEntryPluginDynamicEntryPlugin 如何发挥作用的❓
  2. Compier & Compilation的区别❓

0 人点赞