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

一、前文回顾

上文中我们完成了 ehanced-resolve 内部的有关各个流水线的注册,主要完成的是后半段的部部分:从 module 到 resolved 的过程:

  1. resolve 开始解析:注册 UnsafeCachePlugin、ParsePlugin 插件;
  2. paresed-resolve:request 解析阶段;
  3. described-resolve 描述文件已解析;
  4. raw-resolve: 原始解析阶段,处理 alias/aliasFields/extension/extensionAlias;
  5. normal-resolve: 普通解析阶段,处理 preferRealative、preferAbsolute
  6. internal: 内部解析阶段,处理 importsFields 选项声明的能力;
  7. raw-module 阶段,处理 resolve.modules、exportsFields 选项声明的能力;
  8. module:处理带有 @ 符号的路径;
  9. resolve-as-module:处理 resolveToContext 配置项目;
  10. undescribed-resolve-in-package:重新读取 package.json;
  11. resolve-in-package:处理 mexportsFields 配置;
  12. resolve-in-existing-directory:
  13. relative:注册 DescriptionFilePlugin 插件;
  14. described-relative:target 为 directory hook;
  15. directory:注册 DirectoryExistsPlugin 插件;
  16. undescribed-existing-directory:根据有无 resolveToContext 配置注册不同插件;
  17. described-existing-directory:注册 MainFieldPlugin 和 UseFilePlugin;
  18. raw-file:处理扩展名相关,尝试匹配;
  19. file:处理 alias
  20. final-file:处理各种 dependencies;
  21. resolved:处理 restrictions 配置,符合就返回否则报错;

另外,其设计的 hook 系统是 pipeline 形式的,有别于 webpack 的,这一点大家还是需要注意的!

二、createResolver 注册的插件

从这篇开始我们开始讲解注册在各个阶段的的 enhanced-resolve 插件,学习这个插件将学习它解析路径的核心流程。

2.1 ParsePlugin

该插件用于解析原始的 request 字符串中的信息,具体实现如下:

向 resolver.hooks.resolve/internal-resolve(newResolve/newInternalResolve)钩子注册事件。在事件 handler 中,解析 request.request ,将其中的查询字符串、fragment(#fragment)等解析成对象 obj:{ query, fragment }

得到 obj 后,调用 doResolve 将结果传递到 target 钩子:parsedResolve(parsed-resolve)

2.2 DescriptionFilePlugin

描述文件插件,通过向 resolver.hooks.parsedResolve 钩子注册事件:读取 package.json 文件中的内容并且计算模块描述文件的绝对路径、根目录、以及相对路径;

js 复制代码
const obj = {
 ...request,
 descriptionFilePath: result.path, // 描述文件的绝对路径
 descriptionFileData: result.content, // 描述文件的内容
 descriptionFileRoot: result.directory, // 该描述文件的根目录
 relativePath: relativePath // 相对于项目目录的相对路径
};

最终结果如图:

其中:

  • descriptionFilePath: 描述文件的绝对路径
  • descriptionFileData: 描述文件的内容
  • descriptionFileRoot: 该描述文件的根目录
  • relativePath: 相对于项目目录的相对路径

再得到 obj 以后,通过 resolver.doResolve() 将结果传递给 described-resolve(describedResolve) 钩子;

2.3 NextPlugin

NextPlugin 的作用就是单纯将 resolver 的 resolve 流程从一个 source 钩子推进到 target 钩子;

js 复制代码
module.exports = class NextPlugin {

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

 
 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("NextPlugin", (request, resolveContext, callback) => {
    resolver.doResolve(target, request, null, resolveContext, callback);
   });
 }
};

2.4 AliasPlugin

AliasPlugin 的注册有以下情况:

  1. webpackConfig.resolve.fallback 配置,即【二、Factory.createResolver《7.....》】情形,处理模块解析失败重定向到兜底模块,比如处理 webpack 对 Node.js 原生模块的 pollyfill
  2. webpackConfig.resolve.alias 配置,说明对一些路径进行别名设置,比如大家常见的场景 @src/components/some.vue 这种操作,@src 就是你项目下的 src 目录的别名,别名的目的是简化组件、模块导入时的路径认知成本。

AliasPlugin 的工作原理:

  1. 首先在插件注册时拿到标准化的别名和真实路径的映射集合 this.options 数组;这个数组中项为:{ name: 'Src', alias: '/User/rmb/proj-dir/src' },这个对象下称 item
  2. 当插件的 handler 执行的时候则遍历 this.options ,判断如果 request 以 item.name 开头,尝试用当前 item.alias 替换掉路径中的别名,然后调用 resolver.doResolve 推动解析执行流程到 target 钩子。简言之就是将别名替换成其对应的真实路径,然后重新执行解析流程
js 复制代码
module.exports = class AliasPlugin {

 constructor(source, options, target) {
  this.source = source;
  this.options = Array.isArray(options) ? options : [options];
  this.target = target;
 }

 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  const getAbsolutePathWithSlashEnding = maybeAbsolutePath => {
   const type = getType(maybeAbsolutePath);
   if (type === PathType.AbsolutePosix || type === PathType.AbsoluteWin) {
    return resolver.join(maybeAbsolutePath, "_").slice(0, -1);
   }
   return null;
  };
  const isSubPath = (path, maybeSubPath) => {
   const absolutePath = getAbsolutePathWithSlashEnding(maybeSubPath);
   if (!absolutePath) return false;
   return path.startsWith(absolutePath);
  };
  resolver
   .getHook(this.source)
   .tapAsync("AliasPlugin", (request, resolveContext, callback) => {
    const innerRequest = request.request || request.path;
    if (!innerRequest) return callback();
    forEachBail(
     this.options, // options 是 fallback 数组
     (item, callback) => {
      let shouldStop = false;
      if (
       innerRequest === item.name ||
       (!item.onlyModule &&
        (request.request
         ? innerRequest.startsWith(`${item.name}/`) // 如果有 request.request 就要判断是否以别名开头,'Src/s/a' Src 就是别名
         : isSubPath(innerRequest, item.name)))
      ) {
       const remainingRequest = innerRequest.substr(item.name.length); // 除了 alias 以外的别名 Src/s/a 的 remainingRequest 为 /s/a
       const resolveWithAlias = (alias, callback) => { // 这个 alias 入参是真实路径,callback 是 stoppingCallback 函数
        if (alias === false) {
         /** @type {ResolveRequest} */
         const ignoreObj = {
          ...request,
          path: false
         };
         if (typeof resolveContext.yield === "function") {
          resolveContext.yield(ignoreObj);
          return callback(null, null);
         }
         return callback(null, ignoreObj);
        }
        if (
         innerRequest !== alias &&
         !innerRequest.startsWith(alias + "/") // innerRequest 是带有 别名 的 request 字符串,这个字符串不是以真实路径开头,如果是以alias(真实路径)开头的就没必要走下面的流程了
        ) {
         shouldStop = true;
         const newRequestStr = alias + remainingRequest;
         const obj = {
          ...request,
          request: newRequestStr,
          fullySpecified: false
         };
         return resolver.doResolve(
          target,
          obj,
          "aliased with mapping '" +
           item.name +
           "': '" +
           alias +
           "' to '" +
           newRequestStr +
           "'",
          resolveContext,
          (err, result) => { // 这下面的 callback 都是 stoppingCallback
           if (err) return callback(err);
           if (result) return callback(null, result);
           return callback();
          }
         );
        }
        return callback();
       };
       const stoppingCallback = (err, result) => {
        // 这里面的 callback 是 forEachBail 的控制下一个的 callback
        if (err) return callback(err);

        if (result) return callback(null, result);
        // Don't allow other aliasing or raw request
        if (shouldStop) return callback(null, null);
        return callback();
       };
       if (Array.isArray(item.alias)) {
        return forEachBail(
         item.alias,
         resolveWithAlias,
         stoppingCallback
        );
       } else {
        return resolveWithAlias(item.alias, stoppingCallback);
       }
      }
      return callback(); // 这个 callback 是 forEachBail 的下一个 callback
     },
     callback
    );
   });
 }
};

2.5 AliasFieldPlugin

实现原理也很简单就是用当面 request 简化后的请求,去匹配当前 npm 包的 package.json.browser 字段里面的对应的值:

  1. 直接替换掉 main 的,browser 是字符串的情况
  2. browser 是对象,取值 data = browser[简化请求]
  3. 判断 data 不是 undefined/false 就正常引导至 target 钩子,因为如果是 false 表示屏蔽(忽略)这个模块,undefined 表示无效处理。
js 复制代码
const DescriptionFileUtils = require("./DescriptionFileUtils");
const getInnerRequest = require("./getInnerRequest");



module.exports = class AliasFieldPlugin {

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

 /**
  * @param {Resolver} resolver the resolver
  * @returns {void}
  */
 apply(resolver) {
  const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("AliasFieldPlugin", (request, resolveContext, callback) => {
    if (!request.descriptionFileData) return callback();
    const innerRequest = getInnerRequest(resolver, request);
    if (!innerRequest) return callback();

    // fileData 就是 npm 包的 package.json.browser 等字段对象:
    // 形如:{ name: 'xxx', browser: 'lib/browser-only.js' } 替换掉模块的 main 整个入口模块
    // 形如:{ name: 'xxx', browser: { './some-server-use.js': './some-client-use.js' } } 在浏览器环境下如果 require('xxx/some-server-use.js') 需要替换成 xxx/some-client-use.js 这个插件的工作就是干着的
    const fieldData = DescriptionFileUtils.getField(
     request.descriptionFileData,
     this.field
    );
    if (fieldData === null || typeof fieldData !== "object") {
     // 没有这个字段退出
     if (resolveContext.log)
      resolveContext.log(
       "Field '" +
        this.field +
        "' doesn't contain a valid alias configuration"
      );
     return callback();
    }

    // 这个就是 Obj.hasOwnProperty()
    const data = Object.prototype.hasOwnProperty.call(
     fieldData,
     innerRequest
    )
     ? fieldData[innerRequest]
     : innerRequest.startsWith("./") // 处理相对路径 './some-server-use.js' 变成 'some-server-use.js'
     ? fieldData[innerRequest.slice(2)]
     : undefined; // 没找到
    if (data === innerRequest) return callback();
    if (data === undefined) return callback();
    if (data === false) { // 屏蔽某些模块引用这个是规范的一部分
     /** @type {ResolveRequest} */
     const ignoreObj = {
      ...request,
      path: false
     };
     if (typeof resolveContext.yield === "function") {
      resolveContext.yield(ignoreObj);
      return callback(null, null);
     }
     return callback(null, ignoreObj);
    }
    const obj = {
     ...request,
     path: request.descriptionFileRoot,
     request: data,
     fullySpecified: false
    };
    resolver.doResolve(
     target,
     obj,
     "aliased from description file " +
      request.descriptionFilePath +
      " with mapping '" +
      innerRequest +
      "' to '" +
      data +
      "'",
     resolveContext,
     (err, result) => {
      if (err) return callback(err);

      // Don't allow other aliasing or raw request
      if (result === undefined) return callback(null, null);
      callback(null, result);
     }
    );
   });
 }
};

三、总结

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

  1. ParsePlugin:该插件用于解析原始的 request;
  2. DescriptionFilePlugin:读取 package.json 文件;
  3. NextPlugin:从一个 source 钩子推进到 target 钩子;
  4. AliasPlugin:处理 fallback、alias 等;
  5. AliasFieldPlugin:处理 package.json.browser 字段;
相关推荐
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
SRY122404192 小时前
javaSE面试题
java·开发语言·面试
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
不二人生5 小时前
SQL面试题——连续出现次数
hive·sql·面试
郝晨妤5 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui
尝尝你的优乐美6 小时前
vue3.0中h函数的简单使用
前端·javascript·vue.js