概述如何實現(xiàn)一個簡單的瀏覽器端js模塊加載器
在es6之前,js不像其他語言自帶成熟的模塊化功能,頁面只能靠插入一個個script標簽來引入自己的或第三方的腳本,并且容易帶來命名沖突的問題。js社區(qū)做了很多努力,在當時的運行環(huán)境中,實現(xiàn)"模塊"的效果。
通用的js模塊化標準有CommonJS與AMD,前者運用于node環(huán)境,后者在瀏覽器環(huán)境中由Require.js等實現(xiàn)。此外還有國內的開源項目Sea.js,遵循CMD規(guī)范。(目前隨著es6的普及已經(jīng)停止維護,不論是AMD還是CMD,都將是一段歷史了)
瀏覽器端js加載器
實現(xiàn)一個簡單的js加載器并不復雜,主要可以分為解析路徑、下載模塊、解析模塊依賴、解析模塊四個步驟。
首先定義一下模塊。在各種規(guī)范中,通常一個js文件即表示一個模塊。那么,我們可以在模塊文件中,構造一個閉包,并傳出一個對象,作為模塊的導出:
define(factory() { var x = { a: 1 }; return x; });
define函數(shù)接收一個工廠函數(shù)參數(shù),瀏覽器執(zhí)行該腳本時,define函數(shù)執(zhí)行factory,并把它的return值存儲在加載器的模塊對象modules里。
如何標識一個模塊呢?可以用文件的uri,它是唯一標識,是天然的id。
文件路徑path有幾種形式:
- 絕對路徑:http://xxx, file://xxx
- 相對路徑:./xxx , ../xxx , xxx(相對當前頁面的文件路徑)
- 虛擬絕對路徑:/xxx /表示網(wǎng)站根目錄
因此,需要一個resolvePath函數(shù)來將不同形式的path解析成uri,參照當前頁面的文件路徑來解析。
接著,假設我們需要引用a.js與b.js兩個模塊,并設置了需要a與b才能執(zhí)行的回調函數(shù)f。我們希望加載器去拉取a與b,當a與b都加載完成后,從modules里取出a與b作為參數(shù)傳給f,執(zhí)行下一步操作。這里可以用觀察者模式(即訂閱/發(fā)布模式)實現(xiàn),創(chuàng)建一個eventProxy,訂閱加載a與加載b事件;define函數(shù)執(zhí)行到最后,已經(jīng)把導出掛載modules里之后,emit一個本模塊加載完成的事件,eventProxy收到后檢查a與b是否都加載完成,如果完成,就傳參給f執(zhí)行回調。
同理,eventProxy也可以實現(xiàn)模塊依賴加載
// a.js define([ 'c.js', 'd.js' ], factory (c, d) { var x = c + d; return x; });
define函數(shù)的第一個參數(shù)可以傳入一個依賴數(shù)組,表示a模塊依賴c與d。define執(zhí)行時,告訴eventProxy訂閱c與d加載事件,加載好了就執(zhí)行回調函數(shù)f存儲a的導出,并emit事件a已加載。
瀏覽器端加載腳本的原始方法是插入一個script標簽,指定src之后,瀏覽器開始下載該腳本。
那么加載器中的模塊加載可以用dom操作實現(xiàn),插入一個script標簽并指定src,此時該模塊為下載中狀態(tài)。
PS:瀏覽器中,動態(tài)插入script標簽與初次加載頁面dom時的script加載方式不同:
初次加載頁面,瀏覽器會從上到下順序解析dom,碰到script標簽時,下載腳本并阻塞dom解析,等到該腳本下載、執(zhí)行完畢后再繼續(xù)解析之后的dom(現(xiàn)代瀏覽器做了preload優(yōu)化,會預先下載好多個腳本,但執(zhí)行順序與它們在dom中順序一致,執(zhí)行時阻塞其他dom解析)
動態(tài)插入script,
var a = document.createElement('script'); a.src='xxx'; document.body.appendChild(a);
瀏覽器會在該腳本下載完成后執(zhí)行,過程是異步的。
下載完成后執(zhí)行上述的操作,解析依賴->加載依賴->解析本模塊->加載完成->執(zhí)行回調。
模塊下載完成后,如何在解析它時知道它的uri呢?有兩種發(fā)發(fā),一種是用srcipt.onload獲取this對象的src屬性;一種是在define函數(shù)中采用document.currentScript.src。
實現(xiàn)基本的功能比較簡單,代碼不到200行:
var zmm = { _modules: {}, _configs: { // 用于拼接相對路徑 basePath: (function (path) { if (path.charAt(path.length - 1) === '/') { path = path.substr(0, path.length - 1); } return path.substr(path.indexOf(location.host) + location.host.length + 1); })(location.href), // 用于拼接相對根路徑 host: location.protocol + '//' + location.host + '/' } }; zmm.hasModule = function (_uri) { // 判斷是否已有該模塊,不論加載中或已加載好 return this._modules.hasOwnProperty(_uri); }; zmm.isModuleLoaded = function (_uri) { // 判斷該模塊是否已加載好 return !!this._modules[_uri]; }; zmm.pushModule = function (_uri) { // 新模塊占坑,但此時還未加載完成,表示加載中;防止重復加載 if (!this._modules.hasOwnProperty(_uri)) { this._modules[_uri] = null; } }; zmm.installModule = function (_uri, mod) { this._modules[_uri] = mod; }; zmm.load = function (uris) { var i, nsc; for (i = 0; i < uris.length; i++) { if (!this.hasModule(uris[i])) { this.pushModule(uris[i]); // 開始加載 var nsc = document.createElement('script'); nsc.src = uri; document.body.appendChild(nsc); } } }; zmm.resolvePath = function (path) { // 返回絕對路徑 var res = '', paths = [], resPaths; if (path.match(/.*:\/\/.*/)) { // 絕對路徑 res = path.match(/.*:\/\/.*?\//)[0]; // 協(xié)議+域名 path = path.substr(res.length); } else if (path.charAt(0) === '/') { // 相對根路徑 /開頭 res = this._configs.host; path = path.substr(1); } else { // 相對路徑 ./或../開頭或直接文件名 res = this._configs.host; resPaths = this._configs.basePath.split('/'); } resPaths = resPaths || []; paths = path.split('/'); for (var i = 0; i < paths.length; i++) { if (paths[i] === '..') { resPaths.pop(); } else if (paths[i] === '.') { // do nothing } else { resPaths.push(paths[i]); } } res += resPaths.join('/'); return res; }; var define = zmm.define = function (dependPaths, fac) { var _uri = document.currentScript.src; if (zmm.isModuleLoaded(_uri)) { return; } var factory, depPaths, uris = []; if (arguments.length === 1) { factory = arguments[0]; // 掛載到模塊組中 zmm.installModule(_uri, factory()); // 告訴proxy該模塊已裝載好 zmm.proxy.emit(_uri); } else { // 有依賴的情況 factory = arguments[1]; // 裝載完成的回調函數(shù) zmm.use(arguments[0], function () { zmm.installModule(_uri, factory.apply(null, arguments)); zmm.proxy.emit(_uri); }); } }; zmm.use = function (paths, callback) { if (!Array.isArray(paths)) { paths = [paths]; } var uris = [], i; for (i = 0; i < paths.length; i++) { uris.push(this.resolvePath(paths[i])); } // 先注冊事件,再加載 this.proxy.watch(uris, callback); this.load(uris); }; zmm.proxy = function () { var proxy = {}; var taskId = 0; var taskList = {}; var execute = function (task) { var uris = task.uris, callback = task.callback; for (var i = 0, arr = []; i < uris.length; i++) { arr.push(zmm._modules[uris[i]]); } callback.apply(null, arr); }; var deal_loaded = function (_uri) { var i, k, task, sum; // 當一個模塊加載完成時,遍歷當前任務棧 for (k in taskList) { if (!taskList.hasOwnProperty(k)) { continue; } task = taskList[k]; if (task.uris.indexOf(_uri) > -1) { // 查看這個任務中的模塊是否都已加載好 for (i = 0, sum = 0; i < task.uris.length; i++) { if (zmm.isModuleLoaded(task.uris[i])) { sum ++; } } if (sum === task.uris.length) { // 都加載完成 刪除任務 delete(taskList[k]); execute(task); } } } }; proxy.watch = function (uris, callback) { // 先檢查一遍是否都加載好了 for (var i = 0, sum = 0; i < uris.length; i++) { if (zmm.isModuleLoaded(uris[i])) { sum ++; } } if (sum === uris.length) { execute({ uris: uris, callback: callback }); } else { // 訂閱新加載任務 var task = { uris: uris, callback: callback }; taskList['' + taskId] = task; taskId ++; } }; proxy.emit = function (_uri) { console.log(_uri + ' is loaded!'); deal_loaded(_uri); }; return proxy; }();
循環(huán)依賴問題
"循環(huán)加載"指的是,a腳本的執(zhí)行依賴b腳本,而b腳本的執(zhí)行又依賴a腳本。這是一種應該盡量避免的設計。
瀏覽器端
用上面的zmm工具加載模塊a:
// main.html zmm.use('/a.js', function(){...}); // a.js define('/b.js', function(b) { var a = 1; a = b + 1; return a; }); // b.js define('/a.js', function(a) { var b = a + 1; return b; });
就會陷入a等待b加載完成、b等待a加載完成的死鎖狀態(tài)。sea.js碰到這種情況也是死鎖,也許是默認這種行為不應該出現(xiàn)。
seajs里可以通過require.async來緩解循環(huán)依賴的問題,但必須改寫a.js:
// a.js define('./js/a', function (require, exports, module) { var a = 1; require.async('./b', function (b) { a = b + 1; module.exports = a; //a= 3 }); module.exports = a; // a= 1 }); // b.js define('./js/b', function (require, exports, module) { var a = require('./a'); var b = a + 1; module.exports = b; }); // main.html seajs.use('./js/a', function (a) { console.log(a); // 1 });
但這么做a就必須先知道b會依賴自己,且use中輸出的是b還沒加載時a的值,use并不知道a的值之后還會改變。
在瀏覽器端,似乎沒有很好的解決方案。node模塊加載碰到的循環(huán)依賴問題則小得多。
node/CommonJS
CommonJS模塊的重要特性是加載時執(zhí)行,即腳本代碼在require的時候,就會全部執(zhí)行。CommonJS的做法是,一旦出現(xiàn)某個模塊被"循環(huán)加載",就只輸出已經(jīng)執(zhí)行的部分,還未執(zhí)行的部分不會輸出。
// a.js var a = 1; module.exports = a; var b = require('./b'); a = b + 1; module.exports = a; // b.js var a = require('./a'); var b = a + 1; module.exports = b; // main.js var a = require('./a'); console.log(a); //3
上面main.js的代碼中,先加載模塊a,執(zhí)行require函數(shù),此時內存中已經(jīng)掛了一個模塊a,它的exports為一個空對象a.exports={};接著執(zhí)行a.js中的代碼;執(zhí)行var b = require('./b');之前,a.exports=1,接著執(zhí)行require(b);b.js被執(zhí)行時,拿到的是a.exports=1,b加載完成后,執(zhí)行權回到a.js;最后a模塊的輸出為3。
CommonJS與瀏覽器端的加載器有著實現(xiàn)上的差異。node加載的模塊都是在本地,執(zhí)行的是同步的加載過程,即按依賴關系依次加載,執(zhí)行到加載語句就去加載另一個模塊,加載完了再回到函數(shù)調用點繼續(xù)執(zhí)行;瀏覽器端加載scripts由于天生限制,只能采取異步加載,執(zhí)行回調來實現(xiàn)。
ES6
ES6模塊的運行機制與CommonJS不一樣,它遇到模塊加載命令import時,不會去執(zhí)行模塊,而是只生成一個引用。等到真的需要用到時,再到模塊里面去取值。因此,ES6模塊是動態(tài)引用,不存在緩存值的問題,而且模塊里面的變量,綁定其所在的模塊。
這導致ES6處理"循環(huán)加載"與CommonJS有本質的不同。ES6根本不會關心是否發(fā)生了"循環(huán)加載",只是生成一個指向被加載模塊的引用,需要開發(fā)者自己保證,真正取值的時候能夠取到值。
來看一個例子:
// even.js import { odd } from './odd'; export var counter = 0; export function even(n) { counter++; return n == 0 || odd(n - 1);} // odd.js import { even } from './even'; export function odd(n) { return n != 0 && even(n - 1);} // main.js import * as m from './even.js'; m.even(10); // true; m.counter = 6
上面代碼中,even.js里面的函數(shù)even有一個參數(shù)n,只要不等于0,就會減去1,傳入加載的odd()。odd.js也會做類似作。
上面代碼中,參數(shù)n從10變?yōu)?的過程中,foo()一共會執(zhí)行6次,所以變量counter等于6。第二次調用even()時,參數(shù)n從20變?yōu)?,foo()一共會執(zhí)行11次,加上前面的6次,所以變量counter等于17。
而這個例子要是改寫成CommonJS,就根本無法執(zhí)行,會報錯。
// even.js var odd = require('./odd'); var counter = 0; exports.counter = counter; exports.even = function(n) { counter++; return n == 0 || odd(n - 1); } // odd.js var even = require('./even').even; module.exports = function(n) { return n != 0 && even(n - 1); } // main.js var m = require('./even'); m.even(10); // TypeError: even is not a function
上面代碼中,even.js加載odd.js,而odd.js又去加載even.js,形成"循環(huán)加載"。這時,執(zhí)行引擎就會輸出even.js已經(jīng)執(zhí)行的部分(不存在任何結果),所以在odd.js之中,變量even等于null,等到后面調用even(n-1)就會報錯。
以上就是本文的全部內容,希望本文的內容對大家的學習或者工作能帶來一定的幫助,如果有疑問大家可以留言交流,同時也希望多多支持腳本之家!
相關文章
javascript當onmousedown、onmouseup、onclick同時應用于同一個標簽節(jié)點Element
先通過一個簡單例子測試并發(fā)現(xiàn)我說的問題,讓你有個直觀的印象,再接著看我的解決辦法。2010-01-01