「前端工程四部曲」模塊化的前世今生(上)

語言: CN / TW / HK
ead>

聲明:本文為掘金首發簽約文章,未經授權禁止轉載。

寫在前面

在日益複雜和多元的Web業務背景下,前端工程化這個概念經常被提及。“説説你對Web工程化的理解?” 相信很多初學者在面試時會經常遇到,而大多數人腦子會直接浮現出 Webpack,認為工程化就是 Webpack 做的那些事情兒,當然也不能説不對,準確説 Webpack 只是工程化背景下產生的工具。

工程化的目的是高性能、是穩定性、是可用性、是可維護性、是高效協同,只要是以這幾個角度為目標所做的操作,都可稱為工程化的一部分。工程化其實是軟件工程中的一種思想。當下的前端工程化可以分為四個方面:模塊化、組件化、規範化、自動化

工程四部曲為題,此文我們以 模塊化 為始來剖析前端工程化的概念,碼字不易,先贊後看,養成習慣!

什麼是模塊化

模塊化其實是指解決一個複雜問題時 自頂向下逐層把系統劃分成若干模塊 的過程,每個模塊完成一個特定的子功能(單一職責),所有的模塊按某種方法組裝起來,成為一個整體,從而完成整個系統所要求的功能,不理解沒關係,接着往下看。

為什麼需要模塊化

早期在網頁這個東西剛出現的時候,頁面、樣式都很簡單,極少有交互以及設計元素,一個頁面也不會依賴很多文件,邏輯代碼非常少,就是靜態頁面那種,那個時候的前端叫網頁設計。

隨着 Web 技術的發展,各種交互以及新技術等使網頁變得越來越豐富,逐漸我們前端工程師登上了舞台,同時也使得我們前端同學的代碼量急速上漲、複雜度在逐步增高,越來越多的業務邏輯和交互都放在 Web 層實現,代碼一多,各種命名衝突、代碼宂餘、文件間依賴變大等等一系列的問題就出來了,甚至導致後期難以維護。

在這些問題上,其他如 java、php 等後端語言中早已有了很多實踐經驗,那就是模塊化,因為小的、組織良好的代碼遠比龐大的代碼更易理解和維護,於是前端也開啟了模塊化歷程。

JS模塊化

説到JS模塊化,肯定少不了CommonJS、AMD、CMD、UMD、ESM,相信很多前端新同學甚至有些喜好摸魚的老同學經常把它們搞混,如果你知道這些概念卻不知道它們之間的聯繫,或者知道它們之間的聯繫卻不瞭解核心實現,那請好好看此文。

早期前端三劍客中佔據主導地位的 JS 不是一種模塊化編程語言,規範中也沒有模塊(即module)的概念,所以模塊的實現就顯得很麻煩了,不過早期的前端工程師通過 JS 的語言特性來模擬實現了模塊化。

早期JS模塊化方案

普通函數

首先考慮到函數實現,因為 JS 中函數是有獨立作用域的,並且函數中可以放任何代碼,只需要在需要使用的地方調用即可,就比如下面代碼:

js function fn1(){ //... } function fn2(){ //... } function fn3() { fn1() fn2() }

可以看到這樣做實現了代碼分離及組織,看着挺清晰,其實是因為代碼量小,如果函數過多,並且在多個文件中,還是無法保證它們不與其它模塊發生命名衝突,而且模塊成員之間看不出直接關係,還是會給後期的維護造成麻煩。

命名空間

在上面普通函數的方式中,很多變量和函數會直接在全局作用域下面聲明,很容易產生命名衝突,於是,命名空間模式(namespace)就被提出了。

因為對象可以有屬性,而它的屬性既可以是數據,也可以是方法,剛好能夠很好地滿足需求,而且對象的屬性通過對象名字來訪問,相當於設定了一個命名空間。

我們來看看把模塊寫成一個對象,所有的模塊成員都放到這個對象裏面是怎麼樣的:

```js var myModule = { name: "isboyjc", getName: function (){ console.log(this.name) } }

// 使用 myModule.getName() ```

顯然這是可行的,但是很快我們又發現了其缺點,對象內部屬性全部會暴露出來,內部狀態可以被外部更改,如下:

js myModule.name = "哈哈哈" myModule.getName() // 哈哈哈

立即執行函數(IIFE)

儘管命名空間模式一定程度上解決了全局命名空間上的變量污染問題,但是它沒辦法解決代碼和數據隔離的問題,大概在 2003 年,立即執行函數簡稱 IIFE 出現了 ,它其實是利用函數閉包的特性來實現私有數據和共享方法,如下:

```js var myModule = (function() { var name = 'isboyjc'

function getName() { console.log(name) }

return { getName } })() ```

這樣我們就可以通過 myModule.getName() 來獲取 name,並且實現 name 屬性的私有化,即外部調用不到:

js myModule.getName() // isboyjc myModule.name // undefined

那假如我們這個模塊需要依賴其他模塊呢?這時候就用到了引入依賴,即函數傳參:

```js // otherModule.js模塊文件 var otherModule = (function(){ return { a: 1, b: 2 } })()

// myModule.js模塊文件 - 依賴 otherModule 模塊 var myModule = (function(other) { var name = 'isboyjc'

function getName() { console.log(name) console.log(other.a, other.b) }

return { getName } })(otherModule) ```

通過這種傳參的形式,我們就可以在 myModule 模塊中使用其他模塊,從而解決了很多問題,這也是現代模塊化規範的思想來源。

依賴注入

模塊化發展的歷程中還有模版定義依賴、註釋定義依賴等方案,這些我覺得並不具有很強的學習性質,不再贅述。我們下面説説依賴注入(Dependency Indection, DI),説到這個,不得不提起三大框架之一的 Angular,它誕生於 2009 年,其核心特性之一就是依賴注入。

假如我們有兩個原始模塊 fnAfnB

```js // 模塊fnA let fnA = function(){ return {name: '我是fnA'} }

// 模塊fnB let fnB = function(){ return {name: '我是fnB'} } ```

我們編寫一個函數 fnC,想要使用上面兩個模塊,我們可以通過下面這種方式:

js let fnC = function(){ let a = fnA() let b = fnB() console.log(a, b) }

我們也知道,上面這樣的代碼無論從哪個角度看都很不靈活,我們不知道這段代碼中有哪些依賴,也不能對引入的依賴進行二次修改因為會造成原函數的更改,這個時候我們要做的就是將依賴的函數作為參數顯式的傳入:

js let fnC = function(fnA, fnB){ let a = fnA() let b = fnB() console.log(a, b) }

問題又來了,如果我們在很多地方都調用了函數 fnC,後面突然有需求需要調用第三個依賴項怎麼辦呢?難道要去修改調用處函數傳入參嗎?這樣做也可以,但很不明智,那這裏就需要一段代碼幫助我們做這個事情,也就是所謂的依賴注入器,它需要幫我們解決下面這幾個問題:

  • 可以實現依賴的註冊
  • 依賴注入器應該可以接收依賴(函數等),注入成功後給我們返回一個可以獲取所有資源的函數
  • 依賴注入器要能夠保持傳遞函數的作用域
  • 傳遞的函數能夠接收自定義的參數,而不僅僅是被描述的依賴項

我們來簡單實現一個依賴註冊器,我們新建一個 injector 對象,它是獨立的,以便它能夠在我們應用的各個部分都擁有同樣的功能。

js let injector = { dependencies: {}, register: function(key, value) { this.dependencies[key] = value; }, resolve: function(deps, func, scope) { var args = []; for(var i = 0; i < deps.length, d = deps[i]; i++) { if(this.dependencies[d]) { // 存在此依賴 args.push(this.dependencies[d]); } else { // 不存在 throw new Error('不存在依賴:' + d); } } return function() { func.apply(scope || {}, args.concat(Array.prototype.slice.call(arguments, 0))); } } }

可以看到,這個對象非常簡單,只有三個屬性,dependencies 用來保存依賴,register 用來添加依賴,最後的 resolve 用來注入依賴。

resolve 函數需要做的事情很簡單,先檢查 deps 數組,然後在 dependencies 對象種尋找依賴,依次添加至 args 數組中,scope 參數存在則指定其作用域,返回的函數中將其參數使用 .apply 的方法傳入我們傳遞回去的 func 回調。

再來看使用:

```js // 添加 injector.register('fnA', fnA) injector.register('fnB', fnB)

// 注入 (injector.resolve(['fnA', 'fnB'], function(fnA, fnB){ let a = fnA() let b = fnB() console.log(a, b) }))() ```

調用時,我們也可以傳入額外的參數:

js (injector.resolve(['fnA', 'fnB'], function(fnA, fnB, str){ let a = fnA() let b = fnB() console.log(a, b, str) }))('isboyjc')

由此,我們實現了一個簡單的依賴注入,依賴注入並不是一個新的東西,它在其他語言中存在已久,它是一種設計模式,也可以説是一種風格。

早期的模塊化演變過程中還有很多方案,就不一一寫了。我們所説的模塊化方案,並不是相互獨立的,每種方案之間可能相互借鑑,就像依賴注入這種方式也用到了 IIFE ,一個好的模塊化方案,無非就像是解決我們上面依賴注入提出的幾個問題一樣解決實際問題而存在。

隨着前端發展對模塊需求越來越大,社區中逐漸出現了一些優秀且被大多數人認同的模塊化解決方案,慢慢演變成了通用的社區模塊化規範,它們不僅解決了依賴注入的這些問題,還具備了很多獨有的模塊化特性,再到後面 ES6 的出現,也意味着官方(語言層面)的模塊化規範 ESM 的落地。

JS模塊化規範演進

CommonJS規範

簡介

JS 標準定義的 API 只是為了構建基於瀏覽器的應用程序,並沒有制定一個用於更廣泛的應用程序的標準庫。

CommonJS 規範的提出主要是為了彌補 JS 沒有標準的缺陷,它由社區提出,終極目標就是提供一個類似 PythonRubyJava語言的標準庫,而不只是停留在腳本程序的階段。

即用 CommonJS API 編寫出的應用不僅可利用 JS 來開發客户端應用,還可編寫服務器端 JS 應用程序、命令行工具、桌面圖形界面應用程序等。

2009 年,美國程序員 Ryan DahlCommonJs 規範為基礎創造了 node.js 項目,將 JS 語言用於服務器端編程,為前端奠基,從此之後 nodejs 就成為了 CommonJs 的代名詞。

CommonJS 規範中規定每個文件就是一個獨立的模塊,有自己的作用域,模塊的變量、函數、類都是私有的,外部想要調用,必須使用 module.exports 主動暴露,而在另一個文件中引用則直接使用 require(path) 即可,如下:

```js // num.js var a = 1 var b = 2 var add = function (){ return a + b }

// 導出 module.exports.a = a module.exports.b = b module.exports.add = add ```

引用如下:

```js var num = require('./num.js')

console.log(num.a) // 1 console.log(num.b) // 2 console.log(num.add(a,b)) // 3 ```

require 命令則負責讀取並執行一個 JS 文件,並返回該模塊的 exports 對象,沒找到的話就拋出一個錯誤。

上面也説過,CommonJS 規範適用於服務端,也就是隻適用於 NodeJS ,其實簡單來説就是 Node 內部提供一個構造函數 Module,所有模塊都是構造函數 Module 的實例,如下:

js function Module(id, parent) { this.id = id this.exports = {} this.parent = parent // ... }

每個模塊內部,都有一個 module 實例,該對象就會有下面幾個屬性:

  • module.id 模塊的識別符,通常是帶有絕對路徑的模塊文件名
  • module.filename 模塊的文件名,帶有絕對路徑
  • module.loaded 返回一個布爾值,表示模塊是否已經完成加載
  • module.parent 返回一個對象,表示調用該模塊的模塊
  • module.children 返回一個數組,表示該模塊要用到的其他模塊
  • module.exports 表示模塊對外輸出的值

總的來説 CommonJS 規範的特點有下面幾個方面:

  • 所有代碼都運行在模塊作用域,不會污染全局作用域
  • 模塊可以多次加載,但是隻會在第一次加載時運行一次,然後運行結果就被緩存了,以後再加載,就直接讀取緩存結果,要想讓模塊再次運行,必須清除緩存
  • 模塊加載的順序,按照其在代碼中出現的順序

説了這麼多,不如我們直接實現一個簡單的。

核心實現

不多説,先上代碼為敬,簡單幾十行代碼,帶大家體會一下 commonJS。

首先,我們創建一個 test.js,寫如下代碼:

js module.exports = { a:1, b:2, c(){ return 3 } }

有人會問: module.exports 明明是原生的,不是要手寫實現嗎?接着看。

新建一個 commonJS.js 文件,全部代碼如下,看一遍註釋,後面再略微介紹下就 OK 了。

```js let path = require('path'); let fs = require('fs'); let vm = require('vm');

let n = 0

// 構造函數Module function Module(filename){ this.id = n++; // 唯一ID this.filename = filename; // 文件的絕對路徑 this.exports = {}; // 模塊對應的導出結果 }

// 存放可解析的文件模塊擴展名 Module._extensions = ['.js']; // 緩存 Module._cache = {}; // 拼湊成閉包的數組 Module.wrapper = ['(function(exports,require,module){','\r\n})'];

// 沒寫擴展名,默認添加擴展名 Module._resolveFilename = function (p) { p = path.join(__dirname, p); if(!/.\w+$/.test(p)){ //如果沒寫擴展名,嘗試添加擴展名 for(let i = 0; i < Module._extensions.length; i++){ //拼接出一個路徑 let filePath = p + Module._extensions[i]; // 判斷文件是否存在 try{ fs.accessSync(filePath); return filePath; }catch (e) { throw new Error('module not found') } } }else { return p } }

// 加載模塊本身 Module.prototype.load = function () { // 解析文件後綴名 isboyjc.js -> .js let extname = path.extname(this.filename); // 調用對應後綴文件加載方法 Module._extensionsextname; };

// 後綴名為js的加載方法 Module._extensions['.js'] = function (module) { // 讀文件 let content = fs.readFileSync(module.filename, 'utf8'); // 形成閉包函數字符串 let script = Module.wrapper[0] + content + Module.wrapper[1]; // 創建沙箱環境,運行並返回結果 let fn = vm.runInThisContext(script); // 執行閉包函數,將被閉包函數包裹的加載內容 fn.call(module, module.exports, req, module) };

// 仿require方法, 實現加載模塊 function req(path) { // 根據輸入的路徑 轉換絕對路徑 let filename = Module._resolveFilename(path); // 查看緩存是否存在,存在直接返回緩存 if(Module._cache[filename]){ return Module._cache[filename].exports; } // 通過文件名創建一個Module實例 let module = new Module(filename); // 加載文件,執行對應加載方法 module.load(); // 入緩存 Module._cache[filename] = module; return module.exports }

let str = req('./test'); console.log(str); ```

如上,附帶註釋也不過 80 行代碼。

首先我們寫了一個構造函數 Module,其中 id 是唯一ID,filename 存文件的絕對路徑,exports 存模塊對應的導出結果。

我們還為 Module 添加了幾個靜態屬性,其中 _extensions 存放可解析模塊擴展名,而在後面將擴展名作為 key,添加其解析方法。_cache 則是緩存加載過的模塊,wrapper 是一個數組,包含兩個字符串項,兩個字符串合起來就是一個函數字符串,它作為我們後面拼湊函數的數組。

其次還添加了一個靜態方法 _resolveFilename 用於解析文件完整路徑,還有一個比較核心的原型方法 load ,用於加載模塊。

平常我們使用 node 加載模塊時,使用的是 require 方法,而我們手寫則是用 req 方法,該方法傳入一個文件路徑(可省略後綴),方法中我們首先調用構造函數 Module 的 _resolveFilename 方法把傳入的路徑解析成一個絕對路徑 filename,接着校驗 _cache 對象中是否存在以 filename 路徑為 key 的值,如果有,直接讀取緩存。

如果緩存中沒有,new 一個 Module 實例,再調用 load 方法加載模塊。

最重要的是 load 的過程,load 首先解析 filename 字符串,拿到文件的後綴名,通過調用 _extensions 中後綴名對應的方法加載對應文件,我們在代碼中,已經為 Module._extensions['.js'] 添加了對應解析方法,也就是解析 js 後綴的文件。

文中的文件是 test.js,其後綴是 .js,正好對應,調用該方法,傳入 this(即 module 實例)。

目光來到 Module._extensions['.js'] 方法,其實也簡單,首先通過 filename 讀取該文件內容,接着,開始拼湊一個方法,也就是下面這行代碼:

js let script = Module.wrapper[0] + content + Module.wrapper[1];

此行代碼拼湊出來的字符串 script 其實就是一個方法,只不過是字符串方法,如下:

js 'function(exports,require,module){ test.js文件內容 }'

再接下來,就是大家不太理解的 vm.runInThisContext 方法了,這裏簡單介紹下:

vm.runInThisContext(code) 會創建一個獨立的沙箱環境,執行對參數代碼 code 的編譯,運行並返回結果。該方法運行的代碼沒有權限訪問本地作用域,但是可以訪問 Global 全局對象。

這樣説不理解的話,那大家總知道 eval 吧!其實它和 eval 類似,來看示例:

```js var vm = require('vm'); var str = '111';

//在runInThisContext創建的沙箱環境中執行 var vmRes = vm.runInThisContext('str = "vm222";'); console.log('vmRes: ', vmRes); // vmRes: vm222 console.log('str: ', str); // str: 111

//在eval中執行 var evalRes = eval('str = "eval222";'); console.log('evalRes: ', evalRes); // evalRes: eval222 console.log('str: ', str); // str: eval222 ```

如上,使用 vm.runInThisContext 執行的字符串 code 並不會改變當前作用域,而 eval 可以,僅此而已。

思緒回來,vm.runInThisContext(script) 把我們拼成的字符串方法,變成了一個可執行的方法,隨後調用並傳入參數:

js fn.call(module, module.exports, req, module)

由於使用了 call 方法,所以第一個參數是將轉換後的 script 也就是函數 fn 的 this 指向變為 當前 module 實例,剩餘三個即函數調用參數,回顧當時拼函數時這個函數的形參與當前函數調時傳入值的對比:

```js // 原來函數 fn = function(exports, require, module){ // test.js文件內容 }

// 調用 fn(module.exports, req, module) ```

三個參數分別是:

  • module 實例的 exports 對象
  • req 模塊導入方法
  • module 實例本身

看到這裏我想大家應該明白示例最開始的 test.js 中我們為什麼可以直接使用 module.exports 導出了,很明顯因為在加載過程中,我們把整個 test 文件作為一塊代碼塞進了匿名的加載方法中,而這個加載方法在執行時,形參中存在 module 實例,所以我們就可以直接操作 module 實例,向其 exports 屬性中塞數據了!!!如此,一個非常簡單的 commonJS 手寫例子就結束了,你 Get 了嗎?

最後總結,簡單點説,CommonJs 就是模塊化的社區標準,而 Nodejs 就是 CommonJs 模塊化規範的實現,它對模塊的加載是同步的,也就是説,只有引入的模塊加載完成,才會執行後面的操作,在 Node 服務端應用當中,模塊一般存在本地,加載較快,同步問題不大,在瀏覽器中就不太合適了,你試想一下,如果一個很大的項目,所有的模塊都同步加載,那體驗是極差的,所以還需要異步模塊化方案,所以 AMD規範 就此誕生。

AMD規範

簡介

AMD(異步模塊定義)是專門為瀏覽器環境設計的,它定義了一套異步加載標準來解決同步的問題

語法如下:

js define(id?: String, dependencies?: String[], factory: Function|Object)

  • id 即模塊的名字,字符串,可選
  • dependencies 指定了所要依賴的模塊列表,它是一個數組,也是可選的參數,每個依賴的模塊的輸出將作為參數一次傳入 factory 中。如果沒有指定 dependencies,那麼它的默認值是 ["require", "exports", "module"]
  • factory 包裹了模塊的具體實現,可為函數或對象,如果是函數,返回值就是模塊的輸出接口或者值

我們簡單列舉一些用法,如下,我們定義一個名為 myModule 的模塊,依賴於 jQuery 模塊:

```js // 定義依賴 myModule,該模塊依賴 JQ 模塊 define('myModule', ['jquery'], function($) { // $ 是 jquery 模塊的輸出 $('body').text('isboyjc') })

// 引入依賴 require(['myModule'], function(myModule) { // todo... }) ```

沒有 ID 值的匿名模塊,此時文件名就是它的標識名,通常都作為啟動模塊:

js define(['jquery'], function($) { $('body').text('isboyjc') })

依賴多個模塊:

js define(['jquery', './math.js'], function($, math) {})

模塊輸出:

```js define(['jquery'], function($) { var writeName = function(selector){ $(selector).text('isboyjc') }

// writeName 是該模塊輸出的對外接口 return writeName }) ```

模塊內部引用依賴:

js define(function(require) { // 引入依賴 var $ = require('jquery') $('body').text('isboyjc') })

大家應該都知道 RequireJS ,一個遵守 AMD 規範的工具庫,用於客户端的模塊管理。

它就是通過 define 方法,將代碼定義為模塊,通過 require 方法,實現代碼的模塊加載,使用時需要下載和導入,也就是説我們在瀏覽器中想要使用 AMD 規範時先在頁面中引入 require.js 就可以了。

可以説 RequireJS 就是 AMD 的標準化實現

核心實現

上面的用法大家可能都知道,我們接下來來簡單實現一個 AMD 規範的模塊加載器,類似 RequireJS

寫之前,我們先把使用的例子寫出來:

index.html 入口文件:

```html

Document

```

如上所示,大概就是引入 requireJS.js 文件,然後使用它引入 ab 兩個依賴項並返回其相加的和。

我們自來看模塊 ab :

```js // a.js define([], function () { return 1 })

// b.js define(['c'], function (c) { return 2 + c })

// c.js define([], function () { return 2 }) ```

可以看到,a.js 文件中返回或者説導出了變量 1。

b.js 文件確又依賴了模塊 c ,返回 2 + c 的和。

最後的 c.js 模塊文件則是直接返回了變量 2。

我們要做到的效果就是執行 index.html 文件,最終輸出 5 即可。

接下來我們來手寫,其實最主要的就是兩個方法 require & define ,還是先放代碼再解釋,requireJS.js 文件如下:

```js (function () { // 緩存 const cache = {} let moudle = null const tasks = []

// 創建script標籤,用來加載文件模塊 const createNode = function (depend) { let script = document.createElement("script"); script.src = ./${depend}.js; // 嵌入自定義 data-moduleName 屬性,後可由dataset獲取 script.setAttribute("data-moduleName", depend); let fs = document.getElementsByTagName('script')[0]; fs.parentNode.insertBefore(script, fs); return script; }

// 校驗所有依賴是否都已經解析完成 const hasAlldependencies = function (dependencies) { let hasValue = true dependencies.forEach(depd => { if (!cache.hasOwnProperty(depd)) { hasValue = false } }) return hasValue }

// 遞歸執行callback const implementCallback = function (callbacks) { if (callbacks.length) { callbacks.forEach((callback, index) => { // 所有依賴解析都已完成 if (hasAlldependencies(callback.dependencies)) { const returnValue = callback.callback(...callback.dependencies.map(it => cache[it])) if (callback.name) { cache[callback.name] = returnValue } tasks.splice(index, 1) implementCallback(tasks) } }) } }

// 根據依賴項加載js文件 const require = function (dependencies, callback) { if (!dependencies.length) { // 此文件沒有依賴項 moudle = { value: callback()
} } else { //此文件有依賴項 moudle = { dependencies, callback } tasks.push(moudle) dependencies.forEach(function (item) { if (!cache[item]) { // script表親加載文件結束 createNode(item).onload = function () { // 獲取嵌入屬性值,即module名 let modulename = this.dataset.modulename console.log(moudle) // 校驗module中是否存在value屬性 if (moudle.hasOwnProperty('value')) { // 存在,將其module value(模塊返回值|導出值)存入緩存 cache[modulename] = moudle.value } else { // 不存在 moudle.name = modulename if (hasAlldependencies(moudle.dependencies)) { // 所有依賴解析都已完成,執行回調,拋出依賴返回(導出)值 cache[modulename] = callback(...moudle.dependencies.map(v => cache[v])) } } // 遞歸執行callback implementCallback(tasks) } } }) } } window.require = require window.define = require })(window) ```

同樣這也是簡化版本,不超過 90 行代碼,附帶註釋,大部分同學看一遍應該就懂了。不過分講解,簡單介紹一下流程。

調用 require 或者 define 方法,首先是根據依賴數組加載 js 文件,不同於 commonJS,AMD 基於瀏覽器,要讀文件,我們只能動態創建 script 標籤,所以 createNode 即創建script標籤,用來加載文件模塊。

script 引入文件加載完成後會觸發 onload 事件,我們以此控制依賴的加載順序。只有在 JS 模塊加載完成後,才能執行其 callback 回調,但是我們引入的 JS 依賴項中都是使用 define 方法定義的,而 define 方法還可能會依賴某些 js 文件模塊,但總有一個源頭是不存在依賴的,如此,遞歸便派上了用場。

我們的目的是模塊加載完成後執行 callbck 回調,但如果是 A 依賴 B,B 又依賴 C 等等的關係,我們想要執行 A 回調,那必須等 B 和 C 都加載完,所以我們使用一個棧(數組) tasks 來存儲 callback 回調,等所有依賴都加載完了,再依次執行,就和 Node 框架 koa 的洋葱模型一樣。這是為了讓 callback 回調函數的執行順序正確。

大致如上,剩下還有一些校驗,因為代碼簡單,不細説了。但可不要以為 requireJS 源碼真的這麼簡單,並不是,真正的源碼考慮了太多的東西,此代碼只是為了方便大家理解,有興趣自己看源碼吧~

CMD規範

簡介

CMD 的出現較為晚一些,它汲取了 CommonJSAMD 規範的優點,也是專門用於瀏覽器的異步模塊加載。

CMD 規範中,一個模塊就是一個文件,define 是一個全局函數,用來定義模塊。

define 接受 factory 參數,factory 可以是一個函數,也可以是一個對象或字符串。

factory 為對象和字符串時,表示模塊的接口就是該對象、字符串,如下:

```js // factory 為JSON數據對象 define({'name': 'isboyjc'})

// factory 為字符串模版 define('my name is {{name}}!!!') ```

factory 為函數時,表示是模塊的構造方法,執行該構造方法,可以得到模塊向外提供的接口,即 function(require, exports, module)

  • require 是一個方法,接受模塊標識作為唯一參數,用來獲取其他模塊提供的接口
  • exports 是一個對象,用來向外提供模塊接口
  • module 是一個對象,上面存儲了與當前模塊相關聯的一些屬性和方法

factory 為函數時,如下:

```js define(function(require, exports, module) { var a = require('./a') a.doSomething()

// 依賴就近原則:依賴就近書寫,什麼時候用到什麼時候引入 var b = require('./b') b.doSomething() }) ```

再來看看更多用法:

```js define(function(require, exports, module) { // 同步引入 var a = require('./a')

// 異步引入 require.async('./b', function (b) { })

// 條件引入 if (status) { var c = requie('./c') }

// 暴露模塊 exports.aaa = 'hahaha' }) ```

和上面 CommonJSAMD 類似,CMDSeaJS 在推廣過程中對模塊定義的規範化產出 ,而 CMD 規範以及 SeaJS 在國內曾經十分被推崇,原因不只是因為它足夠簡單方便,更是因為 SeaJS 的作者是阿里的 玉伯 大佬所寫,同 Vue 一樣的國人作者,堪稱國人之光。

核心實現

對於 CMD 規範下的 SeaJS,同 AMD 規範下的 RequireJS 一樣,都是瀏覽器端模塊加載器,兩者很相似,但又有明顯不同,個人認為 SeaJS 的實現相對來説更精美一些,一度風靡前端圈,礙於篇幅,放在這裏肯定是不合適的,後面有機會單獨來介紹 SeaJS 的實現,此文我們先了解 CMD 與 AMD 區別即可。

CMD 與 AMD

| 規範 | 推崇 | 代表作 | | ---- | -------- | --------- | | AMD | 依賴前置 | requirejs | | CMD | 依賴就近 | seajs |

CMD 對比 AMD 來説,CMD 比較推崇 as lazy as possible(儘可能的懶加載,也稱為延遲加載,即在需要的時候才加載)。

對於依賴的模塊,AMD 是提前執行,CMD 是延遲執行,兩者執行方式不一樣,AMD 執行過程中會將所有依賴前置執行,也就是在自己的代碼邏輯開始前全部執行,而 CMD 如果 require 引入了但整個邏輯並未使用這個依賴或未執行到邏輯使用它的地方前是不會執行的,不過 RequireJS 從 2.0 開始,也能改成延遲執行(根據寫法不同,處理方式不同),另外一方面 CMD 推崇依賴就近,而 AMD 推崇依賴前置。

UMD規範

簡介

UMD(Universal Module Definition),即通用模塊定義,從名字就可以看出來,這東西是做大一統的。

它隨着大前端的趨勢所誕生,可以通過運行時或者編譯時讓同一個代碼模塊在使用 CommonJs、CMD 甚至是 AMD 的項目中運行,也就是説同一個 JavaScript 包運行在瀏覽器端、服務區端甚至是 APP 端都只需要遵守同一個寫法就行了,那它是怎樣實現的呢?

核心實現

我們來看看這樣一段代碼

js ((root, factory) => { if (typeof define === 'function' && define.amd) { // AMD define(factory); } else if (typeof exports === 'object') { // CommonJS module.exports = factory(); } else if (typeof define === 'function' && define.cmd){ // CMD define(function(require, exports, module) { module.exports = factory() }) } else { // 都不是 root.umdModule = factory(); } })(this, () => { console.log('我是UMD') // todo... });

可以看到,defineAMD/CMD 語法,而 exports 只在 CommonJS 中存在,你會發現它在定義模塊的時候會檢測當前使用環境和模塊的定義方式,如果匹配就使用其規範語法,全部不匹配則掛載再全局對象上,我們看到傳入的是一個 this ,它在瀏覽器中指的就是 window ,在服務端環境中指的就是 global ,使用這樣的方式將各種模塊化定義都兼容。

其實社區形成的的規範還有很多,目的都是為了 JS 的模塊化開發,只是我們上面説的這幾個是最常用的。

截止到目前為止我們説的 CommonJSAMDCMD 等都只是社區比較認可的統一模塊化規範,但並不是官方(JS語言層面)的,那接下來要説的這個就是 JS 的官方模塊化規範了。

ES Module

2015年6月,ECMAScript2015 也就是我們説的 ES6 發佈了,JS 終於在語言標準的層面上,實現了模塊功能,使得在編譯時就能確定模塊的依賴關係,以及其輸入和輸出的變量,不像 CommonJSAMD 之類的需要在運行時才能確定(例如 FIS 這樣的工具只能預處理依賴關係,本質上還是運行時解析),成為瀏覽器和服務器通用的模塊解決方案。

所以説在 ES6 之前 JS 是沒有官方的模塊機制的,ES6在語言標準的層面上,實現了模塊化功能,而且實現的相當簡單,旨在成為瀏覽器和服務器通用的模塊化解決方案,其模塊化功能主要由倆個命令構成:exports和import,export命令由於規定模塊的對外接口,import命令用於輸入其他模塊的功能。ES6還提供了export default的命令。為模塊指定默認輸出。對應的import語句不需要大括號。這也更接近AMD的引用寫法。

ES6 Module不是對象,import命令被JavaScript引擎靜態分析,在編譯的時候就引入模塊代碼。而不是在代碼運行時加載,所以無法實現條件加載。也就使得靜態分析成為可能。

  • export

export可以導出的是對象中包含多個屬性、方法,export default只能導出一個可以不具名的函數。我們可以輸用import引入。同時我們也可以直接使用require使用,原因是webpack啟用了server相關。

  • import

```js import { fn } from './xxx' // export導出的方式

import fn from 'xx' // export default方式 ```

ES6模塊運行機制與commonjs運行機制不一樣。js引擎對腳本靜態分析的時候,遇到模塊加載指令後會生成一個只讀引用。等到腳本真正執行的時候。才會通過引用模塊中獲取值,在引用到執行的過程中,模塊中的值發生變化,導入的這裏也會跟着發生變化。ES6模塊是動態引入的。並不會緩存值。模塊裏總是綁定其所在的模塊。

最後

其實説白了,對於 JS 模塊化,上述這些方案都在解決幾個同樣的問題:

  • 謎一樣的全局變量污染
  • 惱人的命名衝突
  • 繁瑣的文件依賴

不同的模塊化手段都在致力於解決這些問題。前兩個問題其實很好解決,使用閉包配合立即執行函數,高級一點使用沙箱編譯,緩存輸出等等。難點在於文件依賴關係梳理以及加載。CommonJS 在服務端使用 fs 模塊同步讀取文件,而在瀏覽器中,不管是 AMD 規範的 RequireJs 還是 CMD 規範的 SeaJs,其實都是使用動態創建 script 標籤方式加載,在依賴加載完畢之後再執行,以此省去開發手動書寫 script 標籤還需關注加載順序這一煩惱。

ESM 作為語言標準層面的模塊化方案,不需要我們額外引入用於模塊化的三方包,拋開兼容問題,絕對是最好的選擇,也是未來趨勢,這點在 Vite 上就足以證明。

讀到這裏,你是不是對模塊化了解有了更清晰的認識呢?此文我們主要講 JS 模塊化,當然模塊化並不是只有 JS,下文將會介紹 CSS 模塊化的內容,歡迎關注!有問題評論區碼字,也歡迎指錯勘誤!

參考

Dependency injection in JavaScript

【推薦看看】JavaScript模塊化七日談