太強了,僅3000字解析webpack核心庫enhanced-resolve流程和插拔式外掛機制

付俊奎發表於2023-02-20

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內容如下:

image.png
翻譯為中文就是:

image.png
大家想了解關於原生的 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 年了,竟然還有人不知道這個方法??】

image.png

程式碼拉取完畢以後,觀察專案目錄,發現使用的 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 檔案(不必寫入內容,該庫只做路徑解析), 此時檔案目錄如下:

image.png

執行test-hook.js 輸出如下:

image.png

demo 執行成功,第 2 關透過

3. 開啟 Debug 模式,分析大體邏輯

本人喜歡用 webStorm 進行除錯 (之前是搞 Python 開發的,習慣了)。

3.1 webStorm 使用 debug 模式 (不是本文重點,簡單說明一下)

webStorm 的只需要 當前檔案 下 右擊,然後 點選 Debug test-hook.js 即可

image.png

3.2 vscode 使用 debug 模式

vscode 的 debug 方式很多,這裡只說一個 自帶 debug 終端 的除錯方法,此法也是很方便除錯 node 程式的。

image.png
點選完畢以後,產生一個新的終端:(上面的 ws 地址 請自行探索)

image.png

新的終端預設是在 根目錄下的,隨便在 test-hook.js 打一個 斷點,然後 執行 node 命令:

node demo/test-hook.js

image.png

它就進來了。

3.2 分析大體邏輯

3.2.1 使用 ResolverFactory 工廠類 呼叫 createResolver 方法 建立一個 resolver 例項

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

我看到這段的程式碼的主要邏輯就是去想:這方法吃了啥?吐出了啥?能根據變數名得到啥? 然後再去看方法的大致實現。

  1. 這方法吃了 類似於 webpack resolver 裡的配置
  2. 從命名來猜測 這方法吐出了 一個 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;
};

一個簡單的流程圖如下:

image.png

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 的 unsafeCachefalse 所以此處 執行的是 347 行的程式碼 (關於此引數的作用,先 TODO 下,第一次看原始碼不能追深,應該追廣)。這次要進入ParsePlugin 外掛裡,看它到底實現了哪些邏輯。(優秀的開源庫,關於事件和資料的處理就是這麼 callback,必須耐心 ?)

image.png

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 終於開始解析了。來張流程圖,總結一下全文。

image.png

  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,前期準備工作結束,鏈式呼叫開始,真正的解析檔案的流程也開始。

本文 GitHub 解析地址: fu1996/enhanced-resolve at feature-study-enhanced (github.com),看到這裡,如果感覺頭癢(是要長知識了),學到了一丟丟知識,歡迎各位大佬點start
初步確定下一篇文件:enhance-resolve 中的資料流動。