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

晴天633發表於2019-01-10

1. 前言

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

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

2. 需求的產生

前端模組化之AMD與CMD原理(附原始碼)
如上圖,一開始前端的內容不多,主要是頁面和簡單的互動,所以前端的開發環境就是純幾個靜態檔案(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檔案越來越多且互相引用更復雜的情況下,真心亂套了,所以需要有一個新的模組化工具。

我們當然是希望像現在這樣,檔案之間互相 import、export 就行了,但遺憾的是,這是es6配合node的用法,而以前的AMD、CMD可不是這麼實現的。

3. AMD

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

require.js的用法

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

login.html中

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

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

~~

入口檔案login.js中

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

loginModule.js中

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

loginCtrl.js中

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

注意:

  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) {

    })

最需要注意的是!!!:

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

4. require.js 原理

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

以前:

編碼:

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

瀏覽器:

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

require.js後

編碼:

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

瀏覽器:

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

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

分析實現原理:

可能1:

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

可能2:

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

工具目的:

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

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

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

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

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

也就是說,紅框裡的<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標籤插入文件的時機順序)

檔案之間有依賴關係的,所以載入js檔案 (dom中插入script節點) 是要有順序的,比如: 1.js 依賴 2.js,2.js 依賴 3.js

那麼,實際的載入順序是為 1.js,2.js,3.js

有同學可能會問,按理說載入順序應該是反過來 3.js,2.js,1.js啊。。。那是因為,只有 1.js 載入並且執行之後,才知道1.js依賴啥啊。。。仔細想想,你在 1.js 裡面寫得define([2.js]),那是不是得 先載入執行 1.js 後才能從define函式的引數中拿到依賴 2.js ?

注意: 而實際模組執行的順序,才是 3.js,2.js,1.js。。。所以,檔案的載入、載入後檔案的執行、模組的執行,這是 3 個東西啊,別混了先,下一部分再細說。

繼續這裡,那麼就需要判斷,1.js什麼時候載入完呢?

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

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

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


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

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

複製程式碼

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

3. 檔案模組的執行時機

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

剛才說了3個東西,檔案的載入、載入後檔案的執行、模組的執行,這裡千萬別蒙圈,舉例:

// 1.js 中的程式碼
require([], functionA() {
    // 主要邏輯程式碼
})
複製程式碼

js檔案載入後就會瞬間執行檔案,那麼

  • 檔案的載入:將<script src='1.js' > 節點插入dom中,之後,下載 1.js 檔案

  • 載入後檔案的執行:1.js 檔案載入完後,執行 1.js 中的程式碼,即執行 require() 函式!!!

  • 模組的執行: require回撥函式,上方的,主要邏輯程式碼,所在的函式,functionA,的執行!!!

所以我們以後所說的 執行,都指的是 模組的執行 ,而檔案的執行預設和載入一起了就,不需考慮。。。而且我們每個頁面的邏輯程式碼都要是寫在require/define 的回撥函式,functionA中的啊。。。

!!!一定要要仔細想清楚,模組載入順序和模組執行的順序?

就像 es6 你 1.js 中 import '2.js',不得 2.js 先執行並且返給你個值?即:

1.js 依賴 2.js 時,那麼是 1.js 先載入, 但是 2.js 模組先執行(還是要注意:是模組的執行,不是檔案的執行!!!)

總結順序:

  • 檔案載入/檔案執行 順序: 1.js , 2.js , 3.js

  • 模組執行 順序:3.js , 2.js , 1.js

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

用法例子

// 1.js 中(入口用require,其他用define)
require(['2.js'], function(A) {
    // A得到的就是2.js模組的返回值
    // 主要的執行程式碼
    // 2.js 3.js都載入完,才執行1.js的這回撥函式!!!!!!!!!!!!!!!
})

// 2.js 中
define(['3.js', 'xxxx.js'], functionA(B, C) {
    // B得到的就是3.js模組的返回值,C是xxxx.js的
    return aaaaa    // 2.js 模組的返回值
})

// 3.js 中
define([], functionA() {
    
    retrun {}   // 3.js 模組的返回值
})

複製程式碼

require.js 簡單原始碼原理

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

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

// 上面說了,每個檔案模組都要有個id,這個函式是返回當前執行的js檔案的檔名,拿檔名作為檔案物件的id
// 比如,當前載入 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( ['2.js', 'xxx.js'], functionA(B, C){} ),則執行的是require函式, 在下面是require的定義

window.require = function(deps, callback) {
    // deps 就是對應的 ['2.js', 'xxx.js']
    // callback 就是對應的 functionA
    // 在這裡,是不會執行callback的(即模組的執行!),得等到所有依賴都載入完的啊
    // 所以得有個地方,把一個檔案的所有資訊都先存起來啊,尤其是deps和callback
    var id = getCurrentJs();// 當前執行的是1.js,所以id就是'1.js'
    if(!modules.id) {
    	modules[id] = { // 該模組物件資訊
    		id: id,
    		deps: deps,
    		callback: callback, 
    		exports: null,  // 該模組的返回值return ,
    		就是functionA(B, C)執行後的返回值,仔細想想?在後面的getExports中詳細講
    		
    		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是**深度**的探查所關聯的依賴
	// 還是舉例吧。。。假如除了1.js, 2.js, 3.js, 還有個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 = checkCycle(deps)
	
	if(flag) {
            console.log(i, loadings[i] ,'全部依賴已經loaded');
		
            loadings.splice(i,1);
            // !!!執行模組,然後同時得到該模組的返回值!!!
            getExport(obj.id)
            // 不斷的迴圈探查啊~~~~
            checkDeps()
	}
	
    }
}
// 深層次的遞迴的去判斷,層級依賴是否都加在完了
// 進入1.js的依賴2.js,再進入2.js的依賴3.js ......
function checkCycle(deps) {
   var flag = true
   
   function cycle(deps) {
        deps.forEach(item => {
             if(!modules[item] || modules[item].status == 1) {
                   flag = false
             } else if(modules[item].deps.length) {
//                         console.log('inner deps', modules[item].deps);
                   
                   cycle(modules[item].deps)
             }
                   
        })
   }
   
   cycle(deps)
   
   return flag
}

/*
    執行該id的模組,同時得到模組返回值,modules[id].export
*/
function getExport(id) {
/*
    先想一下,例如模組2.js, 這時 id == 2.js
    define(['3.js', 'xxxx.js'], functionA(B, C) {
        // B得到的就是3.js模組的返回值,C是xxxx.js的
        return aaaaa    // 2.js 模組的返回值
    })
    所以:
    1. 執行模組,就是執行 functionA (模組的callback)
    2. 得到模組的返回值,就是functionA執行後的返回值 aaaaa
    問題:
    1. 執行functionA(B, C)   B, C是什麼?怎麼來的?
    2. 有B, C 了,怎麼執行functionA ?
    
*/
    // 解決問題1
    // B, C 就是該模組依賴 deps [3.js, xxxx.js]對應的返回值啊 
    // 那麼迴圈deps 得到 依賴模組Id, 取模組的export。。。
   var params = [];
   var deps = modules[id].deps  
   
   for(var i = 0; i < deps.length; i++) {
        // 取依賴模組的exports即模組返回值,注意不要害怕取不到,因為你這個模組
        都進來打算執行了,那麼你的所有依賴的模組早都進來過執行完了(還記得模組執行順序不?)
        let depId = deps[i]
        params.push( modules[ depId ].exports ) 
        
   }
   
   // 到這裡,params就是依賴模組的返回值的陣列,也就是B,C對應的實參
   // 也就是 params == [3.js的返回值,xxxx.js的返回值]
   
   if(!modules[id].exports) {
        // 解決問題2: callback(functionA)的執行,用.apply,這也是為什麼params是個陣列了
        // 這一行程式碼,既執行了該模組,同時也得到了該模組的返回值export
        modules[id].exports = modules[id].callback.apply(global, params)
   }
  
}
	
複製程式碼

程式碼的難點就在於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函式其實在一開始執行define()中,就已經通過引數,賦到了modules上了啊,所以無論CMD還是AMD,執行模組,都是執行modules[id].callback()

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

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

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

7. sea.js原始碼

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

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

CMD的define

用法

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

define原始碼的定義

window.define = function(callback) {
    var id = getCurrentJs()
    var depsInit = s.parseDependencies(callback.toString())
    var a = depsInit.map(item => basepath + item)
    // 和require.js的define相比,就多了上面的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

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

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

相關文章