涨薪面技:写个 enhanced-resolve 插件(10)

115 阅读4分钟

一、前文回顾

上文按照 ehanced-resolve 执行的流程注讲解其插件系统,这篇主要介绍了前 10 个插件:

  1. ParsePlugin:该插件用于解析原始的 request;
  2. DescriptionFilePlugin:读取 package.json 文件;
  3. NextPlugin:从一个 source 钩子推进到 target 钩子;
  4. AliasPlugin:处理 fallback、alias 等;
  5. AliasFieldPlugin:处理 package.json.browser 字段;
  6. ExtensionAliasPlguin:拼接各个扩展名并尝试解析;
  7. JoinRequestPlugin:拼接请求,把 request.path 和 request 拼接;
  8. ConditionalPlugin:判断本次 request 的类型是否满足条件;
  9. RootsPlugin: 把的第一个 /(表示根目录) 改写成 resolve.roots 设定的目录;
  10. ImportsFieldPlugin:这个插件用于处理包的内部 request;

最后我们还用 强盛集团 的例子讲了一下内部 request 的处理过程,这是全网最全的讲解了。再次声讨那些用机器翻译瞎TM翻译一通,例子不写一个就瞎写的博主!

这里我们继续 enhanced-resolve 插件的注册工作;

二、插件注册

接上文继续后面的插件注册!

2.11 SelfReferencePlugin

这个插件的作用并不算复杂,他的原理是结合 exportsFields 配置项和 package.json.exports 字段改写默认的主入口以及子路径的解析规则的:

相关实现如下:

const DescriptionFileUtils = require("./DescriptionFileUtils");

const slashCode = "/".charCodeAt(0);

module.exports = class SelfReferencePlugin {

 constructor(source, fieldNamePath, target) {
  this.source = source;
  this.target = target;
  this.fieldName = fieldNamePath;
 }


 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("SelfReferencePlugin", (request, resolveContext, callback) => {
    if (!request.descriptionFilePath) return callback();

    const req = request.request;
    if (!req) return callback();

    // Feature is only enabled when an exports field is present
    // 当 package.json.exports 字段存在时才启用该特性
    // 获取 exports 字段对应的对象:
    const exportsField = DescriptionFileUtils.getField(
     request.descriptionFileData,
     this.fieldName
    );
    if (!exportsField) return callback();

    // 获取包的名字
    const name = DescriptionFileUtils.getField(
     request.descriptionFileData,
     "name"
    );
    if (typeof name !== "string") return callback();

    // 处理直接导入 包名 : import gao from 'qiang-sheng-group'
    // 还有子路径: import tangxiaolong from 'qiang-sheng-group/tangxiaolong/some.js'
    if (
     req.startsWith(name) &&
     (req.length === name.length || // 包名
      req.charCodeAt(name.length) === slashCode) // 包名后面紧跟着 / 的就是子路径
    ) {
     // 如果直接是包名:则 remainingRequest 就是 "."
     // 如果是子路径:则变成 ./除了包名以外剩下的路径
     const remainingRequest = `.${req.slice(name.length)}`;

     const obj = {
      ...request,
      request: remainingRequest,
      path: /** @type {string} */ (request.descriptionFileRoot),
      relativePath: "."
     };
     
     // 继续解析:
     resolver.doResolve(
      target,
      obj,
      "self reference",
      resolveContext,
      callback
     );
    } else {
     return callback();
    }
   });
 }
};

2.12 ModulesInHierarchicalDirectoriesPlugin

这个插件中文名 在有层级的目录中的模块插件,所谓层级就是 Node.js 中 require 查找算法一样,从当前目录查 node_modules,如果没找到就向上找,一直找到 / 目录下的 node_modules。

该插件的实现主要有以下步骤:

  1. 构造可能的查找路径。方式其实很简单,就是把 request.path 拆分成一段一段的,给各段拼接 node_modules,相当于查到到 / 下的 node_modules 目录。比如 request.path = /users/rmb/Document/w5-proj,addrs 如下:
    • /users/rmb/Document/w5-proj/node_modules
    • /users/rmb/Document/node_modules
    • /users/rmb/node_modules
    • /users/node_modules
    • /node_modules
  2. 遍历 addrs 数组,期间通过 fs.stat 检验这些目录是否存在;
  3. 目录不存在就是 missingDependencies;
const forEachBail = require("./forEachBail");
const getPaths = require("./getPaths");

module.exports = class ModulesInHierarchicalDirectoriesPlugin {

 constructor(source, directories, target) {
  this.source = source;
  this.directories = ([]).concat(directories);
  this.target = target;
 }

 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync(
    "ModulesInHierarchicalDirectoriesPlugin",
    (request, resolveContext, callback) => {
     const fs = resolver.fileSystem;
     // 1. 构造可能的查找路径
     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;
      }, []);
     
     // 2. 遍历 addrs 数组,期间通过 fs.stat 校验这些目录是否存在
     forEachBail(
      addrs,
      (addr, callback) => {
       // fs.stat 验证包管理目录存在与否
       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
         );
        }
        // 3.
        if (resolveContext.log)
         resolveContext.log(
          addr + " doesn't exist or is not a directory"
         );
        if (resolveContext.missingDependencies)
         resolveContext.missingDependencies.add(addr);
        return callback();
       });
      },
      callback
     );
    }
   );
 }
};

2.13 PnpPlugin

这个玩意儿歇会儿吧😂,我没用过 pnp 。。。。等我学会了再来补,留个坑!! 如果有大神有链接,评论一下,我想这里引(白)用(嫖)一下

2.14 JoinRequestPartPlugin

每个高大上的插件都有一个朴素的实现😂,这个插件检查 @namespace 这种带有 @ 符号命名空间的 request 中是否带有2个 /,例如:@namespace/sub/sub2/module.js 这种。这个时候改写 path 为 @namespace/sub/sub2,改写 request ./module.js

const namespaceStartCharCode = "@".charCodeAt(0);

module.exports = class JoinRequestPartPlugin {

 constructor(source, target) {
  this.source = source;
  this.target = target;
 }

 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync(
    "JoinRequestPartPlugin",
    (request, resolveContext, callback) => {
     //  request 有长这样的: ./@jridgewell/trace-mapping
     const req = request.request || "";
     let i = req.indexOf("/", 3); // 所以就是从 ./@ 后面查找第一个 /

     if (i >= 0 && req.charCodeAt(2) === namespaceStartCharCode) { // @ 符号
      // 如果是带命名空间的要看在命名空间的第一个 / 后面是不是还有 /
      // 比如: ./@some-name/sug-module/sub2-m.js 这种,要找的是 sub2-m.js 前面的 /
      i = req.indexOf("/", i + 1);
     }

     let moduleName, remainingRequest, fullySpecified;
     if (i < 0) {
      // 没有第二个斜杠,都是 @namespace/ 的第一级子模块
      moduleName = req;
      remainingRequest = "."; // . 表示当前
      fullySpecified = false;
     } else {
      // 有第二个斜杠,说名是 @namespace/sub1 的子模块,也就是 @namespace 的孙子模块
      moduleName = req.slice(0, i); // 改写 moduleName 到第二个斜杠 ,@namespace/sub1 这个
      remainingRequest = "." + req.slice(i); // reaminingRequest 变成 ./sub2-module.js
      fullySpecified = request.fullySpecified;
     }
     // 重新构造 path、relativePath、request
     const obj = {
      ...request,
      path: resolver.join(request.path, moduleName),
      relativePath:
       request.relativePath &&
       resolver.join(request.relativePath, moduleName),
      request: remainingRequest,
      fullySpecified
     };
     resolver.doResolve(target, obj, null, resolveContext, callback);
    }
   );
 }
};

2.15 DirectoryExistsPlugin

这个插件的作用就更朴素了,主要是判断截止到当前流程,path 是不是一个真实存在的目录。

用 fs.stat 检测 request.path(下称 directory),如果 fs.stat 异常或者 fs.stat 结果显示不是文件夹(目录,stat.isDirectory() 返回 false),则把这个目录加入到 resolveContext.missingDependencies 中。否则,就把 directory 加入到 resolveContext.fileDependencies 中。

module.exports = class DirectoryExistsPlugin {
 
 constructor(source, target) {
  this.source = source;
  this.target = target;
 }
 
 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync(
    "DirectoryExistsPlugin",
    (request, resolveContext, callback) => {
     const fs = resolver.fileSystem;
     const directory = request.path;
     // 判断 path 是否为 falsy 值,如果为假值就返回
     if (!directory) return callback();

     // fs.stat 验证路径
     fs.stat(directory, (err, stat) => {
      if (err || !stat) {
       // 如果有错或者没得到 stat 对象说明有问题
       // 把这个路径加入到 resolveContext.missingDependencies 中
       if (resolveContext.missingDependencies)
        resolveContext.missingDependencies.add(directory);
       if (resolveContext.log)
        resolveContext.log(directory + " doesn't exist");
       return callback();
      }
      if (!stat.isDirectory()) {
       // directory 路径不是个目录(比如是文件)
       // 把这个路径加入到 resolveContext.missingDependencies 中
       if (resolveContext.missingDependencies)
        resolveContext.missingDependencies.add(directory);
       if (resolveContext.log)
        resolveContext.log(directory + " is not a directory");
       return callback();
      }
      // 如果 resolveContext.fileDependencies 属性存在则收集当前目录
      if (resolveContext.fileDependencies)
       resolveContext.fileDependencies.add(directory);

      // 执行后续解析流程
      resolver.doResolve(
       target,
       request,
       `existing directory ${directory}`,
       resolveContext,
       callback
      );
     });
    }
   );
 }
};

2.16 ExportsFieldPlugin

这个插件是解析 package.json.exports 字段的,package.json.exports 字段是用于重新定义模块的导出规则的。除了可以覆盖默认的 main 字段,还可以通过重写实现部分子目录的模块导出重定向。

const path = require("path");
const DescriptionFileUtils = require("./DescriptionFileUtils");
const forEachBail = require("./forEachBail");
const { processExportsField } = require("./util/entrypoints");
const { parseIdentifier } = require("./util/identifier");
const { checkImportsExportsFieldTarget } = require("./util/path");

module.exports = class ExportsFieldPlugin {

 constructor(source, conditionNames, fieldNamePath, target) {
  this.source = source;
  this.target = target;
  this.conditionNames = conditionNames;
  this.fieldName = fieldNamePath;
  /** @type {WeakMap<any, FieldProcessor>} */
  this.fieldProcessorCache = new WeakMap();
 }


 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("ExportsFieldPlugin", (request, resolveContext, callback) => {
    // When there is no description file, abort
    // 没有 package.json 跳过
    if (!request.descriptionFilePath) return callback();
    if (
     // When the description file is inherited from parent, abort
     // (There is no description file inside of this package)
     // 如果 package.json 是从父目录继承的,跳过
     request.relativePath !== "." ||
     request.request === undefined
    )
     return callback();

    const remainingRequest =
     request.query || request.fragment
      ? (request.request === "." ? "./" : request.request) +
        request.query +
        request.fragment
      : request.request;
    /** @type {ExportsField|null} */
    // 获取 package.json.exports 字段值
    const exportsField = DescriptionFileUtils.getField(
     request.descriptionFileData,
     this.fieldName
    );
    if (!exportsField) return callback();

    if (request.directory) {
     return callback(
      new Error(
       `Resolving to directories is not possible with the exports field (request was ${remainingRequest}/)`
      )
     );
    }

    let paths;

    try {
     let fieldProcessor = this.fieldProcessorCache.get(
      request.descriptionFileData
     );
     if (fieldProcessor === undefined) {
      // 把 package.json 指定的 exports 对象处理成 fileProcessor 函数
      fieldProcessor = processExportsField(exportsField);
      this.fieldProcessorCache.set(
       request.descriptionFileData,
       fieldProcessor
      );
     }
     // 利用根据 request 和 条件得到最终的路径数组
     paths = fieldProcessor(remainingRequest, this.conditionNames);
    } catch (err) {
     if (resolveContext.log) {
      resolveContext.log(
       `Exports field in ${request.descriptionFilePath} can't be processed: ${err}`
      );
     }
     return callback(err);
    }

    if (paths.length === 0) {
     return callback(
      new Error(
       `Package path ${remainingRequest} is not exported from package ${request.descriptionFileRoot} (see exports field in ${request.descriptionFilePath})`
      )
     );
    }
    
    // 遍历 path 尝试

    forEachBail(
     paths,
     (p, callback) => {
      const parsedIdentifier = parseIdentifier(p);

      if (!parsedIdentifier) return callback();

      const [relativePath, query, fragment] = parsedIdentifier;

      const error = checkImportsExportsFieldTarget(relativePath);

      if (error) {
       return callback(error);
      }

      const obj = {
       ...request,
       request: undefined,
       path: path.join(
        /** @type {string} */ (request.descriptionFileRoot), // 拼接当前包的 package.json 和模块相对路径 就是解析出来的绝对路径
        relativePath
       ),
       relativePath,
       query,
       fragment
      };

      resolver.doResolve(
       target,
       obj,
       "using exports field: " + p,
       resolveContext,
       callback
      );
     },
     (err, result) => callback(err, result || null)
    );
   });
 }
};

2.17 UseFilePlugin

处理导入目录时自动解析到目录下的 index.js;

module.exports = class UseFilePlugin {
 constructor(source, filename, target) {
  this.source = source;
  this.filename = filename;
  this.target = target;
 }

 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("UseFilePlugin", (request, resolveContext, callback) => {
    // 处理导入目录时自动解析到目录下的 index.js;
    // resolve.mainFiles: ['index.js']
    // 构造文件名:
    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
    );
   });
 }
};

2.18 MainFieldPlugin

这个插件的作用是把包名和 package.json.main 字段解析拼接,得到具体的主入口路径,解析的基础就是这个包的 package.json 所在的路径。

const path = require("path");
const DescriptionFileUtils = require("./DescriptionFileUtils");

const alreadyTriedMainField = Symbol("alreadyTriedMainField");

module.exports = class MainFieldPlugin {

 constructor(source, options, target) {
  this.source = source;
  this.options = options;
  this.target = target;
 }

 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("MainFieldPlugin", (request, resolveContext, callback) => {
    if (
     request.path !== request.descriptionFileRoot ||
     request[alreadyTriedMainField] === request.descriptionFilePath ||
     !request.descriptionFilePath
    )
     return callback();

    // package.json
    const filename = path.basename(request.descriptionFilePath);

    // package.json.main 字段值
    let mainModule = DescriptionFileUtils.getField(
     request.descriptionFileData,
     this.options.name
    );

    if (
     !mainModule ||
     typeof mainModule !== "string" ||
     mainModule === "." ||
     mainModule === "./"
    ) {
     // 不处理
     return callback();
    }
    if (this.options.forceRelative && !/^..?\//.test(mainModule))
     mainModule = "./" + mainModule;
    const obj = {
     ...request,
     request: mainModule, // 重写 request 为 包名/主入口模块
     module: false,
     directory: mainModule.endsWith("/"), // 是否为目录
     [alreadyTriedMainField]: request.descriptionFilePath
    };
    return resolver.doResolve(
     target,
     obj,
     "use " +
      mainModule +
      " from " +
      this.options.name +
      " in " +
      filename,
     resolveContext,
     callback
    );
   });
 }
};

三、总结

本文主要接上文介绍了 enhance-resolve 中实现 resolve 功能的内部插件实现细节,本小结主要讨论了以下插件:

  1. SelfReferencePlugin:其作用是结合 exportsFields 配置项和 package.json.exports 字段改写默认的主入口以及子路径的解析规则;
  2. ModulesInHierarchicalDirectoriesPlugin:其作用是模拟 require.resolve 行为,从当前目录查找 node_moduels 一直到 / 下的这一行为;
  3. PnpPlugin:咱也不知道,也不想学(一言不合就开摆,就问你气不气😂)
  4. JoinRequestPartPlugin:将通过命名空间引用其包内部模块的 @ns/sub/module 语法 变成相对路径;
  5. DirectoryExistsPlugin:主要是判断截止到当前流程,path 是不是一个真实存在的目录;
  6. ExportsFieldPlugin:主要实现的是 package.json 中的 exports 字段相关能力的插件;
  7. UseFilePlugin:实现导入写只写到目录的情况下自动解析到该目录下的 index.js 能力;
  8. MainFieldPlugin:实现 package.json 中 main 字段相关能力的插件;