從零開始構建實現一個 JavaScript 模塊化加載器
對任何程序,都存在一個規模的問題,起初我們使用函數來組織不同的模塊,但是隨著應用規模的不斷變大,簡單的重構函數并不能順利的解決問題。尤其對JavaScript程序而言,模塊化有助于解決我們在前端開發中面臨的越來越復雜的需求。
為什么需要模塊化
對開發者而言,有很多理由去將程序拆分為小的代碼塊。這種模塊拆分的過程有助于開發者更清晰的閱讀和編寫代碼,并且能夠讓編程的過程更多的集中在模塊的功能實現上,和算法一樣,分而治之的思想有助于提高編程生產率。
在本文中,我們將集中討論JavaScript的模塊化開發,并實現一個簡單的module loader。對于模塊化的基礎知識,可以參考閱讀 這篇文章 。
實現模塊化
使用函數作為命名空間
在JavaScript中,函數是唯一的可以用來創建新的作用域的途徑。考慮到一個最簡單的需求,我們通過數字來獲得星期值,例如通過數字0得到星期日,通過數字1得到星期一。我們可以編寫如下的程序:
var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];function dayName(number) { return names[number]; }
console.log(dayName(1));</pre>
上面的程序,我們創建了一個函數dayName() 來獲取星期值。但問題是,names 變量被暴露在全局作用域中。更多的時候,我們希望能夠構造私有變量,而暴露公共函數作為接口。
對JavaScript中的函數而言,我們可以通過創建立即調用的函數表達式來達到這個效果,我們可以通過如下的方式重構上面的代碼,使得私有作用域成為可能:
var dayName = function() { var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; return { name: function(number) { return names[number]; }, number: function(name) { return names.indexOf(name); } }; }(); console.log(dayName.name(3)); console.log(dayName.number("Sunday"));上面的程序中,我們通過將變量包括在一個函數中,這個函數會立即執行,并返回一個包含兩個屬性的對象,返回的對象會被賦值給dayName 變量。在后面,我們可以通過dayName 變量來訪問暴露的兩個函數接口name 和number 。
對代碼進一步改進,我們可以利用一個exports 對象來達到暴露公共接口的目的,這種方法可以通過如下方法實現,代碼如下:
var weekDay = {}; (function(exports) { var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; exports.name = function(number) { return names[number]; }; exports.number = function(name) { return names.indexOf(name); }; })(weekDay); // outside of a function, this refers to the global scope object console.log(weekDay.name(weekDay.number("Saturday")));上面的這種模塊構造方式在以瀏覽器為核心的前端編碼中非常常見,通過暴露一個全局變量的方式來將代碼包裹在私有的函數作用域中。但這種方法依然會存在問題,在復雜應用中,你依然無法避免同名變量。
從全局作用域中分離,實現require 方法
更進一步的,為了實現模塊化,我們可以通過構造一個系統,使得一個函數可以require 另一個函數的方式來實現模塊化編程。所以我們的目標是,實現一個require 方法,通過傳入模塊名來取得該模塊的調用。這種實現方式要比前面的方法更為優雅的體現模塊化的理念。對require 方法而言,我們需要完成兩件事。
- 我們需要實現一個readFile 方法,它能通過給定字符串返回文件的內容。
- 我們需要能夠將返回的字符串作為代碼進行執行。
</ol>我們假設已經存在了readFile 這個方法,我們更加關注的是如何能夠將字符串作為可執行的程序代碼。通常我們有好幾種方法來實現這個需求,最常見的方法是eval 操作符,但我們常常在剛學習JavaScript的時候被告知,使用eval 是一個非常不明智的決策,因為使用它會導致潛在的安全問題,因此我們放棄這個方法。
一個更好的方法是使用Function 構造器,它需要兩個參數:使用逗號分隔的參數列表字符串,和函數體字符串。例如:
var plusOne = new Function("n", "return n+1"); console.log(plusOne(5)); // 6下面我們可以來實現require 方法了:
// module.js function require(name) { // 調用一個模塊,首先檢查這個模塊是否已被調用 if(name in require.cache) { return require.cache[name]; } var code = new Function("exports, module", readFile(name)); var exports = {}, module = {exports: exports}; code(exports, module); require.cache[name] = module.exports; return module.exports; } // 緩存對象,為了應對重復調用的問題 require.cache = Object.create(null); // todo: function readFile(fileName) { ... }在頁面中使用require 函數:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>demo</title> <script src="module.js"></script> </head> <body> <script> var weekDay = require("weekDay"); var today = require("today"); console.log(weekDay.name(today.dayNumber())); </script> </body> </html>通過這種方式實現的模塊化系統通常被稱為是CommonJS模塊風格的,Node.js正式使用了這種風格的模塊化系統。這里只是提供了一個最簡單的實現方法,在真實應用中會有更加精致的實現方法。
慢載入模塊和AMD
對瀏覽器編程而言,通常不會使用CommonJS 風格的模塊化系統,因為對于Web而言,加載一個資源遠沒有在服務端來的快,這收到網絡性能的影響,尤其一個模塊如果過大的話,可能會中斷方法的執行。 Browserify 是解決這個問題的一個比較出名的模塊化方案。
這個過程大概是這樣的:首先檢查模塊中是否存在require 其他模塊的語句,如果有,就解析所有相關的模塊,然后組裝為一個大模塊。網站本身為簡單的加載這個組裝后的大模塊。
模塊化的另一個解決方案是使用AMD,即異步模塊定義,AMD允許通過異步的方式加載模塊內容,這種就不會阻塞代碼的執行。關于AMD的相關背景知識,可以參考 這篇文章 。
我們想要實現的功能大概是這個樣子的:
// index.html 中的部分代碼 define(["weekDay.js", "today.js"], function (weekDay, today) { console.log(weekDay.name(today.dayNumber())); document.write(weekDay.name(today.dayNumber())); });問題的核心是實現define 方法,它的第一個參數是定義該模塊說需要的依賴列表,參數而是該模塊的具體工作函數。一旦所依賴的模塊都被加載后,define 便會執行參數2所定義的工作函數。weekDay 模塊的內容大概是下面的內容:
// weekDay.js define([], function() { var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; return { name: function(number) { return names[number]}, number: function(name) { return names.indexOf(name)} } });下面我們來關注如何實現define() 方法。為了實現這個方法,我們需要定義一個backgroundReadFile() 方法來異步的獲取文件內容。此外我們需要能夠監視模塊的加載狀態,當模塊加載完后能夠告訴函數去執行具體的工作函數(回調)。
// 通過Ajax來異步加載模塊 function backgroundReadFile(url, callback) { var req = new XMLHttpRequest(); req.open("GET", url, true); req.addEventListener("load", function () { if (req.status < 400) callback(req.responseText); }); req.send(null); }通過實現一個getModule 函數,通過給定的模塊名進行模塊的調度運行工作。同樣,我們需要通過緩存的方式避免同一個模塊被重復的載入。實現代碼如下:
// module.js 的部分內容 var defineCache = Object.create(null); var currentMod = null; function getModule(name) { if (name in defineCache) { return defineCache[name]; } var module = { exports: null, loaded: false, onLoad: [] }; defineCache[name] = module; backgroundReadFile(name, function(code) { currentMod = module; new Function("", code)(); }); return module; }有了getModule() 了函數之后,define 方法可以借助該方法來為當前模塊的依賴列表獲取或創建模塊對象。define 方法的簡單實現如下:
// module.js 的部分內容 function define(depNames, moduleFunction) { var myMod = currentMod; var deps = depNames.map(getModule); deps.forEach(function(mod) { if(!mod.loaded) { mod.onLoad.push(whenDepsLoaded); } }); // 用于檢查是否所有的依賴模塊都被成功加載了 function whenDepsLoaded() { if(!deps.every(function(m) { return m.loaded; })) { return; } var args = deps.map(function(m) { return m.exports; }); var exports = moduleFunction.apply(null, args); if (myMod) { myMod.exports = exports; myMod.loaded = true; myMod.onLoad.forEach(function(f) { f(); }); } } whenDepsLoaded(); }關于AMD的更加常見的實現是 RequireJS ,它提供了AMD風格的更加流行的實現方式。
小結
模塊通過將代碼分離為不同的文件和命名空間,為大型程序提供了清晰的結構。通過構建良好的接口可以使得開發者更加建議的閱讀、使用、擴展代碼。尤其對JavaScript語言而言,由于天生的缺陷,使得模塊化更加有助于程序的組織。在JavaScript的世界,有兩種流行的模塊化實現方式,一種稱為CommonJS,通常是服務端的模塊化實現方案,另一種稱為AMD,通常針對瀏覽器環境。其他關于模塊化的知識,你可以參考 這篇文章 。
References
- Eloquent JavaScript, chapter 10, Modules
- Browserify運行原理分析
- Why AMD?
- JavaScript模塊化知識點小結
</ol> 原文 http://wwsun.me/posts/creating-javascript-modules-loader.html