关于前端:干货耗时7个小时用近50张图来学习enhanceresolve中的数据流动和插件调度机制

34次阅读

共计 13953 个字符,预计需要花费 35 分钟才能阅读完成。

0. 食用本文的文档阐明:

本篇文章 耗时 7 个小时 左右才竣工,篇幅波及到 大量的源码及其剖析的过程图解和数据 ,浏览前,请保障本人有充沛的工夫,纵情的去享受 排汇常识进入脑子的过程

因为篇幅无限,心愿你把握以下前置常识:

  1. 曾经学习过 enhanced-resolve 工作流程和插拔式插件机制,点这里温习:webpack 外围库 enhanced-resolve 工作流程和插拔式插件机制
  2. 理解 tabaple 是一个 订阅公布 的设计模式(晓得啥是订阅公布即可)
  3. 大抵理解 node 中的模块查找机制,如:
require(‘./xxx.js’);
require('./xxx');
require('xxx');

通过本文你将学到如下内容(或者带着如下疑难去学习):

  1. enhance-resolve是如何在简单的插件调用之间传递数据的?
  2. Resolver 和 ResolverFactory的关系是什么?
  3. Resolver是如何设计实现的?
  4. 软链接和硬链接 是什么?区别在哪里?
  5. 如何开发一个 enhance-resolve 的插件利用到 webpack 中?
  6. 如何去一步步的 debug 一个开源库?

1 webpack 和 enhance-resolve 的关系是什么?

webpack 作为一个弱小的打包工具,其弱小的不仅仅是插件机制,还有其外围包 enhance-resolve 来实现模块的门路查找。性能上来说它能够 加强 Webpack 的模块解析能力 ,使其更容易找到所需的模块,从而进步 Webpack 的 性能和可维护性 。从配置上来说它能够为 Webpack 解析器增加额定的搜寻门路以及解析规定,让 Webpack 更好地解释门路和文件,进而让 webpack 更加分心的做模块打包相干的事件。

理解完背景和需要当前,如果让咱们去实现一个 enhance-resolve 呢?

性能点:

  1. 首先解析器满足模块查找中的所有的规定 模块:通用 JS 模块 | 节点.js v14.21.3 文档 (nodejs.org)
  2. 要和 webpack 一样,有弱小的 插件加载机制和良好的配置性能

本人能够心中默默的想一下如何实现上述性能点呢?

2. 接下来就根据上述性能点通过代码去理解一下 enhance-resolve

咱们上回太强了,3000 字图文并茂的解析 webpack 外围库 enhanced-resolve 工作流程和插拔式插件机制,真香 – 掘金 (juejin.cn)说到:

  1. ResolverFactory.createResolver 依据 Resolver 类创立实例:myResolve (吃了配置,吐出对象myResolve)
  2. myResolve 上 注册并订阅 大量的 hook(枪支弹药贮备好,一刻激发)
  3. 调用 myResolver.resolve 办法开始进行 文件解析 的主流程
  4. 外部通过 resolve.doResolve办法,开始调用第一个 hook: this.hooks.resolve
  5. 找到之前 订阅 hook 的 plugin:ParsePlugin
  6. ParsePlugin 进行初步解析,而后 通过 doResolve 执行下一个 hook parsed-resolve,后期筹备工作完结,链式调用开始, 真正的解析文件的流程 也开始。

从下面的第 2 步开始整起,第 2 步注册了哪些 hook 呢?接下来开始瞅瞅

2.1 细细回顾 myResolve 上注册的 hooks

代码跳转到 lib/ResolverFactory.js295 行左右,代码如下:

//// pipeline ////

resolver.ensureHook("resolve");
resolver.ensureHook("internalResolve");
resolver.ensureHook("newInternalResolve");
resolver.ensureHook("parsedResolve");
resolver.ensureHook("describedResolve");
resolver.ensureHook("rawResolve");
resolver.ensureHook("normalResolve");
resolver.ensureHook("internal");
resolver.ensureHook("rawModule");
resolver.ensureHook("module");
resolver.ensureHook("resolveAsModule");
resolver.ensureHook("undescribedResolveInPackage");
resolver.ensureHook("resolveInPackage");
resolver.ensureHook("resolveInExistingDirectory");
resolver.ensureHook("relative");
resolver.ensureHook("describedRelative");
resolver.ensureHook("directory");
resolver.ensureHook("undescribedExistingDirectory");
resolver.ensureHook("existingDirectory");
resolver.ensureHook("undescribedRawFile");
resolver.ensureHook("rawFile");
resolver.ensureHook("file");
resolver.ensureHook("finalFile");
resolver.ensureHook("existingFile");
resolver.ensureHook("resolved");

为了便于了解,放出 ensureHook的局部外围代码,其次要作用就是创立一个 AsyncSeriesBailHook 异步串行保险型的 hook,(所谓的 保险 你能够设想成漂泊星球 2 中的 饱和式救济,1 个工作派出多个救援队【订阅多个 hook】,只有一个救援队胜利了【一个 hook 存在返回值】这次救济就算胜利了【这个订阅事件就算完结了】)

ensureHook(name) {if (typeof name !== "string") {return name;}
 name = toCamelCase(name);
 const hook = this.hooks[name];
 if (!hook) {return (this.hooks[name] = new AsyncSeriesBailHook(["request", "resolveContext"],
   name
  ));
 }
 return hook;
}

PS: ensureHook的作用是

能够看到作者在头部特意写了一个简短的正文 //// pipeline ////,翻译过去也就是流水线。

流水线是一种工业生产方式,它将 一个大型工程分解成若干个小步骤 ,每个步骤都有 专门的工人或机器 来实现,从而进步生产效率。流水线的劣势在于能够 进步生产效率,缩小生产成本,进步产品质量,并且能够更快地实现大型工程 。在 IT 界就能够认为是 模块间解耦,进步代码可读性和可维护性

到这里流水线流程组装结束【可了解成为每个工种调配了相干的工作】,那下一步就是要开始组装每局部流程用到的 工具集(plugins),【而后再为每个工种调配不同的工具】。局部外围代码如下:

// 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"));
 }
}

// parsed-resolve
plugins.push(
 new DescriptionFilePlugin(
  "parsed-resolve",
  descriptionFiles,
  false,
  "described-resolve"
 )
);
plugins.push(new NextPlugin("after-parsed-resolve", "described-resolve"));

...... 此处省略局部注册插件逻辑

//// RESOLVER ////

for (const plugin of plugins) {if (typeof plugin === "function") {plugin.call(resolver, resolver);
 } else {plugin.apply(resolver);
 }
}

始终到最初把依据用户配置生成的相干的插件列表 plugins 给注册到 resolver 上,整个的resolver 的 hook 和 plugin 的绑定才胜利完结。

本次调试代码绑定的 总的插件的数量为 41 个:

其中因为 NextPlugin 是流程推动性插件和业务逻辑无关,就过滤掉,还剩下 32 个

2.2 开始调试正式流程吧(流水线关上电源,跑起来了)

lib/Resolver.jsresolve 办法中是查找门路开始的终点,首先就是把 用户传入的 门路 path 和 要查找文件的门路 request 赋值给 obj 对象【此 obj 是外围对象,将在各个插件中流转批改】。

而后就开始调用本身的 doResolve 办法,正式开始流程了。

3. 从 resolve hook 开始的流程,到完结

断点到 doResolve办法的 hook.callAsync 局部,看下相干的参数。

从图中能够看出,此 hook 名为 resolve,入参有两个:Array(2)[request,resolveContext],绑定此 hook 的插件只有一个 ParsePlugin 的插件,传递上来的参数是 request 对象:pathrequest是重要的数据。

下一步就开始进入 ParsePlugin 插件看看它到底做了什么。

3.1 视察 ParsePlugin工种的工作

ParsePlugin 其外围 apply 代码如下:

apply(resolver) {const target = resolver.ensureHook(this.target);
 resolver
  .getHook(this.source)
  .tapAsync("ParsePlugin", (request, resolveContext, callback) => {
   // 调用 resolver 中的 parse 办法初步解析
   const parsed = resolver.parse(/** @type {string} */ (request.request));
   // 合并成新的 obj 对象
   const obj = {...request, ...parsed, ...this.requestOptions};
   if (request.query && !parsed.query) {obj.query = request.query;}
   if (request.fragment && !parsed.fragment) {obj.fragment = request.fragment;}
   if (parsed && resolveContext.log) {if (parsed.module) resolveContext.log("Parsed request is a module");
    if (parsed.directory)
     resolveContext.log("Parsed request is a directory");
   }
   // There is an edge-case where a request with # can be a path or a fragment -> try both
   if (obj.request && !obj.query && obj.fragment) {const directory = obj.fragment.endsWith("/");
    const alternative = {
     ...obj,
     directory,
     request:
      obj.request +
      (obj.directory ? "/" : "") +
      (directory ? obj.fragment.slice(0, -1) : obj.fragment),
     fragment: ""
    };
    resolver.doResolve(
     target,
     alternative,
     null,
     resolveContext,
     (err, result) => {if (err) return callback(err);
      if (result) return callback(null, result);
      resolver.doResolve(target, obj, null, resolveContext, callback);
     }
    );
    return;
   }
   resolver.doResolve(target, obj, null, resolveContext, callback);
  });
}

通过断点发现,obj 对象第一次进入这个 plugin逛了一圈,而后最终走到了 resolver.doResolve(target, obj, null, resolveContext, callback); 这里,解决完的数据如下:【思考一下吃了啥数据,吐出了啥数据?】

ParsePlugin 吃了 obj,当前对其进行初步解析,减少了如下属性【红色是吃进去的,绿色是吐出来的】

而后下一个要执行 hook 是 parsedResolve,其绑定的业务插件是 DescriptionFilePluginNextPlugin 插件属于流程插件,能够疏忽。

3.2 视察 DescriptionFilePlugin工种的工作

以后流程的 DescriptionFilePlugin 插件的外围是在 DescriptionFileUtils.loadDescriptionFile办法里,

当看到 ['package.json']的那一刻是不是能够联想并猜测到:此插件的作用就是在实现 查找以后的门路 是否是一个 具备package.json 文件的模块?持续 debug loadDescriptionFile办法,

看到这个门路拼接,验证了猜测是正确的,持续 debug 发现,走到了此办法的 callback 函数里,执行了一个 cdUp 的办法。

咱们不去看办法实现,仅仅看变更,变量从 directory 变成了 dir,数据从 /Users/fujunkui/Desktop/github-project/enhanced-resolve/demo/test-find-file 变成了/Users/fujunkui/Desktop/github-project/enhanced-resolve/demo,卧槽,还真是进入了下级目录,cdUp 66666。

不出所料的话,他会始终 cdUp 晓得进入到根目录的, 查找 /package.json 为止【图中,我把 enhance-resolve 我的项目的 package.json 文件给删除了,不删除的话找到这一级就进行了】
局部截图

最初找呀找呀,就是找不到一个目录具备package.json 文件,没方法只能走 callback 了。

后果就是这个插件一顿 cdUp 操作,啥都没变,留神此处的 callback()返回值为空,他就要进入此 hook 的下一个插件了,NextPlugin 正式退场。

3.3 外卖小哥 NextPlugin 正式退场

NextPlugin 外围代码如下:

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

间接调用 resolver.doResolve 把上一个 hook 的丢出的数据,给下一个 hook 应用,不做任何扭转(像极了 辛苦帮商家送餐的外卖小哥,点赞)。

那就有请下一位 hook 闪亮退场:

好家伙,下一个 hook 是 rawResolve,让咱们来看看他的监听者 都有谁,拉倒吧,还是 NextPlugin 外卖小哥,这就是外卖小哥点饭(外卖小哥送给外卖小哥)???

[](https://img.soogif.com/SkisPO…)

那就持续吧,看看这个 rawResolve 的下一个 hook 是谁,监听的插件都有谁?

下一个 hook 名叫 normalResolve,居然有 3 个插件监听了此 hook,那么开始表演吧。

3.4 视察 hook 名为normalResolve 上面的三个工种(插件)的工作

3.4.1 第一位和第二位 靓仔都是 ConditionalPlugin (翻译为中文就是:条件插件)

大抵猜想一下条件插件:就是满足了哪些条件才会继续执行上来。

两者的区别在初始化的传参里:

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"
 )
);

总体代码是:

class ConditionalPlugin {constructor(source, test, message, allowAlternatives, target) {
  this.source = source;
  this.test = test;
  this.message = message;
  this.allowAlternatives = allowAlternatives;
  this.target = target;
 }
 
 apply(resolver) {const target = resolver.ensureHook(this.target);
  const {test, message, allowAlternatives} = this;
  const keys = Object.keys(test);
  resolver
   .getHook(this.source)
   .tapAsync("ConditionalPlugin", (request, resolveContext, callback) => {for (const prop of keys) {if (request[prop] !== test[prop]) return callback();}
    resolver.doResolve(
     target,
     request,
     message,
     resolveContext,
     allowAlternatives
      ? callback
      : (err, result) => {if (err) return callback(err);

        // Don't allow other alternatives
        if (result === undefined) return callback(null, null);
        callback(null, result);
        }
    );
   });
 }
};

执行后果如下:
第一次 插件的 callback 后果是 空【下图】,进入 第二个 插件,

第二个插件的 callback 后果是 空【下图】,进入 JoinRequestPlugin 插件

3.4.2 视察 JoinRequestPlugin 插件的工作

看名字就晓得是干啥的,工作比较简单,就是把 path 和 request 合并成新的门路 赋值给 path(绿色圈中局部),

resolver.join(request.path, request.request),

这个 hook 的事件实现了,有请下一个 hook relative,以及它的两位监听者们。

3.5 视察 hook 名为relative 上面的两个工种(插件)的工作

兜兜转转的又进入 DescriptionFilePlugin 插件了,然而 此时的参数和之前的不一样了,然而如同也没有什么不同,最初还是 callback 为空,灰头土脸的走进下一个插件了。

持续走到 NextPlugin,而后被送到 describedRelative 的 hook,此 hook 的监听者有:

3.5 视察 hook 名为describedRelative 上面的两个工种(条件插件)的工作

条件插件要满足的第一个逻辑就是,不是文件夹,揣测咱们是满足的,开始 debug。

plugins.push(
 new ConditionalPlugin(
  "described-relative",
  {directory: false},
  null,
  true,
  "raw-file"
 )
);
plugins.push(
 new ConditionalPlugin(
  "described-relative",
  {fullySpecified: false},
  "as directory",
  true,
  "directory"
 )
);

的确满足了不是文件夹的条件,推动到下一个 hook rawFile,其相干的监听者有 5 个。

3.6 视察 hook 名为rawFile 上面的工种的工作

不满足此插件,走进下一个插件TryNextPlugin:

// raw-file
plugins.push(
 new ConditionalPlugin(
  "raw-file",
  {fullySpecified: true},
  null,
  false,
  "file"
 )
);

TryNextPlugin(尝试下一个插件)的代码如下:

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

个人感觉其实此处的逻辑更应该是尝试下一个 hook,而不是 插件 ,所以改为 TryNextHook 更好. 之所以这么说看上面的代码:

plugins.push(new TryNextPlugin("raw-file", "no extension", "file"));

下面代码简略了解为,被查找的文件是 不带扩大的文件,能够间接走到 名为 file 的 hook 里。此 hook 的监听插件有:

那就持续走 NextPlugin 插件的逻辑,而后走向了 finalFile 的 hook【下图】, 进入 FileExistsPlugin 插件的逻辑里。

3.7 视察 hook 名为 finalFile 上面的工种FileExistsPlugin 插件的工作

代码比较简单:获取查找门路,直接判断是不是文件即可。

发现不是文件,那就执行 callback 函数,此插件的 callback 函数是Resolver 中的 hook.callAsync 中的 callback 函数

而后 Resolver 中的 hook.callAsync 中的 callback 函数承受到的 err 和 result 都是 undefined,就又走了 doResolve 中承受的 callback 函数,那就要开始从当初这个 finalFile 向前找了,查找的过程要疏忽掉 外卖小哥型插件 比方 TryNextPluginNextPlugin

finalFile 上一个是 file的 hook 监听(NextPlugin可疏忽),file 的上一个是 raw-file,触发 raw-file 下的插件的监听,接下来就是查找监听了 hook 位 raw-file 的插件了。

这块的代码可能因为都叫 callback,并且跳来跳去的有些难以了解,能够参考我上面简化过的代码。

let {AsyncSeriesBailHook} = require("tapable");

const hook1 = new AsyncSeriesBailHook(["request", "resolveContext"], "hook1");
const hook2 = new AsyncSeriesBailHook(["request", "resolveContext"], "hook2");

const hook1Tap1 = hook1.tapAsync(
 "hook1Tap1",
 (request, resolveContext, callback) => {console.log("hook1Tap1", request, resolveContext);
  return callback();}
);

const hook1Tap2 = hook1.tapAsync(
 "hook1Tap2",
 (request, resolveContext, callback) => {console.log("hook1Tap2", request, resolveContext);
  return callback();}
);

const hook2Tap1 = hook2.tapAsync(
 "hook2Tap1",
 (request, resolveContext, callback) => {console.log("hook2Tap1", request, resolveContext);
  return callback();}
);

const hook2Tap2 = hook2.tapAsync(
 "hook2Tap2",
 (request, resolveContext, callback) => {console.log("hook2Tap2", request, resolveContext);
  return callback("err");
 }
);

hook1.callAsync("111", "222", () => {console.log("hook1 callback");
 hook2.callAsync("333", "455", err => {console.log("hook2 callback", err);
 });
});

执行后果如下:

这块的内容是定义了两个 异步的 hook, 而后在 hook1 调用 callAsync 的时候,外面传递了 hook2 的 callAsync 调用,这样就会在调用完 hook1 的触发事件,而后去接着调用 hook2 的触发事件。

这样是不是能够了解 多个 hook 之前传递 callback 的逻辑了?

那么接下来就要找监听了 hook 名为 raw-file 的插件有哪些了,间接看 ResolverFactory 注册工夫得悉【下图】,有 3 个插件监听了。而当初的程序 又是依照监听程序倒着执行 callback 的,那就应该是先执行 AppendPlugin 插件了,打上断点,跑一下

3.8 回首掏,去视察 hook 名为 raw-file 上面的工种AppendPlugin 插件的工作

AppendPlugin 代码较为简单,就是把传入的 this.appendingrequest.path 进行拼接,生成新的 request.path

module.exports = class AppendPlugin {constructor(source, appending, target) {
  this.source = source;
  this.appending = appending;
  this.target = target;
 }
 
 apply(resolver) {const target = resolver.ensureHook(this.target);
  resolver
   .getHook(this.source)
   .tapAsync("AppendPlugin", (request, resolveContext, callback) => {
    const obj = {
     ...request,
     path: request.path + this.appending,
     relativePath:
      request.relativePath && request.relativePath + this.appending
    };
    resolver.doResolve(
     target,
     obj,
     this.appending,
     resolveContext,
     callback
    );
   });
 }
};

查找 this.appending 是在实例化时候传入的,断点得悉。这个就是咱们传入的 extensions 配置

const myResolver = ResolverFactory.createResolver({fileSystem: new CachedInputFileSystem(fs, 4000),
 extensions: [".json", ".js", ".ts"]
});

而后断点到此处,看吃进去了啥,吐出来了啥。

而后下一个 hook 是 file,只有一个 NextPlugin 插件监听了此 hook,用来推动流程【下图】。

NextPlugin 插件是将流程 从 file 推向了 final-file hook,走到 3.7 的流程,判断一下带有此后缀的文件是否存在,不存在的话,持续 反复 raw-file hook 的 AppendPlugin 的流程,此时的参数是 this.appending.js【下图】

持续 反复以上的操作:NextPlugin 插件是将流程 从 file 推向了 final-file hook,而后 FileExistsPlugin 插件判断到,此文件存在,推动流程到 existingFile 的 hook,此 hook 有 2 个插件监听【下图】。

3.9 文件存在了,下一步去视察 hook 名为existingFile 上面的插件的工作

先去执行SymlinkPlugin 通过 fs.readlink 办法判断其是否是符号链接下的文件,符号链接 symlink_什么是符号链接或符号链接?如何为 Windows 和 Linux 创立 Symlink?_cunjiu9486 的博客 -CSDN 博客,

再补充一点 硬链接和软链接的区别?– 掘金 (juejin.cn)

对于符号链接这里有非凡阐明,假如你新建了 b.js,删除了当前目录下的 a.js,当前目录状况如下:

建设硬链接 进行测试:

建设软链接,进行测试:

其实软链接,还辨别绝对路径和相对路径的状况【下图】,本次只思考相对路径,大家能够应用绝对路径进行 debug.

咱们进行软链接的 debug,最初发现查找到 b.js 的门路,那么持续 debug。

到此是发现了软链接的源文件,那么下一步必定是判断 此源文件是否是存在,又走到 existingFile的 hook【下图】,反复 3.9 的步骤,又走 SymlinkPlugin 插件的逻辑(放心软链接的源文件还是软链接),

持续 debug SymlinkPlugin,发现走到了 callback() 的状况【下图】,那就是要进入下一个监听者(NextPlugin)了,

NextPlugin中发现终于走到了最初的 hook resolved,只有一个插件 ResultPlugin 进行监听。

进入 ResultPlugin 插件外部,其次要是调用了 result 的 hook,

apply(resolver) {
 this.source.tapAsync(
  "ResultPlugin",
  (request, resolverContext, callback) => {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);
    }
   });
  }
 );
}

debug 一下那些插件监听了此 hook,发现是空的,间接走到本身的 callback 函数里,

持续 debug 此 callback 函数,就会发现这个 callback 在一层一层的向上传递值,接着传到 Resolver 里的 resolve 函数 里,通过 finishResolved 解决解析一次【下图】,最初传递给 咱们本身的 callback 函数里。

debug 停在咱们本人监听的 callback 函数里,至此实现整体流程。

4 完结撒花,回顾总结。

通过一步一步的 debug,会发现 enhance-resolve 这个库,把 tapable 给用的炉火纯青,外围的解决逻辑都在 Resolver 上,而 ResolverFactory 则像是 流水线的 线长,借用Resolver 的能力,去指定流水线的流程,调配流水线每个流程应该合作的工种。

总的逻辑通下来,你会发现,所有的插件都是在对 obj 对象 做数据变更,每个插件都有本人的职责,互不干涉,互不影响,通过 NextPlugin,这个外卖小哥插件,把 数据在各个 hook 流程之间进行流转,进而建设起一套高效的流水线零碎,耦合性低,定制化水平高,功能强大

这里就不画流程图做总结了,偷个懒,因为此文章耗时 7 个小时 左右(啊,我的眼镜),从头到尾 debug 下来,发现播种不少,当前齐全能够模拟此库基于本人的业务流程,开发定制一套属于本人的高效可定制化的可插拔插件的工程。

心愿大家看完此文章会有所播种,缓缓的开始本人的学习源码之路。冲吧,兄弟们。

别忘记思考解答一下结尾的问题,学有所获。下一篇文档 的方向是 解析 webpack 源码。

正文完
 0