【webpack進階】你真的掌握了loader麼?- loader十問

AlienZHOU發表於2018-10-14

往期文章:

1. loader 十問

在我學習webpack loader的過程中,也閱讀了網上很多相關文章,收穫不少。但是大多都只介紹了loader的配置方式或者loader的編寫方式,對其中引數、api及其他細節的介紹並不清晰。

這裡有一個「loader十問」,是我在閱讀loader原始碼前心中的部分疑問:

  1. webpack預設配置是在哪處理的,loader有什麼預設配置麼?
  2. webpack中有一個resolver的概念,用於解析模組檔案的真實絕對路徑,那麼loader和普通模組的resolver使用的是同一個麼?
  3. 我們知道,除了config中的loader,還可以寫inline的loader,那麼inline loader和normal config loader執行的先後順序是什麼?
  4. 配置中的module.rules在webpack中是如何生效與實現的?
  5. webpack編譯流程中loader是如何以及在何時發揮作用的?
  6. loader為什麼是自右向左執行的?
  7. 如果在某個pitch中返回值,具體會發生什麼?
  8. 如果你寫過loader,那麼可能在loader function中用到了this,這裡的this究竟是什麼,是webpack例項麼?
  9. loader function中的this.data是如何實現的?
  10. 如何寫一個非同步loader,webpack又是如何實現loader的非同步化的?

也許你也會有類似的疑問。下面我會結合loader相關的部分原始碼,為大家還原loader的設計與實現原理,解答這些疑惑。

2. loader執行的總體流程

webpack編譯流程非常複雜,但其中涉及loader的部分主要包括了:

  • loader(webpack)的預設配置
  • 使用loaderResolver解析loader模組路徑
  • 根據rule.modules建立RulesSet規則集
  • 使用loader-runner執行loader

其對應的大致流程如下:

【webpack進階】你真的掌握了loader麼?- loader十問

首先,在Compiler.js中會為將使用者配置與預設配置合併,其中就包括了loader部分。

然後,webpack就會根據配置建立兩個關鍵的物件——NormalModuleFactoryContextModuleFactory。它們相當於是兩個類工廠,通過其可以建立相應的NormalModuleContextModule。其中NormalModule類是這篇文章主要關注的,webpack會為原始碼中的模組檔案對應生成一個NormalModule例項。

在工廠建立NormalModule例項之前還有一些必要步驟,其中與loader最相關的就是通過loader的resolver來解析loader路徑。

NormalModule例項建立之後,則會通過其.build()方法來進行模組的構建。構建模組的第一步就是使用loader來載入並處理模組內容。而loader-runner這個庫就是webpack中loader的執行器。

最後,將loader處理完的模組內容輸出,進入後續的編譯流程。

上面就是webpack中loader涉及到的大致流程。下面會結合原始碼對其進行具體的分析,而在原始碼閱讀分析過程中,就會找到「loader十問」的解答。

3. loader執行部分的具體分析

3.1. webpack預設配置

Q:1. webpack預設配置是在哪處理的,loader有什麼預設配置麼?

webpack和其他工具一樣,都是通過配置的方式來工作的。隨著webpack的不斷進化,其預設配置也在不斷變動;而曾經版本中的某些最佳實踐,也隨著版本的升級進入了webpack的預設配置。

webpack的入口檔案是lib/webpack.js,會根據配置檔案,設定編譯時的配置options (source code)(上一篇《視覺化展示webpack內部外掛與鉤子關係?》提到的plugin也是在這裡觸發的)

options = new WebpackOptionsDefaulter().process(options);
compiler = new Compiler(options.context);
compiler.options = options;
複製程式碼

由此可見,預設配置是放在WebpackOptionsDefaulter裡的。因此,如果你想要檢視當前webpack預設配置項具體內容,可以在該模組裡檢視。

例如,在module.rules這部分的預設值為[];但是此外還有一個module.defaultRules配置項,雖然不開放給開發者使用,但是包含了loader的預設配置 (source code)

this.set("module.rules", []);
this.set("module.defaultRules", "make", options => [
    {
        type: "javascript/auto",
        resolve: {}
    },
    {
        test: /.mjs$/i,
        type: "javascript/esm",
        resolve: {
            mainFields:
                options.target === "web" ||
                options.target === "webworker" ||
                options.target === "electron-renderer"
                    ? ["browser", "main"]
                    : ["main"]
        }
    },
    {
        test: /.json$/i,
        type: "json"
    },
    {
        test: /.wasm$/i,
        type: "webassembly/experimental"
    }
]);
複製程式碼

此外值得一提的是,WebpackOptionsDefaulter繼承自OptionsDefaulter,而OptionsDefaulter則是一個封裝的配置項存取器,封裝了一些特殊的方法來操作配置物件。

3.2. 建立NormalModuleFactory

NormalModule是webpack中不得不提的一個類函式。原始碼中的模組在編譯過程中會生成對應的NormalModule例項。

NormalModuleFactoryNormalModule的工廠類。其建立是在Compiler.js中進行的,Compiler.js是webpack基本編譯流程的控制類。compiler.run()方法中的主體(鉤子)流程如下:

【webpack進階】你真的掌握了loader麼?- loader十問

.run()在觸發了一系列beforeRunrun等鉤子後,會呼叫.compile()方法,其中的第一步就是呼叫this.newCompilationParams()建立NormalModuleFactory例項。

newCompilationParams() {
    const params = {
        normalModuleFactory: this.createNormalModuleFactory(),
        contextModuleFactory: this.createContextModuleFactory(),
        compilationDependencies: new Set()
    };
    return params;
}
複製程式碼

3.3. 解析(resolve)loader的真實絕對路徑

Q:2. webpack中有一個resolver的概念,用於解析模組檔案的真實絕對路徑,那麼loader模組與normal module(原始碼模組)的resolver使用的是同一個麼?

NormalModuleFactory中,建立出NormalModule例項之前會涉及到四個鉤子:

  • beforeResolve
  • resolve
  • factory
  • afterResolve

其中較為重要的有兩個:

  • resolve部分負責解析loader模組的路徑(例如css-loader這個loader的模組路徑是什麼);
  • factory負責來基於resolve鉤子的返回值來建立NormalModule例項。

resolve鉤子上註冊的方法較長,其中還包括了模組資源本身的路徑解析。resolver有兩種,分別是loaderResolver和normalResolver。

const loaderResolver = this.getResolver("loader");
const normalResolver = this.getResolver("normal", data.resolveOptions);
複製程式碼

由於除了config檔案中可以配置loader外,還有inline loader的寫法,因此,對loader檔案的路徑解析也分為兩種:inline loader和config檔案中的loader。resolver鉤子中會先處理inline loader。

3.3.1. inline loader

import Styles from `style-loader!css-loader?modules!./styles.css`;
複製程式碼

上面是一個inline loader的例子。其中的request為style-loader!css-loader?modules!./styles.css

首先webpack會從request中解析出所需的loader (source code):

let elements = requestWithoutMatchResource
    .replace(/^-?!+/, "")
    .replace(/!!+/g, "!")
    .split("!");
複製程式碼

因此,從style-loader!css-loader?modules!./styles.css中可以取出兩個loader:style-loadercss-loader

然後會將“解析模組的loader陣列”與“解析模組本身”一起並行執行,這裡用到了neo-async這個庫。

neo-async庫和async庫類似,都是為非同步程式設計提供一些工具方法,但是會比async庫更快。

解析返回的結果格式大致如下:

[ 
    // 第一個元素是一個loader陣列
    [ { 
        loader:
            `/workspace/basic-demo/home/node_modules/html-webpack-plugin/lib/loader.js`,
        options: undefined
    } ],
    // 第二個元素是模組本身的一些資訊
    {
        resourceResolveData: {
            context: [Object],
            path: `/workspace/basic-demo/home/public/index.html`,
            request: undefined,
            query: ``,
            module: false,
            file: false,
            descriptionFilePath: `/workspace/basic-demo/home/package.json`,
            descriptionFileData: [Object],
            descriptionFileRoot: `/workspace/basic-demo/home`,
            relativePath: `./public/index.html`,
            __innerRequest_request: undefined,
            __innerRequest_relativePath: `./public/index.html`,
            __innerRequest: `./public/index.html`
        },
	resource: `/workspace/basic-demo/home/public/index.html`
    }
]
複製程式碼

其中第一個元素就是該模組被引用時所涉及的所有inline loader,包含loader檔案的絕對路徑和配置項。

3.3.2. config loader

Q:3. 我們知道,除了config中的loader,還可以寫inline的loader,那麼inline loader和normal config loader執行的先後順序是什麼?

上面一節中,webpack首先解析了inline loader的絕對路徑與配置。接下來則是解析config檔案中的loader (source code),即module.rules部分的配置:

const result = this.ruleSet.exec({
    resource: resourcePath,
    realResource:
        matchResource !== undefined
            ? resource.replace(/?.*/, "")
            : resourcePath,
    resourceQuery,
    issuer: contextInfo.issuer,
    compiler: contextInfo.compiler
});
複製程式碼

NormalModuleFactory中有一個ruleSet的屬性,這裡你可以簡單理解為:它可以根據模組路徑名,匹配出模組所需的loader。RuleSet細節此處先按下不表,其具體內容我會在下一節介紹。

這裡向this.ruleSet.exec()中傳入原始碼模組路徑,返回的result就是當前模組匹配出的config中的loader。如果你熟悉webpack配置,會知道module.rules中有一個enforce欄位。基於該欄位,webpack會將loader分為preLoader、postLoader和loader三種 (source code)

for (const r of result) {
    if (r.type === "use") {
        // post型別
        if (r.enforce === "post" && !noPrePostAutoLoaders) {
            useLoadersPost.push(r.value);
        // pre型別
        } else if (
            r.enforce === "pre" &&
            !noPreAutoLoaders &&
            !noPrePostAutoLoaders
        ) {
            useLoadersPre.push(r.value);
        } else if (
            !r.enforce &&
            !noAutoLoaders &&
            !noPrePostAutoLoaders
        ) {
            useLoaders.push(r.value);
        }
    }
    // ……
}
複製程式碼

最後,使用neo-aysnc來並行解析三類loader陣列 (source code)

asyncLib.parallel(
    [
        this.resolveRequestArray.bind(
            this,
            contextInfo,
            this.context,
            useLoadersPost, // postLoader
            loaderResolver
        ),
        this.resolveRequestArray.bind(
            this,
            contextInfo,
            this.context,
            useLoaders, // loader
            loaderResolver
        ),
        this.resolveRequestArray.bind(
            this,
            contextInfo,
            this.context,
            useLoadersPre, // preLoader
            loaderResolver
        )
    ]
    // ……
}
複製程式碼

那麼最終loader的順序究竟是什麼呢?下面這一行程式碼可以解釋:

loaders = results[0].concat(loaders, results[1], results[2]);
複製程式碼

其中results[0]results[1]results[2]loader分別是postLoader、loader(normal config loader)、preLoader和inlineLoader。因此合併後的loader順序是:post、inline、normal和pre。

然而loader是從右至左執行的,真實的loader執行順序是倒過來的,因此inlineLoader是整體後於config中normal loader執行的。

3.3.3. RuleSet

Q:4. 配置中的module.rules在webpack中是如何生效與實現的?

webpack使用RuleSet物件來匹配模組所需的loader。RuleSet相當於一個規則過濾器,會將resourcePath應用於所有的module.rules規則,從而篩選出所需的loader。其中最重要的兩個方法是:

  • 類靜態方法.normalizeRule()
  • 例項方法.exec()

webpack編譯會根據使用者配置與預設配置,例項化一個RuleSet。首先,通過其上的靜態方法.normalizeRule()將配置值轉換為標準化的test物件;其上還會儲存一個this.references屬性,是一個map型別的儲存,key是loader在配置中的型別和位置,例如,ref-2表示loader配置陣列中的第三個。

p.s. 如果你在.compilation中某個鉤子上列印出一些NormalModule上request相關欄位,那些用到loader的模組會出現類似ref-的值。從這裡就可以看出一個模組是否使用了loader,命中了哪個配置規則。

例項化後的RuleSet就可以用於為每個模組獲取對應的loader。這個例項化的RuleSet就是我們上面提到的NormalModuleFactory例項上的this.ruleSet屬性。工廠每次建立一個新的NormalModule時都會呼叫RuleSet例項的.exec()方法,只有當通過了各類測試條件,才會將該loader push到結果陣列中。

3.4. 執行loader

3.4.1. loader的執行時機

Q:5. webpack編譯流程中loader是如何以及在何時發揮作用的?

loader的絕對路徑解析完畢後,在NormalModuleFactoryfactory鉤子中會建立當前模組的NormalModule物件。到目前為止,loader的前序工作已經差不多結束了,下面就是真正去執行各個loader。

我們都知道,執行loader讀取與處理模組是webpack模組處理的第一步。但如果說到詳細的執行時機,就涉及到webpack編譯中compilation這個非常重要的物件。

webpack是以入口維度進行編譯的,compilation中有一個重要方法——.addEntry(),會基於入口進行模組構建。.addEntry()方法中呼叫的._addModuleChain()會執行一系列的模組方法 (source code)

this.semaphore.acquire(() => {
    moduleFactory.create(
        {
            // ……
        },
        (err, module) => {
            if (err) {
                this.semaphore.release();
                return errorAndCallback(new EntryModuleNotFoundError(err));
            }
            // ……
            if (addModuleResult.build) {
                // 模組構建
                this.buildModule(module, false, null, null, err => {
                    if (err) {
                        this.semaphore.release();
                        return errorAndCallback(err);
                    }

                    if (currentProfile) {
                        const afterBuilding = Date.now();
                        currentProfile.building = afterBuilding - afterFactory;
                    }

                    this.semaphore.release();
                    afterBuild();
                });
            }
        }
    )
}
複製程式碼

其中,對於未build過的模組,最終會呼叫到NormalModule物件的.doBuild()方法。而構建模組(.doBuild())的第一步就是執行所有的loader

這時候,loader-runner就登場了。

3.4.2. loader-runner —— loader的執行庫

Q:6. loader為什麼是自右向左執行的?

【webpack進階】你真的掌握了loader麼?- loader十問

webpack將loader的執行工具剝離出來,獨立成了loader-runner庫。因此,你可以編寫一個loader,並用獨立的loader-runner來測試loader的效果。

loader-runner分為了兩個部分:loadLoader.js與LoaderRunner.js。

loadLoader.js是一個相容性的模組載入器,可以載入例如cjs、esm或SystemJS這種的模組定義。而LoaderRunner.js則是loader模組執行的核心部分。其中暴露出來的.runLoaders()方法則是loader執行的啟動方法。

如果你寫過或瞭解如何編寫一個loader,那麼肯定知道,每個loader模組都支援一個.pitch屬性,上面的方法會優先於loader的實際方法執行。實際上,webpack官方也給出了pitch與loader本身方法的執行順序圖:

|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution
複製程式碼

這兩個階段(pitch和normal)就是loader-runner中對應的iteratePitchingLoaders()iterateNormalLoaders()兩個方法。

iteratePitchingLoaders()會遞迴執行,並記錄loader的pitch狀態與當前執行到的loaderIndexloaderIndex++)。當達到最大的loader序號時,才會處理實際的module:

if(loaderContext.loaderIndex >= loaderContext.loaders.length)
    return processResource(options, loaderContext, callback);
複製程式碼

loaderContext.loaderIndex值達到整體loader陣列長度時,表明所有pitch都被執行完畢(執行到了最後的loader),這時會呼叫processResource()來處理模組資源。主要包括:新增該模組為依賴和讀取模組內容。然後會遞迴執行iterateNormalLoaders()並進行loaderIndex--操作,因此loader會“反向”執行。

接下來,我們討論幾個loader-runner的細節點:

Q:7. 如果在某個pitch中返回值,具體會發生什麼?

官網上說:

if a loader delivers a result in the pitch method the process turns around and skips the remaining loaders

這段說明表示,在pitch中返回值會跳過餘下的loader。這個表述比較粗略,其中有幾個細節點需要說明:

首先,只有當loaderIndex達到最大陣列長度,即pitch過所有loader後,才會執行processResource()

if(loaderContext.loaderIndex >= loaderContext.loaders.length)
    return processResource(options, loaderContext, callback);
複製程式碼

因此,在pitch中返回值除了跳過餘下loader外,不僅會使.addDependency()不觸發(不將該模組資源新增進依賴),而且無法讀取模組的檔案內容。loader會將pitch返回的值作為“檔案內容”來處理,並返回給webpack。


Q:8. 如果你寫過loader,那麼可能在loader function中用到了this,這裡的this究竟是什麼,是webpack例項麼?

其實這裡的this既不是webpack例項,也不是compiler、compilation、normalModule等這些例項。而是一個loaderContext的loader-runner特有物件

每次呼叫runLoaders()方法時,如果不顯式傳入context,則會預設建立一個新的loaderContext。所以在官網上提到的各種loader API(callback、data、loaderIndex、addContextDependency等)都是該物件上的屬性。


Q:9. loader function中的this.data是如何實現的?

知道了loader中的this其實是一個叫loaderContext的物件,那麼this.data的實現其實就是loaderContext.data的實現 (source code)

Object.defineProperty(loaderContext, "data", {
    enumerable: true,
    get: function() {
        return loaderContext.loaders[loaderContext.loaderIndex].data;
    }
});
複製程式碼

這裡定義了一個.data的(存)取器。可以看出,呼叫this.data時,不同的normal loader由於loaderIndex不同,會得到不同的值;而pitch方法的形參data也是不同的loader下的data (source code)

runSyncOrAsync(
    fn,
    loaderContext,
    [loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}],
    function(err) {
        // ……
    }
);
複製程式碼

runSyncOrAsync()中的陣列[loaderContext.remainingRequest, loaderContext.previousRequest, currentLoaderObject.data = {}]就是pitch方法的入參,而currentLoaderObject就是當前loaderIndex所指的loader物件。

因此,如果你想要儲存一個“貫穿始終”的資料,可以考慮儲存在this的其他屬性上,或者通過修改loaderIndex,來取到其他loader上的資料(比較hack)。


Q:10. 如何寫一個非同步loader,webpack又是如何實現loader的非同步化的?

pitch與normal loader的實際執行,都是在runSyncOrAsync()這個方法中。

根據webpack文件,當我們呼叫this.async()時,會將loader變為一個非同步的loader,並返回一個非同步回撥。

在具體實現上,runSyncOrAsync()內部有一個isSync變數,預設為true;當我們呼叫this.async()時,它會被置為false,並返回一個innerCallback作為非同步執行完後的回撥通知:

context.async = function async() {
    if(isDone) {
        if(reportedError) return; // ignore
        throw new Error("async(): The callback was already called.");
    }
    isSync = false;
    return innerCallback;
};
複製程式碼

我們一般都使用this.async()返回的callback來通知非同步完成,但實際上,執行this.callback()也是一樣的效果:

var innerCallback = context.callback = function() {
    // ……
}
複製程式碼

同時,在runSyncOrAsync()中,只有isSync標識為true時,才會在loader function執行完畢後立即(同步)回撥callback來繼續loader-runner。

if(isSync) {
    isDone = true;
    if(result === undefined)
        return callback();
    if(result && typeof result === "object" && typeof result.then === "function") {
        return result.catch(callback).then(function(r) {
            callback(null, r);
        });
    }
    return callback(null, result);
}
複製程式碼

看到這裡你會發現,程式碼裡有一處會判斷返回值是否是Promise(typeof result.then === "function"),如果是Promise則會非同步呼叫callback。因此,想要獲得一個非同步的loader,除了webpack文件裡提到的this.async()方法,還可以直接返回一個Promise。

4. 尾聲

以上就是webapck loader相關部分的原始碼分析。相信到這裡,你已經對最開始的「loader十問」有了答案。希望這篇文章能夠讓你在學會配置loader與編寫一個簡單的loader之外,能進一步瞭解loader的實現。

閱讀原始碼的過程中可能存在一些紕漏,歡迎大家來一起交流。

告別「webpack配置工程師」

webpack是一個強大而複雜的前端自動化工具。其中一個特點就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行?但是,難道你真的只滿足於玩轉webpack配置麼?

顯然不是。在學習如何使用webpack之外,我們更需要深入webpack內部,探索各部分的設計與實現。萬變不離其宗,即使有一天webpack“過氣”了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。因此,在學習webpack過程中,我會總結一系列【webpack進階】的文章和大家分享。

歡迎感興趣的同學多多交流與關注!

往期文章:

相關文章