通过一个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中涉及了哪些设计模式呢?
上一节说到normalModuleFactory.create来创建模块实例,下面从该方法开始分析创建模块实例需要哪些准备工作。
NormalModuleFactory
该部分有大段篇幅分析loader的解析,因为会涉及内联loader,因此以解析main.js中的第一个import
引入的依赖为例。 该资源的解析是在main.js模块构建之后获取其dependencies,而后基于dependencies进行依赖模块的构建。
在addModuleDependencies -> factory.create (这里的factory
也是NormalModuleFactory
类型)
此时的create方法的参数如下:
看到dependencies有两个元素,这是前面processModuleDependencies
方法分类的结果,指向相同资源路径(这里request="./custom-loaders/custom-inline-loader.js??share-opts!./a?c=d")的dependency构建一次即可。
// NormalModuleFactory.js
create(data, callback) {
const dependencies = data.dependencies;
// dependencies[0]是否缓存过,缓存过则直接返回
const request = dependencies[0].request; //...
this.hooks.beforeResolve.callAsync({ /*...*/ }, (err, result) => {
const factory = this.hooks.factory.call(null);
factory(result, (err, module) => {/*...*/});
}
);
}
constructor(context, resolverFactory, options) {
//...
// 注意:返回一个函数: 模块工厂用来构造模块实例
this.hooks.factory.tap("NormalModuleFactory", () => (result, callback) => {
let resolver = this.hooks.resolver.call(null); // 返回一个函数
// 执行this.hooks.resolver.tap返回的函数或构造模块需要的信息
resolver(result, (err, data) => {
// hooks.afterResolve、hooks.createModule //...
createdModule = new NormalModule(result);
// hooks.module
});
}
// 注意:返回一个函数
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
// ... 关键,获取应用在该模块的真实路径、loaders等信息,收集依赖需要的parser等
callback(null, { // new NormalModule的入参
context: context,
request: loaders.map(loaderToIdent).concat([resource]).join("!"),
dependencies: data.dependencies,
userRequest,
rawRequest: request,
loaders,
resource,
matchResource,
resourceResolveData,
settings,
type,
parser: this.getParser(type, settings.parser),
generator: this.getGenerator(type, settings.generator),
resolveOptions
});
})
}
主要两个步骤:
hooks.resolver
的目的是解析loader和resource等信息,创建模块实例需要用到hooks.factory
钩子的目的是创建模块实例
注意这两个订阅函数的执行结果是返回一个函数
:factroy()、resolver()
resolver(): 收集各种模块构建过程中需要的信息
该部分有大量代码解析loader,下面先介绍下loader的特性。
loader的类型、运行阶段、覆盖特性
loader的类型:
默认是normal,
Rule.enforce,enfoce:可能的值:pre | post 可以强制当前loader作用的阶段,前置还是后置。
另外还有inlined loader,内联loader应用在import/require的路径中,比如
代码语言:javascript复制import {logA} from './custom-loaders/custom-inline-loader.js!./a'
运行阶段:
webpack的loaders的执行实际是交个
loader-runner
这个库,后面会以单独小结分析该库。这里简单说下,所有 loader 依次进入两个阶段:
- Pitching 阶段:loader 上的 pitch 方法按 post、inline、normal、pre 的顺序调用。Pitching Loader。
- normal阶段:loader 上的正常方法按 pre、normal、inline、post 的顺序执行。模块源代码的转换发生在这个阶段。
覆盖特性:
- 所有normal loaders 都可以通过请求中(request)的前缀
!
来省略(覆盖)。 - 所有normal、pre loaders都可以通过前缀
-!
省略(覆盖) - 所有normal、pre、post loaders 都可以通过前缀
!!
省略(覆盖)。
分析: 主要是loaders的匹配和本地路径解析
一共分为五个部分介绍
代码语言:javascript复制this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
//...
// 第一部分 -----------------------
// 获取xxxResolver
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
let matchResource = undefined; //... 先遗留
let requestWithoutMatchResource = request;
// 通过request判断是否需要忽略部分loaders
// 如果request以'-!'开始,则忽略normal、pre loaders
const noPreAutoLoaders = requestWithoutMatchResource.startsWith("-!");
// 如果request以'!'开始,则忽略normal loaders
const noAutoLoaders = noPreAutoLoaders || requestWithoutMatchResource.startsWith("!");
// 如果request以'-!!'开始,则忽略pre、post、normal loaders
const noPrePostAutoLoaders = requestWithoutMatchResource.startsWith("!!");
// 获取内联形式的loader信息
let elements = requestWithoutMatchResource.replace(/^-?! /, "").replace(/!! /g, "!").split("!");
let resource = elements.pop(); // 最后一个是资源路径,排除掉
let resource = elements.pop();
// loader形式可能是xxx-loader?a=b等,转换为 {loader、options}结构,loader是loader的路径,options是query部分。
elements = elements.map(identToLoaderRequest);
// 第二部分 -----------------------
asyncLib.parallel([callback => (/*..获取内联系形式loader绝对路径.*/),
callback => { /*..获取resource绝对路径.*/ }],
(err, results) => {
// 第三部分 -----------------------
// loader匹配、分类(normal、pre、post)、确定模块的类型,见下面具体分析
// 第四部分 -----------------------
// 获取normal、pre、post各类型loader的绝对路径
asyncLib.parallel([/*..useLoadersPost.*/, /*.useLoaders..*/,/*..useLoadersPre.*/],
(err, results) => {
// 连接所有的loaders:post -> inline -> normal -> pre
loaders = results[0].concat(loaders, results[1], results[2]);
// 第五部分 -----------------------
const type = settings.type; // 模块类型
const resolveOptions = settings.resolve; //
callback(null, { ... });
}
);
}
);
});
第一部分: 获取内联loader
- 获取 loaderResolver、normalResolver,具体的获取逻辑会在下面介绍
三个核心对象 - resolver
时介绍 - 根据
request
来判断是否要屏蔽pre、normal、post等类型的loaders(即上面介绍的覆盖特性
)。确定了这几个变量noPreAutoLoaders
、noAutoLoaders
、noPrePostAutoLoaders
,后面通过this.ruleSet.exec
初步匹配loaders的基础上根据这些变量进行进一步的过滤 - 从
request
中解析出内联的loaders
,存储到elements
中
第二部分: 获取内联loader本地路径和当前资源的本地路径
代码语言:javascript复制asyncLib.parallel([
callback => (/*..获取内联系形式loader绝对路径.*/),
callback => { /*..获取resource绝对路径.*/ }],
(err, results) => { /*...*/ }
)
- 获取内联loaders的绝对路径
- 获取resource的绝对路径
示例介绍:
代码语言:javascript复制import {logA} from './custom-loaders/custom-inline-loader.js!./a'
比如上面的loader路径和js资源路径都是相对路径
,这里会经过loaderResolver
和normalResolver
来解析为本地路径
,需要注意的是,二者的解析路径存在一些差异,因此有两个resolver实例。路径的解析webpack交给了一个单独的库enhanced-resolver
,后面会单独介绍该库。
第三部分:匹配非内联loaders
代码语言:javascript复制let loaders = results[0];
const resourceResolveData = results[1].resourceResolveData;
resource = results[1].resource;
// 如果内联loader携带了ident,即形式如xxx-loader??xxxident,会从当前所有的loaders(内置 用户配置的)
// 查找到相同ident的loader的options,并赋值给该内联loader,
// 如果这个options是对象,则多个loader之间可以共享该对象。
try {
for (const item of loaders) {
if (typeof item.options === "string" && item.options[0] === "?") {
const ident = item.options.substr(1);
item.options = this.ruleSet.findOptionsByIdent(ident);
item.ident = ident;
}
}
} catch (e) //...
// ... 没有resouce的异常处理
// 原始内联形式的request的重新连接,区别是loaders和resource都改为绝对路径了
const userRequest =
(matchResource !== undefined ? `${matchResource}!=!` : "")
loaders.map(loaderToIdent).concat([resource]).join("!");
let resourcePath = matchResource !== undefined ? matchResource : resource;
let resourceQuery = "";
const queryIndex = resourcePath.indexOf("?");
// resourcePath拆分为path和query两部分
if (queryIndex >= 0) {
resourceQuery = resourcePath.substr(queryIndex);
resourcePath = resourcePath.substr(0, queryIndex);
}
// this.ruleSet包含了所有的loaders信息(内置和用户的),根据resourceQuery、resource等信息来获取匹配的loaders
const result = this.ruleSet.exec({ /*...*/ });
const settings = {};
// loaders分类
const useLoadersPost = []; // post loaders
const useLoaders = []; // normal loaders
const useLoadersPre = []; // pre loaders
for (const r of result) {
if (r.type === "use") {
// 声明为post,并且request中没有屏post loaders
if (r.enforce === "post" && !noPrePostAutoLoaders) {
useLoadersPost.push(r.value);
// 声明为pre,并且没有屏蔽 pre loaders
} else if (r.enforce === "pre" && !noPreAutoLoaders && !noPrePostAutoLoaders) {
useLoadersPre.push(r.value);
// 没有屏蔽normal loaders
} else if (!r.enforce && !noAutoLoaders && !noPrePostAutoLoaders) {
useLoaders.push(r.value);
}
} else if (typeof r.value === "object" && r.value !== null && typeof settings[r.type] === "object" && settings[r.type] !== null) {
settings[r.type] = cachedCleverMerge(settings[r.type], r.value);
} else {
settings[r.type] = r.value;
}
}
这里我们需要关心一下setting.type
,在后面会用到;
首先this.ruleSet
来内内置规则和用户提供的规则
// NormalModuleFactory.Constructor
this.ruleSet = new RuleSet(options.defaultRules.concat(options.rules));
options.defaultRules
规则由WebpackOptionsDefaulter
中提供的默认规则,在获取parser
和generator
时需要用到,作用是确定模块化类型,该值的可选值参考,用户也可以自己提供该配置来覆盖默认规则。默认的规则如下:
看到后面三个都提供了test
,显然不会命中我们的.js
文件,也就是如果开发者不主动设置的话,默认的js,ts等文件都会命中第一个规则,会得到 setting.type = "javascript/auto"
;因此在getParser
和getGenerator
中的入参type
都是该值
这里主要做了三件事情
设置内联loader的ident和options
对loaders进行匹配并且根据屏蔽规则确定最终可以应用的loaders,并根据normal、pre、post loader进行分类,分类的目的是为了后面按照这个顺序在加上内联loaders组装成最终的loaders
代码语言:javascript复制// results[0]:post loaders
// loaders: 内联loaders,从request中解析出来
// results[0]: normal loaders
// results[0]: pre loaders
// 类型:post -> inline -> normal -> pre
loaders = results[0].concat(loaders, results[1], results[2]);
设置setting.type
确定模块类型
我们的demo在这里的loader分类结果如下:
代码语言:javascript复制// loaders: 内联loaders
[{
"loader": "/Users/.../src/simple/custom-loaders/custom-inline-loader.js",
"options": { "a": "b" },
"ident": "share-opts"
}]
// useLoaders: normal loaders
[{
"options": { "a": "b" },
"ident": "share-opts",
"loader": "./src/simple/custom-loaders/custom-normal-loader"
}]
// useLoadersPost: post loaders
[{ "loader": "./src/simple/custom-loaders/custom-post-loader", "options": undefined }]
// useLoadersPre: pre loaders
[{ "loader": "./src/simple/custom-loaders/custom-pre-loader", "options": undefined }]
这里的内联loader:custom-inline-loader通过在后面追加??share-opts
共享了在webpack.config.js中配置的custom-normal-loader提供的options,二者具有相同的ident
(identifier的缩写)
除了内联loader的路径是本地路径外(因为在上面已经解析过了),其余都是原始路径,比如我们示例中使用的相对路径,这里依然是相对路径。在下一个部分会被loaderResolver解析为本地路径
第四部分: 获取非内联loaders本地路径
代码语言:javascript复制asyncLib.parallel([ /*..useLoadersPost.*/,
/*.useLoaders..*/,
/*..useLoadersPre.*/],
(err, results) => { /*...*/ })
在第二部分获取了内联loader的本地路径
,经过第三部分确定了最终被应用的pre
、normal
、post
loaders,这里就是对第三部分获取的非内联
loader通过loaderResolver
进行本地路径
的获取。
useLoadersPost
: 存储post loaders;useLoaders
: 存储normal loaders;useLoadersPre
: 存储pre loaders
上面的pre、normal、post loaders的路径被转为了本地路径
(下面示例省略了中间部分)
[
[{
"loader": "/Users/.../src/simple/custom-loaders/custom-post-loader.js"
}],
[{
"options": {
"a": "b"
},
"ident": "share-opts",
"loader": "/Users/.../src/simple/custom-loaders/custom-normal-loader.js"
}],
[{
"loader": "/Users/.../src/simple/custom-loaders/custom-pre-loader.js"
}]
]
第五部分: 获取parser、generator等核心对象
确认了资源的本地路径
以及最终需要应用的loaders后,另外还另外获取两个核心对象parser
、generator
,会在三个核心对象
段落中介绍
const type = settings.type;
const resolveOptions = settings.resolve;
callback(null, {
context: context,
request: loaders.map(loaderToIdent).concat([resource]).join("!"),
dependencies: data.dependencies,
userRequest,
rawRequest: request,
loaders,
resource,
matchResource,
resourceResolveData,
settings,
type,
parser: this.getParser(type, settings.parser),
generator: this.getGenerator(type, settings.generator),
resolveOptions
});
注意原先的request
被赋给了rawRequest
,request被重新更改为所有loaders和当前resource路径的拼接后的字符串,如下
'/Users/.../src/simple/custom-loaders/custom-post-loader.js!/Users/.../src/simple/custom-loaders/custom-inline-loader.js??share-opts!/Users/.../src/simple/custom-loaders/custom-normal-loader.js??share-opts!/Users/.../src/simple/custom-loaders/custom-pre-loader.js!/Users/.../src/simple/a.js?c=d'
然后就是调用callback
进入到回调函数中创建NormalModule
实例,如下
resolver(result, (err, data) => {
//...
new NormalModule(...)
})
三个核心对象的获取:parser | generator | resolver
三个核心对象 parser、generator、resolver
下面的createParser
和craeteGenerator
都用到了上面解析出的type: javascript/auto
class NormalModuleFactory extends Tapable {
getParser(type, parserOptions) {
// 缓存中是否创建过 this.parserCache,有则返回
// 没有则调用createParser,并缓存到parserCache
}
createParser(type, parserOptions = {}) {
const parser = this.hooks.createParser.for(type).call(parserOptions);
this.hooks.parser.for(type).call(parser, parserOptions);
return parser
}
getGenerator(type, generatorOptions) {
// 缓存中是否创建过 this.generatorCache,有则返回
// 没有则调用createGenerator,并缓存到generatorCache
}
createGenerator(type, generatorOptions = {}) {
const generator = this.hooks.createGenerator.for(type).call(generatorOptions);
this.hooks.generator.for(type).call(generator, generatorOptions);
return generator;
}
getResolver(type, resolveOptions) { /*...*/ }
}
parser: 通过解析ast收集依赖、generator: 最终产物的代码生成
在WebpackOptionsApply
中注册了JavascriptModulesPlugin
插件
// WebpackOptionsApply.js
new JavascriptModulesPlugin().apply(compiler);
JavascriptModulesPlugin
注册hooks.createParser
和hooks.createGenerator
钩子
// JavascriptModulesPlugin.js
// apply方法中注册了下面两个钩子
const Parser = require("./Parser");
const JavascriptGenerator = require("./JavascriptGenerator");
normalModuleFactory.hooks.createParser.for("javascript/auto").tap("JavascriptModulesPlugin", options => {
return new Parser(options, "auto");
});
// "javascript/dynamic"
// "javascript/esm"
normalModuleFactory.hooks.createGenerator.for("javascript/auto").tap("JavascriptModulesPlugin", () => {
return new JavascriptGenerator();
});
// "javascript/dynamic"
// "javascript/esm"
当前案例中的createParser
和createGenerator
入参type
是javascript/auto
resolver:路径解析器
代码语言:javascript复制// Compiler.js
const ResolverFactory = require("./ResolverFactory");
createNormalModuleFactory() {
const normalModuleFactory = new NormalModuleFactory(
this.options.context,
this.resolverFactory, // new ResolverFactory()
this.options.module || {}
);
//...
}
代码语言:javascript复制// NormalModuleFactory.js
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
//...
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
//...
}
getResolver(type, resolveOptions) { // type: normal、loader
return this.resolverFactory.get(
type,
resolveOptions || EMPTY_RESOLVE_OPTIONS
);
}
webpack/lib/ResolverFactory
代码语言:javascript复制const Factory = require("enhanced-resolve").ResolverFactory;
get(type, resolveOptions) {
// 是否缓存过,巧妙的是缓存的key,JSON.stringify
// 调用_create创建resolver
const newResolver = this._create(type, resolveOptions);
//...
}
_create(type, resolveOptions) {
const originalResolveOptions = Object.assign({}, resolveOptions);
resolveOptions = this.hooks.resolveOptions.for(type).call(resolveOptions);
const resolver = Factory.createResolver(resolveOptions);
//...
}
这里实际会进入到enhanced-resolve
库中进行创建工作,具体创建工作和使用后面会单独出一节介绍。
小结
主要的步骤如下
- 获取loaders信息、resource本地路径、parser、generator等信息。
- 解析出内联loader
- 解析内联loader和resource本地路径
- 通过this.ruleSet匹配所有的非内联loader
- 解析非内联loader路径为本地路径
- 获取parser、generator:hooks.createParser、hooks.createGenerator
- 创建模块实例(new NormalModule(...))
NormalModule
NormalModuleFactory
.create创建完NormalModule
实例后,会调用module.build
进行模块的真正的构建。
为什么说是真正的构建,因为之前都是准备工作,并没有获取模块内容和内容解析相关的工作。现在才开始获取原始资源内容,执行loaders,解析ast收集依赖等工作。
build()
代码语言:javascript复制// NormalModule.js
build(options, compilation, resolver, fs, callback) {
//...
this._source = null;
this._ast = null;
return this.doBuild(options, compilation, resolver, fs, err => {
this._cachedSources.clear();
// 如果配置了module.noParse,会校验阻止命中模块的parse,直接返回
//...
const result = this.parser.parse(this._ast || this._source.source(), { /*...*/ }, (err, result) => {/*...*/ });
});
}
noParse的作用:
防止 webpack 解析任何匹配给定正则表达式的文件。被忽略的文件不应调用 import、require、define 或任何其他导入机制。当忽略大型库时,这可以提高构建性能。
主要步骤:
- 调用this.doBuild来应用loaders获取
_source
- 获取完
_source
后调用parser.parse
来解析_source对应的ast来收集依赖
(Dependency)
doBuild()
代码语言:javascript复制const { getContext, runLoaders } = require("loader-runner");
doBuild(options, compilation, resolver, fs, callback) {
// 创建loader的执行上下文,提供了部分api和属性共loader使用
const loaderContext = this.createLoaderContext(resolver, options, compilation, fs);
// 调用loader-runner库执行各种loader,在loader-runner会被转为数组返回
runLoaders({ /*...*/ }, (err, result) => {
// result.result是最后一个loader返回的结果
const source = result.result[0]; // resouce指向的资源的内容
// 显然是sourceMap,在开发模式下帮忙debug时很有帮助
const sourceMap = result.result.length >= 1 ? result.result[1] : null;
// 可以用来提供 ast,比如有些loader会需要用到ast,可以在这里返回,
// 这样在后面parse的时候可以复用该ast,避免多做一次工作
const extraInfo = result.result.length >= 2 ? result.result[2] : null;
// 创建source对象
this._source = this.createSource(this.binary ? asBuffer(source) : asString(source),
resourceBuffer,
sourceMap
);
this._ast = (typeof extraInfo === "object" && extraInfo !== null && extraInfo.webpackAST !== undefined)
? extraInfo.webpackAST : null;
//...
}
);
}
显示调用createLoaderContext
方法创建执行loader时的上下文(loader函数执行时的this指向,该上下文包含了很多API
,如日志(getLogger),错误,警告(emitError、emitWarning)等收集,文件输出(emitFile
)等),然后调用由loader-runner
库提供的runLoaders
方法,后面有单独一节会对loader-runner
库进行源码分析。
看下Source
类型
如果loader返回的source是Buffer
类型的,则使用RawSource
,如果loader返回了sourceMap并且webpack中提供了devtool即需要生成sourceMap,会返回SourceMapSource
,否则会生成OrinalSource
其中 RawSource
、SourceMapSouce
、OriginalSource
可以理解为是平级的,都是在loaders转换完资源之后的初始Source
。
而初始Source
->ReplaceSource
-> CachedSource
三者存在递进关系。最终输出的文件内容和原始内容是有很大出入的,被做了很多修改(替换、插入等操作),这些都是ReplaceSource
实现的,而ReplaceSource
内部有一个_source
指向了初始Source
,RepalceSouce
提供修改能力,后面在介绍代码生成
的时候看到这一部分。当应用修改生成最终Source
时会再次升级为CachedSource
,提供缓存能力。
doBuild的主要功能就是执行匹配的loaders生成初始Source
,然后进入回调中调用this.parser.parse
,该函数的主要工作就是收集各种Dependencty
,这部分具体解析会单独作为一个小节讲解
小结
- runLoaders: 获取_source
- this.parser.parse: 收集依赖
总结
- NormalModuleFactory.create
- 调用normalResolver获取资源的`本地路径`
- 获取内联loaders并匹配(过滤)需要应用的loaders,调用loaderResovler来获取loader的`本地路径`
- 构造一个NormalModule实例NormalModule.build
代码语言:txt复制- doBuild 执行loaders(runLoaders)获取原始_source
- 调用this.parser.parse收集`Dependency
引出三个大方向,下面每个点都会以一个单独小结来讲解
- resolver是如何解析路径的
- runLoaders的执行过程
- parser.parse如何收集依赖