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

往期文章:javascript

1. loader 十問

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

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

  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的部分主要包括了:java

  • loader(webpack)的默認配置
  • 使用loaderResolver解析loader模塊路徑
  • 根據rule.modules建立RulesSet規則集
  • 使用loader-runner運行loader

其對應的大體流程以下:node

首先,在Compiler.js中會爲將用戶配置與默認配置合併,其中就包括了loader部分。webpack

而後,webpack就會根據配置建立兩個關鍵的對象——NormalModuleFactoryContextModuleFactory。它們至關因而兩個類工廠,經過其能夠建立相應的NormalModuleContextModule。其中NormalModule類是這篇文章主要關注的,webpack會爲源碼中的模塊文件對應生成一個NormalModule實例。git

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

NormalModule實例建立以後,則會經過其.build()方法來進行模塊的構建。構建模塊的第一步就是使用loader來加載並處理模塊內容。而loader-runner這個庫就是webpack中loader的運行器。web

最後,將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()方法中的主體(鉤子)流程以下:

.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-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進階】的文章和你們分享。

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

往期文章:

相關文章
相關標籤/搜索