小邵教你玩轉nodejs之剖析/實現commonJs原始碼(2)
前言:大家好,我叫邵威儒,大家都喜歡喊我小邵,學的金融專業卻憑藉興趣愛好入了程式猿的坑,從大學買的第一本vb和自學vb,我就與程式設計結下不解之緣,隨後自學易語言寫遊戲輔助、交易軟體,至今進入了前端領域,看到不少朋友都寫文章分享,自己也弄一個玩玩,以下文章純屬個人理解,便於記錄學習,肯定有理解錯誤或理解不到位的地方,意在站在前輩的肩膀,分享個人對技術的通俗理解,共同成長!
後續我會陸陸續續更新javascript方面,儘量把javascript這個學習路徑體系都寫一下
包括前端所常用的es6、angular、react、vue、nodejs、koa、express、公眾號等等
都會從淺到深,從入門開始逐步寫,希望能讓大家有所收穫,也希望大家關注我~
文章列表: ofollow,noindex">juejin.im/user/5a84f8…
小邵教你玩轉nodejs系列: juejin.im/collection/…
Author: 邵威儒
Email:[email protected]
Wechat: 166661688
github: github.com/iamswr/
十分抱歉,最近比較忙,臨時需要把h5快速遷移到app,又不懂co swift android,基本每天都在踩坑當中,不過收穫也很大,將來前端的趨勢肯定是大前端,不管是pc 移動m站還是app 後端都需要懂一些,後面有時間,我也會把最近接觸ios/android原生遇到的問題總結出來。
在這篇文章中,你會明白模組化的發展,明白以sea.js為代表的cmd規範和以require.js為代表的amd規範的異同,剖析node.js使用的commonJs規範的原始碼以及手寫實現簡陋版的commonJs,最後你會明白模組化是怎樣一個載入過程。
Javascript最開始是怎樣實現模組化呢?
我們知道javascript最開始是面向過程的思維程式設計,隨著程式碼越來越龐大、複雜,在這種實際遇到的問題中,大佬們逐漸把面向物件、模組化的思想用在javascript當中。
一開始,我們是把不同功能寫在不同函式當中
// 比如getCssAttr函式來獲取Css屬性,當我們需要獲取Css屬性的時候可以直接呼叫該方法 function getCssAttr(obj, attr) { if (obj.currentStyle) { return obj.currentStyle[attr]; } else { return window.getComputedStyle(obj, null)[attr]; } } // 比如toJSON函式能夠把url的query轉為JSON物件 function toJSON(str) { var obj = {}, allArr = [], splitArr = []; str = str.indexOf('?') >= 0 ? str.substr(1) : str; allArr = str.split('&'); for (var i = 0; i < allArr.length; i++) { splitArr = allArr[i].split('='); obj[splitArr[0]] = splitArr[1]; } return obj; } 複製程式碼
這樣getCssAttr函式和toJSON組成了模組,當需要使用的時候,直接呼叫即可,但是隨著專案程式碼量越來越龐大和複雜,而且這種方式會對全域性變數造成了汙染。
為了解決上面的問題,會想到把這些方法、變數放到物件中
let utils = new Object({ getCssAttr:function(){...}, toJSON:function(){...} }) 複製程式碼
當需要呼叫相應函式時,我們通過物件呼叫即可, utils.getCssAttr()
、 utils.toJSON()
,但是這樣會存在一個問題,就是可以直接通過外部修改內部方法屬性。
utils.getCssAttr = null 複製程式碼
那麼我們有辦法讓內部方法屬性不被修改嗎?
答案是可以的,我們可以通過閉包的方式,使私有成員不暴露在外部。
let utils = (function(){ let getCssAttr = function(){...} let toJSON = function(){...} return { getCssAttr, toJSON } })() 複製程式碼
這樣的話,外部就無法改變內部的私有成員了。
CMD和AMD規範
試想一下,如果一個專案,所有輪子都自己造,在現在追求敏捷開發的環境下,我們有必要所有輪子都自己造嗎?一些常用通用的功能,是否可以提取出來,供大家使用,提高開發效率?
正所謂,無規矩不成方圓,每個程式猿的程式碼風格肯定是有差異的,你寫你的,我寫我的,這樣就很難流通了,但是如果大家都遵循一個規範編寫程式碼,形成一個個模組,就顯得非常重要了。
在這樣的背景下,形成了兩種規範,一種是以sea.js為代表的CMD規範,另外一種是以require.js為代表的AMD規範。
- CMD規範(Common Module Definition 通用模組定義)
- AMD規範(Asynchronous Module Definition 非同步模組定義)
這一點一定要明白,非常重要!
這一點一定要明白,非常重要!
這一點一定要明白,非常重要!
在node.js中是遵循commonJS規範的,在對模組的匯入是同步的,為什麼這樣說?因為在伺服器中,模組都是存在本地的,即使要匯入模組,也只是耗費了從硬碟讀取模組的時間,而且可控。
但是在瀏覽器中,模組是需要通過網路請求獲取的,如果是同步獲取的話,那麼網路請求的時間沒辦法保證,會造成瀏覽器假死的,但是非同步的話,是不會阻塞主執行緒,所以不管是CMD還是AMD,都是屬於非同步的,CMD和AMD都是屬於非同步載入模組,當所需要依賴的模組載入完畢後,才通過一個回撥函式,寫我們所需要的業務邏輯。
CMD和AMD的異同
- CMD是
延遲執行,依賴就近
,而AMD是提前執行,依賴前置
(require2.0開始可以改成延遲執行),怎麼理解呢?看看下面程式碼
// CMD define(function(require,exports,module){ var a = require('./a') a.run() var b = require('./b') b.eat() }) // AMD define(['./a','./b'],function(a,b){ a.run() b.eat() }) 複製程式碼
上面CMD和AMD都是非同步獲取到這些模組,但是載入的時機是不同的
CMD是使用的時候再進行載入
AMD則是執行回撥函式之前就已經把模組載入了
這樣的話會存在一個問題,就是在CMD執行的時候,require模組的時候,
因為要載入指定的模組,所以當執行到var a = require('./a')、var b = require('./b')
的時候,會稍微耗費多一些時間,也就是俗稱的懶載入,所以CMD中執行
這個回撥函式的時間會比AMD的快。
但是在AMD中,是預載入,意思就是執行回撥函式之前就把依賴的模組都載入完了,
所以AMD執行回撥函式的時間會比CMD慢,但是因為已經預載入了,在AMD執行回
調函式內的業務邏輯會比CMD快。 CMD AMD 執行回撥函式的時機 快 慢 執行回撥函式內的業務 慢 快

- 還有一些什麼定位有差異、遵循的規範不同、推廣理念有差異、對開發除錯的支援有差異、外掛機制不同等等就不衍生說了,最主要的還是前面說的那一條。
node.js遵循的commonJs規範
首先,我們來剖析一下commonJs的原始碼
我們分別建立兩個檔案 useModule.js
、 module.js
,並且打上斷點。

// useModule.js let utils = require('./module') utils = require('./module') utils.sayhello() 複製程式碼
// module.js let utils = { sayhello:function(){ console.log('hello swr') } } module.exports = utils 複製程式碼
然後開始執行,我們首先會進入commonJs的原始碼了

在最上面可以看出是一個閉包的形式 (function(exports,require,module,__filename,__dirname))
,這裡可以看出 __dirname
和 __filename
並非是 global
上的屬性,而是每個模組對應的路徑。
而且我們在模組當中 this
並不是指向 global
的,而是指向 module.exports
,至於為什麼會這樣呢?下面會講到。
在紅框中,我們可以看到 require
函式, exports.requireDepth
可以暫時不用管,是一個引用深度的變數,接下來我們往下看, return mod.require(path)
,這裡的 mod
就是每一個檔案、模組,而裡面都有一個 require
方法,接下來我們看看 mod.require
函式內部是怎麼寫的。

進來後,我們會看到2個 assert
斷言,用來判斷 path
引數是否傳遞了, path
是否字串型別等等。
return Module._load(path,this,false)
, path
為我們傳入的模組路徑,this則是這個模組,false則不是主要模組,主要模組的意思是,如果a.js載入了b.js,那麼a.js是主要模組,而b.js則是非主要模組。
接下來我們看看 Module._load
這個靜態方法


var filename = Module._resolveFilename(request, parent, isMain)
,這裡的目的是解析出一個絕對路徑,我們可以進去看看 Module._resolveFilename
函式是怎麼寫的

Module._resolveFilename
函式也沒什麼好說的,就是判斷各種情況,然後解析出一個絕對路徑出來,我們跳出這個函式,回到 Module._load
中
然後我們看到 var cachedModule = Module._cache[filename]
,這是我們載入模組的快取機制,就是說我們載入過一次模組後,會快取到Module._cache這個物件中,並且是以 filename
作為鍵名,因為路徑是唯一的,所以以路徑作為唯一標識,如果已經快取過,則會直接返回這個快取過的模組。
NativeModule.nonInternalExists(filename)
判斷是否原生模組,是的話則直接返回模組。
經過上面兩個判斷,基本可以判定這個模組沒被載入過,那麼接下來看到 var module = new Module(filename, parent)
,建立了一個模組,我們看看 Module
這個建構函式有什麼內容

這裡的 id
,實際上就是 filename
唯一路徑,另外一個很重要的是 this.exports
,也就是將來用於暴露模組的。
我們接著往下看,在建立一個例項後,接下來把這個例項存在快取當中, Module._cache[filename] = module
然後執行 tryModuleLoad(module, filename)
,這個函式非常重要,是用來載入模組的,我們看看是怎麼寫的

這裡有個 module.load
,我們再往裡面看看是怎麼寫的

兜兜轉轉,終於來到最核心的地方了
this.paths = Module._nodeModulePaths(path.dirname(filename))
,我們知道,我們安裝npm包時,node會由裡到外一層層找 node_modules
資料夾,而這一步,則是路徑一層層丟進數組裡,我們可以看看 this.paths
的陣列

繼續往下看, var extension = path.extname(filename) || '.js'
是獲取字尾名,如果沒有後綴名的話,暫時預設新增一個 .js
字尾名。
繼續往下看, if (!Module._extensions[extension]) extension = '.js'
是判斷 Module._extensions
這個物件,是否有這個屬性,如果沒有的話,則讓這個字尾名為 .js
繼續往下看, Module._extensions[extension](this, filename)
,根據字尾名,執行對應的函式,那麼我們看一下 Module._extensions
物件有哪幾個函式

從這裡我們可以看到, Module._extensions
中有3個函式,分別是 .js
、 .json
、 .node
函式,意思是根據不同的字尾名,執行不同的函式,來解析不同的內容,我們可以留意到讀取檔案都是用 fs.readFileSync
同步讀取,因為這些檔案都是儲存在伺服器硬碟中,讀取這些檔案耗費時間非常短,所以採用了同步而不是非同步
其中 .json
最為簡單,讀取出檔案後,再通過 JSON.parse
把字串轉化為 JSON
物件,然後把結果賦值給 module.exports
接下來看看 .js
,也是一樣先讀取出檔案內容,然後通過 module._compile
這個函式來解析 .js
的內容,我們看一下 module._compile
函式怎麼寫的


var wrapper = Module.wrap(content)
這裡對 .js
檔案的內容進行了一層處理,我們可以看看 Module.wrap
怎麼寫的

在這裡可以看出, NativeModule.wrapper
陣列中有兩個陣列成員,是不是看起來似曾相識?沒錯,這就是閉包的形式,而 Module.wrap
中,是直接把js檔案的內容,和這個閉包拼接成一段字串,對,就是在這裡,把一個個模組,套一層閉包!實際上拼接出來的是
// 字串 "(function(exports,require,module,__filename,__dirname){ let utils = { sayhello:function(){ console.log('hello swr') } } })" 複製程式碼
我們跳出來,回到 Module.prototype._compile
看看,接下來看到 var compiledWrapper = vm.runInThisContext(wrapper,{...})
,在nodejs中是通過vm這個虛擬機器,執行字串,而且這樣的好處是使內部完全是封閉的,不會被外在變數汙染,而在前端的字串模板則是通過 new Function()
來執行字串,達到不被外在變數汙染
繼續往下看, result = compiledWrapper.call(this.exports, this.exports, require, this,filename, dirname)
,其中 compiledWrapper
就是我們通過vm虛擬機器執行的字串後返回的閉包,而且通過 call
來把這個模組中的 this
指向更改為當前模組,而不是全域性的 global
,這裡就是為什麼我們在模組當中列印 this
時,指向的是當前的 module.exports
而不是 global
,然後後面依次把相應的引數傳遞過去
最終一層層跳出後 Module._load
中,最後是 return module.exports
,也就是說我們通過 require
匯入的模組,取的是 module.exports
通過剖析commonJs原始碼,我們收穫了什麼?
- 懂得了模組載入的整個流程
- 第一步:解析出一個絕對路徑
- 第二步:如檔案沒新增字尾,則新增
.js
、.json
、.node
作為字尾,然後通過fs.existsSync
來判斷檔案是否存在 - 第三步:到快取中找該模組是否被載入過
- 第四步:new一個模組例項
- 第五步:把模組存到快取當中
- 第六步:根據字尾名,載入這個模組
- 知道如何實現由裡到外一層層查詢
node_modules
- 知道針對
.js
和.json
是怎麼解析的-
.js
是通過拼接字串,形成一個閉包形式的字串 -
.json
則是通過JSON.parse
轉為JSON
物件
-
- 知道如何執行字串,並且不受外部變數汙染
new Function()
- 知道為什麼模組中的
this
指向的是this.exports
而不是global
- 通過
call
把指標指向了this.exports
- 通過
曾經有個小夥伴問我,在vue中,想在export default{}外讀取裡面的data的值
<script> export default { data(){ return{ name:"邵威儒" } } } // 在這外面取裡面的name值,如何取呢? </script> 複製程式碼
首先,我們知道, .vue
檔案在vue當中相當於一個模組,而模組的 this
是指向於 exports
,那麼我們可以打印出 this
看看是什麼
<script> export default { data(){ return{ name:"邵威儒" } } } // 在這外面取裡面的name值,如何取呢? console.log(this) </script> 複製程式碼
打印出來是這樣的

那麼就是說 this.a.data
則是 data
函數了, 那麼我們執行 this.a.data()
,返回了 {name:"邵威儒"}
所以當我們瞭解這個模組化的原始碼後,會為我們工作當中解決問題,提供了思路的
接下來,我們手寫一個簡陋版的commonJs原始碼
commonJs其實在載入模組的時候,做了以下幾個步驟
- 第一步:解析出一個絕對路徑
- 第二步:如檔案沒新增字尾,則新增
.js
、.json
、.node
作為字尾,然後通過fs.existsSync
來判斷檔案是否存在 - 第三步:到快取中找該模組是否被載入過
- 第四步:new一個模組例項
- 第五步:把模組存到快取當中
- 第六步:根據字尾名,載入這個模組
那麼我們根據這幾個步驟,來手寫一下原始碼~
// module.js let utils = { sayhello: function () { console.log('hello swr') } } console.log('執行了') module.exports = utils 複製程式碼
首先寫出解析一個絕對路徑以及如檔案沒新增字尾,則新增 .js
、 .json
作為字尾,然後通過 fs.existsSync
來判斷檔案是否存在( .. 每個步驟我都會標識1、2、3…
// useModule.js // 1.引入核心模組 let fs = require('fs') let path = require('path') // 3.宣告一個Module建構函式 function Module(id) { this.id = id this.exports = {} // 將來暴露模組的內容 } // 8.支援的字尾名型別 Module._extensions = { ".js":function(){}, ".json":function(){} } // 5.解析出絕對路徑,_resolveFilename是Module的靜態方法 Module._resolveFilename = function (relativePath) { // 6.返回一個路徑 let p = path.resolve(__dirname,relativePath) // 7.該路徑是否存在檔案,如果存在則直接返回 //這種情況主要考慮使用者自行添加了字尾名 //如'./module.js' let exists = fs.existsSync(p) if(exists) return p // 9.如果relativePath傳入的如'./module',沒有新增字尾 //那麼我們給它新增字尾,並且判斷新增字尾後是否存在該檔案 let keys = Object.keys(Module._extensions) let r = false for(let val of keys){ // 這裡用for迴圈,是當找到檔案後可以直接break跳出迴圈 let realPath = p + val // 拼接字尾 let exists = fs.existsSync(realPath) if(exists){ r = realPath break } } if(!r){ // 如果找不到檔案,則丟擲錯誤 throw new Error('file not exists') } return r } // 2.為了不與require衝突,這個函式命名為req //傳入一個引數p 路徑 function req(p) { // 10.因為Module._resolveFilename存在找不到檔案 //找不到檔案時會丟擲錯誤,所以我們這裡捕獲錯誤 try { // 4.通過Module._resolveFilename解析出一個絕對路徑 let filename = Module._resolveFilename(p) } catch (e) { console.log(e) } } // 匯入模組,並且匯入兩次,主要是校驗是否載入過一次後 // 在有快取的情況下,會不會直接返回快取的模組 // 為此特意在module.js中添加了console.log("執行了") // 來看列印了幾次 let utils = req('./module') utils = req('./module') utils.sayhello() 複製程式碼
然後到快取中找該模組是否被載入過,如果沒有載入過則new一個模組例項,把模組存到快取當中,最後根據字尾名,載入這個模組( .. 每個步驟我都會標識1、2、3…
// useModule.js // 1.引入核心模組 let fs = require('fs') let path = require('path') // 3.宣告一個Module建構函式 function Module(id) { this.id = id this.exports = {} // 將來暴露模組的內容 } // * 21.因為處理js檔案時,需要包裹一個閉包,我們寫一個數組 Module.wrapper = [ "(function(exports,require,module){", "\n})" ] // * 22.通過Module.wrap包裹成閉包的字串形式 Module.wrap = function(script){ return Module.wrapper[0] + script + Module.wrapper[1] } // 8.支援的字尾名型別 Module._extensions = { ".js":function(module){ // * 20.其次看看js是如何處理的 let str = fs.readFileSync(module.id,'utf8') // * 23.通過Module.wrap函式把內容包裹成閉包 let fnStr = Module.wrap(str) // * 24.引入vm虛擬機器來執行字串 let vm = require('vm') let fn = vm.runInThisContext(fnStr) // 讓產生的fn執行,並且把this指向更改為當前的module.exports fn.call(this.exports,this.exports,req,module) }, ".json":function(module){ // * 18.首先看看json是如何處理的 let str = fs.readFileSync(module.id,'utf8') // * 19.通過JSON.parse處理,並且賦值給module.exports let json = JSON.parse(str) module.exports = json } } // * 15.載入 Module.prototype._load = function(filename){ // * 16.獲取字尾名 let extension = path.extname(filename) // * 17.根據不同字尾名 執行不同的方法 Module._extensions[extension](this) } // 5.解析出絕對路徑,_resolveFilename是Module的靜態方法 Module._resolveFilename = function (relativePath) { // 6.返回一個路徑 let p = path.resolve(__dirname,relativePath) // 7.該路徑是否存在檔案,如果存在則直接返回 //這種情況主要考慮使用者自行添加了字尾名 //如'./module.js' let exists = fs.existsSync(p) if(exists) return p // 9.如果relativePath傳入的如'./module',沒有新增字尾 //那麼我們給它新增字尾,並且判斷新增字尾後是否存在該檔案 let keys = Object.keys(Module._extensions) let r = false for(let val of keys){ // 這裡用for迴圈,是當找到檔案後可以直接break跳出迴圈 let realPath = p + val // 拼接字尾 let exists = fs.existsSync(realPath) if(exists){ r = realPath break } } if(!r){ // 如果找不到檔案,則丟擲錯誤 throw new Error('file not exists') } return r } // * 11.快取物件 Module._cache = {} // 2.為了不與require衝突,這個函式命名為req //傳入一個引數p 路徑 function req(p) { // 10.因為Module._resolveFilename存在找不到檔案 //找不到檔案時會丟擲錯誤,所以我們這裡捕獲錯誤 try { // 4.通過Module._resolveFilename解析出一個絕對路徑 let filename = Module._resolveFilename(p) // * 12.判斷是否有快取,如果有快取的話,則直接返回快取 if(Module._cache[filename]){ // * 因為例項的exports才是最終暴露出的內容 return Module._cache[filename].exports } // * 13.new一個Module例項 let module = new Module(filename) // * 14.載入這個模組 module._load(filename) // * 25.把module存到快取 Module._cache[filename] = module // * 26.返回module.exprots return module.exports } catch (e) { console.log(e) } } // 匯入模組,並且匯入兩次,主要是校驗是否載入過一次後 // 在有快取的情況下,會不會直接返回快取的模組 // 為此特意在module.js中添加了console.log("執行了") // 來看列印了幾次 let utils = req('./module') utils = req('./module') utils.sayhello() 複製程式碼
這樣我們就完成了一個簡陋版的commonJs,而且我們多次匯入這個模組,只會打印出一次 執行了
,說明了只要快取中有的,就直接返回,而不是重新載入這個模組
這裡建議大家一個步驟一個步驟去理解,嘗試敲一下程式碼,這樣感悟會更加深
那麼為什麼exports = xxx 卻失效了呢?
// 從上面原始碼我們可以看出,實際上 // exports = module.exports = {} // 但是當我們exports = {name:"邵威儒"}時, // require出來卻獲取不到這個物件,這是因為我們在上面原始碼中, // req函式(即require)內部return出的是module.exports,而不是exports, // 當我們exports = { name:"邵威儒" }時,實際上這個exports指向了一個新的物件, // 而不是module.exports // 那麼我們的exports是不是多餘的呢?肯定不是多餘的,我們可以這樣寫 exports.name = "邵威儒" // 這樣寫沒有改變exports的指向,而是在exports指向的module.exports物件上新增了屬性 // 那麼什麼時候用exports,什麼時候用module.exports呢? // 如果匯出的東西是一個,那麼可以用module.exports,如果匯出多個屬性可以用exports, // 一般情況下是用module.exports // 還有一種方式,就是把屬性掛載到global上供全域性訪問,不過不推薦。 複製程式碼