vlambda博客
学习文章列表

从一个webpack打包bug到探索resolve背后的机制

背景

最近在业务项目配置升级改造的时候遇到了一个诡异的运行时报错

经过进一步调试发现,原因是在业务代码的lib/axios.tsimport 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

从一个webpack打包bug到探索resolve背后的机制

来都来了,不如就研究清楚这个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-pathcreateMatchPathAsync函数实现这个模块路径的查找功能

还需要注意一点的是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提供的

从一个webpack打包bug到探索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其实就是遵循着从sourcetarget去的一直执行流程。每个Plugin都是通过hook监听source事件触发,执行完本插件的逻辑后触发target事件到下一个对应的hook去。resolver的plugin之间的通信监听回调函数都是符合(request, resolveContext, callback: (err?: any, result: any) => void) => void 签名的格式

  • request:resolver模块请求对象,包含当前需要查找的文件的一些信息

从一个webpack打包bug到探索resolve背后的机制


  • 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一起实现

从一个webpack打包bug到探索resolve背后的机制

tsconfig-paths-webpack-plugin原理

经过上面的分析,这是一个resolve plugin,该plugin总体流程如下:

  1. hook是由监听的 described-resolve事件后触发,将结果传递给 resolve事件。
  2. 如果request请求的文件路径是 .或者 ..前缀,则为相对路径,plugin不作处理。直接跳过处理流程,应用webpack默认resolve流程
  3. 如果是非相对路径,则通过 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

  1. 配置`compilerOptions.baseUrl = "./src"
  2. 建一个 src/lib/axios文件
  3. src/indeximport axios from 'axios'

BUG表现,index中import的axios居然是lib/axios!而不是node_modules中的,就会导致了错误。

通过断点调试加上面的代码逻辑解读可以找出bug的原因

  1. 我们在 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的封装)。

从一个webpack打包bug到探索resolve背后的机制

至于为何request.relativePath.,通过上面的分析可知,tsconfig-paths-webpack-plugin是由described-resolve hook事件触发,在enhanced-resolve中可以找到触发described-resolve的是DescriptionFilePlugin

从一个webpack打包bug到探索resolve背后的机制


对应的relativePath其实含义就是当前被request文件相对于所在包的路径,对于node_modules/axios的index.js来说就是.

从一个webpack打包bug到探索resolve背后的机制

因此,锚点2用getInnerRequest的返回值来判断是否相对路径是个bug。getInnerRequest会将相对路径的requestrelativePath 进行join导致丢失了前面的相对路径前缀

正确的解法则是应该用request.request去判断,对于相对路径则plugin跳过处理。request.request对应就是源代码中的引入路径的部分

解决

解决的PR已经有了。但是维护者暂时还没合进。直接通过暴力修改文件名的方式虽然能暂时解决这个问题,但是这难免以后会再次踩坑。这时候可以通过patch-package方式来解决node_modules带来的问题

  1. package.json添加 "postinstall": "patch-package"
npm i -D patch-package
  1. 直接到 node_modules/tsconfig-paths-webpack-plugin/lib/plugin.js对应位置修改为正确的代码
  2. 执行 npx patch-package tsconfig-paths-webpack-plugin
  3. 此时会生成一个patch文件,将这个文件一同提交到git仓库


  1. 运行代码,没有再出现开头所示的报错,bug解除