0. 食用本文的文件說明:
因為篇幅有限,希望你掌握以下前置條件:
- 希望你最好了解 訂閱釋出模型
- 希望你知道
tapable
的 以下 3 個鉤子函式AsyncSeriesBailHook, AsyncSeriesHook, SyncHook
透過本文你將學到如下內容(或者帶著如下疑問去學習)
- 如何
除錯
一個 nodejs 開源庫 - 瞭解
webpack
解析庫 enhance-resolve 的大致工作流程 - 初步瞭解 webpack/enhance-resolve 中
tapable
的使用,以及外掛機制實現的原理
(這裡寫 webpack,是因為二者的外掛機制是一樣的實現原理)
本文 GitHub 解析地址:
fu1996/enhanced-resolve at feature-study-enhanced (github.com),先看全文,再考慮要不要給個star
⭐️。
1. 初步瞭解該庫的作用,明白這個庫是幹啥的?
想初步瞭解一個庫的作用,以及建立初衷,最好的方式就是閱讀當前庫的README.md
(前提是該庫作者維護了此文件 ?)。
README.md
內容如下:
翻譯為中文就是:
大家想了解關於原生的 require.resolve 的介紹可以看這篇文章 ===> node 的路徑解析 require.resolve - 掘金 (juejin.cn)
該庫也是作為 webpack
裡核心的依賴解析庫存在,在 webpack.config.js
裡配置的 resolve
欄位 實際上就是當做引數傳遞給該庫的,所以深入的瞭解一下該庫的工作原理以及外掛機制的實現,也有益於 webpack
的最佳化 和 後期閱讀 webpack
原始碼。
2. 拉取並跑起來一個簡單的 demo,初步瞭解該庫對於 resolve 的 enhance (增強)
GitHub 地址如下:webpack/enhanced-resolve: Offers an async require.resolve function. It's highly configurable. (github.com) PS: 國外訪問較慢,強烈推薦 使用 Gitee
匯入該倉庫 【不會吧,不會吧,都 2023 年了,竟然還有人不知道這個方法??】
程式碼拉取完畢以後,觀察專案目錄,發現使用的 yarn,執行 yarn install
進行安裝依賴安裝。如果沒報錯的話,寫一個簡單的 demo 小試牛刀。
新建一個 demo
資料夾,並建立 test-hook.js
(名稱可以自定義),然後寫入如下內容:
const { ResolverFactory, CachedInputFileSystem } = require("../lib");
const fs = require("fs");
const path = require("path");
const myResolver = ResolverFactory.createResolver({
fileSystem: new CachedInputFileSystem(fs, 4000),
extensions: [".json", ".js", ".ts"],
});
const context = {};
const resolveContext = {};
const lookupStartPath = path.resolve(__dirname);
const request = "./a";
myResolver.resolve(
context,
lookupStartPath,
request,
resolveContext,
(err, path, result) => {
if (err) {
console.log("createResolve err: ", err);
} else {
console.log("createResolve path: ", path);
}
}
);
新建 a.js
檔案(不必寫入內容,該庫只做路徑解析
), 此時檔案目錄如下:
執行test-hook.js
輸出如下:
demo 執行成功
,第 2 關透過
3. 開啟 Debug 模式,分析大體邏輯
本人喜歡用 webStorm 進行除錯 (之前是搞 Python 開發的,習慣了)。
3.1 webStorm 使用 debug 模式 (不是本文重點,簡單說明一下)
webStorm 的只需要 當前檔案 下 右擊
,然後 點選 Debug test-hook.js
即可
’
3.2 vscode 使用 debug 模式
vscode 的 debug 方式很多,這裡只說一個 自帶 debug 終端 的除錯方法,此法也是很方便除錯 node 程式的。
點選完畢以後,產生一個新的終端:(上面的 ws 地址 請自行探索)
新的終端預設是在 根目錄下的,隨便在 test-hook.js 打一個 斷點,然後 執行 node 命令:
node demo/test-hook.js
它就進來了。
3.2 分析大體邏輯
3.2.1 使用 ResolverFactory
工廠類 呼叫 createResolver
方法 建立一個 resolver 例項
const myResolver = ResolverFactory.createResolver({
fileSystem: new CachedInputFileSystem(fs, 4000),
extensions: [".json", ".js", ".ts"],
});
我看到這段的程式碼的主要邏輯就是去想:這方法吃了啥?吐出了啥?能根據變數名得到啥?
然後再去看方法的大致實現。
- 這方法吃了 類似於 webpack resolver 裡的配置
- 從命名來猜測 這方法吐出了 一個 myResolver 的 物件
3.2.2 進入 createResolver
方法 大致分析流程 (進入該方法:按住 Ctrl + 【滑鼠左鍵點選】)
這裡只貼部分核心程式碼
exports.createResolver = function (options) {
// 解析並規範化使用者傳入的配置
const normalizedOptions = createOptions(options);
const {
plugins: userPlugins,
} = normalizedOptions;
// 深複製一下 使用者用到的 plugins
const plugins = userPlugins.slice();
// 根據配置建立 resolver 例項
const resolver = customResolver
? customResolver
: new Resolver(fileSystem, normalizedOptions);
//// pipeline ////
// 確保該 hook 存在,不存在則註冊它
resolver.ensureHook("resolve");
resolver.ensureHook("internalResolve");
// 根據配置 把用到的 內建 plugin 丟到 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"));
}
}
// ...省略部分plugins.push的邏輯程式碼...
//// RESOLVER ////
// 遍歷 plugins 列表 並傳入resolver 例項
for (const plugin of plugins) {
if (typeof plugin === "function") {
// 是函式 this 指向 resolver
plugin.call(resolver, resolver);
} else {
// 是類, 開始呼叫apply 方法 ,apply 方法 會註冊一些 上面ensure的 hook
plugin.apply(resolver);
}
}
// 返回resolve 物件
return resolver;
};
一個簡單的流程圖如下:
plugin.apply(resolver);
所有的事件 都已經成功訂閱。
所有的鉤子都在 resolver
物件 身上了 (子彈已經上膛,準備發射)。
3.3 粗略過下 Resolver
類的方法
我們使用 resolver 的 方式如下:
const context = {};
const resolveContext = {};
const lookupStartPath = path.resolve(__dirname);
const request = "./a";
myResolver.resolve(
context,
lookupStartPath,
request,
resolveContext,
(err, path, result) => {
if (err) {
console.log("createResolve err: ", err);
} else {
console.log("createResolve path: ", path);
}
}
);
那第一步就是 看 resolve
方法
3.3.1 初步瞭解 resolve
方法
核心程式碼如下:
看原始碼時候不能心急,第一步 應該保大丟小,先掌握全域性視角,然後逐個深入,看到後期,會有恍然大悟的感覺,原來那塊寫的是這個意思啊。?
class Resolver {
resolve(context, path, request, resolveContext, callback) {
// 所有流程的核心 就是這個 obj 物件
const obj = {
context: context,
path: path,
request: request,
};
const message = `resolve '${request}' in '${path}'`;
const finishResolved = (result) => {
return callback(
null,
result.path === false
? false
: `${result.path.replace(/#/g, "\0#")}${
result.query ? result.query.replace(/#/g, "\0#") : ""
}${result.fragment || ""}`,
result
);
};
const finishWithoutResolve = (log) => {
/`
* @type {Error & {details?: string}}
*/
const error = new Error("Can't " + message);
error.details = log.join("\n");
this.hooks.noResolve.call(obj, error);
return callback(error);
};
if (resolveContext.log) {
// We need log anyway to capture it in case of an error
const parentLog = resolveContext.log;
const log = [];
return this.doResolve(
this.hooks.resolve,
obj,
message,
{
log: (msg) => {
parentLog(msg);
log.push(msg);
},
yield: yield_,
fileDependencies: resolveContext.fileDependencies,
contextDependencies: resolveContext.contextDependencies,
missingDependencies: resolveContext.missingDependencies,
stack: resolveContext.stack,
},
(err, result) => {
if (err) return callback(err);
if (yieldCalled || (result && yield_)) return finishYield(result);
if (result) return finishResolved(result);
return finishWithoutResolve(log);
}
);
} else {
// Try to resolve assuming there is no error
// We don't log stuff in this case
return this.doResolve(
this.hooks.resolve,
obj,
message,
{
log: undefined,
yield: yield_,
fileDependencies: resolveContext.fileDependencies,
contextDependencies: resolveContext.contextDependencies,
missingDependencies: resolveContext.missingDependencies,
stack: resolveContext.stack,
},
(err, result) => {
if (err) return callback(err);
if (yieldCalled || (result && yield_)) return finishYield(result);
if (result) return finishResolved(result);
// log is missing for the error details
// so we redo the resolving for the log info
// this is more expensive to the success case
// is assumed by default
const log = [];
return this.doResolve(
this.hooks.resolve,
obj,
message,
{
log: (msg) => log.push(msg),
yield: yield_,
stack: resolveContext.stack,
},
(err, result) => {
if (err) return callback(err);
// In a case that there is a race condition and yield will be called
if (yieldCalled || (result && yield_)) return finishYield(result);
return finishWithoutResolve(log);
}
);
}
);
}
}
}
大致看完,發現這一步其實也是根據不同的條件去組裝資料,把傳入的資料,賦值到 obj 物件上,然後把 obj 物件傳入doResolve
方法,當做此方法的第二個引數,真正呼叫的還是 doResolve
方法,下一步就是大致瞅下doResolve
方法。
3.3.2 初步瞭解 doResolve
方法
上面resolve
傳遞的 obj 物件作為 doResolve 的第二個引數,命名為:request
,一起來看下。
doResolve(hook, request, message, resolveContext, callback) {
// 靜態方法 根據當前 hook 資訊 生成 呼叫棧資訊
const stackEntry = Resolver.createStackEntry(hook, request);
let newStack;
// 當前 hook 呼叫棧資訊 存入 newStack 裡
if (resolveContext.stack) {
newStack = new Set(resolveContext.stack);
if (resolveContext.stack.has(stackEntry)) {
/`
* Prevent recursion
* @type {Error & {recursion?: boolean}}
*/
const recursionError = new Error(
"Recursion in resolving\nStack:\n " +
Array.from(newStack).join("\n ")
);
recursionError.recursion = true;
if (resolveContext.log)
resolveContext.log("abort resolving because of recursion");
return callback(recursionError);
}
newStack.add(stackEntry);
} else {
newStack = new Set([stackEntry]);
}
// 傳入 hook, request 呼叫 resolveStep 的 hook
this.hooks.resolveStep.call(hook, request);
// 如果當前hook 被使用了
if (hook.isUsed()) {
const innerContext = createInnerContext(
{
log: resolveContext.log,
yield: resolveContext.yield,
fileDependencies: resolveContext.fileDependencies,
contextDependencies: resolveContext.contextDependencies,
missingDependencies: resolveContext.missingDependencies,
stack: newStack
},
message
);
// 觸發當前hook 並傳入 request 和 innerContext 當做引數
return hook.callAsync(request, innerContext, (err, result) => {
if (err) return callback(err);
if (result) return callback(null, result);
callback();
});
} else {
// 執行 callback 邏輯
callback();
}
}
callback
的邏輯比較簡單,我們應該看當前 hook
(指的是:this.hooks.resolve)被使用的時候,resolve 的處理邏輯。
關鍵程式碼如下:
hook.callAsync(request, innerContext, (err, result) => {
當前 hook 直接呼叫了 callAsync
進行了 觸發之前 plugin
的訂閱事件,這時候我們要去找到之前 plugin.apply(resolver);
的時候,哪一個 plugin
的訂閱型別 為resolve
事件。
3.3.3 去 ResolverFactory.js
檔案尋找註冊了 resolve 事件的 鉤子
場景切回到 ResolverFactory.js
檔案,顯而易見的在 327
行左右 看到了這個註冊事件,此 demo 的 unsafeCache
為false
所以此處 執行的是 347
行的程式碼 (關於此引數的作用,先 TODO 下
,第一次看原始碼不能追深,應該追廣)。這次要進入ParsePlugin
外掛裡,看它到底實現了哪些邏輯。(優秀的開源庫,關於事件和資料的處理就是這麼 callback
,必須耐心 ?)
3.3.4 去 ParsePlugin
外掛裡,看最後一層的處理邏輯,實現閉環
ParsePlugin
外掛,是當前主流程 閉環的結束,也是 檔案解析
流程的開始,因為 從文章開頭開始到現在,還沒有真正的針對 檔案解析
相關的事情 做相關操作,全是在註冊一些 hook
,例項化 Resolve
物件,處理格式化入參。
上程式碼,看具體邏輯,現身吧 我的小寶貝
:
/*
MIT License http://www.opensource.org/licenses/mit-license.php
Author Tobias Koppers @sokra
*/
"use strict";
/` @typedef {import("./Resolver")} Resolver */
/` @typedef {import("./Resolver").ResolveRequest} ResolveRequest */
/` @typedef {import("./Resolver").ResolveStepHook} ResolveStepHook */
module.exports = class ParsePlugin {
/`
* @param {string | ResolveStepHook} source source
* @param {Partial<ResolveRequest>} requestOptions request options
* @param {string | ResolveStepHook} target target
*/
constructor(source, requestOptions, target) {
// 接受引數 並繫結到this 上
this.source = source;
this.requestOptions = requestOptions;
this.target = target;
}
/`
* @param {Resolver} resolver the resolver
* @returns {void}
*/
apply(resolver) {
// 這個resolver 就是之前 建立的 Resolver 的實體類
const target = resolver.ensureHook(this.target);
resolver
// 得到 this.source 對應的 hook
.getHook(this.source)
// 監聽 this.source 對應的 hook,並設定 訂閱函式
.tapAsync("ParsePlugin", (request, resolveContext, callback) => {
// 先初步解析 得到大致結果:
const parsed = resolver.parse(/` @type {string} */ (request.request));
// 合併引數
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: ""
};
// 這個 hook 做完了 它該做的事情了 進入 this.target 的 hook 邏輯吧,
// 並把當前hook 處理過的結果傳遞給this.target 的 hook
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);
});
}
};
你會發現這個外掛 確實開始 進行 request
欄位的解析了,終於 它開始分析你在 test-hook.js
傳入的 "./a" 到底是資料夾,還是檔案了。?
const request = "./a";
在該外掛又經過一系列的解析以後,發現又開始使用 resolver.doResolve
方法 流轉到 this.target
的 hook 了。
場景回溯:
先回溯一下當前的 this.target 是代表的那個引數?
plugins.push(new ParsePlugin(source, resolveOptions, "parsed-resolve"));
然後回想一下resolver.doResolve
方法做了啥? 此時 hook 的入參是 "parsed-resolve"
, request
引數代表的是 resolve
hook 處理過的 alternative
變數。
doResolve(hook, request, message, resolveContext, callback) {
// 靜態方法 根據當前 hook 資訊 生成 呼叫棧資訊
const stackEntry = Resolver.createStackEntry(hook, request);
let newStack;
// 當前 hook 呼叫棧資訊 存入 newStack 裡
if (resolveContext.stack) {
newStack = new Set(resolveContext.stack);
if (resolveContext.stack.has(stackEntry)) {
/`
* Prevent recursion
* @type {Error & {recursion?: boolean}}
*/
const recursionError = new Error(
"Recursion in resolving\nStack:\n " +
Array.from(newStack).join("\n ")
);
recursionError.recursion = true;
if (resolveContext.log)
resolveContext.log("abort resolving because of recursion");
return callback(recursionError);
}
newStack.add(stackEntry);
} else {
newStack = new Set([stackEntry]);
}
// 傳入 hook, request 呼叫 resolveStep 的 hook
this.hooks.resolveStep.call(hook, request);
// 如果當前hook 被使用了
if (hook.isUsed()) {
const innerContext = createInnerContext(
{
log: resolveContext.log,
yield: resolveContext.yield,
fileDependencies: resolveContext.fileDependencies,
contextDependencies: resolveContext.contextDependencies,
missingDependencies: resolveContext.missingDependencies,
stack: newStack
},
message
);
// 觸發當前hook 並傳入 request 和 innerContext 當做引數
return hook.callAsync(request, innerContext, (err, result) => {
if (err) return callback(err);
if (result) return callback(null, result);
callback();
});
} else {
// 執行 callback 邏輯
callback();
}
}
所以當前的this.target
指的是parsed-resolve
相關的 hook,相當的見名知意。至於接下來的流程,打算另開一篇文章去 解說 resolver
詳細的 hook
流轉過程,感興趣的兄弟們可以自己拉程式碼進行學習。
4. 完結撒花
終於,經過了一路的兜兜轉轉,這個 resolve 終於開始解析了。來張流程圖,總結一下全文。
- ResolverFactory.createResolver 根據
Resolver
類建立例項:myResolve
(吃了配置,吐出物件myResolve
) myResolve 上 註冊並訂閱
大量的 hook (槍支彈藥貯備好,一刻激發)- 呼叫
myResolver.resolve
方法開始進行 檔案解析 的主流程 - 內部透過
resolve.doResolve
方法,開始呼叫第一個 hook:this.hooks.resolve
- 找到之前 訂閱 hook 的 plugin:
ParsePlugin
ParsePlugin
進行初步解析,然後 透過doResolve
執行下一個 hookparsed-resolve
,前期準備工作結束,鏈式呼叫開始,真正的解析檔案的流程
也開始。
本文 GitHub 解析地址:
fu1996/enhanced-resolve at feature-study-enhanced (github.com),看到這裡,如果感覺頭癢(是要長知識了),學到了一丟丟知識,歡迎各位大佬點start
。
初步確定下一篇文件:enhance-resolve 中的資料流動。