从“webpack 路径解析失败”看“失败”的架构智慧

367 阅读6分钟

一、前文回顾

本文讲述了 webpack 在 resolve 阶段解析 内联 loader 的细节工作,主要包含了一些内容:


class NormalModuleFactory extends ModuleFactory {
    constructor () {
        this.hooks.resolve.tapAsync(  
            {  
                name: "NormalModuleFactory",  
                stage: 100  
            },  
            (data, callback) => {
                // 1. 解析内联  loader 的过程
                const loaderResolver = this.getResolver("loader");
                let loaders;
                const continueCallback = needCalls(2, err => {});
                this.resolveRequestArray(
                    contextInfo,
                    contextScheme ? this.context : context,
                    elements,
                    loaderResolver,
                    resolveContext,
                    (err, result) => {
                        loaders = result;
                        continueCallback();
                    }
                );
            }
        );
    
    }
}
  1. needCall 这个小方法的意义,以及它背后的设计理念;
  2. NormalModuleFactory.prototype.resolveRequestArray 方法的实现:并发调用 resolver.reslve 解析,得到解析结果后调用 _parseResourceWithoutFragment 方法后处理解析结果后返回;
  3. 最后我们分享了 webpack 内部对于 内联loader 从设计到实现的一致性,这一点非常值得推荐;

下面我们开始进入更精彩的环境——常规 loader 的解析以及 loader 顺序的确定还有资源模块的解析!

二、模块的资源路径解析

接上文,我们大致回归一下之前的代码,之前我们的代码说道 this.resolveRequestArray(elements, loader Resolver);


class NormalModuleFactory extends ModuleFactory {
    constructor () {
        this.hooks.resolve.tapAsync(  
            {  
                name: "NormalModuleFactory",  
                stage: 100  
            },  
            (data, callback) => {
                // 1. 解析内联  loader 的过程
                // ....
                
                // 2.
                defaultResolver();
            }
        );
    
    }
}

下面的我们的重点放在 defaultResolver 这个方法中!

2.1 defaultResolver

defaultResolver 函数用于解析资源模块的路径,以下是经过简化后的代码:

const defaultResolve = context => {
    if (/^($|\?)/.test(unresolvedResource)) {
       // 忽略 resource with scheme and with path
    }  else {
        // 1.
        const normalResolver = this.getResolver(
            "normal",
            dependencyType
                ? cachedSetProperty(
                    resolveOptions || EMPTY_RESOLVE_OPTIONS,
                    "dependencyType",
                    dependencyType
                  )
                : resolveOptions
        );
        
        // 2.
        this.resolveResource(
            contextInfo,
            context,
            unresolvedResource,
            normalResolver,
            resolveContext,
            (err, resolvedResource, resolvedResourceResolveData) => {
                // 3.
                if (resolvedResource !== false) {
                    // 4.
                    resourceData = {
                        resource: resolvedResource,
                        data: resolvedResourceResolveData,
                        ...cacheParseResource(resolvedResource)
                    };
                }
                
                // 5.
                continueCallback();
            }
        );
    }
};

我们把这个过程分为 5 个步骤:

  1. 调用 nmf.getResolver 方法创建 normalResolver 对象,第一个代表 resolver 类型的参数是 “normal”,注意以后 normal 就表示普通模块的 resolver,有别于 loader resolver;
  2. 调用 nmf.resoveResource 方法,解析资源路径 —— unresolvedResource(这个是前面经处理后的request,不含内联 loader 和 match-resource);
  3. 在 nmf.resolveResource 的回调中判断 resolvedResource 即解析结果是否 不为 false,如果不为 false 说明解析成功,如果是 false 则说明失败了;
  4. 在 resolvedResource 不为 false 后,把解析结果赋值给 resourceData 对象;注意这里没有给回调,所以后面取用模块资源路径要从 reesolveResource 中获取!
  5. 调用 continueCallback 函数,这个函数是前面由 needCall 创建的计数回调控制器,调用2次后就会自动执行预期回调;

2.2 nmf.resolveResource

该方法由 NormalModuleFactory 实现,用于解析模块的资源路径用。

class NormalModuleFactory extends ModuleFactory {
    // ...
    
    resolveResource(
        contextInfo,
        context,
        unresolvedResource,
        resolver,
        resolveContext,
        callback
    ) {
        // 1.
        resolver.resolve(
            contextInfo,
            context,
            unresolvedResource,
            resolveContext,
            (err, resolvedResource, resolvedResourceResolveData) => {
                if (err) {
                    // 2.
                    return this._resolveResourceErrorHints(
                        err,
                        contextInfo,
                        context,
                        unresolvedResource,
                        resolver,
                        resolveContext,
                        (err2, hints) => {
                            // 
                        });
                }
                
                // 3.
                callback(err, resolvedResource, resolvedResourceResolveData);
            }
        );
    }
}

2.2.1 参数

  1. contextInfo:包含构建信息的上下文信息;
  2. context:当前项目的目录
  3. unresolvedResource:待解析的资源路径
  4. resolver:用于解析 3. unresolvedResource 的解析器;
  5. resolveContext:resolver 解析时用到的上下文对象,这里面包含 file/missing/context Dependencies 三种依赖类型;
  6. callback:受理解析结果的回调函数;

下图为 src/index.js 这个模块解析时传入的实参:

image.png

2.2.2 逻辑

整体逻辑比较简单,我们把这个过程分为 3 个步骤:

  1. 调用 resolver.resolve() 方法启动对 unresolvedResource 的解析工作;
  2. 在 resolver.resolve() 的回调函数中判断是否有错误,若有则调用 this._resolveResourceErrorHints() 方法处理错误信息;
  3. 调用结果受理回调 callback 将解析所得 resolvedResource, resolvedResourceResolveData 返回;

至此我们得到了模块资源的路径。

2.4 nmf._resolveResourceErrorHints

该方法用于处理解析失败时的错误信息,webpack 这个处理也是个相当有意思的地方,它不是直接把 enhanced-resolve 没能得到结果的错误直接抛出。而是预判了三种容易出现的路径设置错误,如果是这种三种错误的话,webpack 会给出友好的提示!下面我们看看他是怎么实现的:

class NormalModuleFactory extends ModuleFactory {
    // ...
    
    
    _resolveResourceErrorHints(
            error,
            contextInfo,
            context,
            unresolvedResource,
            resolver,
            resolveContext,
            callback
    ) {
        asyncLib.parallel(
            [
                callback => {
                    // 1.
                    // 测试 fullySpecified
                },
                callback => {
                    // 2.
                    // 测试 enforceExtension

                },
                callback => {
                    // 3.
                    // 测试  preferRelative
                }
            ],
            (err, hints) => {
                if (err) return callback(err);
                callback(null, hints.filter(Boolean));
            }
        );
    }
}

2.4.1 整体逻辑

整体上来看相当简单,调用 asyncLib.parallel 方法并行调用,以下为 parallel 语法:

asyncLib.parrell(\[runnerA, runnerB, ...runerN], cb) 

第一个参数是由并行运行的函数组成的数组,这些 runner 将会被并行执行,当这些 runner 都执行结束后调用 cb;

结合上面的代码就可知,整体上并行预判是否是 fullySpecified 错误、enforceExtension 错误、preferRelative 错误;

最后把错误提示信息给到 callback;

2.4.2 错误预判

  1. 首先需要知道 fullySpecified 是干啥的?

它是一个 webpack 中的 resolver 配置选项,默认值 boolean = true,启用后,如果在 .mjs 文件或其他 .js 文件中导入模块,且该报的 package.json 中包含 "type" 字段,且值为 "module"时,你必须为此文件提供扩展名。

  1. 我们看看它是怎么判断的? 这种事儿听着十分唬人,如果面试的时候给你一道面试题限时5分钟:

如果快速判断一个路径解析失败时是否是由 resovle.fullySpecified 检查?

这里就为我们提供了一个简单粗暴但有效的判别思路:

控制变量法

把 resolve.fullySpecified 选项置为 false,相当于关闭这个特性,再 resolve 一遍,如果 resolve 到了,就说明是因为命中了 fullySpecified 导致。

这让我想到了 燕双鹰 经典名场面:我赌你的枪里没有子弹!

image.png

只需要扣动扳机(再 resolve 一遍),枪响了(resolve 成功)大洋归你,枪不响(没有 resolve 成功)你输给我一块大洋。

下面我们看看这个用代码怎么实现:


callback => {
    // 1.
    if (!resolver.options.fullySpecified) return callback();
   
    resolver
            .withOptions({ // 2.
                    fullySpecified: false
            })
            .resolve( // 3.
                    contextInfo,
                    context,
                    unresolvedResource,
                    resolveContext,
                    (err, resolvedResource) => {
                        // 4.
                        if (!err && resolvedResource) {
                            // 
                        }
                    }
            );
},
  1. 判断 resolver.options.fullySpecified 是否已经禁用,若已经禁用则没必要再进行测试了;
  2. 调用 resolver.withOptions() 修改 fullySpecified 设置,将其置为 false;
  3. 最后调用 resolver.resolve 再次尝试解析;
  4. 在 resolver.resolve() 的回调中判断 if (!err && resolvedResource) 标识解析成功,这里成功则预示着我们代码里面写的有错误;

使用上面的方式,webpack 在这个方法里面依次完成了以下三个判别:

  1. fullySpecified:package.js 设置 type 为 module,但是导入时不写扩展名;
  2. enforceExtension:设置强制路径必须写扩展名但是路径不写的;
  3. preferRelative:设置相对路径优先,但是不写相对路径的;

三、总结

本文主要讨论了 webpack 在创建模块的 resolve 阶段解析模块资源路径的过程,主要有以下细节:

  1. 通过 defaultResolver 去解析模块的资源路径;
  2. nmf.resolveResouce 方法用于解析资源路径;
  3. nmf._resolveResourceErrorHints 用于处理解析失败的信息,内部通过控制变量的方式预判三种易犯错误类型,并给出友好提示;

到这里我们已经完成了内联 loader、资源路径的解析工作,下文我们进入到 webpack 内部对于常规 loader 的计算及解析过程。