前言:大家好,我叫邵威儒,大家都喜歡喊我小邵,學的金融專業卻憑藉興趣愛好入了程式猿的坑,從大學買的第一本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 執行回撥函式的時機 快 慢 執行回撥函式內的業務 慢 快
- 還有一些什麼定位有差異、遵循的規範不同、推廣理念有差異、對開發除錯的支援有差異、外掛機制不同等等就不衍生說了,最主要的還是前面說的那一條。
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
物件
- 知道如何執行字串,並且不受外部變數汙染
- 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
來判斷檔案是否存在( .. 每個步驟我都會標識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上供全域性訪問,不過不推薦。
複製程式碼