3
本文内容基于webpack 5.74.0enhanced-resolve 5.12.0版本进行分析

webpack5核心流程专栏共有5篇,使用流程图的形式分析了webpack5的构建原理

  1. 「Webpack5源码」make阶段(流程图)分析
  2. 「Webpack5源码」enhanced-resolve路径解析库源码分析
  3. 「Webpack5源码」seal阶段(流程图)分析(一)
  4. 「Webpack5源码」seal阶段分析(二)-SplitChunksPlugin源码
  5. 「Webpack5源码」seal阶段分析(三)-生成代码&runtime

由于enhanced-resolve兼容了多种复杂情况的解析,想要将这些情况进行详细分析太耗费精力,因此本文只是尝试将所有流程进行浅显分析,通过本文,你可以对webpackresolve配置有一个整体的了解,如果对这方面想更加深入地研究,请结合其它文章进行阅读

本文是「Webpack5源码」make阶段(流程图)分析的补充文章,如果对webpack流程不熟悉,请先看「Webpack5源码」make阶段(流程图)分析

文章内容

  1. 简要介绍webpack是如何使用enhanced-resolve进行路径解析
  2. 分为三个流程图展示enhanced-resolve的解析流程,并为每一个流程图简单描述整体流程

整体流程分析

Webpack5-3-make-resolve(1).svg
从上图可以知道,我们在webpack解析过程中,会初始化this.getResolve("loader")this.getResolve("normal")

resolve一共分为两种配置,一种是文件类型的路径解析配置,一种是 loader 包的解析配置,比如上图中的内联url解析出来的到"babel-loader""./index_item-line""babel-loader"会使用getResolve("loader")进行解析,"./index_item-line"会使用getResolve("normal")进行解析

必须是内联url才会触发resolveRequestArray()defaultResolve()?是的,从源码上看只有解析到"-!""!"!!"才会使得unresolvedResourceelements两个数据不为空,才会触发resolveRequestArray()defaultResolve()

如果是import _ from "loadsh"会不会触发getResolve("loader")进行解析?答案是不会的

typenormalloader
resolveOptions截屏2023-03-13 19.42.30.png截屏2023-03-13 19.42.57.png

其中normal的参数获取是合并了webpack.config.js和默认options的结果
入口文件是EntryDependency,它具有默认的category="esm"

class EntryDependency extends ModuleDependency {
    /**
     * @param {string} request request path for entry
     */
    constructor(request) {
        super(request);
    }
    get type() {
        return "entry";
    }
    get category() {
        return "esm";
    }
}

而在初始化normal类型的Resolver时,会触发hooks.resolveOptions进行webpack.config.js和一些默认参数的初始化

// node_modules/webpack/lib/ResolverFactory.js
_create(type, resolveOptionsWithDepType) {
    /** @type {ResolveOptionsWithDependencyType} */
    const originalResolveOptions = { ...resolveOptionsWithDepType };

    const resolveOptionsTemp = this.hooks.resolveOptions.for(type).call(resolveOptionsWithDepType);
    const resolveOptions = convertToResolveOptions(
        resolveOptionsTemp
    );
}
// node_modules/webpack/lib/WebpackOptionsApply.js
compiler.resolverFactory.hooks.resolveOptions
    .for("normal")
    .tap("WebpackOptionsApply", resolveOptions => {
        resolveOptions = cleverMerge(options.resolve, resolveOptions);
        resolveOptions.fileSystem = compiler.inputFileSystem;
        return resolveOptions;
    });

this.hooks.resolveOptions.for(type).call(resolveOptionsWithDepType)获取到的数据如下图所示

截屏2023-03-13 19.34.13.png

然后触发convertToResolveOptions()方法,经历几个方法的调用执行后,最终触发resolveByProperty(),如下图所示,会根据上面EntryDependency得到的"esm"进行参数的合并,最终得到完整的配置参数

截屏2023-03-13 19.09.03.png


然后使用resolver.resolve()进行路径的解析

// node_modules/enhanced-resolve/lib/Resolver.js
resolve(context, path, request, resolveContext, callback) {
    return this.doResolve(this.hooks.resolve, ...args);
}
// node_modules/enhanced-resolve/lib/Resolver.js
doResolve(hook, request, message, resolveContext, callback) {
    const stackEntry = Resolver.createStackEntry(hook, request);
    if (resolveContext.stack) {
        newStack = new Set(resolveContext.stack);
        newStack.add(stackEntry);
    } else {
        newStack = new Set([stackEntry]);
    }
    return hook.callAsync(request, innerContext, (err, result) => {
        if (err) return callback(err);
        if (result) return callback(null, result);
        callback();
    });
}

一开始调用doResolve()时,我们传入的第一个参数hook=this.hooks.resolve,我们从上面代码块可以知道,会直接触发hook.callAsync,也就是this.hooks.resolve.callAsync()

我们在初始化Resolver时,会调用ResolverFactory.createResolver,因此我们在调试这个流程时只要看这个方法即可,这个方法注册了很多Plugin插件,每一个Plugin对其(多个)下家Plugin的引用而连接起来形成一条链。 请求在这个链上传递,如果符合其中一个下家Plugin的条件,则以符合条件下家Plugin为基础,传递该请求(到下下家Plugin),直到某一条链走到底部
ResolverFactory.createResolver = function (options) {
    const normalizedOptions = createOptions(options);
    // resolve
    for (const { source, resolveOptions } of [
        { source: "resolve", resolveOptions: { fullySpecified } },
        { source: "internal-resolve", resolveOptions: { fullySpecified: false } }
    ]) {
        if (unsafeCache) {
            plugins.push(
                new UnsafeCachePlugin(
                    source,
                    cachePredicate,
                    unsafeCache,
                    cacheWithContext,
                    `new-${source}`
                )
            );
            plugins.push(
                new ParsePlugin(`new-${source}`, resolveOptions, "parsed-resolve")
            );
        } else {
            plugins.push(new ParsePlugin(source, resolveOptions, "parsed-resolve"));
        }
    }
}

每一个Plugin都符合单一职责原则,比如有的Plugin就是专门处理alias问题,有的Plugin处理文件后缀问题(添加.js后缀)

初始流程

Webpack5-enhanced-resolve-1.svg

ParsePlugin

核心代码就是使用resolver.parse(request.request)方法,解析出目前路径所属的类型以及所携带的参数

其中fragment的定义可以参考URI's fragmentfragment就是hash
// node_modules/enhanced-resolve/lib/Resolver.js
parse(identifier) {
    const part = {
        request: "",
        query: "",
        fragment: "",
        module: false,
        directory: false,
        file: false,
        internal: false
    };
    const parsedIdentifier = parseIdentifier(identifier);
    if (!parsedIdentifier) return part;
    [part.request, part.query, part.fragment] = parsedIdentifier;
    if (part.request.length > 0) {
        part.internal = this.isPrivate(identifier);
        part.module = this.isModule(part.request);
        part.directory = this.isDirectory(part.request);
        if (part.directory) {
            part.request = part.request.substr(0, part.request.length - 1);
        }
    }
    return part;
}

其中requestqueryfragment的解析使用的是正则表达式匹配拿到对应的值

这正则表达式也太长了=_=
// node_modules/enhanced-resolve/lib/util/identifier.js
const PATH_QUERY_FRAGMENT_REGEXP = /^(#?(?:\0.|[^?#\0])*)(\?(?:\0.|[^#\0])*)?(#.*)?$/;
function parseIdentifier(identifier) {
    const match = PATH_QUERY_FRAGMENT_REGEXP.exec(identifier);
    if (!match) return null;
    return [
        match[1].replace(/\0(.)/g, "$1"),
        match[2] ? match[2].replace(/\0(.)/g, "$1") : "",
        match[3] || ""
    ];
}

使用一个简单的示例可以帮助我们更好地理解正则表达式匹配出什么东西

至于这个正则表达式能否匹配出更加复杂的路径,请参考其它文章进行了解

截屏2023-03-12 21.41.23.png

DescriptionFilePlugin

const directory = this.pathIsFile
    ? DescriptionFileUtils.cdUp(path)
    : path;
DescriptionFileUtils.loadDescriptionFile(
    resolver,
    directory,
    this.filenames,
    request.descriptionFilePath
        ? {
            path: request.descriptionFilePath,
            content: request.descriptionFileData,
            directory: /** @type {string} */ (request.descriptionFileRoot)
        }
        : undefined,
    resolveContext,
    (err, result) => {
        const relativePath =
            "." + path.substr(result.directory.length).replace(/\\/g, "/");
        const obj = {
            ...request,
            descriptionFilePath: result.path,
            descriptionFileData: result.content,
            descriptionFileRoot: result.directory,
            relativePath: relativePath
        };
        resolver.doResolve(target, obj, ...);
    }
);

DescriptionFileUtils.cdUp(path): 获取最后一个"/"的位置,然后使用path.substr(0, position),获取directory

比如传入path="/Users/A/B/js-enhanced_resolve",获取到的是directory="/Users/A/B"

DescriptionFileUtils.loadDescriptionFile: 先使用directory拼接this.filenames[i]进行描述文件的查找,如果找不到,则将directory往上一级变更,即从"/Users/A/B"->"/Users/A",然后再拼接this.filenames[i]进行描述文件的查找

this.filenames的内容是什么呢?

初始化DescriptionFilePlugin时会传入this.filenames,具体的值为createOptions()创建默认配置时的descriptionFiles参数,默认为["package.json"]

function createOptions(options) {
    return {
        descriptionFiles: Array.from(
            new Set(options.descriptionFiles || ["package.json"])
        )
    }
}

延续上面示例,我们通过该Plugin可以得到的数据如下所示,其中descriptionFileDatapackage.json的具体内容

relativePath="." + request.path.substr(descriptionFileRoot.length).replace(/\\/g, "/")

截屏2023-03-13 01.04.50.png

AliasPlugin

主要核心点如下图所示
截屏2023-03-13 01.25.23.png

  • innerRequest = request.request || request.path
  • item.name是我们在webpack.config.js中配置的别名alias,比如在这个例子中,我们配置了alias如下所示,因此我们能拿到item.name="aliasTest"
// webpack.config.js
alias: {
    aliasTest: resolve(__dirname, 'src/item'),
}

// src/entry1.js
import {getG} from "aliasTest/common_____g";
  • 别名替换的规则是,我们的路径innerRequest.startsWith("aliasTest"),也就是我们路径的第一个单词必须是别名,才能正常匹配
  • 然后就是替换逻辑,将"aliasTest"替换为我们在webpack.config.js配置的路径,形成新的路径newRequestStr
  • 最后替换我们的obj数据中对应的request参数,然后进入下一个Plugin

AliasFieldPlugin

根据环境的不同,使用不同的配置,比如浏览器环境和node环境
默认type="normal"会初始化aliasFields=['browser']
typenormalloader
resolveOptions截屏2023-03-13 19.42.30.png截屏2023-03-13 19.42.57.png

如果webpack.config.js配置了aliasFields,则最终normal类型的resolveOptions会直接使用webpack.config.js配置的aliasFields

// webpack.config.js
resolve: {
    alias: {
        aliasTest: resolve(__dirname, 'src/item'),
    },
    aliasFields: ['node222'],
}

截屏2023-03-14 00.25.19.png
同时我们还需要在package.json中配置对应的aliasFields属性,比如下面的代码,当我们找到文件路径为src/dir时,我们就会替换为node_src/dir

// package.json
{
    "node222": {
        "src/dir": "node_src/dir"
    }
}

AliasFieldPluginapply()方法解析如下,我们可以获取到一个文件路径为"src/dir",然后我们通过判断fieldData={"src/dir": "node_src/dir"}是否满足fileldData[innerRequest]触发替换规则

截屏2023-03-14 00.58.57.png

最终将替换成功后的data覆盖obj.request路径,原本的路径如上图所示是"src/dir",现在被替换为"node_src/dir",然后触发下一个Plugin

AliasFieldPlugin这个属性个人理解为可以根据不同环境,比如根据browser/node环境分别进行文件路径替换,比如入口文件:browser环境为browser/index.jsnode环境为node/index.js,详细用法请参考其它文章,本文不会做过多的分析
const obj = {
    ...request,
    path: request.descriptionFileRoot,
    request: data,
    fullySpecified: false
};
resolver.doResolve(target, obj, ...)

ExtensionAliasPlugin

// webpack.config.js
module.exports = {
  //...
  resolve: {
    extensionAlias: {
      '.js': ['.ts', '.js'],
      '.mjs': ['.mts', '.mjs'],
    },
  },
};

// node_modules/enhanced-resolve/lib/ResolverFactory.js
extensionAlias.forEach(item =>
  plugins.push(
    new ExtensionAliasPlugin("raw-resolve", item, "normal-resolve")
  )
);

具体的apply()代码如下所示,流程也比较简单

  • 判断目前requestPath.endsWith(extension)是否成立,比如"./entry1.js"满足配置的extensionAlias中的".js",因此会命中继续下面的逻辑
  • 判断拿到的alias类型,比如命中extension='.js',那么alias=['.ts', '.js'],会使用forEachBail()遍历尝试每一种情况替换是否满足提议
  • 最终结果调用stoppingCallback()方法,比如"./entry1.js"->"./entry1.ts""./entry1.js"->"./entry1.js",最终只有"./entry1.js"->"./entry1.js"满足提议,调用stoppingCallback()方法
const target = resolver.ensureHook(this.target);
const { extension, alias } = this.options;
resolver
    .getHook(this.source)
    .tapAsync("ExtensionAliasPlugin", (request, resolveContext, callback) => {
        const requestPath = request.request;
        if (!requestPath || !requestPath.endsWith(extension)) return callback();
        const resolve = (alias, callback) => {
            resolver.doResolve(
                target,
                {
                    ...request,
                    request: `${requestPath.slice(0, -extension.length)}${alias}`,
                    fullySpecified: true
                },
                `aliased from extension alias with mapping '${extension}' to '${alias}'`,
                resolveContext,
                callback
            );
        };

        const stoppingCallback = (err, result) => {
            if (err) return callback(err);
            if (result) return callback(null, result);
            // Don't allow other aliasing or raw request
            return callback(null, null);
        };
        if (typeof alias === "string") {
            resolve(alias, stoppingCallback);
        } else if (alias.length > 1) {
            forEachBail(alias, resolve, stoppingCallback);
        } else {
            resolve(alias[0], stoppingCallback);
        }
    });

模块处理

Webpack5-enhanced-resolve-2.svg

ConditionalPlugin

一种条件判断的Plugin,全部代码如下所示,初始化会传入一个条件,判断是否满足这个条件,则可以继续下一个Plugin

for (const prop of keys) {
    if (request[prop] !== test[prop]) return callback();
}
resolver.doResolve(target,request,...)

举一个例子,下面初始化时传入{ module: true },因此如果这个文件是module类型,那么遇到这个ConditionalPlugin则符合条件,那么它就会从"after-normal-resolve"->"raw-module"ConditionalPlugin起到一种根据处理类型不同从而跳转到不同Plugin的目的

// node_modules/enhanced-resolve/lib/ResolverFactory.js
plugins.push(
    new ConditionalPlugin(
        "after-normal-resolve",
        { module: true },
        "resolve as module",
        false,
        "raw-module"
    )
);
plugins.push(
    new ConditionalPlugin(
        "after-normal-resolve",
        { internal: true },
        "resolve as internal import",
        false,
        "internal"
    )
);

RootsPlugin

// webpack.config.js
const fixtures = path.resolve(__dirname, 'fixtures');
module.exports = {
  //...
  resolve: {
    roots: [__dirname, fixtures],
  },
};

处理resolve.roots参数,判断目前的请求路径是否以"/"开头,如果是,则说明是根目录开头的文件,会将roots[i]拼到文件路径上,形成新的path=roots[i]+request.request

// RootsPlugin apply
if (!req.startsWith("/")) return callback();
forEachBail(
    this.roots,
    (root, callback) => {
        const path = resolver.join(root, req.slice(1));
        const obj = {
            ...request,
            path,
            relativePath: request.relativePath && path
        };
        resolver.doResolve(
            target,
            obj,
            `root path ${root}`,
            resolveContext,
            callback
        );
    },
    callback
);

JoinRequestPlugin

改变pathrelativePathrequest

  • path=path+request
  • relativePath=relativePath+request
  • request=undefined

本质是将目前请求的路径加上请求文件的名称,形成请求文件的绝对路径

const obj = {
    ...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);

举个例子,如下图所示,request.request都并入了request.pathrequest.relativePath
截屏2023-03-14 02.37.53.png


ImportsFieldPlugin(简单介绍)

具体配置可以参考webpack官方文档的resolve.importsFields描述

处理resolve.importsFields字段
aliasFields一样,需要在package.json配置字段(配置字段名称跟webpack.config.js声明一致),该字段用于提供包的内部请求(以# 开头的请求被视为内部请求)

感兴趣请参考其它文章,比如 Node最新Module导入导出规范,本文不会深入研究
下面的说明摘录自文章 Node最新Module导入导出规范

imports 字段中的入口必须是以 # 开头的字符串。
导入映射允许映射外部包。这个字段定义了当前包的子路径导入。

// webpack.config.js
module.exports = {
  //...
  resolve: {
    //如果不手动配置下面,默认为['imports']
    importsFields: ['browser', 'module', 'main'],
  },
};
// package.json
{
  "imports": {
    "#dep": {
      "node": "dep-node-native",
      "default": "./dep-polyfill.js"
    }
  }
}

SelfReferencePluginPlugin(简单介绍)

具体配置可以参考https://webpack.js.org/guides/package-exports

处理resolve.exportsFields
aliasFields一样,需要在package.json配置字段(配置字段名称跟webpack.config.js声明一致),用于解析模块请求的字段

感兴趣请参考其它文章,比如 Node最新Module导入导出规范,本文不会深入研究
下面的说明摘录自文章 Node最新Module导入导出规范

"exports" 字段可以定义包的入口,而包则可以通过 node_modules 查找或自引用导入。Node.js 12+ 开始支持 "exports",作为 "main"的替代,它既支持定义子路径导出和条件导出,又封闭了内部未导出的模块。
条件导出也可以在 "exports" 中使用,以定义每个环境中不同的包入口,包括这个包是通过 require 还是通过 import 引入。
所有在 "exports" 中定义的路径必须是以 ./ 开头的相对路径URL。

// webpack.config.js
module.exports = {
  //...
  resolve: {
    //如果不手动配置下面,默认为['exports']
    exportsFields: ['exports', 'myCompanyExports'],
  },
};
// package.json
{
  "exports": {
    "./features/*": "./src/features/*.js"
  }
}

ModulesInHierarchicalDirectoriesPlugin

sourcetarget是上下Plugin的连接,这个在每一个Plugin都具备
directories默认为["node_modules"]

class ModulesInHierarchicalDirectoriesPlugin {
    constructor(source, directories, target) {
        this.source = source;
        this.directories = /** @type {Array<string>} */ ([]).concat(directories);
        this.target = target;
    }
}
// apply()方法
const fs = resolver.fileSystem;
const addrs = getPaths(request.path)
    .paths.map(p => {
        return this.directories.map(d => resolver.join(p, d));
    })
    .reduce((array, p) => {
        array.push.apply(array, p);
        return array;
    }, []);
console.warn(addrs);
forEachBail(
    addrs,
    (addr, callback) => {
        fs.stat(addr, (err, stat) => {
            if (!err && stat && stat.isDirectory()) {
                const obj = {
                    ...request,
                    path: addr,
                    request: "./" + request.request,
                    module: false
                };
                const message = "looking for modules in " + addr;
                return resolver.doResolve(
                    target,
                    obj,
                    message,
                    resolveContext,
                    callback
                );
            }
            if (resolveContext.log)
                resolveContext.log(
                    addr + " doesn't exist or is not a directory"
                );
            if (resolveContext.missingDependencies)
                resolveContext.missingDependencies.add(addr);
            return callback();
        });
    },
    callback
);

如上面代码块所示,我们会使用getPaths()获取所有node_modules目录的可能性,如下图所示,我们会从path逐级向上遍历,拼凑出pathssegments
截屏2023-03-15 10.23.42.png
然后我们利用getPaths()paths数据,拼凑出所有node_modules可能的绝对路径(如下图addrs所示),最终我们通过某一个绝对路径是否是目录,来获取其中某一个路径addr,然后覆盖目前的obj,形成第三方node_modules库的绝对路径,如下图所示的obj.path+obj.request,就第三方库loadsh的绝对路径截屏2023-03-15 10.34.59.png

ModulesInRootPlugin

在初始化过程中,如果modulesitem是数组类型,则触发上面分析的ModulesInHierarchicalDirectoriesPlugin
如果modulesitem不是数组类型,则触发ModulesInRootPlugin

//node_modules/enhanced-resolve/lib/ResolverFactory.js
exports.createResolver = function (options) {
    modules.forEach(item => {
        if (Array.isArray(item)) {
            //...
            plugins.push(
                new ModulesInHierarchicalDirectoriesPlugin(
                    "raw-module",
                    item,
                    "module"
                )
            );
        } else {
            plugins.push(new ModulesInRootPlugin("raw-module", item, "module"));
        }
    });
}

ModulesInRootPluginapply代码也比较粗暴简单,直接认为你传入的item=modules[i]就是node_modules的绝对路径目录,跟ModulesInHierarchicalDirectoriesPlugin相比较,减少了所有可能性目录的寻找以及判断是否是目录stat.isDirectory()的逻辑

const obj = {
    ...request,
    path: this.path,
    request: "./" + request.request,
    module: false
};
resolver.doResolve(
    target,
    obj,
    "looking for modules in " + this.path,
    resolveContext,
    callback
);

JoinRequestPartPlugin

检测import的库是否存在多层目录,比如不是"vue",而是"vue/test",将path拼接为原来的path+前缀的"/vue"relativePath拼接为原来的relativePath+前缀目录名"/vue"request拼接为路径的最后一部分,为"./test"

moduleName就是第三方库的目录名称,remainingRequest就是除去第三方库的目录名称的剩余部分

截屏2023-03-15 11.43.07.png


DirectoryExistsPlugin

直接判断request.path是否是目录,是的话就继续下一个Plugin

// DirectoryExistsPlugin apply
const fs = resolver.fileSystem;
const directory = request.path;
if (!directory) return callback();
fs.stat(directory, (err, stat) => {
    //...省略很多不是directory的提示逻辑
    if (resolveContext.fileDependencies)
        resolveContext.fileDependencies.add(directory);
    resolver.doResolve(
        target,
        request,
        `existing directory ${directory}`,
        resolveContext,
        callback
    );
});

ExportsFieldPlugin(简单介绍)

SelfReferencePluginPlugin(简单介绍)的作用一致,本文不深入研究,请读者自行选择其它文章进行研究
具体配置可以参考https://webpack.js.org/guides/package-exports

处理resolve.exportsFields
aliasFields一样,需要在package.json配置字段(配置字段名称跟webpack.config.js声明一致),用于解析模块请求的字段

目录/文件路径处理

Webpack5-enhanced-resolve-3.svg

UseFilePlugin

处理resolve.mainFiles数据,如果不在webpack.config.js中设置该参数,默认初始化为["index"]

// webpack.config.js
module.exports = {
  //...
  resolve: {
    mainFiles: ['test'],
  },
};

源码也非常简单,进行obj.path=原来path+mainFile的拼接,进行obj.relativePath=原来relativePath+mainFile的拼接,拼接后形成一个文件请求路径,然后进行文件类型的解析

// UseFilePlugin apply
const filePath = resolver.join(request.path, this.filename);
const obj = {
    ...request,
    path: filePath,
    relativePath:
        request.relativePath &&
        resolver.join(request.relativePath, this.filename)
};
resolver.doResolve(
    target,
    obj,
    "using path: " + filePath,
    resolveContext,
    callback
);

举一个具体的例子如下图所示,我们请求的是一个import {dirIndex} from "./dir",这个链接我们是没有声明xx.js文件的,UseFilePlugin会自动帮我们补齐最后面的index成为import {dirIndex} from "./dir"
截屏2023-03-15 15.01.12.png

UseFilePlugin是目录类型的处理,也就是说如果具备了xxx.js是不会走到目录这一条链路的,如下图所示,先进行了目录是否存在的判断,然后才进行index.js的添加

Webpack5-enhanced-UseFilePlugin.svg

MainFieldPlugin

处理resolve.mainFields数据,如果不在webpack.config.js中设置该参数,默认初始化为["main"]

注意!UseFilePlugin处理的是resolve.mainFiles!跟MainFieldPlugin处理的resolve.mainFields是不同名字的!
// webpack.config.js
module.exports = {
  //...
  resolve: {
    mainFields: ['browser', 'module', 'main'],
  },
};

aliasField一样,同样需要在package.json中配置对应的参数
比如bable-loader的描述文件在xxxx/node_modules/babel-loader/package.json
babel-loader/package.json"main"声明为"lib/index.js"

// package.json
{
    "name": "babel-loader",
    "version": "9.1.0",
    "description": "babel module loader for webpack",
    "files": [
      "lib"
    ],
    "main": "lib/index.js",
}

截屏2023-03-15 16.27.28.png
那如果没有在package.json中声明"main"所对应的值,比如下面main: "",那会发生什么?

// package.json
{
    "name": "babel-loader",
    "version": "9.1.0",
    "description": "babel module loader for webpack",
    "files": [
      "lib"
    ],
    "main": ""
}

那就会不走这个MainFieldPlugin,而是直接走上面分析的UseFilePlugin,直接在后面添加一个index.js

UseFilePlugin的分析可以知道,UseFilePluginMainFieldPlugin是并行的两个Plugin

AppendPlugin

上面几个Plugin都是目录类型处理的Plugin,现在开始文件类型处理的Plugin
// webpack.config.js
module.exports = {
  //...
  resolve: {
    extensions: ['.ts', '.js', '.wasm'],
  },
};

处理resolve.extensions参数,如果不在webpack.config.js中设置该参数,默认为[".js", ".json", ".node"],但是convertToResolveOptions()方法由于"esm"进行参数的合并,因此resolve.extensions参数不为空,会取[".js", ".json", ".wasm"]

截屏2023-03-13 19.09.03.png

整体代码逻辑也非常简单,直接用一张示例图就可以明白,就是不断往目前的请求路径加后缀,看看哪一个符合就进入下一个阶段

截屏2023-03-15 16.39.53.png

FileExistsPlugin

直接判断request.path是否存在该文件,是的话就继续下一个Plugin

const file = request.path;
if (!file) return callback();
fs.stat(file, (err, stat) => {
    //...省略很多不是file的提示逻辑
    if (resolveContext.fileDependencies)
        resolveContext.fileDependencies.add(file);
    resolver.doResolve(
        target,
        request,
        "existing file: " + file,
        resolveContext,
        callback
    );
});

SymlinkPlugin

resolve.symlinks默认为true,如果不配置webpack.config.jssymlinks=false,则默认开启symlinks=true,启用后,符号链接资源将解析为它们的真实路径,而不是它们的符号链接位置

// webpack.config.js
module.exports = {
  //...
  resolve: {
    symlinks: true,
  },
};

resolve.symlinks默认为true,这个时候会检查是否存在软链情况(可以简单看作是window系统的快捷方式),如果存在,则替换path成为真实路径

// SymlinkPlugin apply
const pathsResult = getPaths(request.path);
const pathSegments = pathsResult.segments;
const paths = pathsResult.paths;
let containsSymlink = false;
let idx = -1;
forEachBail(
    paths,
    (path, callback) => {
        idx++;
        if (resolveContext.fileDependencies)
            resolveContext.fileDependencies.add(path);
        fs.readlink(path, (err, result) => {
            if (!err && result) {
                pathSegments[idx] = result;
                containsSymlink = true;
                // Shortcut when absolute symlink found
                const resultType = getType(result.toString());
                if (
                    resultType === PathType.AbsoluteWin ||
                    resultType === PathType.AbsolutePosix
                ) {
                    return callback(null, idx);
                }
            }
            callback();
        });
    },
    (err, idx) => {
        if (!containsSymlink) return callback();
        const resultSegments =
            typeof idx === "number"
                ? pathSegments.slice(0, idx + 1)
                : pathSegments.slice();
        const result = resultSegments.reduceRight((a, b) => {
            return resolver.join(a, b);
        });
        const obj = {
            ...request,
            path: result
        };
        resolver.doResolve(
            target,
            obj,
            "resolved symlink to " + result,
            resolveContext,
            callback
        );
    }
)

上面apply()方法的代码虽然有点长,但是逻辑是非常简单的
使用示例

request.path='/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/src/entry1.js'

我们可以得到

pathSegments=['entry1.js', 'src', 'js-enhanced_resolve', 'webpack-debugger', 
              'Frontend-Articles', 'blog', 'wcbbcc', 'Users', '/'];
paths=[
  '/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/src/entry1.js',
  '/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/src',
  ......
]

上面的forEachBail循环中
idx=0时,path="/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/src/entry1.js"
idx=1时,path="/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/src"
idx=2时,path="/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve"

如果fs.readlink(path)成功,能够正确获取到实际的链接result时,我们会使用pathSegments[idx]= result
比如idx=2能够成功,那么

// pathSegments[2] = js-enhanced_resolve
// path的最后一个单词被覆盖为result
pathSegments[2] = fs.readlink(path)的结果

获取目前的resultSegments=pathSegments.slice(0, idx + 1),即

resultSegments=['entry1.js', 'src', fs.readlink(path)的结果];
const result = resultSegments.reduceRight((a, b) => {
    return resolver.join(a, b);
});
result = fs.readlink(path)的结果 + '/src/entry1.js';
const obj = {
    ...request,
    path: result
};
resolver.doResolve(target, obj, ...);

ResultPlugin

最后一个Plugin,返回结果!完成整个resolve流程

const obj = { ...request };
if (resolverContext.log)
    resolverContext.log("reporting result " + obj.path);
resolver.hooks.result.callAsync(obj, resolverContext, err => {
    if (err) return callback(err);
    if (typeof resolverContext.yield === "function") {
        resolverContext.yield(obj);
        callback(null, null);
    } else {
        callback(null, obj);
    }
});

具体实例-普通目录解析

使用流程图展示所有Plugin的流向以及对Plugin的处理做简单的注释

Webpack5-enhanced-resolve-正常js.svg

具体实例-node_modules第三方模块解析

由于篇幅原因,这里不进行具体的流程图分析,将使用简单的文字描述展示整体流程
import _ from "loadsh";
// 第三方模块的resolve测试
console.error( _.add(3, 4));

初始模块resolve

  • ParsePlugin: 初始化流程,使用resolver.parse(request.request)方法,解析出目前路径所属的类型以及所携带的参数
  • DescriptionFilePlugin: 解析package.json,获取package.json对应的路径以及它本身的内容,后面需要读取里面的内容进行resolve
  • NextPlugin: 由于aliasaliasFieldextensionAlias都不需要处理,因此直接跳到normal-resolve状态

模块处理normal-resolve

  • ConditionalPlugin: 触发module=true状态,进入raw-module状态
  • ModulesInHierarchicalDirectoriesPlugin: 根据目前import vue form "vue"所在的文件路径request.path,一层一层不停往parent目录找node_modules的位置,最终拼凑出来path=node_modules的绝对路径以及request="./vue"
  • JoinRequestPartPlugin: 检测import的库是否存在多层目录,比如不是"vue",而是"vue/test",将path拼接为原来的path+前缀的"/vue"relativePath拼接为原来的relativePath+前缀目录名"/vue"request拼接为路径的最后一部分,为"./test"

尝试这个模块是否是单文件,尝试从resolve-as-module转化为undescribed-raw-file(参考模块处理的流程图),即尝试是不是loadsh.js这种模式,经过一圈Plugin的调用,最终发现下面三种文件都不存在(参考AppendPlugin的分析),无法从resolve-as-module转化为undescribed-raw-file

  • /Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/node_modules/loadsh.js
  • /Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/node_modules/loadsh.json
  • /Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/node_modules/loadsh.wasm
  • DirectoryExistsPlugin: 使用resolver.fileSystem.fs.stat进行当前path的判断,如果目录存在,则触发doResolve()
  • DescriptionFilePlugin: 解析package.json,获取package.json对应的路径以及它本身的内容,后面需要读取里面的内容进行resolvepackage.json"/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/package.json"更改为"/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/node_modules/loadsh/package.json"
  • NextPlugin: 由于不需要处理resolve.exportsField字段,直接跳转到resolve-in-existing-directory状态
  • JoinRequestPlugin: 改变pathrelativePathrequest,即path=path+requestrelativePath=relativePath+requestrequest=undefined,本质是将目前请求的路径加上请求文件的名称,形成请求文件的绝对路径

目录/文件路径处理relative

第1次relative

  • DescriptionFilePlugin: 解析package.json,获取package.json对应的路径以及它本身的内容

    • package.json"/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/node_modules/loadsh/package.json"更改为"/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/package.json"
    • relativePath"."更改为"./node_modules/loadsh"
  • DirectoryExistsPlugin: 使用resolver.fileSystem.fs.stat进行当前path的判断,如果目录存在,则触发doResolve(),此时判断该目录loadsh存在!
  • DescriptionFilePlugin: 解析package.json,获取package.json对应的路径以及它本身的内容

    • package.json"/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/package.json"更改为"/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/node_modules/loadsh/package.json"
    • relativePath"./node_modules/loadsh"更改为"."
  • MainFieldPlugin: 处理resolve.mainFields数据,在自己项目的package.json中配置,用于指定从 npm 包中导入模块时,此选项将决定在 (npm包所在的)package.json中使用哪个字段进行入口文件的指定
注意,lodash这个第三方库的入口文件不是"index.js",而是"lodash.js",也就是"./node_modules/loadsh/lodash.js",通过resolve.mainFields指定!!!!!此时obj.request= "./lodash.js"
  • JoinRequestPlugin: 改变pathrelativePathrequest,设置path=path+requestrelativePath=relativePath+requestrequest=undefined,本质是将目前请求的路径加上请求文件的名称,形成请求文件的绝对路径
obj.request并入到pathrelativePath中,path="/Users/wcbbcc/blog/Frontend-Articles/webpack-debugger/js-enhanced_resolve/node_modules/loadsh/lodash.js"

第2次relative

JoinRequestPlugin的下一个状态又是relative!但是跟第1次relative相比较,数据已经从目录loadsh->文件loadsh/lodash.js,因此走的是跟第1次relative不同的路

由于不需要加后缀以及没有别名的替换逻辑,直接跳转到FileExistsPlugin(参考上面目录/文件路径处理的流程图即可明白)

  • FileExistsPlugin: 使用resolver.fileSystem.fs.stat进行当前path的判断,如果路径存在,说明文件存在,则触发doResolve()

    没有软链,直接跳过SymlinkPlugin
  • ResultPlugin: 结束Plugin,返回结果

工程化文章

  1. 「Webpack5源码」热更新HRM流程浅析
  2. 「Webpack5源码」make阶段(流程图)分析
  3. 「Webpack5源码」enhanced-resolve路径解析库源码分析
  4. 「vite4源码」dev模式整体流程浅析(一)
  5. 「vite4源码」dev模式整体流程浅析(二)

白边
209 声望37 粉丝

源码爱好者,已经完成vue2和vue3的源码解析+webpack5整体流程源码+vite4开发环境核心流程源码+koa2源码