Js 中的模組化是如何達成的

小蟲巨蟹發表於2017-10-17

由於 Js 起初定位的原因(剛開始沒想到會應用在過於複雜的場景),所以它本身並沒有提供模組系統,隨著應用的複雜化,模組化成為了一個必須解決的問題。本著菲麥深入原理的原則,很有必要來揭開模組化的面紗

一、模組化需要解決的問題

要對一個東西進行深入的剖析,有必要帶著目的去看。模組化所要解決的問題可以用一句話概括

在沒有全域性汙染的情況下,更好的組織專案程式碼

舉一個簡單的栗子,我們現在有如下的程式碼:

function doSomething () {
  const a = 10;
  const b = 11;
  const add = function (a, b) {
    return a + b
  }
  add (a + b)
}複製程式碼

在現實的應用場景中,doSomething 可能需要做很多很多的事情,add 函式可能也更為複雜,並且可以複用,那麼我們希望可以將 add 函式獨立到一個單獨的檔案中,於是:

// doSomething.js 檔案
const add = require('add.js');
const a = 10;
const b = 11;
add(a+ b);複製程式碼
// add.js 檔案
function add (a, b) {
  return a + b;
}
module.exports = add;複製程式碼

這樣做的目的顯而易見,更好的組織專案程式碼,注意到兩個檔案中的 requiremodule.exports,從現在的上帝視角來看,這出自 CommonJS 規範(後文會有一個章節來專門講規範)中的關鍵字,分別代表匯入和匯出,拋開規範而言,這其實是我們模組化之路上需要解決的問題。另外,雖然 add 模組需要得到複用,但是我們並不希望在引入 add 的時候造成全域性汙染

二、引入的模組如何執行

在上述的例子中,我們已經將程式碼拆分到了兩個模組檔案當中,在不造成全域性汙染的情況下,如何實現 require,才能使得例子中的程式碼做到正常執行呢?

先不考慮模組檔案程式碼的載入過程,假設 require 已經可以從模組檔案中讀取到程式碼字串,那麼 require 可以這樣實現

function require (path) {
   // lode 方法讀取 path 對應的檔案模組的程式碼字串
   // let code = load(path);
   // 不考慮 load 的過程,直接獲得模組 add 程式碼字串
   let code = 'function add(a, b) {return a+b}; module.exports = add';
   // 封裝成閉包
   code = `(function(module) {${code}})(context)`
   // 相當於 exports,用於匯出物件
   let context = {};
   // 執行程式碼,使得結果影響到 context
   const run = new Function('context', code);
   run(context);
   //返回匯出的結果
   return context.exports;
}複製程式碼

這有幾個要點:
1) 為了不造成全域性汙染,需要將程式碼字串封裝成閉包的形式,並且匯出關鍵字 module.exports ,module 是與外界聯絡的唯一載體,需要作為閉包匿名函式的入參,與引用方傳入的上下文 context 進行關聯
2) 使用 new Function 來執行程式碼字串,估計大部分同學對 new Function 是不熟悉的,因為一般情況下定義一個函式無需如此,要知道,用 Function 類可以直接建立函式,語法如下:

var function_name = new function(arg1, arg2, ..., argN, function_body)複製程式碼

在上面的形式中,每個 arg 都是一個引數,最後一個引數是函式主體(要執行的程式碼)。這些引數必須是字串。也就是說,可以使用它來執行字串程式碼,類似於 eval,並且相比 eval, 還可以通過引數的形式傳入字串程式碼中的某些變數的值
3)如果曾經你有疑惑過為什麼規範的匯出關鍵字只有 exports 而我們實際使用過程中卻要使用module.exports(寫過 Node 程式碼的應該不會陌生),那在這段程式碼中就可以找到答案了,如果只用 exports 來接收 context,那麼對 exports 的重新賦值對 context 不會有任何影響(引數的地址傳遞),不信將程式碼改成如下形式再跑一跑:

演示結果
演示結果

三、程式碼載入方式

解決了程式碼的執行問題,還需要解決模組檔案程式碼的載入問題,根據上述例項,我們的目標是將模組檔案程式碼以字串的形式載入

在 Node 容器,所有的模組檔案都在本地,只需要從本地磁碟讀取模組檔案載入字串程式碼,再走上述的流程就可以了。事實證明,Node 非內建、核心、c++ 模組的載入執行方式大體如此(雖然使用的不是 new Function,但也是一個類似的方法)

在 RN/Weex 容器,要載入一個遠端 bundle.js,可以通過 Native 的能力請求一個遠端的 js 檔案,再讀取成字串程式碼載入即可(按照這個邏輯,Node 讀取一個遠端的 js 模組好像也無不可,雖然大多數情況下我們不需要這麼做)

在瀏覽器環境,所有的 Js 模組都需要遠端讀取,尷尬的是,受限於瀏覽器提供的能力,並不能通過 ajax 以檔案流的形式將遠端的 js 檔案直接讀取為字串程式碼。前提條件無法達成,上述執行策略便行不通,只能另闢蹊徑

這就是為什麼有了 CommonJs 規範了,為什麼還會出現 AMD/CMD 規範的原因

那麼瀏覽器上是怎麼做的呢?在瀏覽器中通過 Js 控制動態的載入一個遠端的 Js 模組檔案,需要動態的插入一個 <script> 節點:

// 摘抄自 require.js 的一段程式碼
var node = config.xhtml ?
                document.createElementNS('http://www.w3.org/1999/xhtml', 'html:script') :
                document.createElement('script');
node.type = config.scriptType || 'text/javascript';
node.charset = 'utf-8';
node.async = true;
node.setAttribute('data-requirecontext', context.contextName);
node.setAttribute('data-requiremodule', moduleName);
node.addEventListener('load', context.onScriptLoad, false);
node.addEventListener('error', context.onScriptError, false);複製程式碼

要知道,設定了 <script> 標籤的 src 之後,程式碼一旦下載完成,就會立即執行,根本由不得你再封裝成閉包,所以檔案模組需要在定義之初就要做文章,這就是我們說熟知的 AMD/CMD 規範中的 define,開篇的 add.js 需要重新改寫一下

// add.js 檔案
define ('add'function () {
    function add (a, b) {
      return a + b;
    }
    return add;
})複製程式碼

而對於 define 的實現,最重要的就是將 callback 的執行結果註冊到 context 的一個模組陣列中:

    context.modules = {}
    function define(name, callback) {
        context.modules[name] = callback && callback()
    }複製程式碼

於是 require 就可以從 context.modules 中根據模組名載入模組了,是不是有了一種自己去寫一個 “requirejs” 的衝動感

具體的 AMD 實現當然還會複雜很多,還需要控制模組載入時序、模組依賴等等,但是瞭解了這其中的靈魂,想必去精讀 require.js 的原始碼也不是一件困難的事情

四、Webpack 中的模組化

Webpack 也可以配置非同步模組,當配置為非同步模組的時候,在瀏覽器環境同樣的是基於動態插入 <script> 的方式載入遠端模組。在大多數情況下,模組的載入方式都是類似於 Node 的本地磁碟同步載入的方式

嫑忘記,Webpack 除了有模組化的能力,還是一個在輔助完善開發工作流的工具,也就是說,Webpack 的模組化是在開發階段的完成的,使用 Webpack 構築的工作環境,在開發階段雖然是獨立的模組檔案,但是在執行時,卻是一個合併好的檔案

所以 Webpack 是一種在非執行時的模組化方案(基於 CommonJs),只有在配置了非同步模組的時候對非同步模組的載入才是執行時的(基於 AMD)

五、模組化規範

通用的問題在解決的過程中總會形成規範,上文已經多次提到 CommonJs、AMD、CMD,有必要花點篇幅來講一講規範

Js 的模組化規範的萌發於將 Js 擴充套件到後端的想法,要使得 Js 具備類似於 Python、Ruby 和 Java 那樣具備開發大型應用的基礎能力,模組化規範是必不可少的。CommonJS 規範的提出,為Js 制定了一個美好願景,希望 Js 能在任何地方執行,包括但不限於:

  • 伺服器端 Js 應用
  • 命令列工具
  • 桌面應用
  • 混合應用

CommonJS 對模組的定義並不複雜,主要分為模組引用、模組定義和模組標識

  1. 模組引用:使用 require 方法來引入一個模組
  2. 模組定義:使用 exports 匯出模組物件
  3. 模組標識:給 require 方法傳入的引數,小駝峰命名的字串、相對路徑或者絕對路徑

模組示意
模組示意

CommonJs 規範在 Node 中大放異彩並且相互促進,但是在瀏覽器端,鑑於網路的原因,同步的方式載入模組顯然不太實用,在經過一段爭執之後,AMD 規範最終在前端場景中勝出(全稱 Asynchronous Module Definition,即“非同步模組定義”)

什麼是 AMD,為什麼需要 AMD ?在前述模組化實現的推演過程中,你應該能夠找到答案

除此之外還有國內玉伯提出的 CMD 規範,AMD 和 CMD 的差異主要是,前者需要在定義之初宣告所有的依賴,後者可以在任意時機動態引入模組。CMD 更接近於 CommonJS

兩種規範都需要從遠端網路中載入模組,不同之處在於,前者是預載入,後者是延遲載入

五、總結

如果有心,可以參照本文的推演,來實現一個 “yourRequireJs”,沒有什麼比重複造輪子更能讓知識沉澱~~

菲麥前端 是一個讓知識深入原理的知識社群,我們有 知識星球、公眾號以及群,歡迎加微勾搭:facemagic2014

相關文章