1. 程式人生 > >前端模組化之AMD與CMD原理(附原始碼)

前端模組化之AMD與CMD原理(附原始碼)

1. 前言

可能現在初入前端的同學們,都直接就上手webpack了,而在幾年前,沒有現在這些豐富的工具,還是jquery打天下的時候,不借助node或程式卻讓不同js檔案之間互相引用、模組化開發,確實是一件痛苦的事情。。。

接下來會介紹兩個有名的工具AMD(require.js)和CMD(sea.js),雖然已基本不用了,但是前端們還是需要知道以前是怎麼寫程式碼的。。。

2. 需求的產生

如上圖,一開始前端的內容不多,主要是頁面和簡單的互動,所以前端的開發環境就是純幾個靜態檔案(html、css、js)。而html檔案這樣子引入js檔案,有很多缺點:

  1. 必須按順序引入,如果 1.js中要用到jquery,那就將jquery.js放到1.js上方。
  2. 同步載入各個js,只有1.js載入並執行完,才去載入2.js。
  3. 各個js檔案可能會有多個window全域性變數的建立,汙染。
  4. ......還有很多缺點

總之,上面的結構,在前端內容越來越多,尤其ajax的趨勢、前後端分離、越來越注重前端體驗,js檔案越來越多且互相引用更復雜的情況下,真心亂套了,所以需要有一個新的模組化工具。

而模組化希望的樣子:

html檔案中

1.js檔案中

2.js中

我們當然是希望這樣,improt、export就行了,但遺憾的是,這是es6配合node的用法,而以前的AMD、CMD可不是這麼實現的。

3. AMD

即Asynchronous Module Definition,中文名是非同步模組定義的意思。它是一個模組化開發的規範,是一種思路,是一個概念。我們不用過於糾結它到底是個啥,只需要知道它是一個規範概念,而大名鼎鼎的require.js,是它的一個具體的實現,以前很多都用這個工具開發,接下來我們就研究一下require.js。

require.js的用法

這裡只介紹大概簡單的用法,沒用過的同學最好去看教程。

login.html中

引入了require.js檔案,然後指定了入口檔案login.js

~~

入口檔案login.js中

loginModule.js中

loginCtrl.js中

注意:

  1. 實際上就是定義了兩個全域性變數函式,一個require(),一個define()

  2. 其實這兩個函式功能和原理差不多,你可以認為他倆除了名字不一樣,其他都差不多。

  3. define函式的第一個引數是個陣列,寫的是所依賴的模組;第二個引數是回撥函式,回撥函式裡的引數對應的是依賴數組裡的模組返回值,如:

    es6 :

    import A from 'a.js'

    import B from 'b.js'

    require.js:

    define(['a.js', 'b.js'], function(A, B) {

    })

  4. 從幾個圖中可以看到依賴的順序,所以載入js檔案的順序應該是:(1)other.js (2)loginCtrl.js (3)loginModule.js (4)login.js。所以執行時,瀏覽器載入js的順序也是這個樣子的,不過,應該在最前面加上(0)require.js當然。

最注意!!!:

無論是CMD還是AMD,都只是讓開發者寫程式碼時變爽了,而對於瀏覽器來說,沒啥太大變化,該載入多少js檔案,js檔案的順序,都和以前沒啥區別!!!

4. require.js 原理

到這裡我們大概知道了,這兩個工具的意義,是讓開發者不再需要寫一堆 <script> 標籤 引入js了,讓編碼更爽。。。但是對於瀏覽器那邊,沒啥大的變化。

以前:

編碼:

瀏覽器:

require.js後

編碼:

瀏覽器:

所以結論:require.js只是採取了某種“方法”,讓你在寫程式碼時只寫一個<script>,執行html文件時卻是多個<script>

分析實現原理:

可能1:

編碼時的html檔案和執行時的html檔案是兩個檔案,即通過某些工具複製並修改了html。可惜修改檔案需要服務端程式去做,而require.js只是個js檔案,所以不是這個原理。這在node下webpack可以輕鬆的實現。

可能2:

既然可能1是不對的,那麼說明了,瀏覽器執行的html檔案和編碼時的html檔案是一模一樣的。所以只剩下第二條路了,就是執行時由js程式碼去修改html文件~

工具目的:

所以我們的目的就成了,瀏覽器文件一開始執行時:

就只引用了一個require.js檔案

執行了require.js之後,由js在dom上添加了一堆 <script>

也就是說,紅框裡的<script>,都是require.js裡的js程式碼手動在body元素尾部新增的!!!

還有,添加了<script src="3.js" > , 不能只是在html文件上添加了這行字串,而是要載入並且執行 3.js !!!

!!!重要方法:

1. 插入sciprt節點,並且 載入 + 執行 其js檔案

// 建一個node節點, script標籤
var node = document.createElement('script')
node.type = 'text/javascript'
node.src = '3.js'

// 將script節點插入dom中
document.body.appendChild(node)

複製程式碼

注意:

採用dom.appendChild方法插入script節點,會立即下載js檔案,並且執行檔案!

而採用dom.innerHTML = '<script src="3.js"></script>',則只是在dom中插入了一行字串,就更不會管字串裡引入的js了,所以不能用這個方法插入script!!!

2. 各個js檔案的插入時機

檔案之間有依賴關係的,所以插入script節點是要有順序的,比如:

1.js 依賴 2.js , 2.js 依賴 3.js ,所以瀏覽器載入順序就應該是 先載入 3.js之後,再載入 2.js, 最後載入 1.js。那麼就需要判斷,3.js什麼時候載入完呢?

<script src="3.js" onload="alert()"></script>
複製程式碼

關鍵就在於,onload 這個函式,其作用是,3.js 載入完並且執行完之後,執行 alert

所以實現 3.js 載入完後 再載入 2.js ,則只需這樣:


var node = document.createElement('script')
node.type = 'text/javascript'
node.src = '3.js'

// 給該節點新增onload事件
// 標籤上onload,這裡是load,見事件那裡的知識點
// 3.js 載入完後
node.addEventListener('load', function(evt) {
    // 載入 2.js
    var node2 = document.createElement('script')
    node2.type = 'text/javascript'
    node2.src = '2.js
    document.body.appendChild(node2)
})

document.body.appendChild(node)

複製程式碼

所以,處理依賴的核心就是利用 onload 事件,不斷的遞迴巢狀的載入依賴檔案。事實上,最麻煩的也是這裡處理依賴檔案,尤其是一個檔案可能被多個檔案所依賴,的情況。

3. js檔案中的函式的執行時機

這一點一定要理解,這也是require.js和sea.js的區別之一。

有些同學肯定會蒙了,載入?執行?執行啥?在載入成功的時候自己不就會瞬間執行了嗎?

1.js 中

require([], functionA() {
    // 主要邏輯程式碼
})
複製程式碼

js檔案載入後就瞬間執行的,是require()函式。而我們所經常說的執行,是指的主要邏輯程式碼這裡所在的回撥函式A!

所以 1.js 的回撥函式A,是在所依賴的2、3都載入完後,才執行的。

所以,載入順序和執行順序是不一樣的,比如還是這個 1、2、3.js 的依賴關係:

載入順序:1.js,2.js,3.js

執行順序:3.js,2.js,1.js(主模組在最後執行)

5. require.js 簡單程式碼實現

用法例子

// 1.js 中(入口用require,其他用define)
require(['2.js'], function() {
    // 主要的執行程式碼
    // 2.js 3.js都載入完,才執行1.js的這回調函式!!!!!!!!!!!!!!!
})

// 2.js 中
define(['3.js'], function() {
    
})

// 3.js 中
define([], function() {
    
})

複製程式碼

require.js 簡單原始碼原理

利用遞迴去載入層層的巢狀依賴,程式碼的難點就在於,怎樣判斷遞迴結束?即怎樣判斷所有的依賴都載入完了?

var modules = {},	// 存放所有檔案模組的資訊,每個js檔案模組的資訊
    loadings = [];	//	存放所有已經載入了的檔案模組的id,一旦該id的所有依賴都
                                載入完後,該id將會在陣列中移除

// 上面說了,每個檔案模組都要有個id,這個函式是返回當前執行的js檔案的檔名
// 比如,當前載入 3.js 後執行 3.js ,那麼該函式返回的就是 '3.js'
function getCurrentJs() {
	return document.currentScript.src
}
// 建立節點
function createNode() {
	var node = document.createElement('script')
	node.type = 'text/javascript'
	node.async = true;
	return node
}
// 開始執行
function init() {
    // 載入 1.js
    loadJs('1.js')
}	
// 載入檔案(插入dom中),如果傳了回撥函式,則在onload後執行回撥函式
function loadJs(url, callback) {
    var node = createNode()
    node.src = url;
    node.setAttribute('data-id', url)
    node.addEventListener('load', function(evt) {
    	var e = evt.target
    	setTimeout(() => {  // 這裡延遲一秒,只是讓在瀏覽器上直觀的看到每1秒加載出一個檔案
    		callback && callback(e)
    	}, 1000)
    }, false)
    
    document.body.appendChild(node)
}	
	
// 此時,loadJs(1.js)後,並沒有穿回調函式,所以1.js載入成功後只是自動執行1.js程式碼
// 而1.js程式碼中,是require(),則執行的是require函式, 在下面

window.require = function(deps, callback) {
    // deps 就是對應的 ['2.js']
    // callback 就是對應的 functionA
    // 在這裡,是不會執行callback的,得等到依賴都載入完的
    // 所以得有個地方,把一個檔案的所有資訊都先存起來啊
    var id = getCurrentJs();// 當前執行的是1.js,所以id就是'1.js'
    if(!modules.id) {
    	modules[id] = { // 該模組物件資訊
    		id: id,
    		deps: deps,
    		callback: callback,
    		exports: null,
    		status: 1, 
    		
    	}
    	loadings.unshift(id); // 加入這個id,之後會迴圈loadings陣列,遞迴判斷id所有依賴
    }
    
    loadDepsJs(id); // 載入這個檔案的所有依賴,即去載入[2.js]
}

function loadDepsJs(id) {
    var module = modules[id]; // 獲取到這個檔案模組物件
    // deps是['2.js']
    module.deps.map(item => {   // item 其實是依賴的Id,即 '2.js'
        if(!modules[i]) {   // 如果這個檔案沒被載入過(注:載入過的肯定在modules中有)
        (1)    loadJs(item, function() {   // 載入 2.js,並且傳了個回撥,準備要遞迴了
                    // 2.js載入完後,執行了這個回撥函式
                    loadings.unshift(item); // 此時裡面有兩個了, 1.js 和 2.js
                    // 遞迴。。。要去搞3.js了
                    loadDepsJs(item)// item傳的2.js,遞迴再進來時,就去modules中取2.js的deps了
                    // 每次檢查一下,是否都載入完了
                    checkDeps(); // 迴圈loadings,配合遞迴巢狀和modules資訊,判斷是否都載入完了
                })
        }
    })
}

// 上面(1)那裡,載入了2.js後馬上會執行2.js的,而2.js裡面是
define(['js'], fn)
// 所以相當於執行了 define函式

window.define = function(deps,callback) {
    var id = getCurrentJs()
    if(!modules.id) {
        modules[id] = {
        	id: id,
        	deps: getDepsIds(deps),
        	callback: callback,
        	exports: null,
        	status: 1,
        	
        }
    }
}

// 注意,define執行的結果,只是在modules中添加了該模組的資訊
// 因為其實在上面的loadDepsJs中已經事先做了loadings和遞迴deps的操作,
而且是一直不斷的迴圈往復的進行探查,所以define裡面就不需要再像require中寫一次loadDeps了

// 迴圈loadings,檢視loadings裡面的id,其所依賴的所有層層巢狀的依賴模組是否都載入完了

function checkDeps() {
    for(var i = 0, id; i < loadings.length ; i++) {
	id = loadings[i]
	if(!modules[id]) continue
	
	var obj = modules[id], 
	deps = obj.deps
	
	// 為什麼要執行checkCycle函式呢,checkDeps是迴圈loadings陣列的模組id,而checkCycle是去判斷該id模組所依賴的**層級**的模組是否載入完
	// 即checkDeps是**廣度**的迴圈已經載入(但依賴沒完全載入完的)的id
	// checkCycle是**深度**的探查所關聯的依賴
	// 還是舉例吧。。。假如還有個4.js,依賴5.js,那麼
	// loadings中可能是 ['1.js', '4.js']
	// 所以checkDeps --> 1.js,  4.js
	// checkCycle深入內部 1.js --> 2.js --> 3.js
	// 4.js --> 5.js
	// 一旦比如說1.js的所有依賴2.js、3.js都載入完了,那麼1.js 就會在loadings中移出
	
	
	var flag = myReq.checkCycle(deps)
	
	if(flag) {
            console.log(i, loadings[i] ,'全部依賴已經loaded');
		
            loadings.splice(i,1);
            // 不斷的迴圈探查啊~~~~
            myReq.checkDeps()
	}
	
    }
}
	
複製程式碼

程式碼的難點就在於checkDeps以及對loadings進行遞迴那裡,很難去講清楚,需要自己去寫去實踐,這裡也很難全都描述清楚。。。

結尾會給一個簡單的能執行的例子

不要想著花一兩個小時就搞定所有了,剛開始確實會看的煩,多回來幾次,隔段時間再研究一下,每次都會加深一點

6. CMD

CMD 即Common Module Definition通用模組定義,sea.js是它的實現

sea.js是阿里的大神寫得,和require.js很像,先看一下用法的區別

// 只有define,沒有require
// 和AMD那個例子一樣,還是1依賴2, 2依賴3
1.js中
define(function() {
    
    var a = require('2.js')
    console.log(33333)
    var b = require('4.js')
})

2.js 中
define(function() {
    var b = require('3.js')
})
3.js 中
define(function() {
    // xxx
})

複製程式碼

看著是比require.js要好一點。。。

AMD和CMD的區別

對依賴模組的執行時機不同,注意:不是載入的時機,模組載入的時機是一樣的!!!

載入:還是先載入1.js,再載入2.js,最後載入3.js

執行順序:

AMD: 3.js,2.js,1.js,,,即如果模組以及該模組的依賴都載入完了,那麼就執行。。。 3.js載入完後,發現自己也沒有依賴啊,那麼直接執行3.js的回調了,,,2.js載入完後探查到依賴的3.js也載入完了,那麼2.js就執行自己的回調了。。。。 主模組一定在最後執行

CMD: 1.js,2.js,3.js,,,即先執行主模組1.js,碰到require('2.js')就執行2.js,2.js中碰到require('3.js')就執行3.js

會不會又不理解,怎麼能控制執行哪個檔案模組呢?

還記得不,之前說過,執行模組,是指的執行那個functionA回撥函式,callback,,,那麼這個callback函式其實在一開始已經賦到modules上了啊,所以無論CMD還是AMD,執行模組,都是執行modules[id].callback()

所以,sea.js裡,你用的var a = require('2.js'),中的執行的require函式,原始碼中就是簡單的執行了模組的callback

7. sea.js原始碼

原始碼,大部分和require.js都很像,上面說的執行時機不同,也很簡單,就是控制一下啥時執行callback唄。

之前又說了,載入模組差不多,那麼sea.js是怎麼通過require(3.js),require(2.js)去控制3.js和2.js的載入呢???上面說require函式已經就是執行callback了,那麼require函式就不能承擔起載入模組的功能了啊,再來看

AMD原始碼的define定義

window.define = function(deps,callback) {
    var id = getCurrentJs()
    if(!modules.id) {
        modules[id] = {
        	id: id,
        	deps: getDepsIds(deps),
        	callback: callback,
        	exports: null,
        	status: 1,
        	
        }
    }
}
複製程式碼

而CMD得define呢

用法

define(function() {
    var a = require('2.js')
})
複製程式碼

原始碼定義

window.define = function(callback) {
    var id = getCurrentJs()
    var depsInit = s.parseDependencies(callback.toString())
    var a = depsInit.map(item => basepath + item)
    // 就多了上面的2行程式碼
    // 1. 把傳進來的函式給轉換成字串,'function (){var a = require("2.js")}'
    // 2. 利用一個正則函式,取出字串中require中的2.js,最後拼成一個數組['2.js']返回來。
    // 3. 之後就和require.js差不多了啊。。。
    
    
    // 下面的都差不多
    if(!modules[id]) {
        modules[id] = {
            id: id,
            status: 1,
            callback: callback,
            deps: a,
            exports: null
        }
    }
    
    s.loadDepsJs(id)

    }
複製程式碼

所以sea.js,是寫了一個正則的函式,去查詢define中傳入的fn的字串,然後得到的依賴陣列。。。 而require.js的依賴陣列,是咱們自己寫並且傳入的:define([2.js])。。。

這個正則方法,大家不用去探究,練習時直接用就行了

8. 最後

簡單原始碼地址:

my-require.js

my-sea.js

這個文章內容寫得確實有點多有點羅嗦,還是希望能夠講的通俗易懂一點。之後會不斷的完善文章,也歡迎大家留言提問,不對的地方也歡迎指正~~~希望對大家能有幫助

轉載請註明出處,謝謝~~