1. 程式人生 > 實用技巧 >Webpack原理—編寫Loader和Plugin

Webpack原理—編寫Loader和Plugin

編寫 Loader

Loader就像是一個翻譯員,能把原始檔經過轉化後輸出新的結果,並且一個檔案還可以鏈式的經過多個翻譯員翻譯。
以處理SCSS檔案為例:

  1. SCSS原始碼會先交給sass-loader把SCSS轉換成CSS;
  2. sass-loader輸出的CSS交給css-loader處理,找出CSS中依賴的資源、壓縮CSS等;
  3. css-loader輸出的CSS交給style-loader處理,轉換成通過指令碼載入的JavaScript程式碼;

可以看出以上的處理過程需要有順序的鏈式執行,先sass-loadercss-loaderstyle-loader。 以上處理的Webpack相關配置如下:

module.exports = {
  module: {
    rules: [
      {
        // 增加對 SCSS 檔案的支援
        test: /\.scss/,
        // SCSS 檔案的處理順序為先 sass-loader 再 css-loader 再 style-loader
        use: [
          'style-loader',
          {
            loader:'css-loader',
            // 給 css-loader 傳入配置項
            options:{
              minimize:true, 
            }
          },
          'sass-loader'],
      },
    ]
  },
};

  

Loader的職責

由上面的例子可以看出:一個Loader的職責是單一的,只需要完成一種轉換。 如果一個原始檔需要經歷多步轉換才能正常使用,就通過多個Loader去轉換。 在呼叫多個Loader去轉換一個檔案時,每個Loader會鏈式的順序執行, 第一個Loader將會拿到需處理的原內容,上一個Loader處理後的結果會傳給下一個接著處理,最後的Loader將處理後的最終結果返回給Webpack。
所以,在你開發一個Loader時,請保持其職責的單一性,你只需關心輸入和輸出。

Loader基礎

由於Webpack是執行在Node.js之上的,一個Loader其實就是一個Node.js模組,這個模組需要匯出一個函式。 這個匯出的函式的工作就是獲得處理前的原內容,對原內容執行處理後,返回處理後的內容。
一個最簡單的Loader

的原始碼如下:

module.exports = function(source) {
  // source 為 compiler 傳遞給 Loader 的一個檔案的原內容
  // 該函式需要返回處理後的內容,這裡簡單起見,直接把原內容返回了,相當於該`Loader`沒有做任何轉換
  return source;
};

  

由於Loader執行在Node.js中,你可以呼叫任何Node.js自帶的API,或者安裝第三方模組進行呼叫:

const sass = require('node-sass');
module.exports = function(source) {
  return sass(source);
};

  

Loader進階

Webpack還提供一些API供Loader呼叫。

獲得Loader的options

在最上面處理SCSS檔案的Webpack配置中,給css-loader傳了options引數,以控制css-loader。要在自己編寫的Loader中獲取到使用者傳入的options,需要這樣做:

const loaderUtils = require('loader-utils');
module.exports = function(source) {
  // 獲取到使用者給當前 Loader 傳入的 options
  const options = loaderUtils.getOptions(this);
  return source;
};

  

返回其它結果

上面的Loader都只是返回了原內容轉換後的內容,但有些場景下還需要返回除了內容之外的東西。
例如以用babel-loader轉換ES6程式碼為例,它還需要輸出轉換後的ES5程式碼對應的Source Map,以方便除錯原始碼。 為了把Source Map也一起隨著ES5程式碼返回給Webpack,可以這樣寫:

module.exports = function(source) {
  // 通過 this.callback 告訴 Webpack 返回的結果
  this.callback(null, source, sourceMaps);
  // 當你使用 this.callback 返回內容時,該 Loader 必須返回 undefined,
  // 以讓 Webpack 知道該 Loader 返回的結果在 this.callback 中,而不是 return 中 
  return;
};

  

其中的this.callback是Webpack給Loader注入的API,以方便Loader和Webpack之間通訊。this.callback的詳細使用方法如下:

this.callback(
    // 當無法轉換原內容時,給 Webpack 返回一個 Error
    err: Error | null,
    // 原內容轉換後的內容
    content: string | Buffer,
    // 用於把轉換後的內容得出原內容的 Source Map,方便除錯
    sourceMap?: SourceMap,
    // 如果本次轉換為原內容生成了 AST 語法樹,可以把這個 AST 返回,
    // 以方便之後需要 AST 的 Loader 複用該 AST,以避免重複生成 AST,提升效能
    abstractSyntaxTree?: AST
);

  

Source Map的生成很耗時,通常在開發環境下才會生成Source Map,其它環境下不用生成,以加速構建。 為此Webpack為Loader提供了this.sourceMap API去告訴Loader當前構建環境下使用者是否需要Source Map。

同步與非同步

Loader有同步和非同步之分,上面介紹的Loader都是同步的Loader,因為它們的轉換流程都是同步的,轉換完成後再返回結果。 但在有些場景下轉換的步驟只能是非同步完成的,例如你需要通過網路請求才能得出結果,如果採用同步的方式網路請求就會阻塞整個構建,導致構建非常緩慢。
在轉換步驟是非同步時,你可以這樣:

module.exports = function(source) {
    // 告訴 Webpack 本次轉換是非同步的,Loader 會在 callback 中回撥結果
    var callback = this.async();
    someAsyncOperation(source, function(err, result, sourceMaps, ast) {
        // 通過 callback 返回非同步執行後的結果
        callback(err, result, sourceMaps, ast);
    });
};

  

處理二進位制資料

在預設的情況下,Webpack傳給Loader的原內容都是UTF-8格式編碼的字串。 但有些場景下Loader不是處理文字檔案,而是處理二進位制檔案,例如file-loader,就需要Webpack給Loader傳入二進位制格式的資料。 為此,你需要這樣編寫Loader

module.exports = function(source) {
    // 在 exports.raw === true 時,Webpack 傳給 Loader 的 source 是 Buffer 型別的
    source instanceof Buffer === true;
    // Loader 返回的型別也可以是 Buffer 型別的
    // 在 exports.raw !== true 時,Loader 也可以返回 Buffer 型別的結果
    return source;
};
// 通過 exports.raw 屬性告訴 Webpack 該 Loader 是否需要二進位制資料 
module.exports.raw = true;

  

以上程式碼中最關鍵的程式碼是最後一行module.exports.raw = true;,沒有該行Loader只能拿到字串。

快取加速

在有些情況下,有些轉換操作需要大量計算非常耗時,如果每次構建都重新執行重複的轉換操作,構建將會變得非常緩慢。為此,Webpack會預設快取所有Loader的處理結果,也就是說在需要被處理的檔案或者其依賴的檔案沒有發生變化時, 是不會重新呼叫對應的Loader去執行轉換操作的。
如果想讓Webpack不快取該Loader的處理結果,可以這樣:

module.exports = function(source) {
  // 關閉該 Loader 的快取功能
  this.cacheable(false);
  return source;
};

  

其它Loader API

除了以上提到的在Loader中能呼叫的Webpack API外,還存在以下常用API:

  • this.context:當前處理檔案的所在目錄,假如當前Loader處理的檔案是/src/main.js,則this.context就等於/src
  • this.resource:當前處理檔案的完整請求路徑,包括querystring,例如/src/main.js?name=1
  • this.resourcePath:當前處理檔案的路徑,例如/src/main.js
  • this.resourceQuery:當前處理檔案的querystring
  • this.target:等於Webpack配置中的Target
  • this.loadModule:當Loader在處理一個檔案時,如果依賴其它檔案的處理結果才能得出當前檔案的結果時, 就可以通過this.loadModule(request: string, callback: function(err, source, sourceMap, module))去獲得request對應檔案的處理結果。
  • this.resolve:像require語句一樣獲得指定檔案的完整路徑,使用方法為resolve(context: string, request: string, callback: function(err, result: string))
  • this.addDependency:給當前處理檔案新增其依賴的檔案,以便再其依賴的檔案發生變化時,會重新呼叫Loader處理該檔案。使用方法為addDependency(file: string)
  • this.addContextDependency:和addDependency類似,但addContextDependency是把整個目錄加入到當前正在處理檔案的依賴中。使用方法為addContextDependency(directory: string)
  • this.clearDependencies:清除當前正在處理檔案的所有依賴,使用方法為clearDependencies()
  • this.emitFile:輸出一個檔案,使用方法為emitFile(name: string, content: Buffer|string, sourceMap: {...})

載入本地Loader

在開發Loader的過程中,為了測試編寫的Loader是否能正常工作,需要把它配置到Webpack中後,才可能會呼叫該Loader。使用的Loader都是通過Npm安裝的,要使用Loader時會直接使用Loader的名稱,程式碼如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css/,
        use: ['style-loader'],
      },
    ]
  },
};

  

如果還採取以上的方法去使用本地開發的Loader將會很麻煩,因為你需要確保編寫的Loader的原始碼是在node_modules目錄下。 為此你需要先把編寫的Loader釋出到Npm倉庫後再安裝到本地專案使用。
解決以上問題的便捷方法有兩種,分別如下:

Npm link

Npm link專門用於開發和除錯本地Npm模組,能做到在不釋出模組的情況下,把本地的一個正在開發的模組的原始碼連結到專案的node_modules目錄下,讓專案可以直接使用本地的Npm模組。 由於是通過軟連結的方式實現的,編輯了本地的Npm模組程式碼,在專案中也能使用到編輯後的程式碼。
完成Npm link的步驟如下:

  1. 確保正在開發的本地Npm模組(也就是正在開發的Loader)的package.json已經正確配置好;
  2. 在本地Npm模組根目錄下執行npm link,把本地模組註冊到全域性;
  3. 在專案根目錄下執行npm link loader-name,把第2步註冊到全域性的本地Npm模組連結到專案的node_moduels下,其中的loader-name是指在第1步中的package.json檔案中配置的模組名稱。

連結好Loader到專案後你就可以像使用一個真正的 Npm 模組一樣使用本地的Loader了。

ResolveLoader

ResolveLoader用於配置Webpack如何尋找Loader。 預設情況下只會去node_modules目錄下尋找,為了讓Webpack載入放在本地專案中的Loader需要修改resolveLoader.modules
假如本地的Loader在專案目錄中的./loaders/loader-name中,則需要如下配置:

module.exports = {
  resolveLoader:{
    // 去哪些目錄下尋找 Loader,有先後順序之分
    modules: ['node_modules','./loaders/'],
  }
}

  

加上以上配置後,Webpack會先去node_modules專案下尋找Loader,如果找不到,會再去./loaders/目錄下尋找。

實戰

接下來從實際出發,來編寫一個解決實際問題的Loader
Loader名叫comment-require-loader,作用是把JavaScript程式碼中的註釋語法

// @require '../style/index.css'

  

轉換成

require('../style/index.css');

  

Loader的使用方法如下:

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['comment-require-loader'],
        // 針對採用了 fis3 CSS 匯入語法的 JavaScript 檔案通過 comment-require-loader 去轉換 
        include: [path.resolve(__dirname, 'node_modules/imui')]
      }
    ]
  }
};

  

Loader的實現非常簡單,完整程式碼如下:

function replace(source) {
    // 使用正則把 // @require '../style/index.css' 轉換成 require('../style/index.css');  
    return source.replace(/(\/\/ *@require) +(('|").+('|")).*/, 'require($2);');
}
module.exports = function (content) {
    return replace(content);
};

  

編寫Plugin

Webpack通過Plugin機制讓其更加靈活,以適應各種應用場景。 在Webpack執行的生命週期中會廣播出許多事件,Plugin可以監聽這些事件,在合適的時機通過Webpack提供的API改變輸出結果。
一個最基礎的Plugin的程式碼是這樣的:

class BasicPlugin{
  // 在建構函式中獲取使用者給該外掛傳入的配置
  constructor(options){
  }

  // Webpack 會呼叫 BasicPlugin 例項的 apply 方法給外掛例項傳入 compiler 物件
  apply(compiler){
    compiler.plugin('compilation',function(compilation) {
    })
  }
}
// 匯出 Plugin
module.exports = BasicPlugin;

  

在使用這個Plugin時,相關配置程式碼如下:

const BasicPlugin = require('./BasicPlugin.js');
module.export = {
  plugins:[
    new BasicPlugin(options),
  ]
}

  

Webpack啟動後,在讀取配置的過程中會先執行new BasicPlugin(options)初始化一個BasicPlugin獲得其例項。 在初始化compiler物件後,再呼叫basicPlugin.apply(compiler)給外掛例項傳入compiler物件。 外掛例項在獲取到compiler物件後,就可以通過compiler.plugin(事件名稱, 回撥函式)監聽到Webpack廣播出來的事件。 並且可以通過compiler物件去操作Webpack。

Compiler和Compilation

在開發Plugin時最常用的兩個物件就是CompilerCompilation,它們是Plugin和Webpack之間的橋樑。CompilerCompilation的含義如下:

  • Compiler物件包含了Webpack環境所有的的配置資訊,包含optionsloadersplugins這些資訊,這個物件在Webpack啟動時候被例項化,它是全域性唯一的,可以簡單地把它理解為Webpack例項;
  • Compilation物件包含了當前的模組資源、編譯生成資源、變化的檔案等。當Webpack以開發模式執行時,每當檢測到一個檔案變化,一次新的Compilation將被建立。Compilation物件也提供了很多事件回撥供外掛做擴充套件。通過Compilation也能讀取到Compiler物件。

CompilerCompilation的區別在於:Compiler代表了整個Webpack從啟動到關閉的生命週期,而Compilation只是代表了一次新的編譯。

事件流

Webpack就像一條生產線,要經過一系列處理流程後才能將原始檔轉換成輸出結果。 這條生產線上的每個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。 外掛就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源做處理。
Webpack通過Tapable來組織這條複雜的生產線。 Webpack在執行過程中會廣播事件,外掛只需要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運作。 Webpack的事件流機制保證了外掛的有序性,使得整個系統擴充套件性很好。
Webpack的事件流機制應用了觀察者模式,和Node.js中的EventEmitter非常相似。CompilerCompilation都繼承自Tapable,可以直接在CompilerCompilation物件上廣播和監聽事件,方法如下:

/**
* 廣播出事件
* event-name 為事件名稱,注意不要和現有的事件重名
* params 為附帶的引數
*/
compiler.apply('event-name',params);

/**
* 監聽名稱為 event-name 的事件,當 event-name 事件發生時,函式就會被執行。
* 同時函式中的 params 引數為廣播事件時附帶的引數。
*/
compiler.plugin('event-name',function(params) {

});

  

同理,compilation.applycompilation.plugin使用方法和上面一致。
在開發外掛時,你可能會不知道該如何下手,因為你不知道該監聽哪個事件才能完成任務。
在開發外掛時,還需要注意以下兩點:

  • 只要能拿到CompilerCompilation物件,就能廣播出新的事件,所以在新開發的外掛中也能廣播出事件,給其它外掛監聽使用。
  • 傳給每個外掛的CompilerCompilation物件都是同一個引用。也就是說在一個外掛中修改了CompilerCompilation物件上的屬性,會影響到後面的外掛。
  • 有些事件是非同步的,這些非同步的事件會附帶兩個引數,第二個引數為回撥函式,在外掛處理完任務時需要呼叫回撥函式通知Webpack,才會進入下一處理流程。例如:
compiler.plugin('emit',function(compilation, callback) {
  // 支援處理邏輯
  // 處理完畢後執行 callback 以通知 Webpack 
  // 如果不執行 callback,執行流程將會一直卡在這不往下執行 
  callback();
});

  

常用API

外掛可以用來修改輸出檔案、增加輸出檔案、甚至可以提升Webpack效能、等等,總之外掛通過呼叫 Webpack提供的API能完成很多事情。 由於Webpack提供的API非常多,有很多API很少用的上,又加上篇幅有限,下面來介紹一些常用的API。

讀取輸出資源、程式碼塊、模組及其依賴

有些外掛可能需要讀取Webpack的處理結果,例如輸出資源、程式碼塊、模組及其依賴,以便做下一步處理。
emit事件發生時,代表原始檔的轉換和組裝已經完成,在這裡可以讀取到最終將輸出的資源、程式碼塊、模組及其依賴,並且可以修改輸出資源的內容。 外掛程式碼如下:

class Plugin {
  apply(compiler) {
    compiler.plugin('emit', function (compilation, callback) {
      // compilation.chunks 存放所有程式碼塊,是一個數組
      compilation.chunks.forEach(function (chunk) {
        // chunk 代表一個程式碼塊
        // 程式碼塊由多個模組組成,通過 chunk.forEachModule 能讀取組成程式碼塊的每個模組
        chunk.forEachModule(function (module) {
          // module 代表一個模組
          // module.fileDependencies 存放當前模組的所有依賴的檔案路徑,是一個數組
          module.fileDependencies.forEach(function (filepath) {
          });
        });

        // Webpack 會根據 Chunk 去生成輸出的檔案資源,每個 Chunk 都對應一個及其以上的輸出檔案
        // 例如在 Chunk 中包含了 CSS 模組並且使用了 ExtractTextPlugin 時,
        // 該 Chunk 就會生成 .js 和 .css 兩個檔案
        chunk.files.forEach(function (filename) {
          // compilation.assets 存放當前所有即將輸出的資源
          // 呼叫一個輸出資源的 source() 方法能獲取到輸出資源的內容
          let source = compilation.assets[filename].source();
        });
      });

      // 這是一個非同步事件,要記得呼叫 callback 通知 Webpack 本次事件監聽處理結束。
      // 如果忘記了呼叫 callback,Webpack 將一直卡在這裡而不會往後執行。
      callback();
    })
  }
}

  

監聽檔案變化

Webpack會從配置的入口模組出發,依次找出所有的依賴模組,當入口模組或者其依賴的模組發生變化時, 就會觸發一次新的Compilation
在開發外掛時經常需要知道是哪個檔案發生變化導致了新的Compilation,為此可以使用如下程式碼:

// 當依賴的檔案發生變化時會觸發 watch-run 事件
compiler.plugin('watch-run', (watching, callback) => {
    // 獲取發生變化的檔案列表
    const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
    // changedFiles 格式為鍵值對,鍵為發生變化的檔案路徑。
    if (changedFiles[filePath] !== undefined) {
      // filePath 對應的檔案發生了變化
    }
    callback();
});

  

預設情況下Webpack只會監視入口和其依賴的模組是否發生變化,在有些情況下專案可能需要引入新的檔案,例如引入一個HTML檔案。 由於 JavaScript 檔案不會去匯入HTML檔案,Webpack就不會監聽HTML檔案的變化,編輯HTML檔案時就不會重新觸發新的Compilation。 為了監聽HTML檔案的變化,我們需要把HTML檔案加入到依賴列表中,為此可以使用如下程式碼:

compiler.plugin('after-compile', (compilation, callback) => {
  // 把 HTML 檔案新增到檔案依賴列表,好讓 Webpack 去監聽 HTML 模組檔案,在 HTML 模版檔案發生變化時重新啟動一次編譯
    compilation.fileDependencies.push(filePath);
    callback();
});

  

修改輸出資源

有些場景下外掛需要修改、增加、刪除輸出的資源,要做到這點需要監聽emit事件,因為發生emit事件時所有模組的轉換和程式碼塊對應的檔案已經生成好, 需要輸出的資源即將輸出,因此emit事件是修改Webpack輸出資源的最後時機。
所有需要輸出的資源會存放在compilation.assets中,compilation.assets是一個鍵值對,鍵為需要輸出的檔名稱,值為檔案對應的內容。
設定compilation.assets的程式碼如下:

compiler.plugin('emit', (compilation, callback) => {
  // 設定名稱為 fileName 的輸出資源
  compilation.assets[fileName] = {
    // 返回檔案內容
    source: () => {
      // fileContent 既可以是代表文字檔案的字串,也可以是代表二進位制檔案的 Buffer
      return fileContent;
      },
    // 返回檔案大小
      size: () => {
      return Buffer.byteLength(fileContent, 'utf8');
    }
  };
  callback();
});

  

讀取compilation.assets的程式碼如下:

compiler.plugin('emit', (compilation, callback) => {
  // 讀取名稱為 fileName 的輸出資源
  const asset = compilation.assets[fileName];
  // 獲取輸出資源的內容
  asset.source();
  // 獲取輸出資源的檔案大小
  asset.size();
  callback();
});

  

判斷Webpack使用了哪些外掛

在開發一個外掛時可能需要根據當前配置是否使用了其它某個外掛而做下一步決定,因此需要讀取Webpack當前的外掛配置情況。 以判斷當前是否使用了ExtractTextPlugin為例,可以使用如下程式碼:

// 判斷當前配置使用使用了 ExtractTextPlugin,
// compiler 引數即為 Webpack 在 apply(compiler) 中傳入的引數
function hasExtractTextPlugin(compiler) {
  // 當前配置所有使用的外掛列表
  const plugins = compiler.options.plugins;
  // 去 plugins 中尋找有沒有 ExtractTextPlugin 的例項
  return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}

  

實戰

下面我們去實現一個外掛。
該外掛的名稱取名叫EndWebpackPlugin,作用是在Webpack即將退出時再附加一些額外的操作,例如在Webpack成功編譯和輸出了檔案後執行釋出操作把輸出的檔案上傳到伺服器。 同時該外掛還能區分Webpack構建是否執行成功。使用該外掛時方法如下:

module.exports = {
  plugins:[
    // 在初始化 EndWebpackPlugin 時傳入了兩個引數,分別是在成功時的回撥函式和失敗時的回撥函式;
    new EndWebpackPlugin(() => {
      // Webpack 構建成功,並且檔案輸出了後會執行到這裡,在這裡可以做釋出檔案操作
    }, (err) => {
      // Webpack 構建失敗,err 是導致錯誤的原因
      console.error(err);        
    })
  ]
}

  

要實現該外掛,需要藉助兩個事件:

  • done:在成功構建並且輸出了檔案後,Webpack即將退出時發生;
  • failed:在構建出現異常導致構建失敗,Webpack即將退出時發生;

實現該外掛非常簡單,完整程式碼如下:

class EndWebpackPlugin {
  constructor(doneCallback, failCallback) {
    // 存下在建構函式中傳入的回撥函式
    this.doneCallback = doneCallback;
    this.failCallback = failCallback;
  }

  apply(compiler) {
    compiler.plugin('done', (stats) => {
        // 在 done 事件中回撥 doneCallback
        this.doneCallback(stats);
    });
    compiler.plugin('failed', (err) => {
        // 在 failed 事件中回撥 failCallback
        this.failCallback(err);
    });
  }
}
// 匯出外掛 
module.exports = EndWebpackPlugin;

  

從開發這個外掛可以看出,找到合適的事件點去完成功能在開發外掛時顯得尤為重要。Webpack在執行過程中廣播出常用事件,你可以從中找到你需要的事件。