通过一个demo带你深入进入webpack@4.46.0
源码的世界,分析构建原理,专栏地址,共有十篇。
书接上回: NormalModuleFactory中获取调用getResolver获取loaderResolver
和normalResolver
,getResolver会调用webpack/lib/ResolverFactory._create
来创建,而最终的实际的创建工作在enhanced-resolve库的ResolverFactory.js文件中,同样利用工厂模式来创建Resolver实例。
// NormalModuleFactory.js
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
webpack/lib/ResolverFactory.js
const Factory = require("enhanced-resolve").ResolverFactory;
_create(type, resolveOptions) {
const originalResolveOptions = Object.assign({}, resolveOptions);
resolveOptions = this.hooks.resolveOptions.for(type).call(resolveOptions);
const resolver = Factory.createResolver(resolveOptions);
//...
}
这里type区分了loader和normal,loaderResolver就是用来解析查找loader指向的文件及其本地路径的,normal用来获取普通模块,比如import,require等引入的js文件。
两个resolver获取的主要差异在于resolveOptions,webpack分别提供了两个选项用来区别这俩个Options,分别是resolveLoader、resolve,resolveLoader关联的是loader的选项,resolve关联的normal的选项;这两个选项在WebpackOptionsDefaulter
中提供了默认值,如下
另外在提供了WebpackOptionsApply
在resolverFactory.hooks.resolveOptions
上注册了事件函数,用来根据不同的type
获取不同的resolveOptions
,这里loader和normal是完全一样的,在默认选项基础上添加了fileSystem
下面介绍我们介绍normalResolver的获取和文件的解析,loaderResolver逻辑上是一致的,就不再赘述
上面说到该库是利用工厂模式创建实例的,这里的工厂就是ResolverFactory
,而要创建的对象类是Resolver
// ResolverFactory.js
exports.createResolver = function(options) {
//...
resolver = new Resolver(...);
// 生成下面名称的hoos
// resolve、parsedResolve、describedResolve、rawModule、module、relative
// describedRelative、directory、existingDirectory、undescribedRawFile、rawFile
// file、existingFile、resolved
resolver.ensureHook("resolve"); // 省略其他名称的hook
// 默认plugin、根据resolveOptions收集各种plugins
// plugins.push(new XxxPlugin(source, target))
// 插件注册
plugins.forEach(plugin => {
plugin.apply(resolver);
});
return resolver;
}
createResolver
的主要工作是:创建Resolver实例;并根据resolveOptions收集各种插件实例;并最终注册这些插件,注册的时候提供了当前创建的resolver实例,使得这些插件实例和该resolver实例关联。
另外在createResolver
看到resolver.ensureHook各种事件名称
(resolve、parsedResolve、describedResolve、rawModule、module、relative、describedRelative、directory、existingDirectory、undescribedRawFile、rawFile、file、existingFile、resolved),ensureHook的作用就是确保创建对应的hook,并存储到this.hooks
中
// Resolver.js
ensureHook(name) {
//...
const hook = this.hooks[name];
if (!hook) {
return (this.hooks[name] = withName(
name,
new AsyncSeriesBailHook(["request", "resolveContext"])
));
}
}
// 给创建的hook添加一个name
function withName(name, hook) {
hook.name = name;
return hook;
}
这里需要关注的是使用AsyncSeriesBailHook
,保证了串行,并且当有注册的订阅函数返回undefined
值时继续往后执行否则退出,这一点在后面介绍插件执行流程时会有体现。
另外,上面的一些列事件名称
构成了一条流水线,每个事件名称
都可以理解为流水线上的一个节点,每个节点都会去执行注册在该节点上的事件函数
当前案例中收集的插件如下:
那上下相邻的两个hook如何衔接的呢,上图中看到所有的plugin的构造函数都提供了source
和target
参数(除了最后一个插件ResultPlugin
,因为是最后一个直接结束当前的执行流返回到调用处),以ParsePlugin
为例看下enhanced-resolve中的插件实现,构造函数中提供了source
、target
两个hook name,source
是当前插件注册的钩子名称,target
是当前插件执行完后的进入的下一个hook
module.exports = class ParsePlugin {
constructor(source, target) {
this.source = source;
this.target = target;
}
apply(resolver) {
const target = resolver.ensureHook(this.target);
resolver.getHook(this.source).tapAsync("ParsePlugin", (request, resolveContext, callback) => {
//...
resolver.doResolve(target, obj, null, resolveContext, callback);
});
}
};
ResolverFactory收集完所有的插件后最终会注册执行所有插件的apply方法,看到apply方法会在指定source
的事件上进行事件订阅,订阅函数内部会再调用resovler.doResolver(target)
,该方法用于进入指定事件中(如这里的target事件),通过此种方式将所有的插件进行连接成链,实际上这里是责任链模式
的完美体现。
class Resolver extends Tapable {
constructor(fileSystem) {
this.fileSystem = fileSystem;
this.hooks = {/*...*/}
}
// new AsyncSeriesBailHook(["request", "resolveContext"])
ensureHook(name) {} // 生成指定名称的hook,并挂到this.hooks上
getHook(name) {} // 从this.hooks上获取指定名称的hook
// 解析入口,如 NormalModuleFactory -> normalResolver.resolve(...)
resolve(context, path, request, resolveContext, callback) {
// 从hooks.resolve开始
return this.doResolve(this.hooks.resolve, ..., (err, result) => { ... })
}
// 正式解析
doResolve(hook, request, message, resolveContext, callback) {
//...
return hook.callAsync(request, innerContext, (err, result) => {
if (result) return callback(null, result);
});
}
}
Resolver中的ensureHook
和getHook
用来生成和获取hook,而resolve
方法是暴露给调用方的,即调用方通过xxxResolver.resolve()
开始解析工作,比如上一小节中的需要获取普通文件和loader的本地路径
// NormalModuleFactory.js
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
//...
normalResolver.resolve(...); // 解析普通文件
//...
}
resolveRequestArray(contextInfo, context, array, resolver, callback) {
//...
return resolver.resolve(...); // 解析loader
//...
}
从resolve
事件开始到resolved
事件结束,当前demo中normalResolver的完整的流水线图如下:
红色圈圈表示起始事件
名称,绿色圈圈表示中间事件
名称,方形中内容分别是插件名名称和该插件中的source
、target
。
图中省略了new-resolve事件,该事件不算是主流水线中的事件,在没有缓存的情况下resolve从ParsePlugin开始,有缓存的情况加添加一个衔接事件用来进行衔接。可忽略。
增加一层缓存,在进入下一个事件之前判断是否有缓存,有缓存则返回,没有缓存调用doResolve进入下一个事件开始解析。在回调中(整个解析操作完成后)设置最终的结果。
const cacheId = getCacheId(request, this.withContext);
const cacheEntry = this.cache[cacheId];
if (cacheEntry) { /*返回缓存*/ }
resolver.doResolve(..., (err, result) => {
if (result) return callback(null, (this.cache[cacheId] = result));
callback();
}
);
调用Resolver.parase()分离request为:path和query,并判断当前request是否是模块,是否是文件夹等信息 初始request
(doResovle的第二个参数)
{
"context": {
"issuer": "/Users/.../src/simple/main.js"
},
"path": "/Users/.../src/simple",
"request": "./a?c=d"
}
如下:
{
"context": {
"issuer": "/Users/.../src/simple/main.js"
},
"path": "/Users/.../src/simple",
"request": "./a",
"query": "?c=d",
"module": false,
"directory": false,
"file": false
}
说下如何(初步)判断是否是模块、文件夹,主要是基于下面的正则
const REGEXP_NOT_MODULE = /^.$|^.[\/]|^..$|^..[\/]|^/|^[A-Z]:[\/]/i;
const REGEXP_DIRECTORY = /[\/]$/i;
如果不
是下面模式则认为是模块(比如 import vue from 'vue' ),
1. '.'、
2. '.\xxx' './xx'
3. '..'、
4. '..\xxx' '../xxx'
5. '/xxx'
6. '[a-zA-Z]:[\/]xxx'
如果以/
结尾则认为是文件夹。 介绍一个分析正则表达式的好工具,选择语言,填入表达式,在右侧会有详细的解释。
该插件的功能是为了寻找描述文件(如package.json),首先会在 request.path 比如我们这里是/Users/.../src/simple
这个目录下寻找package.json文件,如果没有找到这个文件则会按照路径一层一层往上寻找。最后读取到 package.json 的信息和其所在的目录/路径信息,存入 request 中
添加了如下字段,descriptionFileXxx表示了描述文件的信息(描述文件路径,描述文件内容)
获取到描述文件内容后,AliasFieldPlugin需要会用到这个数据。
起一个衔接的作用,内部逻辑就是直接调用 doResolve,然后触发下一个事件。当 DescriptionFilePlugin 中未找到 package.json 文件时,会返回undefined
值给AsyncSeriesBailHook
,那么会继续进入下一个NextPlugin,然后让事件流继续。 如果找到了描述文件,则这个NextPlugin就一定不会执行吗?请读者思考(答案是可能会执行)。
type为normal情况下,aliasFields默认值为[browser]
所以会添加AliasFieldPlugin,如果有多个则会创建多个该插件实例;
在AliasFieldPlugin插件中如果有命中的路径,说明请求路径发生变化需要重新解析则则回到 resolve
事件重新来过。如果没有命中则返回undefined
则进入下一个插件,在这里是ModuleKindPlugin
。
类似功能的插件还有AliasPlugin
,AliasPlugin主要是基于webpack配置中resolve.alias,如果发现当前路径是alias中配置的key开始,则会进行路径替换,重新解析(进入resolve),如下。
// webpack.config.js
resolve: {
alias: {
'@': path.resolve(__dirname, '../src/simple/')
}
},
// main.js
import {logA} from './custom-loaders/custom-inline-loader.js??share-opts!@/a?c=d'
// AliasPlugin.js
startsWith(innerRequest, item.name + "/") // item.name: '@'
// 然后替换路径,item.alias: /Users/.../src/simple
const newRequestStr = item.alias + innerRequest.substr(item.name.length);
// '@/a' -> '/Users/.../src/simple
const obj = Object.assign({}, request, {
request: newRequestStr
});
return resolver.doResolve(target, ...) // target: resolve
这里的模块可以认为是node_modules中模块的引用,比如import vue form 'vue',解析'vue'则会命中。
if (!request.module) return callback(); // 不是module
// 是module,则进入raw-module事件
const obj = Object.assign({}, request);
delete obj.module;
resolver.doResolve(target,...);
request.module
就是在ParsePlugin
设置的的值;如果是 module,则后续进入raw-module
的逻辑。当前demo中的'./a'不是module,则这里返回undefined
进入下一个插件JoinRequestPlugin
如果是模块比如下面示例,则会命中进入ModulesInHierachicDirectoriesPlugin
import vue from 'vue'
见ModulesInHierachicDirectoriesPlugin
部分分析
const obj = Object.assign({}, request, {
path: resolver.join(request.path, request.request),
relativePath: request.relativePath && resolver.join(request.relativePath, request.request),
request: undefined
});
resolver.doResolve(target, obj, null, resolveContext, callback); // target: resolve
路径连接:
// 之前:
path: "/Users/.../src/simple",
relativePath: "./src/simple",
request: "./a"
// 之后:
path: "/Users/.../src/simple/a"
relativePath: "./src/simple/a"
request: undefined
进入relative
事件,因为路径发生了变化,进入DescriptionFilePlugin重新获取描述文件,然后进入FileKindPlugin
const fs = resolver.fileSystem;
// 分割路径,然后拼接所有可能的路径
const addrs = getPaths(request.path).paths.map(p => {
return this.directories.map(d => resolver.join(p, d));
})...
forEachBail(addrs, (addr, callback) => {
fs.stat(addr, (err, stat) => {
if (!err && stat && stat.isDirectory()) {
// 如果找到则替换路径
const obj = Object.assign({}, request, {
path: addr,
request: "./" + request.request
});
//...
// 重新从resolve事件开始
return resolver.doResolve(target, ...); // target: resolve
}
//...
});
},
callback
);
依次在 request.path 的每一层目录中寻找 node_modules。那么寻找 node_modules 的过程为
"/Users/.../src/simple/node_modules"
"/Users/.../src/node_modules"
"/Users/.../node_modules"
// ...
"/Users/node_modules"
"/node_modules"
如果fs.stat找到了,则替换路径后重新回到 resolve 开始的阶段。但是这时 request.request 从一个 module 变成了一个普通文件类型./vue
。
path: "/Users/.../node_modules"
request: "./vue"
if (request.directory) return callback();
const obj = Object.assign({}, request);
delete obj.directory;
resolver.doResolve(target, obj, null, resolveContext, callback);
是文件夹则返回undefined
,进入下一个插件TryNextPlugin(relative,directory),随后直接进入directory
事件;如果不是文件夹则认为是文件,进入raw-file
事件的第一个插件TryNextPlugin(rawf-file,file),直接进入file
事件一直走到FileExistsPlugin发现找不到文件(当前文件路径是:/Users/.../src/simple/a
- 文件名称不对应该是a.js),所以这里的TryNextPlugin返回undefined
进入到AppendPlugin
resolve.symlinks。默认值也是true。
用来处理路径中存在软链(symbolic link
)的情况。由于 webpack 默认是按照真实的路径来解析的,所以这里会检查路径中每一段,如果遇到软链,则替换为真实路径。
fs.readlink关注的是软链的情况。hard link and symbolic link
我们这里没有使用软链返回undefined
,进入FileExistsPlugin
const fs = resolver.fileSystem; // compiler.inputFileSystem
fs.stat(file, (err, stat) => {/*...*/})
//webpack.js
new NodeEnvironmentPlugin({
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
// NodeEnvironmentPlugin.js
compiler.inputFileSystem = new CachedInputFileSystem(
new NodeJsInputFileSystem(),
60000
);
CachedInputFileSystem -> NodeJsInputFileSystem -> graceful-fs -> fs(高层在底层的基础上进行了一定的封装),CachedInputFileSystem提供了缓存能力。比如说readFile这个接口会将结果缓存下来,下次再次读取的时候,有效时间内,直接读取缓存结果就好,读写本地文件操作比较耗时,并且对于大型的项目中可能会涉及到大量的文件,因此缓存很有必要。
默认情况下提供了的extensions:wasm, mjs, js, json,会一次添加这些后缀然后进行文件查找。显然当添加到.js
后缀时可以找到文件/Users/.../src/simple/a.js
每个后缀都会生成一个AppendPlugin插件实例
const obj = Object.assign({}, request, {
path: request.path + this.appending, // 关键:添加后缀
relativePath: request.relativePath && request.relativePath + this.appending
});
resolver.doResolve(target, obj, this.appending, resolveContext, callback);
这里的target指向file事件,后面的流程是:file
-> FileExistsPlugin
-> existing-file
-> NextPlugin
-> resolved
-> ResultPlugin
const obj = Object.assign({}, request);
resolver.hooks.result.callAsync(obj, resolverContext, err => {
if (err) return callback(err);
callback(null, obj);
});
.... -> callback 到
// Reslolve.resolve()
return this.doResolve(..., (err, result) => {
if (!err && result) {
return callback( null,
// resource,资源的`本地路径`
result.path === false ? false : result.path + (result.query || ""),
result
);
}
})
打完收工:将'./a'的解析结果即/Users/.../src/simple/a.js
返回给调用者。
显示在DirectoryExistsPlugin
插件中通过resolver.fileSystem.state判断文件夹是否存在,如果存在则进入existing-directory事件,进入MainFieldPlugin
插件
normalResolver
的resolveOptions.mainField
的值为['browser', 'module', 'main']
,每个item都会注册一个MainFieldPlugin
实例,执行时从描述文件中读取该字段的值拿到拼接文件路径,然后进入DescriptionFilePlugin重新获取描述文件内容,到raw-file
事件进入正常文件的解析流程中。
如果MainFieldPlugin
返回undefined
时则会进入UseFilePlugin
,UseFilePlugin
插件的作用是类似的,在文件夹后面后面直接拼接主入口文件名称,这里是index
,然后继续后面流程。
这两个插件的作用都是在文件夹后面拼接一个文件名称,使得文件件路径变成文件路径,继续文件的解析流程。
通过一系列插件(可扩展各种复杂情况)的接力式的执行解析原始路径为本地路径。
思考:这里有没有一些配置项可以优化来来提升构建性能。