webpack組織模塊的原理 - 基礎篇

如今前端用Webpack打包JS和其它文件已是主流了,加上Node的流行,使得前端的工程方式和後端愈來愈像。全部的東西都模塊化,最後統一編譯。Webpack由於版本的不斷更新以及各類各樣紛繁複雜的配置選項,在使用中出現一些迷之錯誤經常讓人無所適從。因此瞭解一下Webpack到底是怎麼組織編譯模塊的,生成的代碼究竟是怎麼執行的,仍是頗有好處的,不然它就永遠是個黑箱。固然了我是前端小白,最近也是剛開始研究Webpack的原理,在這裏作一點記錄。javascript

編譯模塊

編譯兩個字聽起來就很黑科技,加上生成的代碼每每是一大坨不知所云的東西,因此經常會讓人卻步,但其實裏面的核心原理並無什麼難。所謂的Webpack的編譯,其實只是Webpack在分析了你的源代碼後,對其做出必定的修改,而後把全部源代碼統一組織在一個文件裏而已。最後生成一個大的bundle JS文件,被瀏覽器或者其它Javascript引擎執行並返回結果。前端

在這裏用一個簡單的案例來講明Webpack打包模塊的原理。例如咱們有一個模塊mA.jsjava

var aa = 1;

function inc() {
  aa++;
}

module.exports = {
  aa: aa,
  inc: inc
}

我隨便定義了一個變量aa和一個函數inc,而後export出來,這裏是用CommonJS的寫法。webpack

而後再定義一個app.js,做爲main文件,仍然是CommonJS風格:es6

var mA = require('./mA.js');

console.log('mA.aa =' + mA.aa);
mA.inc();

如今咱們有了兩個模塊,使用Webpack來打包,入口文件是app.js,依賴於模塊mA.js,Webpack要作幾件事情:web

  • 從入口模塊app.js開始,分析全部模塊的依賴關係,把全部用到的模塊都讀取進來。
  • 每個模塊的源代碼都會被組織在一個當即執行的函數裏。
  • 改寫模塊代碼中和requireexport相關的語法,以及它們對應的引用變量。
  • 在最後生成的bundle文件裏創建一套模塊管理系統,可以在runtime動態加載用到的模塊。

咱們能夠看一下上面這個例子,Webpack打包出來的結果。最後的bundle文件總的來講是一個大的當即執行的函數,組織層次比較複雜,大量的命名也比較晦澀,因此我在這裏作了必定改寫和修飾,把它整理得儘可能簡單易懂。後端

首先是把全部用到的模塊都羅列出來,以它們的文件名(通常是完整路徑)爲ID,創建一張表:瀏覽器

var modules = {
  './mA.js': generated_mA,
  './app.js': generated_app
}

關鍵是上面的generated_xxx是什麼?它是一個函數,它把每一個模塊的源代碼包裹在裏面,使之成爲一個局部的做用域,從而不會暴露內部的變量,實際上就把每一個模塊都變成一個執行函數。它的定義通常是這樣:緩存

function generated_module(module, exports, webpack_require) {
   // 模塊的具體代碼。
   // ...
}

在這裏模塊的具體代碼是指生成代碼,Webpack稱之爲generated code。例如mA,通過改寫獲得這樣的結果:數據結構

function generated_mA(module, exports, webpack_require) {
  var aa = 1;
  
  function inc() {
    aa++;
  }

  module.exports = {
    aa: aa,
    inc: inc
  }
}

乍一看彷佛和源代碼如出一轍。的確,mA沒有require或者import其它模塊,export用的也是傳統的CommonJS風格,因此生成代碼沒有任何改動。不過值得注意的是最後的module.exports = ...,這裏的module就是外面傳進來的參數module,這其實是在告訴咱們,運行這個函數,模塊mA的源代碼就會被執行,而且最後須要export的內容就會被保存到外部,到這裏就標誌着mA加載完成,而那個外部的東西實際上就後面要說的模塊管理系統。

接下來看app.js的生成代碼:

function generated_app(module, exports, webpack_require) {
  var mA_imported_module = webpack_require('./mA.js');
  
  console.log('mA.aa =' + mA_imported_module['aa']);
  mA_imported_module['inc']();
}

能夠看到,app.js的源代碼中關於引入的模塊mA的部分作了修改,由於不管是require/exports,或是ES6風格的import/export,都沒法被JavaScript解釋器直接執行,它須要依賴模塊管理系統,把這些抽象的關鍵詞具體化。也就是說,webpack_require就是require的具體實現,它可以動態地載入模塊mA,而且將結果返回給app

到這裏你腦海裏可能已經初步逐漸構建出了一個模塊管理系統的想法,一切的關鍵就是webpack_require,咱們來看一下它的實現:

// 加載完畢的全部模塊。
var installedModules = {};

function webpack_require(moduleId) {
  // 若是模塊已經加載過了,直接從Cache中讀取。
  if (installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }

  // 建立新模塊並添加到installedModules。
  var module = installedModules[moduleId] = {
    id: moduleId,
    exports: {}
  };
  
  // 加載模塊,即運行模塊的生成代碼,
  modules[moduleId].call(
    module.exports, module, module.exports, webpack_require);
  
  return module.exports;
}

注意倒數第二句裏的modules就是咱們以前定義過的全部模塊的generated code:

var modules = {
  './mA.js': generated_mA,
  './app.js': generated_app
}

webpack_require的邏輯寫得很清楚,首先檢查模塊是否已經加載,若是是則直接從Cache中返回模塊的exports結果。若是是全新的模塊,那麼就創建相應的數據結構module,而且運行這個模塊的generated code,這個函數傳入的正是咱們創建的module對象,以及它的exports域,這實際上就是CommonJS裏exportsmodule的由來。當運行完這個函數,模塊就被加載完成了,須要export的結果保存到了module對象中。

因此咱們看到所謂的模塊管理系統,原理其實很是簡單,只要耐心將它們抽絲剝繭理清楚了,根本沒有什麼深奧的東西,就是由這三個部分組成:

// 全部模塊的生成代碼
var modules;
// 全部已經加載的模塊,做爲緩存表
var installedModules;
// 加載模塊的函數
function webpack_require(moduleId);

固然以上一切代碼,在整個編譯後的bundle文件中,都被包在一個大的當即執行的匿名函數中,最後咱們須要執行的就是這麼一句話:

return webpack_require('./app.js');

即加載入口模塊app.js,當運行它時,就會運行generated_app;而它須要載入模塊mA,因而就會運行webpack_require('./mA.js'),進而運行generated_mA。也就是說,全部的依賴的模塊就是這樣動態地、遞歸地在runtime完成加載,並被放入InstalledModules緩存。

Webpack真正生成的代碼和我上面整理的結構略有不一樣,它大體是這樣:

(function(modules) {
  var installedModules = {};
  
  function webpack_require(moduleId) {
     // ...
  }

  return webpack_require('./app.js');
}) ({
  './mA.js': generated_mA,
  './app.js': generated_app
});

能夠看到它是直接把modules做爲當即執行函數的參數傳進去的而不是另外定義的,固然這和個人寫法沒什麼本質不一樣,我作這樣的改寫是爲了解釋起來更清楚。

ES6的importexport

以上的例子裏都是用傳統的CommonJS的寫法,如今更通用的ES6風格是用importexport關鍵詞,它們看上去彷佛只是語法糖,但實際上根據ES6的標準,它們和CommonJS在關於模塊加載的使用和行爲上會有一些微妙的不一樣。例如當CommonJS輸出原始類型(非對象)變量時,輸出的是這個變量的拷貝,這樣一旦模塊加載後,再去修改這個內部變量的值,是不會影響到輸出的變量的;而ES6輸出的則是引用,這樣不管模塊內部出現什麼修改,都會反映在已經加載的模塊上。關於ES6和CommonJS在模塊管理上的區別,若是你還不熟悉的話,建議先讀一下阮一峯大神的這篇文章

對於Webpack或者其它模塊管理系統而言,要實現ES6特性的import/export,本質上仍是和require/exports相似的,也就是說仍然使用module.export這一套機制,可是狀況會變得比較複雜,由於可能存在CommonJS和ES6模塊之間的相互引用。爲了保持兼容,而且符合ES6的相應標準,Webpack在生成相應語句的generated code時,就要作不少特殊處理,關於這一塊內容不少,深究起來能夠單獨寫一篇文章,在這裏我只是把我理解的部分寫出來。

export原始類型變量

對於CommonJS而言,export的是很直接的,由於源代碼裏module.exports輸出什麼,生成代碼裏的輸出也原樣不變,例如咱們以前定義的模塊mA.js

var aa = 1;
function inc() {
  aa++;
}

function get_aa() {
  return aa;
}

module.exports = {
  aa: aa,
  inc: inc,
  get_aa: get_aa;
}

生成代碼裏,module.exports也會像源代碼裏這樣寫,注意這裏輸出的時候,aa做爲一個原始類型,輸出到module.exports裏的是一個拷貝,這樣一旦模塊mA加載後,再去調用inc(),修改的是模塊內部的aa,而不會影響輸出後的aa:

var mA = require("./mA.js");

console.log("mA.aa = " + mA.aa);  // 輸出1
mA.inc();
console.log("mA.aa = " + mA.aa);  // 仍然是1

// 這裏會輸出2,由於get_aa()拿到的是模塊內部的aa原始引用。
console.log("mA.get_aa() = " + mA.get_aa());

然而ES6就徹底不是這麼一回事兒了,假如上面的模塊mA,咱們用ES6輸出:

export {aa, inc, get_aa}

而後在別的模塊里加載mA

import {aa, inc} from "./mA.js"

console.log("aa = " + aa);  // 輸出1
inc();
console.log("aa = " + aa);  // 輸出2

這裏無論mA輸出的是什麼類型的數據,輸出的都是它的引用,當別的模塊載入mA時,獲得的也是mA模塊內部變量的引用。要實現這個規則,mAgenerated code就不能簡單地直接給module.exports設置aa這個原始變量類型了,而是像上面的get_aa那樣,給它定義getter。例如當咱們export aa,Webpack會生成相似於這樣的代碼:

var aa = 1;

defineGetter(module.exports, 「aa」, function(){ return aa; });

defineGetter的定義以下:

function defineGetter(exports, name, getter) {
  if (!Object.prototype.hasOwnProperty.call(exports, name)) {
    Object.defineProperty(exports, name, {
      configurable: false,
      enumerable: true,
      get: getter,
    });
  }
}

這樣就實現了咱們須要的引用功能,也就是說,在module.exports上,咱們並非定義aa這個原始類型,而是定義aa的getter,使之指向其原模塊內部aa的引用。

不過對於export default,當輸出原始類型時,它又回到了拷貝,而不是getter引用的方式,即對於這樣的輸出:

export default aa;

Webpack會生成這樣的代碼:

module.exports['default'] = aa;

我還沒徹底弄清楚這樣作是否符合ES6標準,懂的童鞋能夠留下評論。

固然話說回來,模塊中直接輸出aa這樣的原始類型的變量仍是挺少見的,但並不是不可能。源代碼一旦有這樣的行爲,ES6和CommonJS就會表現出徹底不一樣的特性,因此Webpack也必須實現這種區別。

__esModule

Webpack對ES6模塊輸出的另外一個特殊處理是__esModule,例如是咱們定義ES6模塊mB.js:

let x = 3;

let printX = () => {
  console.log('x = ' + x);
}

export {printX}
export default x

它使用了ES6的export,那麼Webpack在mB的generated code會加上一句話:

function generated_mB(module, exports, webpack_require) {
  Object.defineProperty(module.exports, '__esModule', {value: true});
  // mB的具體代碼
  // ....
}

也就是說,它給mB的export標註了一個__esModule,說明它是ES6風格的export。爲何要這樣作?由於當別人引用一個模塊時,它並不知道這個模塊是以CommonJS仍是ES6風格輸出的,因此__esModule的意義就在於告訴別人,這是一個ES6模塊。關於它的具體做用咱們繼續看下面的import部分。

import

這是一種比較簡單的import方式:

import {aa} from './mA.js'
// 基本等價於
var aa = require('./mA.js')['aa']

可是當別人這樣引用時:

import m from './m.js'

狀況會稍微複雜一點,它須要載入模塊mexport default部分,而模塊m可能並不是是由ES6的export來寫的,也可能根本沒有export default。這樣在其它模塊中,當一個依賴模塊以相似import m from './m.js'這樣的方式加載時,必須首先判斷獲得的是否是一個ES6 export出來的模塊。若是是,則返回它的default,若是不是,則返回整個export對象。例如上面的mA是傳統CommonJS的,mB是ES6風格的:

// mA is CommonJS module
import mA from './mA.js'
console.log(mA);

// mB is ES6 module
import mB from './mB.js'
console.log(mB);

這就用到了以前export部分的__esModule了。咱們定義get_export_default函數:

function get_export_default(module) {
  return module && module.__esModule? module['default'] : module;
}

這樣generated code運行後在mAmB上會獲得不一樣的結果:

var mA_imported_module = webpack_require('./mA.js');
// 打印完整的 mA_imported_module
console.log(get_export_default(mA_imported_module));

var mB_imported_module = webpack_require('./mB.js');
// 打印 mB_imported_module['default']
console.log(get_export_default(mB_imported_module));

以上就是在ES6的import/export上,Webpack須要作不少特殊處理的地方。個人分析還並不完整,建議感興趣的童鞋本身去敲一下而且閱讀編譯後的代碼,仔細比較CommonJS和ES6的不一樣。不過就實現而言,它們並無本質區別,並且Webpack最後生成的generated code也仍是基於CommonJS的module/exports這一套機制來實現模塊的加載的。

模塊管理系統

以上就是Webpack如何打包組織模塊,實現runtime模塊加載的解讀,其實它的原理並不難,核心的思想就是創建模塊的管理系統,而這樣的作法也是具備廣泛性的,若是你讀過Node.js的Module部分的源代碼,就會發現其實用的是相似的方法。這裏有一篇文章能夠參考。

相關文章
相關標籤/搜索