通过一个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;
};
主要有以下几个步骤:
- options验证,调用
validateSchema()
进行options的验证,该方法内部调用ajv库。webpack官网提供了具体的配置,用户参考这些配置来定制功能,在运行时webpack基于内置的JSON Schema
(schemas/WebpackOptions.json)文件使用ajv库来对用户提供的options进行校验。 - 设置默认选项。webpack会在用户提供的配置的基础上,补充其他未配置的选项并设置默认值,部分默认值可能会区分环境,比如会根据
mode
的差异设置不同的优化策略(如压缩),又或者根据target即构建目标平台的不同设置相应平台合理的默认值。 - 创建compiler对象。看名字也可以猜得出和核心对象,构建的主体流程由其构建,整个构建过程中只会有一个实例。
- 遍历用户提供的插件(plugins),并进行注册。支持两种形式,有apply方法的对象或者插件本身就是一个函数。通常会在插件中注册构建过程中部分环节的hooks来参与构建流程。
- 注册内置插件、注册resolverFactory相关的钩子。除了用户提供了插件外,webpack自身很多功能也是基于插件体系来参与构建,因此很多的内置插件同样需要进行注册。
- compiler.run() 启动本次构建流程。
下面挑几个关键步骤说下
设置默认配置
代码语言:javascript复制new WebpackOptionsDefaulter().process(options);
如果不提供mode
(枚举值有哪些)选项,mode默认会被按照production
解释
// 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中设置mode
为none
,可以在调试过程中避过很多优化细节更专注的分析主流程。
内置插件的注册,先关注EntryOptionPlugin
代码语言:javascript复制new WebpackOptionsApply().process(options, compiler);
WebpackOptionsApply文件中会根据提供的处理后的options进行内置插件的注册。这里我们先重点关注EntryOptionPlugin
// WebpackOptionsApply.js
process(options, compiler) {
//...
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);
//...
}
显示调用apply()进行插件的注册,随即触发hooks.entryOption
,而后进入EntryOptionPlugin中注册该钩子的的回调中。
// 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
// 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
方法
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
// SingleEntryPlugin.js -> hooks.compilation
compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory);
为什么需要这么做❓❓❓因为XxxDependency是动态添加的,没必要一开始将所有的映射关系都保存下来。
hooks.make
:非常关键的hook,前面SingleEntryPlugin
中有注册该钩子
// 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()获取
entry
:SingleEntryDependency
,继承自ModuleDependency
,有request和userRequest属性,初始时两个值一致,当前案例为:'./src/simple/main.js'
name
:当options.entry为对象时,name指向对象的key,上面的requst指向对应的value。这里是chunkMain
。
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实例
,并将该资源关联的resource
和loaders
等信息交给该模块实例,模块的实际构建
发生在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);
}
主要步骤:
- 将module.build收集的依赖进行过滤(
dep.getResourceIdentifier()
有返回值)和分类,存储到sortedDependencies
- 调用
addModuleDependencies
构建依赖(就像_addModuleChain
处理SingleEntryDependency
一样)
DependenciesBlock的三个属性:dependencies、blocks、variables
看到addDependenciesBlock
方法中有三个分支分别是module.dependencies、module.blocks、module.variables,这三个属性继承自DependenciesBlock
,分别是不同的类型
// 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
对该方法进行了重写
// 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
就是按照这个进行分类的.
{ xxxfactory: {xxxresourceIdent: [xxxdependency,...]} } // Map
2. 遍历blocks: AsyncDependenciesBlock
类型
该类我理解是一个分离点,用于分离异步模块,其本身同Module一样都继承自DependenciesBlock,同样包含blocks、dependencies、variables属性。
当前demo这里产生的结果,blocks保存了ImportDependenciesBlock对象,该对象是因为使用import(/*...*/ './b')
而收集的,其本身可以理解是一个中间对象,也可以认为是一种标记表明异步引用一个模块,具体异步模块的信息作为该block的dependencies等属性中存储,如下面的ImportDependency
。
看到这里递归调用addDependenciesBlock
用来收集异步依赖,如这里的ImportDependency
// 注意: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,三个核心类确定了整个构建的主要框架。
遗留的问题有
- 深入normalModuleFactory.create 和 normalModule.build
- 深入compilation.seal
在后面章节深入介绍这两个问题。
思考:
MultiEntryPlugin
和DynamicEntryPlugin
如何发挥作用的❓- Compier & Compilation的区别❓