webpack原理

webpack原理

查看全部文檔頁面: 全棧開發,獲取更多信息。
原文連接: webpack原理,原文廣告模態框遮擋,閱讀體驗很差,因此整理成本文,方便查找。

工做原理歸納

基本概念

在瞭解 Webpack 原理前,須要掌握如下幾個核心概念,以方便後面的理解:css

  • Entry:入口,Webpack 執行構建的第一步將從 Entry 開始,可抽象成輸入。
  • Module:模塊,在 Webpack 裏一切皆模塊,一個模塊對應着一個文件。Webpack 會從配置的 Entry 開始遞歸找出全部依賴的模塊。
  • Chunk:代碼塊,一個 Chunk 由多個模塊組合而成,用於代碼合併與分割。
  • Loader:模塊轉換器,用於把模塊原內容按照需求轉換成新內容。
  • Plugin:擴展插件,在 Webpack 構建流程中的特定時機會廣播出對應的事件,插件能夠監聽這些事件的發生,在特定時機作對應的事情。

流程歸納

Webpack 的運行流程是一個串行的過程,從啓動到結束會依次執行如下流程:html

  1. 初始化參數:從配置文件和 Shell 語句中讀取與合併參數,得出最終的參數;
  2. 開始編譯:用上一步獲得的參數初始化 Compiler 對象,加載全部配置的插件,執行對象的 run 方法開始執行編譯;
  3. 肯定入口:根據配置中的 entry 找出全部的入口文件;
  4. 編譯模塊:從入口文件出發,調用全部配置的 Loader 對模塊進行翻譯,再找出該模塊依賴的模塊,再遞歸本步驟直到全部入口依賴的文件都通過了本步驟的處理;
  5. 完成模塊編譯:在通過第4步使用 Loader 翻譯完全部模塊後,獲得了每一個模塊被翻譯後的最終內容以及它們之間的依賴關係;
  6. 輸出資源:根據入口和模塊之間的依賴關係,組裝成一個個包含多個模塊的 Chunk,再把每一個 Chunk 轉換成一個單獨的文件加入到輸出列表,這步是能夠修改輸出內容的最後機會;
  7. 輸出完成:在肯定好輸出內容後,根據配置肯定輸出的路徑和文件名,把文件內容寫入到文件系統。

在以上過程當中,Webpack 會在特定的時間點廣播出特定的事件,插件在監聽到感興趣的事件後會執行特定的邏輯,而且插件能夠調用 Webpack 提供的 API 改變 Webpack 的運行結果。node

流程細節

Webpack 的構建流程能夠分爲如下三大階段:webpack

  1. 初始化:啓動構建,讀取與合併配置參數,加載 Plugin,實例化 Compiler。
  2. 編譯:從 Entry 發出,針對每一個 Module 串行調用對應的 Loader 去翻譯文件內容,再找到該 Module 依賴的 Module,遞歸地進行編譯處理。
  3. 輸出:對編譯後的 Module 組合成 Chunk,把 Chunk 轉換成文件,輸出到文件系統。

若是隻執行一次構建,以上階段將會按照順序各執行一次。但在開啓監聽模式下,流程將變爲以下:git

在每一個大階段中又會發生不少事件,Webpack 會把這些事件廣播出來供給 Plugin 使用,下面來一一介紹。github

初始化階段

事件名 解釋
初始化參數 從配置文件和 Shell 語句中讀取與合併參數,得出最終的參數。 這個過程當中還會執行配置文件中的插件實例化語句 new Plugin()
實例化 Compiler 用上一步獲得的參數初始化 Compiler 實例,Compiler 負責文件監聽和啓動編譯。Compiler 實例中包含了完整的 Webpack 配置,全局只有一個 Compiler 實例。
加載插件 依次調用插件的 apply 方法,讓插件能夠監聽後續的全部事件節點。同時給插件傳入 compiler 實例的引用,以方便插件經過 compiler 調用 Webpack 提供的 API。
environment 開始應用 Node.js 風格的文件系統到 compiler 對象,以方便後續的文件尋找和讀取。
entry-option 讀取配置的 Entrys,爲每一個 Entry 實例化一個對應的 EntryPlugin,爲後面該 Entry 的遞歸解析工做作準備。
after-plugins 調用完全部內置的和配置的插件的 apply 方法。
after-resolvers 根據配置初始化完 resolverresolver 負責在文件系統中尋找指定路徑的文件。
空格 空格
空格 空格
空格 空格

編譯階段

事件名 解釋
run 啓動一次新的編譯。
watch-run run 相似,區別在於它是在監聽模式下啓動的編譯,在這個事件中能夠獲取到是哪些文件發生了變化致使從新啓動一次新的編譯。
compile 該事件是爲了告訴插件一次新的編譯將要啓動,同時會給插件帶上 compiler 對象。
compilation Webpack 以開發模式運行時,每當檢測到文件變化,一次新的 Compilation 將被建立。一個 Compilation 對象包含了當前的模塊資源、編譯生成資源、變化的文件等。Compilation 對象也提供了不少事件回調供插件作擴展。
make 一個新的 Compilation 建立完畢,即將從 Entry 開始讀取文件,根據文件類型和配置的 Loader 對文件進行編譯,編譯完後再找出該文件依賴的文件,遞歸的編譯和解析。
after-compile 一次 Compilation 執行完成。
invalid 當遇到文件不存在、文件編譯錯誤等異常時會觸發該事件,該事件不會致使 Webpack 退出。
空格 空格
空格 空格

在編譯階段中,最重要的要數 compilation 事件了,由於在 compilation 階段調用了 Loader 完成了每一個模塊的轉換操做,在 compilation 階段又包括不少小的事件,它們分別是:web

事件名 解釋
build-module 使用對應的 Loader 去轉換一個模塊。
normal-module-loader 在用 Loader 對一個模塊轉換完後,使用 acorn 解析轉換後的內容,輸出對應的抽象語法樹(AST),以方便 Webpack 後面對代碼的分析。
program 從配置的入口模塊開始,分析其 AST,當遇到 require 等導入其它模塊語句時,便將其加入到依賴的模塊列表,同時對新找出的依賴模塊遞歸分析,最終搞清全部模塊的依賴關係。
seal 全部模塊及其依賴的模塊都經過 Loader 轉換完成後,根據依賴關係開始生成 Chunk。

輸出階段

事件名 解釋
should-emit 全部須要輸出的文件已經生成好,詢問插件哪些文件須要輸出,哪些不須要。
emit 肯定好要輸出哪些文件後,執行文件輸出,能夠在這裏獲取和修改輸出內容。
after-emit 文件輸出完畢。
done 成功完成一次完成的編譯和輸出流程。
failed 若是在編譯和輸出流程中遇到異常致使 Webpack 退出時,就會直接跳轉到本步驟,插件能夠在本事件中獲取到具體的錯誤緣由。

在輸出階段已經獲得了各個模塊通過轉換後的結果和其依賴關係,而且把相關模塊組合在一塊兒造成一個個 Chunk。 在輸出階段會根據 Chunk 的類型,使用對應的模版生成最終要要輸出的文件內容。npm

輸出文件分析

雖然在前面的章節中你學會了如何使用 Webpack ,也大體知道其工做原理,但是你想過 Webpack 輸出的 bundle.js 是什麼樣子的嗎? 爲何原來一個個的模塊文件被合併成了一個單獨的文件?爲何 bundle.js 能直接運行在瀏覽器中? 本節將解釋清楚以上問題。json

先來看看由 安裝與使用 中最簡單的項目構建出的 bundle.js 文件內容,代碼以下:數組

<p data-height="565" data-theme-id="0" data-slug-hash="NMQzxz" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async src="static.codepen.io/ass...;></script>

以上看上去複雜的代碼實際上是一個當即執行函數,能夠簡寫爲以下:

(function(modules) {

  // 模擬 require 語句
  function __webpack_require__() {
  }

  // 執行存放全部模塊數組中的第0個模塊
  __webpack_require__(0);

})([/*存放全部模塊的數組*/])
複製代碼

bundle.js 能直接運行在瀏覽器中的緣由在於輸出的文件中經過 __webpack_require__ 函數定義了一個能夠在瀏覽器中執行的加載函數來模擬 Node.js 中的 require 語句。

原來一個個獨立的模塊文件被合併到了一個單獨的 bundle.js 的緣由在於瀏覽器不能像 Node.js 那樣快速地去本地加載一個個模塊文件,而必須經過網絡請求去加載還未獲得的文件。 若是模塊數量不少,加載時間會很長,所以把全部模塊都存放在了數組中,執行一次網絡加載。

若是仔細分析 __webpack_require__ 函數的實現,你還有發現 Webpack 作了緩存優化: 執行加載過的模塊不會再執行第二次,執行結果會緩存在內存中,當某個模塊第二次被訪問時會直接去內存中讀取被緩存的返回值。

分割代碼時的輸出

例如把源碼中的 main.js 修改成以下:

// 異步加載 show.js
import('./show').then((show) => {
  // 執行 show 函數
  show('Webpack');
});
複製代碼

從新構建後會輸出兩個文件,分別是執行入口文件 bundle.js 和 異步加載文件 0.bundle.js

其中 0.bundle.js 內容以下:

// 加載在本文件(0.bundle.js)中包含的模塊
webpackJsonp(
  // 在其它文件中存放着的模塊的 ID
  [0],
  // 本文件所包含的模塊
  [
    // show.js 所對應的模塊
    (function (module, exports) {
      function show(content) {
        window.document.getElementById('app').innerText = 'Hello,' + content;
      }

      module.exports = show;
    })
  ]
);
複製代碼

bundle.js 內容以下:

<p data-height="565" data-theme-id="0" data-slug-hash="yjmRyG" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="bundle.js" class="codepen">See the Pen bundle.js by whjin (@whjin) on CodePen.</p>
<script async src="static.codepen.io/ass...;></script>

這裏的 bundle.js 和上面所講的 bundle.js 很是類似,區別在於:

  • 多了一個 __webpack_require__.e 用於加載被分割出去的,須要異步加載的 Chunk 對應的文件;
  • 多了一個 webpackJsonp 函數用於從異步加載的文件中安裝模塊。

在使用了 CommonsChunkPlugin 去提取公共代碼時輸出的文件和使用了異步加載時輸出的文件是同樣的,都會有 __webpack_require__.ewebpackJsonp。 緣由在於提取公共代碼和異步加載本質上都是代碼分割。

編寫 Loader

Loader 就像是一個翻譯員,能把源文件通過轉化後輸出新的結果,而且一個文件還能夠鏈式的通過多個翻譯員翻譯。

以處理 SCSS 文件爲例:

  • SCSS 源代碼會先交給 sass-loader 把 SCSS 轉換成 CSS;
  • sass-loader 輸出的 CSS 交給 css-loader 處理,找出 CSS 中依賴的資源、壓縮 CSS 等;
  • css-loader 輸出的 CSS 交給 style-loader 處理,轉換成經過腳本加載的 JavaScript 代碼;

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

<p data-height="365" data-theme-id="0" data-slug-hash="YLmbeQ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="編寫 Loader" class="codepen">See the Pen 編寫 Loader by whjin (@whjin) on CodePen.</p>
<script async src="static.codepen.io/ass...;></script>

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 進階

以上只是個最簡單的 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 會生成 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 的步驟以下:

  • 確保正在開發的本地 Npm 模塊(也就是正在開發的 Loader)的 package.json 已經正確配置好;
  • 在本地 Npm 模塊根目錄下執行 npm link,把本地模塊註冊到全局;
  • 在項目根目錄下執行 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 的使用場景是去正確加載針對 Fis3 編寫的 JavaScript,這些 JavaScript 中存在經過註釋的方式加載依賴的 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。

經過以上最簡單的 Plugin 相信你大概明白了 Plugin 的工做原理,但實際開發中還有不少細節須要注意,下面來詳細介紹。

CompilerCompilation

在開發 Plugin 時最經常使用的兩個對象就是 Compiler 和 Compilation,它們是 Plugin 和 Webpack 之間的橋樑。 Compiler 和 Compilation 的含義以下:

  • Compiler 對象包含了 Webpack 環境全部的的配置信息,包含 optionsloadersplugins 這些信息,這個對象在 Webpack 啓動時候被實例化,它是全局惟一的,能夠簡單地把它理解爲 Webpack 實例;
  • Compilation 對象包含了當前的模塊資源、編譯生成資源、變化的文件等。當 Webpack 以開發模式運行時,每當檢測到一個文件變化,一次新的 Compilation 將被建立。Compilation 對象也提供了不少事件回調供插件作擴展。經過 Compilation 也能讀取到 Compiler 對象。

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

事件流

Webpack 就像一條生產線,要通過一系列處理流程後才能將源文件轉換成輸出結果。 這條生產線上的每一個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。 插件就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源作處理。

Webpack 經過 Tapable 來組織這條複雜的生產線。 Webpack 在運行過程當中會廣播事件,插件只須要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運做。 Webpack 的事件流機制保證了插件的有序性,使得整個系統擴展性很好。

Webpack 的事件流機制應用了觀察者模式,和 Node.js 中的 EventEmitter 很是類似。Compiler 和 Compilation 都繼承自 Tapable,能夠直接在 Compiler 和 Compilation 對象上廣播和監聽事件,方法以下:

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

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

});
複製代碼

同理,compilation.applycompilation.plugin 使用方法和上面一致。

在開發插件時,你可能會不知道該如何下手,由於你不知道該監聽哪一個事件才能完成任務。

在開發插件時,還須要注意如下兩點:

  • 只要能拿到 Compiler 或 Compilation 對象,就能廣播出新的事件,因此在新開發的插件中也能廣播出事件,給其它插件監聽使用。
  • 傳給每一個插件的 Compiler 和 Compilation 對象都是同一個引用。也就是說在一個插件中修改了 Compiler 或 Compilation 對象上的屬性,會影響到後面的插件。
  • 有些事件是異步的,這些異步的事件會附帶兩個參數,第二個參數爲回調函數,在插件處理完任務時須要調用回調函數通知 Webpack,纔會進入下一處理流程。例如:
compiler.plugin('emit',function(compilation, callback) {
    // 支持處理邏輯

    // 處理完畢後執行 callback 以通知 Webpack 
    // 若是不執行 callback,運行流程將會一直卡在這不往下執行 
    callback();
  });複製代碼

經常使用 API

插件能夠用來修改輸出文件、增長輸出文件、甚至能夠提高 Webpack 性能、等等,總之插件經過調用 Webpack 提供的 API 能完成不少事情。 因爲 Webpack 提供的 API 很是多,有不少 API 不多用的上,又加上篇幅有限,下面來介紹一些經常使用的 API。

讀取輸出資源、代碼塊、模塊及其依賴

有些插件可能須要讀取 Webpack 的處理結果,例如輸出資源、代碼塊、模塊及其依賴,以便作下一步處理。

emit 事件發生時,表明源文件的轉換和組裝已經完成,在這裏能夠讀取到最終將輸出的資源、代碼塊、模塊及其依賴,而且能夠修改輸出資源的內容。 插件代碼以下:

<p data-height="585" data-theme-id="0" data-slug-hash="RJwjPj" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="emit" class="codepen">See the Pen emit by whjin ( @whjin) on CodePen.</p>
<script async src=" static.codepen.io/ass...;></script>

監聽文件變化

Webpack 會從配置的入口模塊出發,依次找出全部的依賴模塊,當入口模塊或者其依賴的模塊發生變化時, 就會觸發一次新的 Compilation。

在開發插件時常常須要知道是哪一個文件發生變化致使了新的 Compilation,爲此可使用以下代碼:

<p data-height="255" data-theme-id="0" data-slug-hash="jKOabJ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="Compilation" class="codepen">See the Pen Compilation by whjin (@whjin) on CodePen.</p>
<script async src="static.codepen.io/ass...;></script>

默認狀況下 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 在運行過程當中廣播出經常使用事件,你能夠從中找到你須要的事件。

調試 Webpack

在編寫 Webpack 的 Plugin 和 Loader 時,可能執行結果會和你預期的不同,就和你平時寫代碼遇到了奇怪的 Bug 同樣。 對於沒法一眼看出問題的 Bug,一般須要調試程序源碼才能找出問題所在。

雖然能夠經過 console.log 的方式完成調試,但這種方法很是不方便也不優雅,本節將教你如何斷點調試 工做原理歸納 中的插件代碼。 因爲 Webpack 運行在 Node.js 之上,調試 Webpack 就相對於調試 Node.js 程序。

在 Webstorm 中調試

Webstorm 集成了 Node.js 的調試工具,所以使用 Webstorm 調試 Webpack 很是簡單。

1. 設置斷點

在你認爲可能出現問題的地方設下斷點,點擊編輯區代碼左側出現紅點表示設置了斷點。

2. 配置執行入口

告訴 Webstorm 如何啓動 Webpack,因爲 Webpack 實際上就是一個 Node.js 應用,所以須要新建一個 Node.js 類型的執行入口。

以上配置中有三點須要注意:

  • Name 設置成了 debug webpack,就像設置了一個別名,方便記憶和區分;
  • Working directory 設置爲須要調試的插件所在的項目的根目錄;
  • JavaScript file 即 Node.js 的執行入口文件,設置爲 Webpack 的執行入口文件 node_modules/webpack/bin/webpack.js

3. 啓動調試

通過以上兩步,準備工做已經完成,下面啓動調試,啓動時選中前面設置的 debug webpack

4. 執行到斷點

啓動後程序就會停在斷點所在的位置,在這裏你能夠方便的查看變量當前的狀態,找出問題。

原理總結

Webpack 是一個龐大的 Node.js 應用,若是你閱讀過它的源碼,你會發現實現一個完整的 Webpack 須要編寫很是多的代碼。 但你無需瞭解全部的細節,只需瞭解其總體架構和部分細節便可。

對 Webpack 的使用者來講,它是一個簡單強大的工具; 對 Webpack 的開發者來講,它是一個擴展性的高系統。

Webpack 之因此能成功,在於它把複雜的實現隱藏了起來,給用戶暴露出的只是一個簡單的工具,讓用戶能快速達成目的。 同時總體架構設計合理,擴展性高,開發擴展難度不高,經過社區補足了大量缺失的功能,讓 Webpack 幾乎能勝任任何場景。

經過本章的學習,但願你不只能學會如何編寫 Webpack 擴展,也能從中領悟到如何設計好的系統架構。

相關文章
相關標籤/搜索