路径解析 Webpack封装了一套解析库enhanced-resolve 专门用于解析路径,例如我们写了require(‘./index’) ,Webpack在打包时就会用它来解析出./index的完整路径。 我们可以看到他的官方介绍:
1 2 3 4 Offers an async require.resolve function. It's highly configurable. Features: - plug in system - provide a custom filesystem - sync and async node.js filesystems included
可以看到官方定义他是一个可配置化的异步require.resolve。如果不了解reqire.resolve的同学可以先看看require.resolve 是什么
reqire.resolve的不足 可以看到本质上他们做的事情都是一样的,只是在解析路径的规则上enhanced-resolve提供了更强的扩展性,以满足Webpack对解析文件的需求。 由于require.resolve只能用在node的环境下,所以在设计时require.resolve只配置了node相关的依赖规则,而Webpack面对的环境多种多样,以下列举一些Webpack做的增强内容:
扩展名查询配置 例如解析./index时由于没有提供扩展名,所以它们都会去尝试遍历可能会有的文件,node会去尝试路径下是否有.js .json .node文件,而Webpack需要面对的文件扩展不止这三种,可通过配置扩展。
文件夹查询 require.resolve只会去解析文件的完整路径,但是enhanced-resolve既可以查询文件也可以查询文件夹。这个功能在Webpack中非常有用,可以通过它导入一个文件夹下的多个文件,只需要配置resolveToContext: true,就会尝试解析目录的完整路径。
返回结果 在解析成功时,require.resolve的返回值只有一个完整路径,enhanced-resolve的返回值还包含了描述文件等较为丰富的数据。
别名配置 node下使用别名是挺麻烦的事,但是enhanced-resolve非常好地支持了这个功能,即能让我们代码看上去更整洁,配合缓存还能提高解析效率。
使用方式 安装环境 可以到github: enhanced-resolve下载一份源码,本次我们使用4.1.1版本解析,下载完成后切换到对应分支,yarn add下载依赖模块。 在项目的package.json里,我们可以找到项目的导出文件是”main”: “lib/node.js”,可以从这个文件入手开始探究源码。
使用 在主目录新建index.js,这里演示了常用的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 var resolve = require("./lib/node"); // 解析相对目录下index.js文件 resolve(__dirname, './index.js', (err, p, result) => { // p = /Users/enhanced-resolve/index.js console.log(err, p, result) }) // 解析模块diff导出文件 resolve(__dirname, 'diff', (err, p, result) => { // p = /Users/enhanced-resolve/node_modules/diff/diff.js console.log(err, p, result) }) // 解析绝对目录下的index.js文件 resolve(__dirname, '/Users/enhanced-resolve/index.js', (err, p, result) => { // p = /Users/enhanced-resolve/index.js console.log(err, p, result) }) // 解析相对路径下的目录 resolve.context(__dirname, './', (err, p, result) => { // p = /Users/enhanced-resolve console.log(err, p, result) })
打印调试信息 由于项目使用Tapable来组织流程,调试起来比较累,好在它提供的调试打印信息还算丰富,我们在Resolver.js第176行加上配置log: console.log,可以打印调试信息到控制台:
1 2 3 4 5 6 7 8 9 return this.doResolve( this.hooks.resolve, obj, message, { missing: resolveContext.missing, stack: resolveContext.stack, + log: console.log, },
复制代码核心源码分析 node.js 这个文件为我们提供了开箱即用的解析函数,根据不同场景预定义了默认参数,最终通过ResolverFactory.createResolver创建并执行路径解析,主要有以下三种场景:
文件路径解析:默认文件后缀为[“.js”, “.json”, “.node”] 文件夹路径解析:只判断文件夹是否存在 Loader路径解析:专门用于Webpack的Loader文件路径解析 另外每种场景都提供同步和异步调用方式,且默认文件操作通过CachedInputFileSystem包装提供缓存功能。
ResolverFactory.js 主要做了两件事,一是参数解析,二是初始化插件,首先会根据参数来将需要用到的插件创建出来,调用他们的apply方法来初始化插件。
1 2 3 4 5 6 7 8 // ... plugins = [] plugins.push(new ParsePlugin("resolve", "parsed-resolve")); //... plugins.push(new ResultPlugin(resolver.hooks.resolved)); plugins.forEach(plugin => { plugin.apply(resolver); });
复制代码enhanced-resolve通过Tapable将所有插件串联起来,每个插件负责一件事情,通过事件流的方式传递每个插件的解析结果。所以只要看懂了插件之间的流转过程,就能明白它的工作原理。
Resolver.js 这里是整个解析流程的核心,它继承了Tapable类,下面我们重点分析里面的方法。
1 2 3 4 5 6 7 8 9 class Resolver extends Tapable { constructor(fileSystem) { super(); this.fileSystem = fileSystem; this.hooks = { resolve: new AsyncSeriesBailHook(["request", "resolveContext"]) }; } }
ensureHook() 这个函数主要用与动态添加钩子,在构造函数中只定义了3个钩子,但是实际用到的不止这么少,所以在使用某个钩子前,都会调用这个方法保证钩子已经定义。这里创建的钩子都是AsyncSeriesBailHook类型,异步,串行执行,获取第一个返回值不为空的结果。 如果name的前缀带有before或after,则会调整调用优先级
1 2 3 4 5 6 7 8 9 ensureHook(name) { // ... const hook = this.hooks[name]; if (!hook) { return this.hooks[name] = new AsyncSeriesBailHook(["request", "resolveContext"]) } return hook; }
例如有两个插件挂在一个钩子上,此时调用钩子hook.callAsync时,因为优先级高会先进入plugin-a的回调,如果返回值是空继续执行after-plugin-a的回调,否则直接执行hook.callAsync的回调:
1 2 3 4 5 6 7 this.ensureHook('myhook').tapAsync('after-plugin-a',() => {}) this.ensureHook('myhook').tapAsync('plugin-a',(request, resolveContext, callback) => { callback(null, 'ok from plugin-a') }) this.hooks.myhook.callAsync(request, resolveContext, (err, result) => { console.log(result) // ok from plugin-a })
doResolve() 这个函数是两个插件的链接点,核心很简单就是直接调用钩子执行下一个流程而已,源码中还有一大堆代码主要是用来记录日志。 hook.callAsync负责真正的调用,会开始执行挂在这个钩子上的事件。在钩子里又会调用doResolve执行下一个钩子。
1 2 3 4 5 6 7 8 doResolve(hook, request, message, resolveContext, callback) { // ... return hook.callAsync(request, innerContext, (err, result) => { if (err) return callback(err); if (result) return callback(null, result); callback(); }); }
XXXPlugin 在创建插件时一般会传入source和target两个参数:
source:插件拿到Resolver.hooks[‘source’]钩子,并调tap或tapAsync添加处理函数事件。当解析器接收到了source事件时,会执行注册的处理函数; target:在处理完毕后,调用doResolve触发一个target事件,交由下一个监听target事件的插件处理。1 2 3 4 5 6 7 8 9 10 11 12 13 14 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); }); } };
有了注册事件tapAsync和触发事件doResolve,各个插件就可以像积木一样链接起来
实战举例 我们通过分析一个解析目录是否存在的流程,来将上面内容串起来:
1 2 3 4 5 // index.js resolve.context(__dirname, './', (err, p, result) => { // p = /Users/enhanced-resolve console.log(err, p, result) })
打印出来的调试结果如下:
1 2 3 4 5 6 7 resolve './' in '/Users/enhanced-resolve' Parsed request is a directory using description file: /Users/enhanced-resolve/package.json (relative path: .) using description file: /Users/enhanced-resolve/package.json (relative path: .) as directory existing directory reporting result /Users/enhanced-resolve
根据配置一共注册了以下插件,下面我们逐个分析这里用到的插件,插件流转路径如图示:
ParsePlugin 用于预解析查询参数供后续插件使用:
解析查询路径中的query参数,如require(‘./index?id=1’),这是webpack特有的语法,见文档__resourceQuery; 判断查询路径是否是模块 判断查询路径是否是目录
1 2 3 4 5 6 7 8 9 10 11 parse(identifier) { const idxQuery = identifier.indexOf("?"); const part = { request: identifier.slice(0, idxQuery), query: identifier.slice(idxQuery), file: false }; part.module = this.isModule(part.request); part.directory = this.isDirectory(part.request); return part; }
DescriptionFilePlugin 用于获取描述文件路径,会在directory下搜索是否有package.json文件,如果该目录没有,就去上一级目录下查找,并计算出相对与directory的路径,下面是简化版的伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function loadDescriptionFile(directory, callback) { const descriptionFiles = ['package.json'] let json let descriptionFilePath for(let i = 0; i < descriptionFiles.length; i++) { descriptionFilePath = path.join(directory, descriptionFiles[i]) if(json = fileSystem.readJson(descriptionFilePath)) break } if(json === null) { directory = cdUp(directory) if (!directory) { return callback('err') } else { loadDescriptionFile(directory, callback); return } } const relativePath = "." + request.path.substr(result.directory.length).replace(/\\/g, "/"); callback(null, { content: json, directory: directory, path: descriptionFilePath, relativePath, }); } function cdUp(directory) { if (directory === "/") return null; const i = directory.lastIndexOf("/"), j = directory.lastIndexOf("\\"); const p = i < 0 ? j : j < 0 ? i : i < j ? j : i; if (p < 0) return null; return directory.substr(0, p || 1); }
ModuleKindPlugin 如果ParsePlugin解析出来是模块路径,就引导至raw-module钩子,否则往下继续执行JoinRequestPlugin
1 2 3 4 5 6 (request, resolveContext, callback) => { if (!request.module) return callback(); const obj = Object.assign({}, request); delete obj.module; resolver.doResolve(target, obj, "resolve as module", resolveContext, callback); });
JoinRequestPlugin 这里将会输出两个路径供后续查找:
path:指要查找的文件完整路径 relativePath:指描述文件路径相对于待查找文件的路径
关键输入参数:
request.path:在哪个路径下查找 request.request:查找的文件 request.relativePath:描述文件路径相对于request.path的路径
1 2 3 4 5 6 7 8 9 (request, resolveContext, callback) => { 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); });
FileKindPlugin 如果ParsePlugin解析出来是路径,就往下继续执行TryNextPlugin,否则就引导至described-relative钩子
1 2 3 4 5 (request, resolveContext, callback) => { if (request.directory) return callback(); const obj = Object.assign({}, request); delete obj.directory; resolver.doResolve(target, obj, null, resolveContext, callback)
TryNextPlugin 直接引导到执行下一个插件
1 2 (request, resolveContext, callback) => { resolver.doResolve(target, obj, message, resolveContext, callback)
DirectoryExistsPlugin 判断文件夹是否存在
1 2 3 4 5 6 7 8 9 (request, resolveContext, callback) => { const fs = resolver.fileSystem; const directory = request.path; fs.stat(directory, (err, stat) => { if (err || !stat || !stat.isDirectory()) { return callback(); } resolver.doResolve(target, obj, "existing directory", resolveContext, callback) });
NextPlugin 直接引导到执行下一个插件,和TryNextPlugin区别在于有没有携带message日志
1 2 (request, resolveContext, callback) => { resolver.doResolve(target, obj, null, resolveContext, callback)
ResultPlugin 直接返回解析结果
1 2 3 4 (request, resolverContext, callback) => { // ... callback(null, request); }