zl程序教程

您现在的位置是:首页 >  工具

当前栏目

5. 路径解析:enhanced-resolve@4.5.0源码分析

源码 分析 解析 路径 resolve 4.5 enhanced
2023-06-13 09:15:13 时间

通过一个demo带你深入进入webpack@4.46.0源码的世界,分析构建原理,专栏地址,共有十篇。


书接上回: NormalModuleFactory中获取调用getResolver获取loaderResolvernormalResolver,getResolver会调用webpack/lib/ResolverFactory._create来创建,而最终的实际的创建工作在enhanced-resolve库的ResolverFactory.js文件中,同样利用工厂模式来创建Resolver实例。

// NormalModuleFactory.js
const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
webpack/lib/ResolverFactory.js
const Factory = require("enhanced-resolve").ResolverFactory;

_create(type, resolveOptions) {
    const originalResolveOptions = Object.assign({}, resolveOptions); 
    resolveOptions = this.hooks.resolveOptions.for(type).call(resolveOptions); 
    const resolver = Factory.createResolver(resolveOptions); 
    //...
}

这里type区分了loader和normal,loaderResolver就是用来解析查找loader指向的文件及其本地路径的,normal用来获取普通模块,比如import,require等引入的js文件。

两个resolver获取的主要差异在于resolveOptions,webpack分别提供了两个选项用来区别这俩个Options,分别是resolveLoader、resolve,resolveLoader关联的是loader的选项,resolve关联的normal的选项;这两个选项在WebpackOptionsDefaulter中提供了默认值,如下

另外在提供了WebpackOptionsApplyresolverFactory.hooks.resolveOptions上注册了事件函数,用来根据不同的type获取不同的resolveOptions,这里loader和normal是完全一样的,在默认选项基础上添加了fileSystem

下面介绍我们介绍normalResolver的获取和文件的解析,loaderResolver逻辑上是一致的,就不再赘述

enhanced-resolve的两个核心类

上面说到该库是利用工厂模式创建实例的,这里的工厂就是ResolverFactory,而要创建的对象类是Resolver

ResolverFactory

// ResolverFactory.js
exports.createResolver = function(options) {
    //...
    
    resolver = new Resolver(...);

    // 生成下面名称的hoos
    // resolve、parsedResolve、describedResolve、rawModule、module、relative
    // describedRelative、directory、existingDirectory、undescribedRawFile、rawFile
    // file、existingFile、resolved
    resolver.ensureHook("resolve"); // 省略其他名称的hook

    // 默认plugin、根据resolveOptions收集各种plugins
    // plugins.push(new XxxPlugin(source, target))

    // 插件注册
    plugins.forEach(plugin => {
       plugin.apply(resolver);
    });

    return resolver;
}

createResolver的主要工作是:创建Resolver实例;并根据resolveOptions收集各种插件实例;并最终注册这些插件,注册的时候提供了当前创建的resolver实例,使得这些插件实例和该resolver实例关联。

另外在createResolver看到resolver.ensureHook各种事件名称(resolve、parsedResolve、describedResolve、rawModule、module、relative、describedRelative、directory、existingDirectory、undescribedRawFile、rawFile、file、existingFile、resolved),ensureHook的作用就是确保创建对应的hook,并存储到this.hooks

// Resolver.js
ensureHook(name) {
    //...
    const hook = this.hooks[name];
    if (!hook) {
       return (this.hooks[name] = withName(
          name,
          new AsyncSeriesBailHook(["request", "resolveContext"])
       ));
    }
}

// 给创建的hook添加一个name
function withName(name, hook) {
   hook.name = name;
   return hook;
}

这里需要关注的是使用AsyncSeriesBailHook,保证了串行,并且当有注册的订阅函数返回undefined值时继续往后执行否则退出,这一点在后面介绍插件执行流程时会有体现。

另外,上面的一些列事件名称构成了一条流水线,每个事件名称都可以理解为流水线上的一个节点,每个节点都会去执行注册在该节点上的事件函数

当前案例中收集的插件如下:

那上下相邻的两个hook如何衔接的呢,上图中看到所有的plugin的构造函数都提供了sourcetarget参数(除了最后一个插件ResultPlugin,因为是最后一个直接结束当前的执行流返回到调用处),以ParsePlugin为例看下enhanced-resolve中的插件实现,构造函数中提供了sourcetarget两个hook name,source是当前插件注册的钩子名称,target是当前插件执行完后的进入的下一个hook

module.exports = 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);
         });
   }
};

ResolverFactory收集完所有的插件后最终会注册执行所有插件的apply方法,看到apply方法会在指定source的事件上进行事件订阅,订阅函数内部会再调用resovler.doResolver(target),该方法用于进入指定事件中(如这里的target事件),通过此种方式将所有的插件进行连接成链,实际上这里是责任链模式的完美体现。

Resolver

class Resolver extends Tapable {
    constructor(fileSystem) {
        this.fileSystem = fileSystem;
        this.hooks = {/*...*/}
    }

    // new AsyncSeriesBailHook(["request", "resolveContext"])
    ensureHook(name) {} // 生成指定名称的hook,并挂到this.hooks上
    getHook(name) {} // 从this.hooks上获取指定名称的hook

    // 解析入口,如 NormalModuleFactory -> normalResolver.resolve(...)
    resolve(context, path, request, resolveContext, callback) {
        // 从hooks.resolve开始
        return this.doResolve(this.hooks.resolve, ..., (err, result) => { ... })
    }
    // 正式解析
    doResolve(hook, request, message, resolveContext, callback) {
        //...
        return hook.callAsync(request, innerContext, (err, result) => {
            if (result) return callback(null, result);
        });
    }
}

Resolver中的ensureHookgetHook用来生成和获取hook,而resolve方法是暴露给调用方的,即调用方通过xxxResolver.resolve()开始解析工作,比如上一小节中的需要获取普通文件和loader的本地路径

// NormalModuleFactory.js
this.hooks.resolver.tap("NormalModuleFactory", () => (data, callback) => {
    //...
    normalResolver.resolve(...); // 解析普通文件
    //...
}

resolveRequestArray(contextInfo, context, array, resolver, callback) {
    //...
   return resolver.resolve(...); // 解析loader
   //...
}

小结

resolve事件开始到resolved事件结束,当前demo中normalResolver的完整的流水线图如下:

红色圈圈表示起始事件名称,绿色圈圈表示中间事件名称,方形中内容分别是插件名名称和该插件中的sourcetarget

图中省略了new-resolve事件,该事件不算是主流水线中的事件,在没有缓存的情况下resolve从ParsePlugin开始,有缓存的情况加添加一个衔接事件用来进行衔接。可忽略。

普通文件的解析流程及相关插件功能介绍

UnsafeCachePlugin

增加一层缓存,在进入下一个事件之前判断是否有缓存,有缓存则返回,没有缓存调用doResolve进入下一个事件开始解析。在回调中(整个解析操作完成后)设置最终的结果。

const cacheId = getCacheId(request, this.withContext);
const cacheEntry = this.cache[cacheId];
if (cacheEntry) { /*返回缓存*/ }  
resolver.doResolve(..., (err, result) => {
      if (result) return callback(null, (this.cache[cacheId] = result));
      callback();
   }
);

ParsePlugin

调用Resolver.parase()分离request为:path和query,并判断当前request是否是模块,是否是文件夹等信息 初始request(doResovle的第二个参数)

{
    "context": {
        "issuer": "/Users/.../src/simple/main.js"
    },
    "path": "/Users/.../src/simple",
    "request": "./a?c=d"
}

如下:

{
    "context": {
        "issuer": "/Users/.../src/simple/main.js"
     },
    "path": "/Users/.../src/simple",
    "request": "./a",
    "query": "?c=d",
    "module": false,
    "directory": false,
    "file": false
}

说下如何(初步)判断是否是模块、文件夹,主要是基于下面的正则

const REGEXP_NOT_MODULE = /^.$|^.[\/]|^..$|^..[\/]|^/|^[A-Z]:[\/]/i;

const REGEXP_DIRECTORY = /[\/]$/i; 

如果是下面模式则认为是模块(比如 import vue from 'vue' ),

1. '.'、
2. '.\xxx' './xx'
3. '..'、
4. '..\xxx' '../xxx'
5. '/xxx'
6. '[a-zA-Z]:[\/]xxx'

如果以/结尾则认为是文件夹。 介绍一个分析正则表达式的好工具,选择语言,填入表达式,在右侧会有详细的解释。

parsed-resolve 事件

DescriptionFilePlugin

该插件的功能是为了寻找描述文件(如package.json),首先会在 request.path 比如我们这里是/Users/.../src/simple这个目录下寻找package.json文件,如果没有找到这个文件则会按照路径一层一层往上寻找。最后读取到 package.json 的信息和其所在的目录/路径信息,存入 request 中

添加了如下字段,descriptionFileXxx表示了描述文件的信息(描述文件路径,描述文件内容)

获取到描述文件内容后,AliasFieldPlugin需要会用到这个数据。

NextPlugin

起一个衔接的作用,内部逻辑就是直接调用 doResolve,然后触发下一个事件。当 DescriptionFilePlugin 中未找到 package.json 文件时,会返回undefined值给AsyncSeriesBailHook,那么会继续进入下一个NextPlugin,然后让事件流继续。 如果找到了描述文件,则这个NextPlugin就一定不会执行吗?请读者思考(答案是可能会执行)。

described-resolve 事件

AliasFieldPlugin

type为normal情况下,aliasFields默认值为[browser]所以会添加AliasFieldPlugin,如果有多个则会创建多个该插件实例;

在AliasFieldPlugin插件中如果有命中的路径,说明请求路径发生变化需要重新解析则则回到 resolve事件重新来过。如果没有命中则返回undefined则进入下一个插件,在这里是ModuleKindPlugin

package.json中的browser字段的含义和用途

类似功能的插件还有AliasPlugin,AliasPlugin主要是基于webpack配置中resolve.alias,如果发现当前路径是alias中配置的key开始,则会进行路径替换,重新解析(进入resolve),如下。

// webpack.config.js
resolve: {
    alias: {
        '@': path.resolve(__dirname, '../src/simple/')
    }
},

// main.js
import {logA} from './custom-loaders/custom-inline-loader.js??share-opts!@/a?c=d'

// AliasPlugin.js
startsWith(innerRequest, item.name + "/") // item.name: '@'

// 然后替换路径,item.alias: /Users/.../src/simple
const newRequestStr = item.alias + innerRequest.substr(item.name.length);
// '@/a' -> '/Users/.../src/simple
const obj = Object.assign({}, request, {
   request: newRequestStr
});
return resolver.doResolve(target, ...) // target: resolve

ModuleKindPlugin

这里的模块可以认为是node_modules中模块的引用,比如import vue form 'vue',解析'vue'则会命中。

if (!request.module) return callback(); // 不是module

// 是module,则进入raw-module事件
const obj = Object.assign({}, request);
delete obj.module;
resolver.doResolve(target,...);

request.module 就是在ParsePlugin设置的的值;如果是 module,则后续进入raw-module的逻辑。当前demo中的'./a'不是module,则这里返回undefined进入下一个插件JoinRequestPlugin

如果是模块比如下面示例,则会命中进入ModulesInHierachicDirectoriesPlugin

import vue from 'vue'

ModulesInHierachicDirectoriesPlugin部分分析

JoinRequestPlugin

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); // target: resolve

路径连接:

// 之前:
path: "/Users/.../src/simple",
relativePath: "./src/simple",
request: "./a"

// 之后:
path: "/Users/.../src/simple/a"
relativePath: "./src/simple/a"
request: undefined

进入relative事件,因为路径发生了变化,进入DescriptionFilePlugin重新获取描述文件,然后进入FileKindPlugin

raw-module事件: ModulesInHierachicDirectoriesPlugin(如果是模块)

const fs = resolver.fileSystem;
// 分割路径,然后拼接所有可能的路径
const addrs = getPaths(request.path).paths.map(p => {
        return this.directories.map(d => resolver.join(p, d));
    })...
    
forEachBail(addrs, (addr, callback) => {
        fs.stat(addr, (err, stat) => {
            if (!err && stat && stat.isDirectory()) {
                // 如果找到则替换路径
                const obj = Object.assign({}, request, {
                    path: addr,
                    request: "./" + request.request
                });
                //...
                // 重新从resolve事件开始
                return resolver.doResolve(target, ...); // target: resolve
            }
            //...
        });
    },
    callback
);

依次在 request.path 的每一层目录中寻找 node_modules。那么寻找 node_modules 的过程为

"/Users/.../src/simple/node_modules"
"/Users/.../src/node_modules"
"/Users/.../node_modules"
// ...
"/Users/node_modules"
"/node_modules"

如果fs.stat找到了,则替换路径后重新回到 resolve 开始的阶段。但是这时 request.request 从一个 module 变成了一个普通文件类型./vue

path: "/Users/.../node_modules"
request: "./vue"

FileKindPlugin

if (request.directory) return callback();
const obj = Object.assign({}, request);
delete obj.directory;
resolver.doResolve(target, obj, null, resolveContext, callback);

是文件夹则返回undefined,进入下一个插件TryNextPlugin(relative,directory),随后直接进入directory事件;如果不是文件夹则认为是文件,进入raw-file事件的第一个插件TryNextPlugin(rawf-file,file),直接进入file事件一直走到FileExistsPlugin发现找不到文件(当前文件路径是:/Users/.../src/simple/a - 文件名称不对应该是a.js),所以这里的TryNextPlugin返回undefined进入到AppendPlugin

file事件

SymlinkPlugin

resolve.symlinks。默认值也是true。

用来处理路径中存在软链symbolic link)的情况。由于 webpack 默认是按照真实的路径来解析的,所以这里会检查路径中每一段,如果遇到软链,则替换为真实路径。

fs.readlink关注的是软链的情况。hard link and symbolic link

我们这里没有使用软链返回undefined,进入FileExistsPlugin

FileExistsPlugin

const fs = resolver.fileSystem; // compiler.inputFileSystem
fs.stat(file, (err, stat) => {/*...*/})
//webpack.js
new NodeEnvironmentPlugin({
   infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
// NodeEnvironmentPlugin.js
compiler.inputFileSystem = new CachedInputFileSystem(
   new NodeJsInputFileSystem(),
   60000
);

CachedInputFileSystem -> NodeJsInputFileSystem -> graceful-fs -> fs(高层在底层的基础上进行了一定的封装),CachedInputFileSystem提供了缓存能力。比如说readFile这个接口会将结果缓存下来,下次再次读取的时候,有效时间内,直接读取缓存结果就好,读写本地文件操作比较耗时,并且对于大型的项目中可能会涉及到大量的文件,因此缓存很有必要。

AppendPlugin

默认情况下提供了的extensions:[wasm, mjs, js, json],会一次添加这些后缀然后进行文件查找。显然当添加到.js后缀时可以找到文件/Users/.../src/simple/a.js

每个后缀都会生成一个AppendPlugin插件实例

const obj = Object.assign({}, request, {
   path: request.path + this.appending, // 关键:添加后缀
   relativePath: request.relativePath && request.relativePath + this.appending 
});
resolver.doResolve(target, obj, this.appending, resolveContext, callback);

这里的target指向file事件,后面的流程是:file -> FileExistsPlugin -> existing-file -> NextPlugin -> resolved -> ResultPlugin

resolved事件

ResultPlugin

const obj = Object.assign({}, request);
resolver.hooks.result.callAsync(obj, resolverContext, err => {
   if (err) return callback(err);
   callback(null, obj);
});

.... -> callback 到

// Reslolve.resolve() 
return this.doResolve(..., (err, result) => {
    if (!err && result) {
        return callback( null,
                // resource,资源的`本地路径`
                result.path === false ? false : result.path + (result.query || ""), 
                result
        );
    }
})

打完收工:将'./a'的解析结果即/Users/.../src/simple/a.js返回给调用者。

directory事件(引入的路径被判断为是文件夹)

显示在DirectoryExistsPlugin插件中通过resolver.fileSystem.state判断文件夹是否存在,如果存在则进入existing-directory事件,进入MainFieldPlugin插件

normalResolverresolveOptions.mainField的值为['browser', 'module', 'main'],每个item都会注册一个MainFieldPlugin实例,执行时从描述文件中读取该字段的值拿到拼接文件路径,然后进入DescriptionFilePlugin重新获取描述文件内容,到raw-file事件进入正常文件的解析流程中。

如果MainFieldPlugin返回undefined时则会进入UseFilePluginUseFilePlugin插件的作用是类似的,在文件夹后面后面直接拼接主入口文件名称,这里是index,然后继续后面流程。

这两个插件的作用都是在文件夹后面拼接一个文件名称,使得文件件路径变成文件路径,继续文件的解析流程。

总结

通过一系列插件(可扩展各种复杂情况)的接力式的执行解析原始路径为本地路径。

思考:这里有没有一些配置项可以优化来来提升构建性能。