1. 前言
現在初入前端的同學們,都直接就上手webpack了,而在幾年前沒有node還是jquery打天下的時候,不借助node或軟體讓不同js檔案之間互相引用、模組化開發,是件很麻煩的事。
接下來會介紹兩個有名的工具AMD(require.js)和CMD(sea.js),雖然現在用的很少了,但還是需要知道模組化經歷的過程,面試也可能會問到。
2. 需求的產生
一開始前端的工作內容不多,html中如圖中引入js檔案,有很多缺點:- 必須按順序引入,如果 1.js中要用到jquery,那就將jquery.js放到1.js上方。
- 同步載入各個js,只有1.js載入並執行完,才去載入2.js。
- 各個js檔案可能會有多個window全域性變數的建立,汙染。
- ......還有很多缺點
總之,上面的結構,在前端內容越來越多,尤其ajax的趨勢、前後端分離、越來越注重前端體驗,js檔案越來越多且互相引用更復雜的情況下,真心亂套了,所以需要有一個新的模組化工具。
我們當然是希望像現在這樣,檔案之間互相 import、export 就行了,但遺憾的是,這是es6配合node的用法,需要服務端做支撐處理檔案,而一開始僅通過靜態檔案去模組化。
3. AMD
即Asynchronous Module Definition,中文名是非同步模組定義的意思。它是一個模組化開發的規範,是一種思路,是一個概念。我們不用過於糾結它到底是個啥,只需要知道它是一個規範概念,而大名鼎鼎的require.js,是它的一個具體的實現,以前很多都用這個工具開發。
require.js的用法
這裡只介紹大概簡單的用法,沒用過的同學最好去看教程。
login.html中
引入了require.js檔案,然後指定了入口檔案login.js
~~
入口檔案login.js中
loginModule.js中
loginCtrl.js中
注意:
-
實際上就是定義了兩個全域性變數函式,一個require(),一個define()
-
其實這兩個函式功能和原理差不多,你可以認為他倆除了名字不一樣,其他都差不多。
-
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了,讓編碼更爽。。。但是對於瀏覽器那邊,沒啥大的變化。
以前:
編碼:
瀏覽器:
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標籤插入文件的時機順序)
檔案之間有依賴關係的,所以載入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()
所以,sea.js裡,你用的var a = require('2.js'),中的執行的require函式,原始碼中就是簡單的執行了模組的callback
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. 最後
簡單原始碼地址:
這個文章內容寫得確實有點多有點羅嗦,還是希望能夠講的通俗易懂一點。之後會不斷的完善文章,也歡迎大家留言提問,不對的地方也歡迎指正~~~希望對大家能有幫助
轉載請註明出處,謝謝~~