webpack系列之三resolve

avatar
@滴滴出行

作者:崔静

介绍

webpack 的特点之一是处理一切模块,我们可以将逻辑拆分到不同的文件中,然后通过模块化方案进行导出和引入。现在 ES6 的 Module 则是大家最常用的模块化方案,所以你一定写过 import './xxx' 或者 import 'something-in-nodemodules' 再或者 import '@/xxx'(@ 符号通过 webpack 配置中 alias 设置)。webpack 处理这些模块引入 import 的时候,有一个重要的步骤,就是如何正确的找到 './xxx''something-in-nodemodules' 或者 '@/xxx' 等等对应的是哪个文件。这个步骤就是 resolve 的部分需要处理的逻辑。

其实不仅是针对源码中的模块需要 resolve,包括 loader 在内,webpack 的整体处理过程中,涉及到文件路径的,都离不开 resolve 的过程。

同时 webpack 在配置文件中有一个 resolve 的配置,可以对 resolve 的过程进行适当的配置,比如设置文件扩展名,查找搜索的目录等(更多的参考官方介绍)。

下面,将主要介绍针对普通文件的 resolve 流程 和 loader 的 resolve 主流程。

resolve 主流程介绍

首先先准备一个简单的 demo

import { A } from './a.js'

然后针对这个 demo 来看主流程。在 webpack 系列之一总览 文章中有一个 webpack 编译总流程图,图中可以看到在 webpack 处理每一个文件开始之前都会有一个 resolve 的过程,找到完整的文件路径信息。

webpack编译流程

webpack 源码中 resolve 流程开始的入口在 factory 阶段, factory 事件会触发 NormalModuleFactory 中的函数。先放一张粗略的总体流程图,在深入源码前现有一个大概的框架图

resolve总览

接下来我们就从 NormalModuleFactory.js 文件中开始看起

this.hooks.factory.tap("NormalModuleFactory", () => (result, callback) => {
    // 首先得到 resolver
	let resolver = this.hooks.resolver.call(null);

	// Ignored
	if (!resolver) return callback();

   // 执行
	resolver(result, (err, data) => {
		if (err) return callback(err);

		// Ignored
		if (!data) return callback();

		// direct module
		if (typeof data.source === "function") return callback(null, data);

		this.hooks.afterResolve.callAsync(data, (err, result) => {
			//... resolve结束后流程,此处省略
		});
	});
});

第一步获得 resolver 逻辑比较简单,触发 resolver 事件(SyncWaterfallHook类型的Hook,关于Hook的类型,可以参考上一篇文章),同时 NormalModuleFactory 中注册了 resolver 事件。下面是 resolver 事件的代码,可以看到返回了一个函数。

this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
  //...先展示省略具体内容,后面会详细解释。
})

因此 this.hooks.resolver.call(null); 结束后,将得到一个函数。然后接下来就是执行该函数获得 resolver 结果。 resolver 函数中,从整体看分为两大主要流程 loader 和 文件。

loader流程

  1. 获取到 inline loader 的 request 部分。例如,针对如下写法
import Styles from 'style-loader!css-loader?modules!./styles.css';

会从中解析出 style-loadercss-loader。由于此步骤只是为了解析出路径,所以对于 loader 的配置部分并不关心。

  1. 得到 loader 类型的 resolver 处理实例,即 const loaderResolver = this.getResolver("loader");

  2. 对每一个 loader 用 loaderResolver 依次处理,得到执行文件的路径。

文件流程

  1. 得到普通文件的 resolver 处理实例,即代码 const normalResolver = this.getResolver("normal", data.resolveOptions);

  2. 用 normalResolver 处理文件,得到最终文件绝对路径

下面是具体的 resolver 代码:

this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
	const contextInfo = data.contextInfo;
	const context = data.context;
	const request = data.request;
   
   // ... 省略部分和 loader 处理相关的代码
   // 处理 inline loaders,拿到 loader request 部分(loader 的名称或者 loader 的路径,由于这里不关系 loader 的配置等其他细节,所以直接将开头的 -!, 和 ! 直接替换掉,将多个 ! 替换成一个,方便后面处理)
	let elements = request
		.replace(/^-?!+/, "")
		.replace(/!!+/g, "!")
		.split("!");
	let resource = elements.pop();
	// 提取出具体的 loader
	elements = elements.map(identToLoaderRequest);

	const loaderResolver = this.getResolver("loader");
	const normalResolver = this.getResolver("normal", data.resolveOptions);

	asyncLib.parallel(
		[
			callback =>
				this.resolveRequestArray(
					contextInfo,
					context,
					elements,
					loaderResolver,
					callback
				),
			callback => {
				if (resource === "" || resource[0] === "?") {
					return callback(null, {
						resource
					});
				}

				normalResolver.resolve(
					contextInfo,
					context,
					resource,
					{},
					(err, resource, resourceResolveData) => {
						if (err) return callback(err);
						callback(null, {
							resourceResolveData,
							resource
						});
					}
				);
			}
		],
		(err, results) => {
		  // ... reslover callback
		})
	)
})
		

结合上面的步骤和代码看,其实 loader 类和普通文件类型(后面称为 normal 类),大致流程是相似的。我们先看获取不同类型的 resolver 实例部分。

获取不同类型 resolver 处理实例

getResolver 函数,会调用到 webpack/lib/ResolverFactory.js 中的 get 方法。该方法中获取 resolver 实例的具体流程如下图。

获取resolver实例

上图中,首先根据不同 type 获取 options 。那么这些 options 配置都存在哪里呢?

webpack中options配置

webpack 直接对外暴露的 resolve 的配置,在配置文件中 resolve 和 resolveLoader 部分,详细的字段见官网。但是其内部会有一个默认的配置,在 webpack.js 入口处理函数中,初始化了所有的默认配置

// ...
if (Array.isArray(options)) {
	compiler = new MultiCompiler(options.map(options => webpack(options)));
} else if (typeof options === "object") {
	options = new WebpackOptionsDefaulter().process(options);
	compiler = new Compiler(options.context);
	compiler.options = options;
// ...

WebpackOptionsDefaulter() 中,配置了很多关于 resolve 和 resolveLoader 的配置。process 方法将我们写的 webpack 的配置 和默认的配置合并。

// WebpackOptionsDefaulter.js 文件
//...
this.set("resolve", "call", value => Object.assign({}, value));
this.set("resolve.unsafeCache", true); // 默认开启缓存
this.set("resolve.modules", ["node_modules"]); // 默认从 node_modules 中查找
// ...

webpack.js 中,接下来有一句

new WebpackOptionsApply().process(options, compiler);

其中 process 过程里会注入关于 normal/context/loader 的默认配置的获取函数。

  compiler.resolverFactory.hooks.resolveOptions
  	.for("normal")
  	.tap("WebpackOptionsApply", resolveOptions => {
  		return Object.assign(
  			{
  				fileSystem: compiler.inputFileSystem
  			},
  			options.resolve,
  			resolveOptions
  		);
  	});
  compiler.resolverFactory.hooks.resolveOptions
  	.for("context")
  	.tap("WebpackOptionsApply", resolveOptions => {
  		return Object.assign(
  			{
  				fileSystem: compiler.inputFileSystem,
  				resolveToContext: true
  			},
  			options.resolve,
  			resolveOptions
  		);
  	});
  compiler.resolverFactory.hooks.resolveOptions
  	.for("loader")
  	.tap("WebpackOptionsApply", resolveOptions => {
  		return Object.assign(
  			{
  				fileSystem: compiler.inputFileSystem
  			},
  			options.resolveLoader,
  			resolveOptions
  		);
  	});

options 介绍到此先结束,我们继续沿着上面流程图往下看。当获取到 resolver 实例后,就开始 resolver 的过程:根据类型的不同,会有 normalResolver 和 loaderResolver,同时在 normalResolver 中会区分文件和 module。

webpack 中有很多针对路径的配置,例如 alias, extensions, modules 等等,node.js 中的 require 已经无法满足 webpack 对路径的解析的要求。因此,webpack 封装出一个单独的库 enhanced-resolve,专门用来处理各种路径的解析,仍然采用了 webpack 的插件模式来组织代码。 接下来会深入到这个库中,依次介绍普通文件、module 和 loader 的处理过程(webpack 中还有一个 context 的 resolve 过程,由于其过程没太多特别之处,放在 module 过程中一起介绍)。先看普通文件的处理过程。

普通文件的 resolve 过程

普通文件 resolver 处理入口为 webpack 中 normalResolver.resolve 方法,而整个 resolve 过程可以看成事件的串联,当所有串联在一起的事件执行完之后,resolve 就结束了。

resolve事件环

将这些事件一个一个串联起来的关键部分在 doResolve 和每个事件的处理函数中。这里以 doResolve 和调用的 UnsafePlugin 为例,看一下衔接的过程。

// 第一个参数 hook,函数中用到的 hook 是通过参数传进来的。
doResolve(hook, request, message, resolveContext, callback) {
	// ...
	// 生成 context 栈。
	const stackLine = hook.name + ": (" + request.path + ") " +
		(request.request || "") + (request.query || "") +
		(request.directory ? " directory" : "") +
		(request.module ? " module" : "");

	let newStack;
	if(resolveContext.stack) {
		newStack = new Set(resolveContext.stack);
		if(resolveContext.stack.has(stackLine)) {
			// Prevent recursion
			const recursionError = new Error("Recursion in resolving\nStack:\n  " + Array.from(newStack).join("\n  "));
			recursionError.recursion = true;
			if(resolveContext.log) resolveContext.log("abort resolving because of recursion");
			return callback(recursionError);
		}
		newStack.add(stackLine);
	} else {
		newStack = new Set([stackLine]);
	}
	// 简单的demo中这里没有事件注册,先忽略
	this.hooks.resolveStep.call(hook, request);

    // 如果该hook有注册过事件,则调触发该 hook
	if(hook.isUsed()) {
		const innerContext = createInnerContext({
			log: resolveContext.log,
			missing: resolveContext.missing,
			stack: newStack
		}, message);
		return hook.callAsync(request, innerContext, (err, result) => {
			if(err) return callback(err);
			if(result) return callback(null, result);
			callback();
		});
	} else {
		callback();
	}
}

调用到 hook.callAsync 时,进入 UnsafeCachePlugin,然后看 UnsafeCachePlugin 中部分实现:

class UnsafeCachePlugin {
	constructor(source, filterPredicate, cache, withContext, target) {
		this.source = source;
       // ... 省略部分
		this.target = target;
	}
	apply(resolver) {
	   // ensureHook 主要逻辑:如果 resolver 已经有对应的 hook 则返回;如果没有,则会给 resolver 增加一个 this.target 类型的 hook
		const target = resolver.ensureHook(this.target);
		// getHook 会根据 this.source 字符串获取对应的 hook
		resolver.getHook(this.source).tapAsync("UnsafeCachePlugin", (request, resolveContext, callback) => {
		   //... 先省略 UnsafeCache 中其他逻辑,只看衔接部分
			// 继续调用 doResolve,但是注意这里的 target 
			resolver.doResolve(target, request, null, resolveContext, (err, result) => {
				if(err) return callback(err);
				if(result) return callback(null, this.cache[cacheId] = result);
				callback();
			});
		});
	}
}

UnsafeCachePlugin 分为两部分:事件注册(new 和 执行apply) 和事件执行(resolver.getHook(this.source).tapAsync 的回调部分)。事件注册阶段发在 webpack 获取不同类型 resolve 处理实例时(前面获取不同类型 resolver 处理实例小节中,getResolver 的时候),这时会传入一个 source 值(字符串类型)和一个 target 值(字符串类型),代码如下

// source 值为 resolve,target 值为 new-resolve
new UnsafeCachePlugin("resolve", cachePredicate, unsafeCache, cacheWithContext, "new-resolve")`
//...然后会调用 apply 方法

apply 中,将 UnsafeCachePlugin 的处理逻辑注册为 source 事件的回调,同时确保 target 事件的存在(如果没有则注册一个)。

事件执行阶段,完成 UnsafeCachePlugin 本身的逻辑之后,递归调用 resolver.doResolve(target, ...),这时第一个参数为 UnsafeCachePlugin 中的 target 事件。如此,再进入到 doResolve 之后,再触发 target 的事件,这样就形成了事件流。而整体的调用过程,简化来看整体逻辑就是:

doResolve(target1) 
  -> target1 事件(srouce:target1, target: target2) 
  -> 递归调用doResolve(target2) 
     -> target2 事件(srouce:target2, target: target3) 
     -> 递归调用doResolve(target3) 
        -> target3 事件(srouce:target3, target: target4) 
        ...
        ->遇到递归结束标识,结束递归
     

通过对 doResolve 的递归调用,事件之间就衔接了起来,形成完整的处事件流,最终得到 resolve 结果。在 ResolverFactory.js 文件的 createResolver 方法中各个 plugin 的注册方法,决定了整个 resolve 的事件流。

exports.createResolver = function(options) {
    // ...
	// 根据 options 中条件的不同,加入各种 plugin
	if(unsafeCache) {
		plugins.push(new UnsafeCachePlugin("resolve", cachePredicate, unsafeCache, cacheWithContext, "new-resolve"));
		plugins.push(new ParsePlugin("new-resolve", "parsed-resolve"));
	} else {
		plugins.push(new ParsePlugin("resolve", "parsed-resolve"));
	}
    // ... plugin 加入的代码
	plugins.forEach(plugin => {
		plugin.apply(resolver);
	});
	// ...

上面代码整理一下,可以得到完整的事件流图(下图为简化版本,完成版本附图)

resolve事件流简版

结合上面的图和 demo,我们来一步一步看这个事件流中每一环都做了什么。(ps:下面步骤中,会涉及到 request 参数,这个参数贯穿所有事件处理逻辑,保存了整个 resolve 的信息)

  1. UnsafeCachePlugin

增加一层缓存,由于 webpack 处理打包的过程中,涉及到大量的 resolve 过程。所以需要增加一层缓存,提高效率。webpack 默认会启用 UnsafeCache。

  1. ParsePlugin

    初步解析路径,判断是否为 module/directory/file,结果保存到 request 参数中。

  2. DescriptionFilePlugin 和 NextPlugin

    DescriptionFilePlugin 中会寻找描述文件,默认会寻找 package.json。首先会在 request.path 这个目录下寻找,如果没有则按照路径一层一层往上寻找。最后读取到 package.json 的信息和其所在的目录/路径信息,存入 request 中。我们在 demo 的根目录有 package.json 文件,所以这里会获取到根目录的文件。

    NextPlugin 起一个衔接的作用,内部逻辑就是直接调用 doResolve,然后触发下一个事件。当 DescriptionFilePlugin 中未找到 package.json 文件时,会进入 NextPlugin,然后让事件流继续。

  3. AliasPlugin/AliasFieldPlugin

这一步开始处理别名,由于 AliasFieldPlugin 中依赖于 package.json 的配置,所以这一步放在了 DescriptionFilePlugin 之后。 除了我们在配置文件中写一些别名外,webpack 还会有一些自带的 alias;每一个 alias 配置,都会注册一个函数。这一步将执行所有的函数,一一对比。 若命中某一 alias 的配置或者 aliasField,那么就会进入上图红色虚线的分支。用新的别名替换 request 参数内容,然后再次开始 resolve 过程。 没有命中,则进入下一个处理函数 ModuleKindPlugin

  1. ModuleKindPlugin

根据 request.module 的值走不同的分支。如果是 module,则后续进入 rawModule 的逻辑。前面 ParsePlugin 中得到的结果中 request.modulefalse,所以这里返回 undefined,继续进入下一个处理函数。

  1. JoinRequestPlugin

将 request 中 path 和 request 合并起来,将 request 中 relativePath 和 request 合并起来,得到两个完整的路径。在这个 demo 中会得到 /Users/didi/dist/webpackdemo/webpack-demos/demo01/a.js./demo01/a.js

  1. DescriptionFilePlugin

这时会再次进入 DescriptionFilePlugin 。不过与第一次进入时不同之处在于,此时的 request.path 变成了 /dir/demo/a.js`。由于 path 改变了,所以需要再次查找一下 package.json

随后触发 describedRelative 事件,进入下一个流程

  1. FileKindPlugin

判断是否为一个 directory,如果是则返回 undefined, 进入下一个 tryNextPlugin,这时会进入 directory 的分支。否则,则表明是一个文件,进入 rawFile 事件。我们的 demo 中,这里将走向 rawFile 分支。

  1. TryNextPlugin/ConcordExtensionsPlugin/AppendPlugin

由于 webpack 中默认的 enforceExtension 值为 true,所以这里会进入 TryNextPlugin,同时 enableConcord 为 false,不会有 ConcordExtensionsPlugin。

TryNextPlugin 和 NextPlugin 类似,起一个衔接的作用,内部逻辑就是直接调用 doResolve,然后触发下一个事件。所以在这个阶段会直接走到触发 file 事件的分支。 当 TryNextPlugin 有返回,且返回为 undefined 。这时意味着没有找到 request.path 所对应的文件,那么会继续执行后续的 AppendPlugin。

AppendPlugin 主要逻辑:webpack 会设置 resolve.extensions 参数(配置中设置或者使用 webpack 默认的),AppendPlugin 会给 request.path 和 request.relativePath 逐一添加这些后缀,然后进入 file 分支,继续事件流程。

  1. AliasPlugin/AliasFields/ConcorModulesPlugin/SymlinkPlugin

这时会再次进入到 Alias 的处理逻辑,注意在此步中 webpack 内部自带的很多 Alias 不会再有。 与前面相同,这里依然没有 ConcorModulesPlugin SymlinkPlugin 用来处理路径中存在 link 的情况。由于 webpack 默认是按照真实的路径来解析的,所以这里会检查路径中每一段,如果遇到 link,则替换为真实路径。由于 path 改变了,所以会再回到 relative 阶段。 若路径中没有 link,则进入 FileExistsPlugin

  1. FileExistsPlugin

读取 request.path 所在的文件,看文件是否存在。文件存在则进入到 existingFile 事件。

  1. NextPlugin/ResultPlugin

通过 NextPlugin 衔接,再进入 Resolved 事件。然后执行 ResultPlugin,到此 resolve 整个流程就结束了,request 保存了 resolve 的结果。

module 的 resolve 过程

在 webpack 中,我们除了会 import 一个文件以外,还会 import 一个模块,比如 import Vue from 'vue'。那么这时候,webpack 就需要正确找到 vue 所对应的入口文件在哪里。针对 vue,ParsePlugin 结果中 request.module = true,随后在 ModuleKindPlugin 就会进入上面图中 rawModule 的分支。我们就以 import Vue from 'vue' 为 demo,看一下 rawModule 分支流程。

  1. ModuleAppendPlugin/TryNextPlugin

ModuleAppendPlugin 和上面的 AppendPlugin 类似,添加后缀。 TryNextPlugin 进入 module 事件

  1. ModulesInHierachicDirectoriesPlugin/ModulesInRootPlugin

ModulesInHierachicDirectoriesPlugin 中会依次在 request.path 的每一层目录中寻找 node_modules。例如 request.path = 'dir/demo' 那么寻找 node_modules 的过程为:

dir/demo/node_modules
dir/node_modules
/node_modules

如果 dir/demo/node_modules 存在,则修改 request.path 和 request.request

const obj = Object.assign({}, request, {
  	path: addr, // node_module 所在的路径
  	request: "./" + request.request
  });

对于 ModulesInRootPlugin,则默认为在根目录下寻找,直接进行替换

  const obj = Object.assign({}, request, {
  	path: this.path,
  	request: "./" + request.request
  });

随后,由于改变了 request.path 和 request.request,所以重新回到 resolve 开始的阶段。但是这时 request.request 从一个 module 变成了一个普通文件类型./vue

  1. 与普通文件 resolve 过程分叉点

按照普通文件的方式查找 dir/demo/node_module/vue 的过程与前文中普通文件 resolve 过程类似,经历上一节中 1-7 的步骤,然后触发 describedRelative 事件(这个事件下注册了两个函数 FileKindPlugin 和 TryNextPlugin)。 首先进入 FileKindPlugin 的逻辑,由于 dir/demo/node_module/vue 不是一个文件地址,所以在第 8 步 FileKindPlugin 中最终会返回 undefined。 这时候会进入下一个处理事件 TryNextPlugin,然后触发 directory 事件,把 dir/demo/node_module/vue 按照文件夹的方式来解析。

  1. DirectoryExisitsPlugin

确认 dir/demo/node_module/vue 是否存在。(ps: 针对 context 的 resolve 过程,到这里如果文件夹存在,则就结束了。)

  1. MainFieldPlugin

webpack 默认的 mainField 为 ['browser', 'module', 'main']。这里会按照顺序,在 dir/demo/node_module/vue/package.json 中找对应字段。 vue 的 package.json 中定义了

{
  "module": "dist/vue.runtime.esm.js"
}

所以找到该字段后,会将 request.request 的值替换为 ./dist/vue.runtime.esm.js。之后又回到 resolve 节点,开始新一轮,寻找一个普通文件 ./dist/vue.runtime.esm.js 的过程。 当 MainFieldPlugin 执行完,都没有结果时,会进入 UseFilePlugin

  1. UseFilePlugin

当我们 package.json 中没有写 browser、module、main 时,webpack 会自动去找目录下的 index 文件,request 变成如下

{
  //...省略其他部分
  relativePath: "./index",
  path: 'dir/demo/node_modules/vue/index'
}

然后触发 undescribedRawFile 事件

  1. DescriptionFilePlugin/TryNextPlugin

针对新的 request.path ,重新寻找描述文件,即 package.json

  1. AppendPlugin

依次为 'dir/demo/node_modules/vue/index' 添加后缀名,然后寻找该文件是否存在。与前文中 file 之后的流程相同。直到最后找到存在的文件,整个针对 module 的 resolve 过程就结束了。

loader 的 resolve 过程

loader 的 resolve 过程和 module 的过程类似,我们以 url-loader 为例,入口在 NormalModuleFactory.js 中 resolveRequestArray 函数。这里会执行 resolver.resolve,这里的 resolver 为之前得到的 loaderResolver,resolve 过程开始时 request 参数如下:

{
  context: {
    compiler: undefined,
    issuer: "/dir/demos/main.js"
  },
  path: "/dir/demos"
  request: "url-loader"
}

在 ParsePlugin 中,request: "url-loader" 会被解析为 module。随后过程中整个和 module 执行流程相同。

到此 webpack 中关于 resolve 流程就结束了。除此之外 webpack 还有不少的细节处理,鉴于篇幅有限这里就不展开细细讨论了,大家可以结合文章看 webpack 代码时去细细品味。

从原理到优化

webpack 中每涉及到一个文件,就会经过 resolve 的过程。而 resolve 过程中其中针对一些不确定的因素,比如后缀名,node_modules 路径等,会存在探索的过程,从而使得整个 resolve 的链条很长。很多针对 webpack 的优化,都会提到利用 resolve 配置来减少文件搜索范围:

  1. 使用 resolve.alias

我们日常开发项目中,常常会存在类似 common 这样的目录,common 目录下的文件,会被经常引用。比如 'common/index.js'。如果我们针对 common 目录建立一个 alias 的话,在所有用到 'common/index.js' 的文件中,可以写 import xx from 'common/index.js'。 由于 UnsafeCachePlugin 的存在,当 webpack 再次解析到 'common/index.js' 时,就可以直接使用缓存。

不止如此,重点是解析链条变短,缓存只是一部分吧

  1. 设置 resolve.modules

resolve.modules 的默认值为 ['node_modules'],所以在对 module 的 resolve 过程中,会依次查找 ./node_modules、../node_modules、../../node_modules 等,即沿着路径一层一层往上找,直到找到 node_modules。可以直接设置

resolve.modules:[path.resolve(__dirname, 'node_modules')]

如此会进入 ModulesInRootPlugin 而不是 ModulesInHierachicDirectoriesPlugin,避免了层层寻找 node_modules 的开销。

  1. 对第三方模块设置 resolve.alias

对第三方的 module 进行 resolve 过程中,除了上面提到的 node_modules 目录查找过程,还会涉及到对 package.json 中配置的解析等。可以直接为其设置 alias 为执行文件,来简化整个 resolve 过程,如下:

resolve.alias: {
    'vue': path.resolve(__dirname, './node_modules/vue/dist/vue.common.js')
}
  1. 合理设置 resolve.extensions,减少文件查找

当我们的文件没有后缀时,AppendPlugin 会根据 resolve.extensions 中的值,依次添加后缀然后查找文件。为了减少文件查找,我们可以直接将文件后缀写上,或者设置 resolve.extensions 中的值,列表值尽量少频率高的文件类型的后缀写在前面

明白了 resolve 的细节之后,再来看这些优化策略,便可以更好的了解其原因,做到“知其然知其所以然”。

附图(resolve事件流完整版):

resolve事件流完整版