webpack | loader二三事 | loader是如何執行的

語言: CN / TW / HK

前言

承接上文(介紹babel類別及如何實現,有興趣的同學可以戳它,webpack | loader二三事 | loader類別),繼續我們的babel話題吧

核心:在webpack中,對於loader的處理是基於一個第三方庫loader-runner的,其模組內部匯出了一個函式runLoaders,webpack將loader地址及處理完成後回撥傳遞給它,它則將經過loader處理過後的結果返回給webpack(即回撥引數)。

特點:看文件來唄

image-20201222161532582

總結而言

image-20201222170341597

特性
  • loader是執行在node環境的,也就意味著可以使用所有node的API
  • Plugins可以結合loader使用
  • loader都有options選項,可以傳遞引數

目標

閱讀本文期待你能收穫什麼
  • webpack對loader是如何進行處理的
  • loader中如果存在非同步邏輯(比如請求介面),如何實現中斷迭代
  • 實現一個babel-loader
需實現
  • loader其實就是函式,loader身上會有個pitch方法,執行時會先執行所有loader的pitch函式,執行完就觸發讀取資源的操作,然後將資源交給loader函式,執行所有loader函式

  • 當pitch函式有返回值時,則終止pitch的迭代,開始loader的逆向迭代

  • loader支援非同步處理操作

    • 效果:可以在loader中執行非同步(如setTimeout),然後將繼續執行的控制權交由loader

    • 使用:即loader或pitch執行時的上下文物件(this)存在async函式,呼叫之後會返回一個innnerCallback,loader的迭代將不再執行,直到使用者呼叫innerCallback

    • 例子

      function loader(source) {
          let callback = this.async();
          console.log(new Date());
          setTimeout(()=>{
           callback(
               null,
               source + "//async1"
           )
          },3000)
      }
      複製程式碼

正文

第一階段:實現pitch無返回值且loader無非同步邏輯場景

先看使用
webpack.config.js
module: {
        rules: [
            {
                enforce: 'pre',
                test: /\.js$/,
                use: ["pre-loader","pre-loader2"]
            },
            {
                test: /\.js$/,
                use: ["normal-loader","normal-loader2"]
            },
            {
                enforce: 'post',
                test: /\.js$/,
                use: ["post-loader","post-loader2"]
            }
        ]
    }
複製程式碼
loader(每個loader都是這樣的)
function loader(source) {
    console.log('pre-loader1');
    return source+'//pre-loader1'
}
loader.pitch = function (){
    console.log('pitch pre-loader1');
}
module.exports = loader
複製程式碼
index.js
debugger;
let sum = (a,b) => {
    return a + b;
}
複製程式碼
執行後控制檯列印
pitch pre-loader1
pitch pre-loader1
pitch pre-loader2
pitch pre-loader2
pitch normal-loader1
pitch normal-loader1
pitch normal-loader2
複製程式碼

可以看出,執行順序是:先loader的pitch-》loader本身

核心問題

沒啥核心問題,迭代嘛

解決思路
定義處理loader的入口函式runLoaders
  • 入參:

    • opts

       resource:path.join(__dirname, resource), // 載入資源的絕對路徑
       loaders, // loaders的陣列  也是絕對路徑的陣列
       readResource:fs.readFile.bind(fs)   // 讀取檔案的方法  預設是readFile
      複製程式碼
    • callback

      (err,data)=>{
          if(err){
              console.log(err);
              return;
          }
          let {
            result: [ ], // index.js(入口檔案)的檔案內容
            resourceBuffer: null, // index.js 的 buffer格式
            ...
          } =  data;
      }
      複製程式碼
  • 返回:(即傳給callback的data)

    {
      result: [
        'debugger;\r\n' +
          'let sum = (a,b) => {\r\n' +
          '    return a + b;\r\n' +
          '}//inline-loader2//inline-loader1//pre-loader2//pre-loader1'
      ],
      resourceBuffer: <Buffer 64 65 62 75 67 67 65 72 3b 0d 0a 6c 65 74 20 73 75 6d 20 3d 20 28 61 2c 62 29 20 3d 3e 20 7b 0d 0a 	20 20 20 20 72 65 74 75 72 6e 20 61 20 2b 20 62 3b ... 3 more bytes>
    }
    複製程式碼
實現

傳參階段:1. 整合inlineloader和config檔案中的loader 2. 組裝loaders陣列,由loader檔案的絕對路徑組成

函式實現階段:

  • 根據loader地址陣列建立loader物件陣列

    {
            path: '', // loader絕對路徑
            query: '', // 查詢引數
            fragment: '', // 標識
            normal: '', // normal函式
            pitch: '',  // pitch函式
            raw: '', // 是否是buffer
            data: '', // 自定義物件, 每個loader都會有一個data自定義物件
            pitchExecuted: '', // 當前 loader的pitch函式已經執行過了 不需要在執行
            normalExecuted: '' // 當前 loader的normal函式已經執行過了 不需要在執行
        }
    且存在監聽器
    Object.defineProperty(obj,'request', {
            get(){
                return obj.path + obj.query
            }
    }
    複製程式碼
  • 整合loader的上下文物件,在呼叫loader或者pitch時作為this,關鍵屬性:

    loaders // loader物件組成的陣列
    context  // 指向要載入的資源的目錄  (即index.js的父資料夾的絕對路徑)
    loaderIndex // 當前處理的loader索引  從0開始
    resourcePath // 資源地址 (即index.js的絕對路徑)
    resourceQuery // 查詢引數
    async// 是一個方法,可以設定loader的執行從同步到非同步
    //======= 以下均是一definePropery的形式定義 ==========//
    resource // 資源絕對地址+查詢引數+標識(多數情況為空)
    request  // 所有loader的request結合資源絕對路徑以!拼接成的字串
    remainingRequest // 當前處理loader之後的所有loader的request結合資源絕對路徑以!拼接成的字串
    currentRequest // 當前處理loader及其所有loader的request結合資源絕對路徑以!拼接成的字串 (與remainingRequest相比就多了個本身)
    previousRequest // 已載入過的loader的request結合資源絕對路徑以!拼接成的字串
    query // 如果使用者配了options則用options  否則用使用者的query【即inline】
    data // 當前loader的data
    複製程式碼
  • 定義迭代pitch的函式

    /**
     * 迭代loader的patch函式
     * @param {*} options  webpack自定義的物件 存在兩個引數  resourceBuffer 儲存資源原始資料  readSource  讀取檔案的函式
     * @param {*} loaderContext   loader的上下文物件
     * @param {*} callback   包裹使用者定義回撥函式的函式 執行時會執行使用者的回撥  包裹一層的意義是如果丟擲異常,會將err物件傳給使用者cb,而不是直接報錯  這個callback其實是在迭代loader時才會執行
     */
    function iteratePitchingLoaders(opts,loaderContext,callback) {
        // 如果patch執行完了
        if(loaderContext.loaderIndex >= loaderContext.loaders.length){
    		// 則:先讀取檔案 再迭代執行loader函式
            return processResource(opts,loaderContext,callback);
        }
      
        let currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
        if(currentLoaderObject.pitchExecuted){
            loaderContext.loaderIndex++;
        }
        // 進行loader物件的裝載,主要是三個屬性的賦值
                    //  normal(loader本身)會通過require函式進行獲取
                    //  pitch(pitch函式本身)  從normal上獲取
                    //  raw (設定返回的檔案原始資料的資料型別,預設false 為true時設為buffer)
        loadLoader(currentLoaderObject);
      
        let pitchFunction = currentLoaderObject.pitch;
        currentLoaderObject.pitchExecuted = true;
        // 如果當前loader不存在pitch函式 則繼續向下執行
        if(!pitchFunction){
            return iteratePitchingLoaders(opts,loaderContext,callback);
        }
        // 執行pitch函式,並傳遞引數
        let result = fn.apply(context,[
                loaderContext.remainingRequest,
                loaderContext.previousRequest,
                loaderContext.data={}
            ]);
        // 遞迴
        iteratePitchingLoaders(opts,loaderContext,callback)
    }
    複製程式碼

    其實邏輯上來看還是蠻簡單的,就是以loaderIndex為開關,先++,在執行完所有pitch後就先讀取檔案 再迭代執行loader函式(loaderIndex--);

    但要注意,這裡是特地簡化了的,因為缺少了兩個場景,非同步、和pitch有返回值的情況;它們分別代表著:將繼續迭代的執行權交由loader,以及不迭代之後的pitch而是直接轉為迭代對應的loader前一個loader;後文詳談;接下來,我們這個場景就差兩步了:資原始檔的讀取,loader的迭代

第二階段:資原始檔的讀取

那就進入到processResource函式吧

/**
 * patch執行完成後,先讀取檔案 再迭代執行loader函式
 * @param {*} options
 * @param {*} loaderContext
 * @param {*} callback
 */
function processResource(options,loaderContext,callback) {
    loaderContext.loaderIndex--;
    let resourcePath = loaderContext.resourcePath;
    options.readSource(resourcePath,function(err,buffer) {
        if(err){
            return callback(err)
        }
        //  resourceBuffer 代表 資源原始內容
        options.resourceBuffer = buffer;
        iterateNormalLoaders(
            options,
            loaderContext,
            [buffer],
            callback
        )
    });
}
複製程式碼

第三階段:loader的迭代

loader函式和pitch的處理過程是相似的,就不加贅述了,show the code

/**
 * 迭代loader函式本身
 * @param {*} options  webpack自定義的物件 存在兩個引數  resourceBuffer 儲存資源原始資料  readSource  讀取檔案的函式
 * @param {*} loaderContext   loader的上下文物件
 * @param {*} args   包裹資源原始資料的陣列
 * @param {*} callback   包裹使用者定義回撥函式的函式 執行時會執行使用者的回撥  包裹一層的意義是如果丟擲異常,會將err物件傳給使用者cb,而不是直接報錯
 */
function iterateNormalLoaders(options,loaderContext,args,callback) {

    // 當執行完所有loader 則執行使用者定義回撥並將讀取結果返回
    if(loaderContext.loaderIndex < 0){
        return callback(null,args);
    }

    let currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex];
    if(currentLoaderObject.normalExecuted){
        loaderContext.loaderIndex = loaderContext.loaderIndex - 1;
        return iterateNormalLoaders(options,loaderContext,args,callback);
    }
    let normalFn = currentLoaderObject.normal;
    currentLoaderObject.normalExecuted = true;
    // 設定返回資料的格式 buffer or not
    convertArgs(args,currentLoaderObject.raw);
    runSyncOrAsync(normalFn,loaderContext,args,function(err){
        if(err) return callback(err);
        // 注意第一個是error 需要去掉
        let args = Array.prototype.slice.call(arguments,1);
        iterateNormalLoaders(options,loaderContext,args,callback);
    })

}
複製程式碼

唯一需要注意的就是,要在結束時呼叫使用者傳過來的回撥,將讀取到的檔案資源交還;至此,我們就走通了正常場景下的loader執行啦。

第四階段:實現非同步

為了實現非同步的處理,我們可以聯想到koa中的TJ大神寫的co庫,應用於對promise的遞迴處理,其本質是對next函式的把控,有興趣的同學可見此文KOA核心解析,不去看也沒關係啦,原理和本文實現其實一樣的,主要在於兩點

  1. 不直接呼叫函式,而是用一個函式去包裹,從而進行判斷
  2. 設定開關,預設開啟,開啟時函式的遞迴執行正常呼叫,當用戶呼叫了async函式後則將開關設為false,再將迭代的邏輯包裹在另一個函式中,這樣,只有使用者呼叫了這個內部函式,迭代才會繼續向下執行,就實現了“控制權交還”的邏輯了

話不多說,看程式碼+註釋才清晰明瞭

執行處的改變
 // 原先
 // 執行pitch函式,並傳遞引數
    let result = fn.apply(context,[
            loaderContext.remainingRequest,
            loaderContext.previousRequest,
            loaderContext.data={}
        ]);
    // 遞迴
    iteratePitchingLoaders(opts,loaderContext,callback)
// ================ 之後
// 開始執行pitch函式  為了支援非同步  webpack定義了runSyncOrAsync函式
    runSyncOrAsync(
        pitchFunction, // 要執行的pitch函式
        loaderContext, // 上下文物件
        // 要傳遞給pitchFunction的引數陣列
        [
            loaderContext.remainingRequest,
            loaderContext.previousRequest,
            loaderContext.data={}
        ],
        function(err,args) {
            // 如果args存在,說明這個pitch有返回值
            if(args){
                loaderContext.loaderIndex--;
                processResource(opts,loaderContext,callback)
            }else{// 如果沒有返回值則執行下一個loader的pitch函式
                iteratePitchingLoaders(opts,loaderContext,callback)
            }

        }
    )

複製程式碼
包裹函式runSyncOrAsync
/**
 * 為了支援非同步  webpack定義了runSyncOrAsync函式  在上下文物件上掛載async函式  返回值是一個函式innerCallBack 當innerCallBack執行時pitch的迭代才會向下執行
 *      實現效果:當loader函式在pitch(loader本身迭代時同樣適用)中呼叫this.async()時 pitch函式的迭代被中斷 直到使用者主動呼叫innerCallBack才會繼續執行,並且會將innerCallBack的引數傳遞給callback
 *      實現邏輯:1. 執行pitch函式 獲得pitch返回值
 *                2. 定義開關isSync控制同異步 預設為true
 *                      2.1 為true時直接執行回撥,執行時則會繼續pitch函式的迭代
 *                      2.2 為false時(即使用者呼叫了async)將執行回撥邏輯交給innerCallback
 * @param {*} fn 要執行的pitch函式
 * @param {*} context 上下文物件
 * @param {*} args 要傳遞給pitchFunction的引數陣列
 * @param {*} callback  回撥,執行時則會繼續pitch函式的迭代
 */
function runSyncOrAsync(fn,context,args,callback) {
    let isSync = true; // 預設是同步
    let isDone = false; // 是否完成,是否執行過此函數了
    // 呼叫context.async  this.async  可以吧同步程式設計非同步,表示這個loader裡的程式碼是非同步的
    context.async = function(){
        isSync = false;
        return innerCallback;
    }
    // 返回給使用者  呼叫時才繼續向下執行
    const innerCallback = context.callback = function(){
        isDone = true;
        isSync = false;
        callback.apply(null,arguments); // 執行callback
    }
	// 執行pitch
    let result = fn.apply(context,args);
	// 如果同步開關開啟才繼續迭代執行 否則中斷
    if(isSync){
        isDone = true;

        return callback.apply(null,result && [null,result]);
    }
}
複製程式碼

image-20201223222853194

實現pitch有返回值時中斷返回

有了上面的基礎,這就更簡單了,在包裹函式上肯定可以拿到pitch的返回值,進行判斷,如果有值則loaderIndex--,並直接呼叫proceeResource函式進行讀取資源、loader迭代

loaderContext.loaderIndex--;
processResource(opts,loaderContext,callback)
複製程式碼

第四階段:實現babel-loader

使用自己實現的loader

webpack.config.js 中存在配置resolveLoader,其值是個陣列,作用是定義尋找loader的目錄的優先順序

resolveLoader:{
        modules:[
            'node_modules',
            path.join(__dirname,'./loaders')
        ]
    },
module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {}
                    }
                ]
            }
        ]
    },
複製程式碼
開始實現babel-loader
loader其實就是個函式,所以我們可以匯出個函式
//在loader執行的時候this會指向loaderContext物件,它上面有一個callback方法
function loader(source){
 。。。
}
module.exports = loader;
複製程式碼
內部因為babel核心庫,進行編譯
let babel = require('@babel/core');
//在loader執行的時候this會指向loaderContext物件,它上面有一個callback方法
function loader(source){
  let options={
    presets:["@babel/preset-env"],//配置預設,它是一個外掛包,這裡面外掛
    sourceMap:true,//生成sourcemap檔案 才可以除錯真正的原始碼
    filename:this.resourcePath.split('/').pop()  // 程式碼除錯時可以顯示原始檔名
  };
  //轉換後的es5程式碼  新的source-map檔案 ast抽象語法樹
  let {code,map,ast} = babel.transform(source,options);
  //如果babel轉換後提供了ast抽象語法樹,那麼webpack會直接 使用你這個loader提供 的語法樹
  //而不再需要自己把code再轉成語法樹了
  //內建的
  //當這個loader 返回一個值的時候可以直接 return
  //如果想返回多個值 callback();
  return this.callback(null,code,map,ast);
}
module.exports = loader;
複製程式碼

結尾

言盡於此,loader的處理的大致脈絡我們也就瞭解的差不多啦(反正不止於尬聊面試zzz),困了困了,打完收工

感謝閱讀,希望對社群有微薄貢獻