javascript
抖音二面:为什么模块循环依赖不会死循环?CommonJS和ES Module的处理不同?
大廠技術??高級前端??Node進階
點擊上方?程序員成長指北,關注公眾號
回復1,加入高級Node交流群
大家好,我是考拉🐨。如果被問到“CommonJS和ES Module的差異”,大概每個前端都都背出幾條:一個是導出值的拷貝,一個是導出值的引用;一個是運行時加載,一個是靜態編譯...
這篇文章會聚焦于遇到“循環引入”時,兩者的處理方式有什么不同,這篇文章會講清:
CommonJS和ES Module對于循環引用的解決原理是什么?
CommonJS的module.exports和exports有什么不同?
引入模塊時的路徑解析規則是什么。
JavaScript的模塊化
首先說說為什么會有兩種模塊化規范。眾所周知,早期的JavaScript是沒有模塊的概念,引用第三方包時都是把變量直接綁定在全局環境下。
以axios為例,以script標簽引入時,實際是在window對象上綁定了一個axios屬性。
這種全局引入的方式會導致兩個問題,變量污染和依賴混亂。
變量污染:所有腳本都在全局上下文中綁定變量,如果出現重名時,后面的變量就會覆蓋前面的
依賴混亂:當多個腳本有相互依賴時,彼此之間的關系不明朗
所以需要使用“模塊化”來對不同代碼進行隔離。其實模塊化規范遠不止這兩種,JavaScript官方遲遲沒有給出解法,所以社區實現了很多不同的模塊化規范,按照出現的時間前后有CommonJS、AMD、CMD、UMD。最后才是JavaScript官方在ES6提出的ES Module。
聽著很多,但其實只用重點了解CommonJS和ES Module,一是面試基本只會問這兩個,二是實際使用時用得多的也就是這兩個。
CommonJS
CommonJS的發明者希望它能讓服務端和客戶端通用(Common)。但如果一直從事純前端開發,應該對它不太熟悉,因為它原本是叫ServerJS,它主要被應用于Node服務端。
該規范把每一個文件看作一個模塊,首先看它的基本使用:
//?index.js?導入 const?a?=?require("./a.js") console.log('運行入口模塊') console.log(a)//?a.js?導出 exports.a?=?'a模塊' console.log('運行a模塊');我們使用require函數作模塊的引入,使用exports對象來做模塊的導出,這里的require exports正是CommmonJS規范提供給我們的,使用斷點調試,可以看到這幾個核心變量:
exports 記錄當前模塊導出的變量
module 記錄當前模塊的詳細信息
require 進行模塊的導入
exports 導出
首先來看exports導出,面試經常會問的一個題目是exports和module.exports區別是什么。兩者指向同一塊內存,但是使用并不是完全等價的。
當綁定一個屬性時,兩者相同
不能直接賦值給exports,也就是不能直接使用exports={}這種語法
雖然兩者指向同一塊內存,但最后被導出的是module.exports,所以不能直接賦值給exports。
同樣的道理,只要最后直接給module.exports賦值了,之前綁定的屬性都會被覆蓋掉。
exports.propA?=?'A'; module.exports.propB?=?'B'; module.exports?=?{propC:'C'};用上面的例子所示,先是綁定了兩個屬性propA和propB,接著給module.exports賦值,最后能成功導出的只有propC。
require 導入
CommonJS的引入特點是值的拷貝,簡單來說就是把導出值復制一份,放到一塊新的內存中。
循環引入
接下來進入正題,CommonJS如何處理循環引入。
首先來看一個例子:入口文件引用了a模塊,a模塊引用了b模塊,b模塊卻又引用了a模塊。可以思考一下會輸出什么.
//index.js var?a?=?require('./a') console.log('入口模塊引用a模塊:',a)//?a.js exports.a?=?'原始值-a模塊內變量' var?b?=?require('./b') console.log('a模塊引用b模塊:',b) exports.a?=?'修改值-a模塊內變量'//?b.js exports.b?='原始值-b模塊內變量' var?a?=?require('./a') console.log('b模塊引用a模塊',a) exports.b?=?'修改值-b模塊內變量'輸出結果如下:
這種AB模塊間的互相引用,本應是個死循環,但是實際并沒有,因為CommonJS做了特殊處理——模塊緩存。
依舊使用斷點調試,可以看到變量require上有一個屬性cache,這就是模塊緩存
一行行來看執行過程,
【入口模塊】開始執行,把入口模塊加入緩存,
var a = require('./a') 執行 將a模塊加入緩存,進入a模塊,
【a模塊】exports.a = '原始值-a模塊內變量'執行,a模塊的緩存中給變量a初始化,為原始值,
執行var b = require('./b'),將b模塊加入緩存,進入b模塊
【b模塊】exports.b ='原始值-b模塊內變量',b模塊的緩存中給變量b初始化,為原始值,
var a = require('./a'),嘗試導入a模塊,發現已有a模塊的緩存,所以不會進入執行,而是直接取a模塊的緩存,此時打印{ a: '原始值-a模塊內變量' },
exports.b = '修改值-b模塊內變量 執行,將b模塊的緩存中變量b替換成修改值,
【a模塊】console.log('a模塊引用b模塊:',b) 執行,取緩存中的值,打印{ b: '修改值-b模塊內變量' }
exports.a = '修改值-a模塊內變量' 執行,將a模塊緩存中的變量a替換成修改值,
【入口模塊】console.log('入口模塊引用a模塊:',a) 執行,取緩存中的值,打印{ a: '修改值-a模塊內變量' }
上面就是對循環引用的處理過程,循環引用無非是要解決兩個問題,怎么避免死循環以及輸出的值是什么。CommonJS通過模塊緩存來解決:每一個模塊都先加入緩存再執行,每次遇到require都先檢查緩存,這樣就不會出現死循環;借助緩存,輸出的值也很簡單就能找到了。
多次引入
同樣由于緩存,一個模塊不會被多次執行,來看下面這個例子:入口模塊引用了a、b兩個模塊,a、b這兩個模塊又分別引用了c模塊,此時并不存在循環引用,但是c模塊被引用了兩次。
//index.js var?a?=?require('./a') var?b=?require('./b')//?a.js module.exports.a?=?'原始值-a模塊內變量' console.log('a模塊執行') var?c?=?require('./c')//?b.js module.exports.b?=?'原始值-b模塊內變量' console.log('b模塊執行') var?c?=?require('./c')//?c.js module.exports.c?=?'原始值-c模塊內變量' console.log('c模塊執行')執行結果如下:
可以看到,c模塊只被執行了一次,當第二次引用c模塊時,發現已經有緩存,則直接讀取,而不會再去執行一次。
路徑解析規則
路徑解析規則也是面試常考的一個點,或者說,為什么我們導入時直接簡單寫一個'react'就正確找到包的位置。
仔細觀察module這個變量,可以看到還有一個屬性paths
首先把路徑作一個簡單分類:內置的核心模塊、本地的文件模塊和第三方模塊。
對于核心模塊,node將其已經編譯成二進制代碼,直接書寫標識符fs、http就可以
對于自己寫的文件模塊,需要用‘./’'../'開頭,require會將這種相對路徑轉化為真實路徑,找到模塊
對于第三方模塊,也就是使用npm下載的包,就會用到paths這個變量,會依次查找當前路徑下的node_modules文件夾,如果沒有,則在父級目錄查找no_modules,一直到根目錄下,找到為止。
在node_modules下找到對應包后,會以package.json文件下的main字段為準,找到包的入口,如果沒有main字段,則查找index.js/index.json/index.node
ES Module
盡管名為CommonJS,但并不Comomn(通用),它的影響范圍還是僅僅在于服務端。前端開發更常用的是ES Module。
ES Module使用import命令來做導入,使用export來做導出,語法相對比較復雜,熟悉可以先跳過這一部分
普通導入、導出
使用export導出可以寫成一個對象合集,也可以是一個單獨的變量,需要和import導入的變量名字一一對應
默認導入、導出
使用export default語法可以實現默認導出,可以是一個函數、一個對象,或者僅一個常量。默認的意思是,使用import導入時可以使用任意名稱,
混合導入、導出
全部導入
結果如下
重命名導入
重定向導出
第一種方式:重定向導出所有導出屬性, 但是不包括模塊的默認導出。
第二種方式:以相同的屬性名再次導出。
第三種方式:從模塊中導入propA,重命名為renameA導出
只運行模塊
export 導出
ES Module導出的是一份值的引用,CommonJS則是一份值的拷貝。也就是說,CommonJS是把暴露的對象拷貝一份,放在新的一塊內存中,每次直接在新的內存中取值,所以對變量修改沒有辦法同步;而ES Module則是指向同一塊內存,模塊實際導出的是這塊內存的地址,每當用到時根據地址找到對應的內存空間,這樣就實現了所謂的“動態綁定”。
可以看下面這個例子,使用ES Module導出一個變量1和一個給變量加1的方法
//?b.mjs export?let?count?=?1; export?function?add()?{count++; } export?function?get()?{return?count; }//?a.mjs import?{?count,?add,?get?}?from?'./b.mjs'; console.log(count);????//?1 add(); console.log(count);????//?2 console.log(get());????//?2可以看到,調用add后,導出的數字同步增加了。
但使用CommonJS實現這個邏輯:
//?a.js let?count?=?1; module.exports?=?{count,add()?{count++;},get()?{return?count;} };//?index.js const?{?count,?add,?get?}?=?require('./a.js'); console.log(count);????//?1 add(); console.log(count);????//?1 console.log(get());????//?2可以看到,在調用add對變量count增加后,導出count沒有改變,因為CommonJS基于緩存實現,入口模塊中拿到的是放在新內存中的一份拷貝,調用add修改的是模塊a中這塊內存,新內存沒有被修改到,所以還是原始值,只有將其改寫成方法才能獲取最新值。
import 導入
ES module會根據import關系構建一棵依賴樹,遍歷到樹的葉子模塊后,然后根據依賴關系,反向找到父模塊,將export/import指向同一地址。
循環引入
和CommonJS一樣,發生循環引用時并不會導致死循環,但兩者的處理方式大有不同。如果閱讀了上文,應該還記得CommonJS對循環引用的處理基于他的緩存,即:將導出值拷貝一份,放在一塊新的內存,用到的時候直接讀取這塊內存。
但ES module導出的是一個索引——內存地址,沒有辦法這樣處理。它依賴的是“模塊地圖”和“模塊記錄”,模塊地圖在下面會解釋,而模塊記錄是好比每個模塊的“身份證”,記錄著一些關鍵信息——這個模塊導出值的的內存地址,加載狀態,在其他模塊導入時,會做一個“連接”——根據模塊記錄,把導入的變量指向同一塊內存,這樣就是實現了動態綁定,
來看下面這個例子,和之前的demo邏輯一樣:入口模塊引用a模塊,a模塊引用b模塊,b模塊又引用a模塊,這種ab模塊相互引用就形成了循環
//?index.mjs import?*?as?a?from?'./a.mjs' console.log('入口模塊引用a模塊:',a)//?a.mjs let?a?=?"原始值-a模塊內變量" export?{?a?} import?*?as?b?from?"./b.mjs" console.log("a模塊引用b模塊:",?b) a?=?"修改值-a模塊內變量"//?b.mjs let?b?=?"原始值-b模塊內變量" export?{?b?} import?*?as?a?from?"./a.mjs" console.log("b模塊引用a模塊:",?a) b?=?"修改值-b模塊內變量"運行代碼,結果如下。
值得一提的是,import語句有提升的效果,實際執行可以看作這樣:
//?index.mjs import?*?as?a?from?'./a.mjs' console.log('入口模塊引用a模塊:',a)//?a.mjs import?*?as?b?from?"./b.mjs" let?a?=?"原始值-a模塊內變量" export?{?a?} console.log("a模塊引用b模塊:",?b) a?=?"修改值-a模塊內變量"//?b.mjs import?*?as?a?from?"./a.mjs" let?b?=?"原始值-b模塊內變量" export?{?b?} console.log("b模塊引用a模塊:",?a) b?=?"修改值-b模塊內變量"可以看到,在b模塊中引用a模塊時,得到的值是uninitialized,接下來一步步分析代碼的執行。
在代碼執行前,首先要進行預處理,這一步會根據import和export來構建模塊地圖(Module Map),它類似于一顆樹,樹中的每一個“節點”就是一個模塊記錄,這個記錄上會標注導出變量的內存地址,將導入的變量和導出的變量連接,即把他們指向同一塊內存地址。不過此時這些內存都是空的,也就是看到的uninitialized。
接下來就是代碼的一行行執行,import和export語句都是只能放在代碼的頂層,也就是說不能寫在函數或者if代碼塊中。
【入口模塊】首先進入入口模塊,在模塊地圖中把入口模塊的模塊記錄標記為“獲取中”(Fetching),表示已經進入,但沒執行完畢,
import * as a from './a.mjs' 執行,進入a模塊,此時模塊地圖中a的模塊記錄標記為“獲取中”
【a模塊】import * as b from './b.mjs' 執行,進入b模塊,此時模塊地圖中b的模塊記錄標記為“獲取中”,
【b模塊】import * as a from './a.mjs' 執行,檢查模塊地圖,模塊a已經是Fetching態,不再進去,
let b = '原始值-b模塊內變量' 模塊記錄中,存儲b的內存塊初始化,
console.log('b模塊引用a模塊:', a) 根據模塊記錄到指向的內存中取值,是{ a:}
b = '修改值-b模塊內變量' 模塊記錄中,存儲b的內存塊值修改
【a模塊】let a = '原始值-a模塊內變量' 模塊記錄中,存儲a的內存塊初始化,
console.log('a模塊引用b模塊:', b) 根據模塊記錄到指向的內存中取值,是{ b: '修改值-b模塊內變量' }
a = '修改值-a模塊內變量' 模塊記錄中,存儲a的內存塊值修改
【入口模塊】console.log('入口模塊引用a模塊:',a) 根據模塊記錄,到指向的內存中取值,是{ a: '修改值-a模塊內變量' }
總結一下:和上面一樣,循環引用要解決的無非是兩個問題,保證不進入死循環以及輸出什么值。ES Module來處理循環使用一張模塊間的依賴地圖來解決死循環問題,標記進入過的模塊為“獲取中”,所以循環引用時不會再次進入;使用模塊記錄,標注要去哪塊內存中取值,將導入導出做連接,解決了要輸出什么值。
結語
回到開頭的三個問題,答案在文中不難找到:
CommonJS和ES Module都對循環引入做了處理,不會進入死循環,但方式不同:
CommonJS借助模塊緩存,遇到require函數會先檢查是否有緩存,已經有的則不會進入執行,在模塊緩存中還記錄著導出的變量的拷貝值;
ES Module借助模塊地圖,已經進入過的模塊標注為獲取中,遇到import語句會去檢查這個地圖,已經標注為獲取中的則不會進入,地圖中的每一個節點是一個模塊記錄,上面有導出變量的內存地址,導入時會做一個連接——即指向同一塊內存。
CommonJS的export和module.export指向同一塊內存,但由于最后導出的是module.export,所以不能直接給export賦值,會導致指向丟失。
查找模塊時,核心模塊和文件模塊的查找都比較簡單,對于react/vue這種第三方模塊,會從當前目錄下的node_module文件下開始,遞歸往上查找,找到該包后,根據package.json的main字段找到入口文件。
Node 社群我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續有計劃也可以),我們可以一起進行Node.js相關的交流、學習、共建。下方加 考拉 好友回復「Node」即可。如果你覺得這篇內容對你有幫助,我想請你幫我2個小忙:1. 點個「在看」,讓更多人也能看到這篇文章2. 訂閱官方博客?www.inode.club?讓我們一起成長點贊和在看就是最大的支持??總結
以上是生活随笔為你收集整理的抖音二面:为什么模块循环依赖不会死循环?CommonJS和ES Module的处理不同?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C语言中唯一的一个三目运算符(条件运算符
- 下一篇: 【粉丝福利】赠《机器学习算法竞赛实战》1