从一个webpack打包bug到探索resolve背后的机制
背景
最近在业务项目配置升级改造的时候遇到了一个诡异的运行时报错
经过进一步调试发现,原因是在业务代码的lib/axios.ts
的import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
这行代码的引入居然是引入的 业务代码的lib/``axios``.ts
而不是node_modules
中的axios
由于项目使用的是司内一个二次封装webpack的框架,找相关同学排查后发现是tsconfig-paths-webpack-plugin
的一个bug导致。可以看到网上也有人提出了这个issue,并且可以看到有人也提了一个MR去修复这个问题。但是由于提出MR修复的老哥也表示不了解为啥这样改就好了,所作者也表示需要等有足够的单元测试才能将这个进行合并
https://github.com/dividab/tsconfig-paths-webpack-plugin/issues/83 https://github.com/dividab/tsconfig-paths-webpack-plugin/pull/85
来都来了,不如就研究清楚这个bug是如何发生的,以及如何去修复
webpack resolve plugin原理
首先tsconfig-paths-webpack-plugin
这个plugin是做啥的?
Use this to load modules whose location is specified in the paths section of tsconfig.json when using webpack. This package provides the functionality of the tsconfig-paths package but as a webpack plug-in.
Using this plugin means that you should no longer need to add alias entries in your webpack.config.js which correspond to the paths entries in your tsconfig.json. This plugin creates those alias entries for you, so you don't have to!
简单说,就是我们使用ts开发项目的时候通常需要配置tsconfig.compilerOptions.paths
。默认情况下webpack是不认识这个配置的,通过配置这个webpack插件,即可实现无需在webpack中配置resolve.alias
即可让webpack打包时根据tsconfig的paths找到对应的文件。它底层是是依赖了tsconfig-path
的createMatchPathAsync
函数实现这个模块路径的查找功能
还需要注意一点的是tsconfig-paths-webpack-plugin
是webpack的resolve plugin而不是常规的webpack plugin。两者的区别在于webpack plugin是配置在webpack的config.plugins
字段,而webpack resolve plugin是配置在config.resolve.plugins
字段。两种插件对应的api也不太一样,webpack plugin一般通过complier/compilation去监听生命周期处理webpack打包整个过程的一些行为;而resolve plugin则专注于处理模块resolve的过程。而webpack配置文件的config.resolve
字段基本是都传递给enhanced-resolve
这个库是实例化resolver的。webpack内部的模块解析打包的路径处理就是由enhanced-resolve去实现。
下图来自webpack源码的createResolver就是enhanced-resolve
提供的
在查看tsconfig-paths-webpack-plugin
的实现之前了解下enhanced-resolver
的架构有助于我们更好地去查bug。
enhanced-resolver
是一种基于core
+plugin
的运行机制。enhanced-resolver
主要提供一个基础的resolver
对象通过其resolve
方法去处理模块路径查找;提供plugin的机制,基于tapable
实现事件通信串联起webpack内部和resolver
plugin之间的关系。resolver通过调用resolve/doResolve
方法即可串联起来各个plugin进而实现模块查找。
resolver基本用法:可参考https://github.com/webpack/enhanced-resolve#creating-a-resolver
resolver的插件机制:
resolver对象在实例化时内部注册了4个hook对象,hook都是tabable的实例,用到的hook类型如下
resolve AsyncSeriesBailHook tapAsync/tapPromise/callAsync/promise 执行过程中注册的回调返回非 undefined 时就会直接执行 callAsync 或者 promise 中的函数,并且注册的后续回调都不会执行
resolveStep SyncHook 同步hook。call/tap
noResolve SyncHook 同步hook。call/tap
result AsyncSeriesHook tapAsync/tapPromise/callAsync/promise 顺序的执行注册的异步
回调
resolver的plugin其实就是遵循着从source
来target
去的一直执行流程。每个Plugin都是通过hook监听source
事件触发,执行完本插件的逻辑后触发target
事件到下一个对应的hook去。resolver的plugin之间的通信监听回调函数都是符合(request, resolveContext, callback: (err?: any, result: any) => void) => void
签名的格式
-
request:resolver模块请求对象,包含当前需要查找的文件的一些信息
-
resolveContext
贯穿整个流程的上下文对象 -
callback
函数,当前plugin执行完成后传递给下一个plugin的一些信息。result
就是下一个plugin接收到的request
如一个最简单的NextPlugin
的代码如下。按照约定,每个resolve plugin都有一个source和target属性。source表示当前plugin是在source事件触发后执行;target表示当前plugin执行后会触发target事件
// https://github.com/webpack/enhanced-resolve/blob/main/lib/NextPlugin.js
module.exports = class NextPlugin {
/**
* @param {string | ResolveStepHook} source source
* @param {string | ResolveStepHook} target target
*/
constructor(source, target) {
this.source = source;
this.target = target;
}
/**
* @param {Resolver} resolver the resolver
* @returns {void}
*/
apply(resolver) {
const target = resolver.ensureHook(this.target);
resolver
.getHook(this.source)
.tapAsync("NextPlugin", (request, resolveContext, callback) => {
resolver.doResolve(target, request, null, resolveContext, callback);
});
}
};
这个插件的逻辑就是监听source
对应的事件,执行resolver对象的doResolve
方法去查找模块,将结果传递给监听target
事件的plugin
// https://github.com/webpack/enhanced-resolve/blob/main/lib/ResolverFactory.js
//...
plugins.push(
new NextPlugin("after-undescribed-resolve-in-package", "resolve-in-package")
);
如上代码表示监听undescribed-resolve-in-package
触发完成后,执行doResolve
后触发resolve-in-package
事件。而enhanced-resolve本身基础功能的实现就是由若干个内置plugin一起实现
tsconfig-paths-webpack-plugin原理
经过上面的分析,这是一个resolve plugin,该plugin总体流程如下:
-
hook是由监听的 described-resolve
事件后触发,将结果传递给resolve
事件。 -
如果request请求的文件路径是 .
或者..
前缀,则为相对路径,plugin不作处理。直接跳过处理流程,应用webpack默认resolve流程 -
如果是非相对路径,则通过 matchPath
函数查找实际模块路径,若实际模块路径不存在直接跳过处理流程,应用webpack默认resolve流程;若存在将结果传递给resolve
hook进而找到模块
简化后主要逻辑伪代码如下
// https://github.com/dividab/tsconfig-paths-webpack-plugin/blob/master/src/plugin.ts#L230
function createPluginCallback(
matchPath: TsconfigPaths.MatchPathAsync,
resolver: Resolver,
absoluteBaseUrl: string,
hook: Tapable,
extensions: ReadonlyArray<string>
): TapAsyncCallback {
return (
request: ResolveRequest,
resolveContext: ResolveContext,
callback: TapAsyncInnerCallback
) => {
// 锚点1
const innerRequest = getInnerRequest(resolver, request);
// 锚点2
if (
!innerRequest ||
innerRequest.startsWith(".") ||
innerRequest.startsWith("..")
) {
return callback();
}
// 锚点3
matchPath(
innerRequest,
readJsonAsync,
fileExistAsync,
extensions,
(err, foundMatch) => {
if (err) {
return callback(err);
}
if (!foundMatch) {
return callback();
}
const newRequest = {
...request,
request: foundMatch,
path: absoluteBaseUrl,
};
return resolver.doResolve(
hook,
newRequest,
{},
{},
(err2: Error, result2: ResolveRequest): void => {
callback(undefined, result2);
}
);
}
);
};
}
出现bug的原因
最简复现demo
配置`compilerOptions.baseUrl = "./src" 建一个 src/lib/axios
文件src/index
中import axios from 'axios'
BUG表现,index中import的axios居然是lib/axios
!而不是node_modules中的,就会导致了错误。
通过断点调试加上面的代码逻辑解读可以找出bug的原因
-
我们在 index.ts
import了axios,而axios的入口文件node_modules/axios/index.js
有以下代码
module.exports = require('./lib/axios');
此时,到了resolve查找./lib/axios
的流程。进入到锚点1的getInnerRequest
函数,该函数做了一个处理并返回。而此时的request.relativePath
值为.
, innerRequest
值为./lib/axios
。它们join后的结果就是lib/axios
。
innerRequest = resolver.join(request.relativePath, innerRequest);
通过锚点3的matchPath
函数传入lib/axios
参数进行查找,显然这个时候查找的结果就是src/lib/axios
文件了,此时这个文件在项目中又是存在的。于是,import axios
最终就是import了src/lib/axios
,这就产生了开头提的bug。这里的根本原因是axios入口文件内部的./lib/axios
被错误处理了,按道理说这种相对路径导入是不应该被alias插件处理的。我们可以看到锚点2是有判断innerRequest
是否相对路径,但是基于上面的分析可以知道getInnerRequest
的结果必然是非相对路径的(resolver.join
的背后其实是path.join
的封装)。
至于为何request.relativePath
是.
,通过上面的分析可知,tsconfig-paths-webpack-plugin
是由described-resolve
hook事件触发,在enhanced-resolve
中可以找到触发described-resolve
的是DescriptionFilePlugin
对应的relativePath
其实含义就是当前被request文件相对于所在包的路径,对于node_modules/axios
的index.js来说就是.
因此,锚点2用getInnerRequest
的返回值来判断是否相对路径是个bug。getInnerRequest
会将相对路径的request
和relativePath
进行join导致丢失了前面的相对路径前缀
正确的解法则是应该用request.request
去判断,对于相对路径则plugin跳过处理。request.request
对应就是源代码中的引入路径的部分
解决
解决的PR已经有了。但是维护者暂时还没合进。直接通过暴力修改文件名的方式虽然能暂时解决这个问题,但是这难免以后会再次踩坑。这时候可以通过patch-package
方式来解决node_modules
带来的问题
-
package.json
添加"postinstall": "patch-package"
npm i -D patch-package
-
直接到 node_modules/tsconfig-paths-webpack-plugin/lib/plugin.js
对应位置修改为正确的代码 -
执行 npx patch-package tsconfig-paths-webpack-plugin
-
此时会生成一个patch文件,将这个文件一同提交到git仓库
-
运行代码,没有再出现开头所示的报错,bug解除