圖解 webpack(模塊化 - CommonJS)

在前面一篇文章中《模塊化系列》徹底理清 AMD,CommonJS,CDM,UMD,ES6 我們可以學到了各種模塊化的機制。那麼接下里我們就來分析一下 webpack 的模塊化機制。(主要講 JS 部分)

提到 webpack,可以說是與我們的開發工程非常密切的工具,不管是日常開發、進行面試還是對於自我的提高,都離不開它,因爲它給我們的開發帶了極大的便利以及學習的價值。但是由於webpack是一個非常龐大的工程體系,使得我們望之卻步。本文想以這種圖解的形式能夠將它慢慢地剝開一層一層複雜的面紗,最終露出它的真面目。以下是我列出的關於 webpack 相關的體系。

 

本文講的是  打包 - CommonJS  模塊,主要分爲兩個部分

  • webpack 的作用

  • webpack 的模塊化機制與實現

webpack 的作用

在我們前端多樣化的今天,很多工具爲了滿足我們日益增長的開發需求,都變得非常的龐大,例如 webpack 。在我們的印象中,它似乎集成了所有關於開發的功能,模塊打包,代碼降級,文件優化,代碼校驗等等。正是因爲面對如此龐大的一個工具,所以才讓我們望而卻步,當然了還有一點就是,webpack 的頻繁升級,周邊的生態插件配套版本混亂,也加劇我們對它的恐懼。

那麼我們是不是應該思考一下,webpack 的出現究竟給我們帶來了什麼?我們爲啥需要用它?而上面所有的一些代碼降級(babel轉化)、編譯SCSS 、代碼規範檢測都是得益於它的插件系統和loader機制,並不是完完全全屬於它。

所以在我看來,它的功能核心是「打包」,而打包則是能夠讓模塊化的規範得以在瀏覽器直接執行。因此我們來看看打包後所帶來的功能:

  • 模塊隔離

  • 模塊依賴加載

模塊隔離

如果我們不用打包的方式,我們所有的模塊都是直接暴露在全局,也就是掛載在 window/global  這個對象。也許代碼量少的時候還可以接受,不會有那麼多的問題。特別是在代碼增多,多人協作的情況下,給全局空間帶來的影響是不可預估的,如果你的每一次開發都得去一遍一遍查找是否有他們使用當前的變量名。

舉個例子(僅僅爲例子說明,實際工程會比以下複雜許多),一開始我們的 user1 寫了一下幾個模塊,跑起來非常的順暢。

image-20200626231748187
├── bar.js    function bar(){}
├── baz.js    function baz(){}
└── foo.js function foo(){}

但是呢,隨着業務迭代,工程的複雜性增加,來了一個 user2,這個時候 user2,需要開發一個 foo 業務,裏面也有一個 baz 模塊,代碼也很快寫好了,變成了下面這個樣子。

├── bar.js    function bar(){}
├── baz.js    function baz(){}
├── foo
│   └── baz.js function baz(){}
└── foo.js function foo(){}

但是呢這個時候,老闆來找 user2 了,爲什麼增加了新業務後,原來的業務出錯了呢?這個時候發現原來是 user2 寫的新模塊覆蓋了 user1 的模塊,從而導致了這場事故。

image-20200626220806881

因此,當我們開發的時候將所有的模塊都暴露在全局的時候,想要避免錯誤,一切都得非常的小心翼翼,我們很容易在不知情的偷偷覆蓋我們以前定義的函數,從而釀成錯誤。

因此 webpack 帶來的第一個核心作用就是隔離,將每個模塊通過閉包的形式包裹成一個個新的模塊,將其放於局部作用域,所有的函數聲明都不會直接暴露在全局。

image-20200626220851909

原來我們調用的 是 foo 函數,但是 webpack 會幫我們生成獨一無二的模塊ID,完全不需要擔心模塊的衝突,現在可以愉快地書寫代碼啦。

baz.js
module.exports = function baz (){}

foo/baz.js
module.exports = function baz (){}

main.js
var baz = require('./baz.js');
var fooBaz = require('./foo/baz.js');

baz();
fooBaz();

可能你說會之前的方式也可以通過改變函數命名的方式,但是原來的作用範圍是整個工程,你得保證,當前命名在整個工程中不衝突,現在,你只需要保證的是單個文件中命名不衝突。(對於頂層依賴也是非常容易發現衝突)

image-20200627140818771

模塊依賴加載

還有一種重要的功能就是模塊依賴加載。這種方式帶來的好處是什麼?我們同樣先來看例子,看原來的方式會產生什麼問題?

User1 現在寫了3個模塊,其中 baz 是依賴於 bar 的。

image-20200627000240836

寫完後 user1 進行了上線,利用了順序來指出了依賴關係。

<script src="./bar.js"></script>
<script src="./baz.js"></script>
<script src="./foo.js"></script>

可是過了不久 user2 又接手了這個業務。user 2 發現,他開發的 abc 模塊,通過依賴 bar 模塊,可以進行快速地開發。可是 粗心的 user2  不太明白依賴關係。竟然將 abc 的位置隨意寫了一下,這就導致 運行 abc 的時候,無法找到 bar 模塊。

image-20200627000713100
<script src="./abc.js"></script>
<script src="./bar.js"></script>
<script src="./baz.js"></script>
<script src="./foo.js"></script>

因此這裏 webpack 利用 CommonJS/ ES Modules 規範進行了處理。使得各個模塊之間相互引用無需考慮最終實際呈現的順序。最終會被打包爲一個 bunlde 模塊,無需按照順序手動引入。

baz.js
const bar = require('./bar.js');
module.exports = function baz (){
 ...
 bar();
 ...
}

abc.js
const bar = require('./bar.js');
module.exports = function baz (){
 ...
 bar();
 ...
}
<script src="./bundle.js"></script>

image-20200627003815071

webpack 的模塊化機制與實現

基於以上兩項特性,模塊的隔離以及模塊的依賴聚合。我們現在可以非常清晰的知道了webpack所起的核心作用。

  • 爲了儘可能降低編寫的難度和理解成本,我沒有使用 AST 的解析,(當然 AST 也不是什麼很難的東西,以後的文章中我會講解 AST是什麼以及 AST 解析器的實現過程。

  • 僅實現了 CommonJS 的支持

bundle工作原理

爲了能夠實現 webpack, 我們可以通過反推的方法,先看webpack 打包後 bundle 是如何工作的。

「源文件」

// index.js
const b = require('./b');
b();
// b.js
module.exports = function () {
    console.log(11);
}

「build 後」(去除了一些干擾代碼)

(function(modules) {
  var installedModules = {};
  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = (installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
    });
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );
    module.l = true;
    return module.exports;
  }
  return __webpack_require__((__webpack_require__.s = 0));
})([
  /* 0 */
  function(module, exports, __webpack_require__) {
    var b = __webpack_require__(1);
    b();
  },
  /* 1 */
  function(module, exports) {
    module.exports = function() {
      console.log(11);
    };
  },
]);

image-20200627135324956

以上就是 bundle 的運作原理。通過上述的流程圖我們可以看到,有四個關鍵點

  • 已註冊模塊(存放已經註冊的模塊)

  • 模塊列表(用來存放所有的包裝模塊)

  • 模塊查找(從原來的樹形的模塊依賴,變成了扁平查找)

  • 模塊的包裝(原有的模塊都進行了一次包裝)

webpack實現

通過 bundle 的分析,我們只需要做的就是 4 件事

  • 遍歷出所有的模塊

  • 模塊包裝

  • 提供註冊模塊、模塊列表變量和導入函數

  • 持久化導出

模塊的遍歷

首先來介紹一下模塊的結構,能使我們快速有所瞭解, 結構比較簡單,由內容和模塊id組成。

interface GraphStruct {
    context: string;
    moduleId: string;
}
{
 "context": `function(module, exports, require) {
    const bar = require('./bar.js');
  const foo = require('./foo.js');
  console.log(bar());
  foo();
  }`,
  "moduleId": "./example/index.js"
}

接下來我們以拿到一個入口文件來進行講解,當拿到一個入口文件時,我們需要對其依賴進行分析。說簡單點就是拿到 require 中的值,以便我們去尋找下一個模塊。由於在這一部分不想引入額外的知識,開頭也說了,一般採用的是 AST 解析的方式,來獲取 require 的模塊,在這裏我們使用正則。

用來匹配全局的 require 
const REQUIRE_REG_GLOBAL = /require\(("|')(.+)("|')\)/g;
用來匹配 require 中的內容
const REQUIRE_REG_SINGLE = /require\(("|')(.+)("|')\)/;
const context = `
const bar = require('./bar.js');
const foo = require('./foo.js');
console.log(bar());
foo();
`;
console.log(context.match(REQUIRE_REG_GLOBAL));
// ["require('./bar.js')", "require('./foo.js')"]

image-20200627202427794

由於模塊的遍歷並不是只有單純的一層結構,一般爲樹形結構,因此在這裏我採用了深度遍歷。主要通過正則去匹配出require 中的依賴項,然後不斷遞歸去獲取模塊,最後將通過深度遍歷到的模塊以數組形式存儲。(不理解深度遍歷,可以理解爲遞歸獲取模塊)

image-20200627142130902

以下是代碼實現

...
private entryPath: string
private graph: GraphStruct[]
...
createGraph(rootPath: string, relativePath: string) {
    // 通過獲取文件內容
    const context = fs.readFileSync(rootPath, 'utf-8');
    // 匹配出依賴關係
    const childrens = context.match(REQUIRE_REG_GLOBAL);
   // 將當前的模塊存儲下來
    this.graph.push({
        context,
        moduleId: relativePath,
    })
    const dirname = path.dirname(rootPath);
    if (childrens) {
       // 如有有依賴,就進行遞歸
        childrens.forEach(child => {
            const childPath = child.match(REQUIRE_REG_SINGLE)[2];
            this.createGraph(path.join(dirname, childPath), childPath);
        });
    }
}

模塊包裝

爲了能夠使得模塊隔離,我們在外部封裝一層函數, 然後傳入對應的模擬 requiremodule使得模塊能進行正常的註冊以及導入 。

function (module, exports, require){
    ...
},

提供註冊模塊、模塊列表變量和導入函數

這一步比較簡單,只要按照我們分析的流程圖提供已註冊模塊變量、模塊列表變量、導入函數。

/* modules = {
  "./example/index.js": function (module, exports, require) {
    const a = require("./a.js");
    const b = require("./b.js");

    console.log(a());
    b();
  },
  ...
};*/
bundle(graph: GraphStruct[]) {
    let modules = '';
    graph.forEach(module => {
        modules += `"${module.moduleId}":function (module, exports, require){
        ${module.context}
        },`;
    });
    const bundleOutput = `
    (function(modules) {
        var installedModules = {};
        // 導入函數
        function require(moduleId) {
            // 檢查是否已經註冊該模塊
            if (installedModules[moduleId]) {
                return installedModules[moduleId].exports;
            }
           // 沒有註冊則從模塊列表獲取模塊進行註冊
            var module = (installedModules[moduleId] = {
                i: moduleId,
                l: false,
                exports: {},
            });
           // 執行包裝函數,執行後更新模塊的內容
            modules[moduleId].call(
                module.exports,
                module,
                module.exports,
                require
            );
            // 設置標記已經註冊
            module.l = true;
            // 返回實際模塊
            return module.exports;
        }
        require("${graph[0].moduleId}");
    })({${modules}})
    `;
    return bundleOutput;
}

持久化導出

最後將生成的 bundle 持久寫入到磁盤就大功告成。

fs.writeFileSync('bundle.js', this.bundle(this.graph))

完整代碼100行 代碼不到,詳情可以查看以下完整示例。

github地址:  https://github.com/hua1995116/tiny-webpack

結尾

以上僅代表個人的理解,希望讓你對webpack的理解有所幫助, 如有講的不好的請多指出。

歡迎關注公衆號 「「秋風的筆記」」,主要記錄日常中覺得有意思的工具以及分享開發實踐,保持深度和專注度。回覆 webpack 獲取概覽圖 xmind 原圖

FAQ

Q: 爲什麼打算寫這篇文章?

R: 其實主要是爲了畫圖,純粹比較新奇。

Q: 還會有下一篇嗎?

R: 有的,下一篇暫定爲 ES module 和 code splitting 相關。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章