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

邵威儒發表於2018-10-25

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

後續我會陸陸續續更新javascript方面,儘量把javascript這個學習路徑體系都寫一下
包括前端所常用的es6、angular、react、vue、nodejs、koa、express、公眾號等等
都會從淺到深,從入門開始逐步寫,希望能讓大家有所收穫,也希望大家關注我~

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

Author: 邵威儒
Email: 166661688@qq.com
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 執行回撥函式的時機 快 慢 執行回撥函式內的業務 慢 快

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

  • 還有一些什麼定位有差異、遵循的規範不同、推廣理念有差異、對開發除錯的支援有差異、外掛機制不同等等就不衍生說了,最主要的還是前面說的那一條。

node.js遵循的commonJs規範

首先,我們來剖析一下commonJs的原始碼

我們分別建立兩個檔案useModule.jsmodule.js,並且打上斷點。

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

// 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的原始碼了

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

在最上面可以看出是一個閉包的形式(function(exports,require,module,__filename,__dirname)),這裡可以看出__dirname__filename並非是global上的屬性,而是每個模組對應的路徑。

而且我們在模組當中this並不是指向global的,而是指向module.exports,至於為什麼會這樣呢?下面會講到。

在紅框中,我們可以看到require函式,exports.requireDepth可以暫時不用管,是一個引用深度的變數,接下來我們往下看,return mod.require(path),這裡的mod就是每一個檔案、模組,而裡面都有一個require方法,接下來我們看看mod.require函式內部是怎麼寫的。

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

進來後,我們會看到2個assert斷言,用來判斷path引數是否傳遞了,path是否字串型別等等。

return Module._load(path,this,false)path為我們傳入的模組路徑,this則是這個模組,false則不是主要模組,主要模組的意思是,如果a.js載入了b.js,那麼a.js是主要模組,而b.js則是非主要模組。

接下來我們看看Module._load這個靜態方法

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

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

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

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

Module._resolveFilename函式也沒什麼好說的,就是判斷各種情況,然後解析出一個絕對路徑出來,我們跳出這個函式,回到Module._load

然後我們看到var cachedModule = Module._cache[filename],這是我們載入模組的快取機制,就是說我們載入過一次模組後,會快取到Module._cache這個物件中,並且是以filename作為鍵名,因為路徑是唯一的,所以以路徑作為唯一標識,如果已經快取過,則會直接返回這個快取過的模組。

NativeModule.nonInternalExists(filename)判斷是否原生模組,是的話則直接返回模組。

經過上面兩個判斷,基本可以判定這個模組沒被載入過,那麼接下來看到var module = new Module(filename, parent),建立了一個模組,我們看看Module這個建構函式有什麼內容

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

這裡的id,實際上就是filename唯一路徑,另外一個很重要的是this.exports,也就是將來用於暴露模組的。

我們接著往下看,在建立一個例項後,接下來把這個例項存在快取當中,Module._cache[filename] = module

然後執行tryModuleLoad(module, filename),這個函式非常重要,是用來載入模組的,我們看看是怎麼寫的

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

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

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

兜兜轉轉,終於來到最核心的地方了

this.paths = Module._nodeModulePaths(path.dirname(filename)),我們知道,我們安裝npm包時,node會由裡到外一層層找node_modules資料夾,而這一步,則是路徑一層層丟進陣列裡,我們可以看看this.paths的陣列

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

繼續往下看,var extension = path.extname(filename) || '.js'是獲取字尾名,如果沒有字尾名的話,暫時預設新增一個.js字尾名。

繼續往下看,if (!Module._extensions[extension]) extension = '.js'是判斷Module._extensions這個物件,是否有這個屬性,如果沒有的話,則讓這個字尾名為.js

繼續往下看,Module._extensions[extension](this, filename),根據字尾名,執行對應的函式,那麼我們看一下Module._extensions物件有哪幾個函式

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

從這裡我們可以看到,Module._extensions中有3個函式,分別是.js.json.node函式,意思是根據不同的字尾名,執行不同的函式,來解析不同的內容,我們可以留意到讀取檔案都是用fs.readFileSync同步讀取,因為這些檔案都是儲存在伺服器硬碟中,讀取這些檔案耗費時間非常短,所以採用了同步而不是非同步

其中.json最為簡單,讀取出檔案後,再通過JSON.parse把字串轉化為JSON物件,然後把結果賦值給module.exports

接下來看看.js,也是一樣先讀取出檔案內容,然後通過module._compile這個函式來解析.js的內容,我們看一下module._compile函式怎麼寫的

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

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

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

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

在這裡可以看出,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>
複製程式碼

列印出來是這樣的

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

那麼就是說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上供全域性訪問,不過不推薦。
複製程式碼

相關文章