小邵教你玩轉nodejs之剖析/實現commonJs源碼(2)

前言:你們好,我叫邵威儒,你們都喜歡喊我小邵,學的金融專業卻憑藉興趣愛好入了程序猿的坑,從大學買的第一本vb和自學vb,我就與編程結下不解之緣,隨後自學易語言寫遊戲輔助、交易軟件,至今進入了前端領域,看到很多朋友都寫文章分享,本身也弄一個玩玩,如下文章純屬我的理解,便於記錄學習,確定有理解錯誤或理解不到位的地方,意在站在前輩的肩膀,分享我的對技術的通俗理解,共同成長!javascript

後續我會陸陸續續更新javascript方面,儘可能把javascript這個學習路徑體系都寫一下
包括前端所經常使用的es六、angular、react、vue、nodejs、koa、express、公衆號等等
都會從淺到深,從入門開始逐步寫,但願能讓你們有所收穫,也但願你們關注我~前端

文章列表:juejin.im/user/5a84f8…
小邵教你玩轉nodejs系列:juejin.im/collection/…vue

Author: 邵威儒
Email: 166661688@qq.com
Wechat: 166661688
github: github.com/iamswr/java


十分抱歉,最近比較忙,臨時須要把h5快速遷移到app,又不懂co swift android,基本天天都在踩坑當中,不過收穫也很大,未來前端的趨勢確定是大前端,無論是pc 移動m站仍是app 後端都須要懂一些,後面有時間,我也會把最近接觸ios/android原生遇到的問題總結出來。node

在這篇文章中,你會明白模塊化的發展,明白以sea.js爲表明的cmd規範和以require.js爲表明的amd規範的異同,剖析node.js使用的commonJs規範的源碼以及手寫實現簡陋版的commonJs,最後你會明白模塊化是怎樣一個加載過程。react


Javascript最開始是怎樣實現模塊化呢?

咱們知道javascript最開始是面向過程的思惟編程,隨着代碼愈來愈龐大、複雜,在這種實際遇到的問題中,大佬們逐漸把面向對象、模塊化的思想用在javascript當中。android

一開始,咱們是把不一樣功能寫在不一樣函數當中

// 好比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組成了模塊,當須要使用的時候,直接調用便可,可是隨着項目代碼量愈來愈龐大和複雜,並且這種方式會對全局變量形成了污染。ios

爲了解決上面的問題,會想到把這些方法、變量放到對象中

let utils = new Object({
    getCssAttr:function(){...},
    toJSON:function(){...}
})
複製代碼

當須要調用相應函數時,咱們經過對象調用便可,utils.getCssAttr()utils.toJSON(),可是這樣會存在一個問題,就是能夠直接經過外部修改內部方法屬性。git

utils.getCssAttr = null
複製代碼

那麼咱們有辦法讓內部方法屬性不被修改嗎?

答案是能夠的,咱們能夠經過閉包的方式,使私有成員不暴露在外部。es6

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.jsmodule.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對象
  • 知道如何執行字符串,而且不受外部變量污染
    • nodejs中經過vm虛擬機來執行字符串
    • 前端則是經過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看看是什麼

@舞動乾坤 大佬反饋,vue-cli3中打印this是undefined,我後面看看vue-cli3怎麼處理的再更新

<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來判斷文件是否存在( .. 每一個步驟我都會標識一、二、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一個模塊實例,把模塊存到緩存當中,最後根據後綴名,加載這個模塊( .. 每一個步驟我都會標識一、二、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上供全局訪問,不過不推薦。
複製代碼
相關文章
相關標籤/搜索