淺析前端的模組化

神奇排骨發表於2019-02-11

我們可能經常聽到一些模組化的概念,譬如 AMDCommonJSES Modules。這些又是什麼概念呢?它們為什麼而存在,作用又是什麼呢?本文將對模組化的概念進行逐一分析。

為什麼需要模組化

在瞭解模組化的概念前,首先先解決一個問題 - 為什麼需要模組化?

先從實際問題出發,在類似 require.jssea.jsbrowserifywebpack 等工具出現之前,我們可能會遇到如下一些問題:

  • 我們在引用不同的庫或者 js 檔案時,可能會出現命名衝突。
  • 引用的庫或者檔案都會有個先來後到,那如何去決定與維護這些順序呢?
  • 庫與庫之間、檔案與檔案之間可能存在迴圈引用,即在 a.js 中引用了 b.js,而 b.js 中也引用了 a.js;兩者相互依賴。那我們如何決定先引用哪個件呢?

我們再從一個生活中的例子出發,簡要了解一下模組化的優點,即為什麼需要模組化的原因。

假設你有一套修理工具箱,裡面包含了屬於這套修理工具箱的各種型號的螺絲批、鉗子和錘子等等。每次家裡水管破了,燈泡壞了什麼的,你都可以拿這套修理工具箱進行修理。每次修理可能都會造成一些工具的損耗或損壞,損壞之後我們就應該去買相同型號的工具進行補充;其他不對頭的工具不會回收回這套修理工具箱中,同樣這套修理工具箱中的工具也不會隨意扔出工具箱中。

我們把上述例子轉化為模組化來看看。首先,修理工具箱就是 模組,裡面的工具就是 模組 中的各種變數或函式。工具出現損壞等於 模組 內出了什麼問題,這時候我們只需要修復 模組 內的 bug 就好了。其他不對頭的工具不會回收回工具箱中,反之工具箱中的工具不會隨意被扔出表示 模組 內的變數、函式等不會汙染外部的變數、函式等等,反之亦然。這套工具箱可以重複利用也就是 模組 的複用性很強。

總結模組化有三大優點:

  • 可維護性強,更新或修復模組內的邏輯不會影響外部邏輯
  • 獨立的名稱空間,模組內的變數不會汙染外部的變數,即使它們擁有相同的變數名
  • 可複用性強,我們需要在不同的地方用到某個模組,只需要在對應的地方引入它就行了,無需重複地拷貝複製。

什麼是模組化

接下來將會通過一些常見的例子與概念來解釋什麼是模組化。注意,閉包在模組化中有著重要的應用,這裡假設你對閉包概念已有所瞭解。

IIFE - 立即執行函式表示式

顧名思義,立即執行函式表示式就是一個函式在定義時就會立即執行。

var global = "I'm global"

(function () {
  var foo = 'foo'

  function bar () {
    console.log('bar')
  }

  console.log(foo)

  bar()

  console.log(global)
})()
// foo
// bar
// I'm global

var foo = 'global foo'
console.log(foo)  // global foo
bar() // Uncaught ReferenceError: bar is not defined
複製程式碼

可以看到,我們在立即執行函式表示式的外部訪問其變數會丟擲錯誤,而在立即執行函式表示式的內部可以隨時訪問外部變數。外部與立即執行函式表示式同樣有命名為 foo 的變數,但這兩者互不影響。其實這種行為就類似於 C++ 等語言中類的私有變數、私有方法。

當然我們還可以讓立即執行函式表示式放回一些東西,類似類的暴露公共方法、變數的概念。

var module = (function () {
  var _privateCnt = 0
  var _privateProperty = 'I am private property'

  function _privateCnter () {
    return _privateCnt += 1
  }

  function publicCnter () {
    return _privateCnter()
  }

  return {
    property: _privateProperty,
    publicCnter: publicCnter
  }

})()

console.log(module.property) // I am private property
console.log(module.publicCnter()) // 1
console.log(module.publicCnter()) // 2
console.log(module.publicCnter()) // 3
複製程式碼

這個例子展示了通過立即執行函式表示式將一些變數、方法暴露出去,並防止外部直接修改一些我們不希望修改的變數、方法。這樣做還有一個好處,就是我們可以快速地瞭解到這個立即執行函式為我們提供了哪些公共屬性及方法,而不需要閱讀所有邏輯程式碼。這種方式在設計模式中也稱作 模組模式(Module Pattern)

CommonJS

CommonJS 主要是為服務端定義的模組規範,它一開始的名字為 ServerJSnpm 生態系統基本都是基於 CommonJS 規範所建立起來的。

// 在 foo.js 中,我們匯出了變數 foo
module.exports = {
  foo: 'foo'
}

// 在 bar.js,我們通過 require 引入了變數 foo
var module = require('foo')
console.log(module.foo) // foo
複製程式碼

看起來很簡單是吧。可能有人會問了,這個 module 是什麼東西呢?其實 module 是 Node 中的一個內建物件。我們可以在 node 環境下列印看看

淺析前端的模組化

我們可以看到 module 有好幾個屬性,其中 id 是為了讓 node 知道這個模組在哪裡,是啥;exports 就是我們要匯出的物件了。

在確保 foo.jsbar.js 在同一目錄下,我們再將例子稍加修改:

// foo,js
module.exports = {
  foo: 'foo'
}
console.log('module: ', module)

// bar.js
var module = require('./foo')
console.log(module.foo)
複製程式碼

執行 node bar.js 可以得到以下資訊:

淺析前端的模組化

通過 CommonJS 規範定義的模組同樣有一開始說到的模組的三大優點,其實我們只需要把這些模組檔案看出一個個立即執行函式,也就會很好理解了。

CommonJS 裡模組都是同步載入的,在瀏覽器中如果同步去載入模組的話會造成阻塞,導致頁面效能下降;而在服務端中,因為檔案都存在於同一個硬碟上,所以即使是同步載入都不會有什麼影響。

再補充一個小細節,你可能時不時能看到 var exports = module.exports 這樣的程式碼。或許你會問為什麼要怎麼做,難道有什麼技巧嗎?其實這只是簡單的引用而已。即變數 exports 同樣指向了 module.exports 的記憶體地址,也就是兩者指向的物件是完全一樣的。我們想在 module.exports 裡新增匯出的東西時,只需要在 exports 里加就行了。就是這麼簡單,只不過被一些說法搞得高深莫測了而已。

var exports = module.exports

exports.foo = 'foo''
複製程式碼

AMD - Asynchronous Module Definition

剛剛我們說到 CommonJS 主要是用於服務端的規範,而客戶端是無法使用它的,並且 CommonJS 是同步載入模組的。所以我們又有了叫做 AMD 規範的東西,也就是非同步模組定義規範。顧名思義,我們可以利用這個規範來做到模組與模組的依賴可以通過非同步的方式來載入;這也是瀏覽器(客戶端)所希望的。

AMD 中的核心就是 define 這個方法。

define(
  module_id,
  [dependencies],
  definition
)
複製程式碼

其中 define 中的 module_iddependencies 為可選引數。

首先 module_id 它是一個字串,指的是定義的模組的名字,這個名字必須是唯一的。第二個引數 dependencies 是模組所依賴的模組組成的陣列,並作為引數傳入給第三個引數 definition 工廠方法中。第三個引數 definition 就是為模組初始化要執行的函式或物件。如果為函式,它應該只被執行一次。如果是物件,此物件應該為模組的輸出值。

// dep1
define('dep1', [], function () {
  return {
    doSomething: function () {
      console.log('do something')
    }
  }
})

define('dep2', [], function () {
  return {
    doOtherThing: function () {
      console.log('do other thing')
    }
  }
})

define('module', ['dep1', 'dep2'], function (dep1, dep2) {
  dep1.doSomething()
  dep2.doOtherThing()
})
複製程式碼

雖然 AMD 規範提供了非同步載入模組的方案,但是給我的感覺就是邏輯不如 CommonJS 直觀。因此在 ES6 中也就有了原生的模組化: ESM - ES Modules

ES Modules

CommonJS 在服務端中應用廣泛,但由於它是同步載入模組的,它在客戶端不太合適;而 AMD 支援瀏覽器非同步載入模組,但在服務端卻顯得沒有必要,因此 ES Modules 出現了。我們先來看看 ES Modules 是如何工作的。

ES ModulesCommonJS 很相似,較新的瀏覽器均已支援 ES ModulesNode.js 正在慢慢支援相關規範。

ES Modules 的核心為 exportimport,分別對應匯出模組與匯入模組。

匯出模組:

// CommonJS
module.exports = foo () {
  console.log('here is foo')
}

// ES Modules
export default function bar () {
  console.log('here is bar')
}
複製程式碼

匯入模組:

// CommonJS
var foo = require('./foo')

foo() // here is foo

// ES Modules
import bar from './bar'

bar() // here is bar
複製程式碼

這兩者這麼相似,它們在實際的表現上有什麼不同呢?

我分別在 FirefoxEdgeChrome 上測試(Chrome 由於自身的安全策略無法直接通過本地檔案進行測試,所以利用外掛 Web Server for Chrome 起了個本地伺服器。

測試程式碼如下圖(注意我們在使用 ES Modules 時要給 script 標籤加上 type="module"

淺析前端的模組化

測試的結果顯示為:

淺析前端的模組化

開始執行 bar.js
開始執行 foo.js
here is foo
here is bar
複製程式碼

如果我們使用 CommonJS 又會有什麼結果呢?

淺析前端的模組化

開始執行 foo.js
here is foo
開始執行 bar.js
here is bar
複製程式碼

很顯然 CommonJS 是同步載入模組的,所以程式碼的執行也是順序的。而 ES Modules 是非同步載入模組的,且 ES Modules 是編譯時載入模組,在執行時(執行程式碼時)再根據相關的引用去被載入的模組中取值。再詳細一點來說的話,整個過程分如下三個步驟:

  • 構建 - 查詢、下載並將檔案解析到模組記錄中
  • 例項化 - 在記憶體中找到所有匯出(export)的值的位置,但暫不對這些值進行賦值;然後在記憶體中建立 exportsimports 的空間。這一步稱為連結
  • 執行 - 執行程式碼並將實際的值賦予給例項化中匯出的值

ES Modules 解析的相關參考文章

因此在編譯期間,編譯器先找到了 foo.js 的依賴 bar.js,先編譯 bar.js 然後才是 foo.js。所以你才會先看到 開始執行 bar.js

總結

  • 模組化簡單來說就是將相關的邏輯程式碼獨立出來,獨立的形式有很多種,可以是單純的一個函式,亦可以是單獨的一個檔案。
  • 模組化可以更好地組織程式碼結構,增強其可維護性,可複用性強。
  • CommonJS 工作原理為同步載入模組,在 Node.js 中有著廣泛的使用,對客戶端不友好。
  • AMD 工作原理為非同步載入模組。
  • ES ModulesES6 推出的規範,客戶端的支援比較好,Node.js 將會慢慢全面支援。它與 AMD 一樣,也是非同步載入模組。

相關文章