引言
在我們的前端日常工作中,無(wú)時(shí)無(wú)刻不在進(jìn)行著變量的聲明和賦值,你是否也曾碰到過(guò)變量聲明報(bào)錯(cuò)或變量被污染的問(wèn)題,如果你跟筆者一樣碰到過(guò),那么我們應(yīng)該暫時(shí)停下來(lái)好好思考問(wèn)題發(fā)生的原因以及如何采取相應(yīng)的補(bǔ)救措施。當(dāng)然排查問(wèn)題最好的方式就是深入其底層細(xì)節(jié),了解在JavaScript中的內(nèi)存分配方式。只有我們對(duì)底層細(xì)節(jié)有一定的了解之后,才能輕而易舉地化解在寫(xiě)代碼過(guò)程中遇到的各種問(wèn)題。本文基于JavaScript的內(nèi)存模型繼續(xù)衍生出let和const的差異性對(duì)比,若文中有錯(cuò)誤的地方,還請(qǐng)指出。
1、內(nèi)存是什么
在講解JavaScript中的內(nèi)存模型之前,我們先從硬件層面來(lái)簡(jiǎn)單了解下內(nèi)存是什么。
內(nèi)存是計(jì)算機(jī)中重要的部件之一,它是外存與CPU進(jìn)行溝通的橋梁。計(jì)算機(jī)中所有程序的運(yùn)行都是在內(nèi)存中進(jìn)行的,因此內(nèi)存的性能對(duì)計(jì)算機(jī)的影響非常大。內(nèi)存(Memory)也被稱(chēng)為內(nèi)存儲(chǔ)器和主存儲(chǔ)器,其作用是用于暫時(shí)存放CPU中的運(yùn)算數(shù)據(jù),以及與硬盤(pán)等外部存儲(chǔ)器交換的數(shù)據(jù)。只要計(jì)算機(jī)在運(yùn)行中,CPU就會(huì)把需要運(yùn)算的數(shù)據(jù)調(diào)到內(nèi)存中進(jìn)行運(yùn)算,當(dāng)運(yùn)算完成后CPU再將結(jié)果傳送出來(lái),內(nèi)存的運(yùn)行也決定了計(jì)算機(jī)的穩(wěn)定運(yùn)行。
內(nèi)存條是計(jì)算機(jī)組成結(jié)構(gòu)中的關(guān)鍵部分,其本身是一個(gè)非常精密的部件,內(nèi)部包含了上億個(gè)電子元器件,它們很小,達(dá)到了納米級(jí)別。這些元器件,實(shí)際上也就是電路,電路的電壓會(huì)發(fā)生變化,但只有兩種可能,要么0V(低電平),要么5V(高電平),0V是斷電,用0來(lái)表示,5V是通電,用1來(lái)表示,因此一個(gè)元器件包含了兩個(gè)狀態(tài)0和1,即表示一位(bit)。但是作為人類(lèi),我們并不擅長(zhǎng)使用bit來(lái)思考和計(jì)算,因此我們會(huì)將它們劃分成更大的組,例如8位表示1個(gè)byte(字節(jié)),16位表示2個(gè)byte(字節(jié)),32位表示4個(gè)byte(字節(jié))。有很多東西都是存儲(chǔ)在內(nèi)存中的,比如我們的程序代碼,程序中所聲明的變量以及操作系統(tǒng)的代碼等。
2、內(nèi)存的生命周期
了解了內(nèi)存的基本概念后,我們來(lái)簡(jiǎn)單聊聊內(nèi)存的生命周期。JavaScript作為一門(mén)高級(jí)編程語(yǔ)言,不像其他語(yǔ)言(例如C語(yǔ)言)中需要開(kāi)發(fā)人員手動(dòng)地去管理內(nèi)存,系統(tǒng)會(huì)自動(dòng)為你分配內(nèi)存。但是無(wú)論是哪種編程語(yǔ)言,內(nèi)存的生命周期都主要分為三個(gè)階段:
分配內(nèi)存:由操作系統(tǒng)來(lái)分配內(nèi)存,供程序使用。在JavaScript中,這一步由操作系統(tǒng)來(lái)自動(dòng)分配,無(wú)需開(kāi)發(fā)人員手動(dòng)操作。
使用內(nèi)存:程序獲得操作系統(tǒng)所分配的內(nèi)存之后,在內(nèi)存中發(fā)生讀和寫(xiě)操作。
釋放內(nèi)存:程序使用完內(nèi)存之后,會(huì)將這部分內(nèi)存釋放出來(lái)供其他程序使用。在JavaScript中,這一步同樣不需要開(kāi)發(fā)人員手動(dòng)操作,由操作系統(tǒng)自動(dòng)釋放。
我們知道,在JavaScript中的數(shù)據(jù)類(lèi)型分為基本數(shù)據(jù)類(lèi)型和引用數(shù)據(jù)類(lèi)型,其中基本數(shù)據(jù)類(lèi)型包括String、Number、Boolean、Null、Undefined,ES6中新增的Symbol以及最新的BigInt,除了這些以外,其他的均為引用數(shù)據(jù)類(lèi)型,例如Array、Date、Function、RegExp、Error,Object等。那么這兩種數(shù)據(jù)類(lèi)型的其中一個(gè)區(qū)別就是,基本數(shù)據(jù)類(lèi)型的內(nèi)存大小都是固定的,而引用數(shù)據(jù)類(lèi)型的內(nèi)存大小都是動(dòng)態(tài)不固定的,可能會(huì)隨時(shí)發(fā)生變化。因此在內(nèi)存分配階段這兩種數(shù)據(jù)類(lèi)型會(huì)有一定的差異。
編譯器在編譯代碼時(shí),對(duì)于基本數(shù)據(jù)類(lèi)型,由于其空間大小固定,編譯器在檢查時(shí)會(huì)提前計(jì)算它們需要的內(nèi)存大小,并插入與操作系統(tǒng)交互的代碼,向操作系統(tǒng)申請(qǐng)存儲(chǔ)變量所需的堆棧字節(jié)數(shù),然后將申請(qǐng)到的內(nèi)存分配給調(diào)用堆棧中的程序,稱(chēng)為靜態(tài)內(nèi)存分配。例如在調(diào)用函數(shù)時(shí),函數(shù)中的變量所需的內(nèi)存會(huì)被添加到現(xiàn)有的內(nèi)存之上,當(dāng)函數(shù)執(zhí)行完畢后,這部分內(nèi)存又會(huì)以后進(jìn)先出(LIFO)的順序被移除。但是對(duì)于引用數(shù)據(jù)類(lèi)型,其空間大小是動(dòng)態(tài)的,在編譯階段無(wú)法直接確定其需要多少內(nèi)存,因此不能在堆棧上為其分配內(nèi)存,相反,需要在運(yùn)行時(shí)向操作系統(tǒng)申請(qǐng)適當(dāng)?shù)膬?nèi)存,并且這部分內(nèi)存是在堆空間進(jìn)行分配的,稱(chēng)為動(dòng)態(tài)內(nèi)存分配。靜態(tài)內(nèi)存分配和動(dòng)態(tài)內(nèi)存分配的區(qū)別如下表所示:
靜態(tài)內(nèi)存分配 動(dòng)態(tài)內(nèi)存分配
編譯階段可確定大小 編譯階段無(wú)法確定大小
在編譯時(shí)執(zhí)行 在運(yùn)行時(shí)執(zhí)行
分配給堆棧 分配給堆
順序分配,后進(jìn)先出(LIFO) 無(wú)序分配
3、JavaScript中的內(nèi)存分配
在我們的前端開(kāi)發(fā)日常工作中,幾乎每天都在做著變量的聲明和賦值,這些變量最終都會(huì)被存放到內(nèi)存中,所以我們還是有必要了解一下在JavaScript中的內(nèi)存分配方式,這里使用基本數(shù)據(jù)類(lèi)型和引用數(shù)據(jù)類(lèi)型來(lái)分別講述一下內(nèi)存的分配過(guò)程,幫助我們理解JavaScript的底層細(xì)節(jié)。
首先我們從一個(gè)簡(jiǎn)單的基本數(shù)據(jù)類(lèi)型的賦值開(kāi)始,代碼如下:
let num = 1;
當(dāng)JavaScript引擎在執(zhí)行到這行代碼時(shí),會(huì)執(zhí)行如下操作:
為變量num創(chuàng)建一個(gè)唯一標(biāo)識(shí)符(identifier),該標(biāo)識(shí)符用于與棧內(nèi)存中的地址A1形成映射關(guān)系。
在棧內(nèi)存中為其分配一個(gè)地址A1。
將值1存儲(chǔ)到分配的地址。
示例圖如下:
通常我們說(shuō)num變量的值等于1,但其實(shí)嚴(yán)格意義上來(lái)講,num變量的值等于棧內(nèi)存中存放對(duì)應(yīng)值的內(nèi)存地址(如圖中的A1)。接下來(lái)我們創(chuàng)建一個(gè)新的變量newNum并將num賦值給它:
let newNum = num;
經(jīng)過(guò)以上賦值之后,通常說(shuō)newNum的值為1,同樣從嚴(yán)格意義上來(lái)講的話是指newNum和num指向同一個(gè)內(nèi)存地址A1,如下圖所示:
如果接下來(lái)我們執(zhí)行以下操作,看會(huì)發(fā)生什么:
num = num + 1;
我們對(duì)num變量進(jìn)行自增長(zhǎng),很顯然num變量的值為2。由于newNum和num指向同一個(gè)內(nèi)存地址A1,那么此時(shí)newNum的值是否也為2呢,在回答這個(gè)問(wèn)題之前,我們先來(lái)看一下當(dāng)前內(nèi)存地址發(fā)生的變化:
在上圖中我們可以發(fā)現(xiàn),num變量的內(nèi)存地址發(fā)生了改變,由原來(lái)的A1變?yōu)锳2,這是因?yàn)樵贘S中的基本數(shù)據(jù)類(lèi)型都是不可變的,一旦修改,只會(huì)為其分配新的內(nèi)存地址并將修改后的新值存入到新的地址中,因此回答上面的那個(gè)問(wèn)題,newNum的值保持不變,依舊為1,因?yàn)樗膬?nèi)存地址沒(méi)有發(fā)生改變。再看如下示例:
let str = 'ab';
str = str + 'c';
因?yàn)樽址彩菍儆诨緮?shù)據(jù)類(lèi)型,基本數(shù)據(jù)類(lèi)型都是不可變的,所以即使上述代碼中只是簡(jiǎn)單的將c拼接到了原來(lái)的字符串a(chǎn)b后面,但是依舊會(huì)為其分配新的內(nèi)存地址,變量str最終會(huì)指向這個(gè)新的內(nèi)存地址,如下圖所示:
了解了基本數(shù)據(jù)類(lèi)型的內(nèi)存分配方式之后,接下來(lái)我們來(lái)了解下引用數(shù)據(jù)類(lèi)型的內(nèi)存分配方式。同樣我們從一個(gè)簡(jiǎn)單的引用數(shù)據(jù)類(lèi)型的賦值開(kāi)始:
let arr = [];
當(dāng)JavaScript引擎在執(zhí)行到這行代碼時(shí),會(huì)執(zhí)行如下操作:
為變量arr創(chuàng)建一個(gè)唯一標(biāo)識(shí)符(identifier),該標(biāo)識(shí)符用于與棧內(nèi)存中的地址A3形成映射關(guān)系。
在棧內(nèi)存中為其分配一個(gè)地址A3。
棧內(nèi)存中存儲(chǔ)在堆中分配的內(nèi)存地址的值H1。
在堆中存儲(chǔ)分配的值空數(shù)組[]。
示例圖如下:
在JavaScript引擎(例如Chrome和Node的V8引擎)中主要是由兩個(gè)部件組成,一個(gè)叫內(nèi)存堆(Memory Heap),一個(gè)叫調(diào)用堆棧(Call Stack)。其中調(diào)用堆棧除了函數(shù)調(diào)用之外,主要用于存放基本數(shù)據(jù)類(lèi)型的值,而引用數(shù)據(jù)類(lèi)型的值一般都存放在內(nèi)存堆中,堆中存放的數(shù)據(jù)都是無(wú)序的并且可以動(dòng)態(tài)地增長(zhǎng),所以非常適合用于存儲(chǔ)數(shù)組和對(duì)象。
4、let和const的差異性對(duì)比
在了解完以上兩種數(shù)據(jù)類(lèi)型的內(nèi)存分配方式后,我們這里對(duì)let和const的使用方式進(jìn)行一下對(duì)比,通常來(lái)說(shuō),我們建議在寫(xiě)代碼的過(guò)程中能使用const的地方盡量減少使用let,這樣可以在某種程度上避免變量被無(wú)端修改而引發(fā)的一系列問(wèn)題。如下代碼:
let num = 1;
num = num + 1;
let arr = [];
arr.push(1);
arr.push(2);
arr.push(3);
在上述代碼中,變量num因?yàn)槭褂胠et的方式聲明,所以允許其被修改,因?yàn)榛绢?lèi)型的值是不可變的,所以會(huì)為num變量分配新的內(nèi)存地址。對(duì)于arr變量,這里同樣使用let方式進(jìn)行聲明,表示允許其修改,但是對(duì)于push操作其實(shí)并沒(méi)有修改arr變量的內(nèi)存地址,只是將新的值推入了堆內(nèi)存的數(shù)組中,所以此處建議修改為使用const進(jìn)行聲明。
筆者的觀點(diǎn)是:將修改理解為修改內(nèi)存地址,若允許修改內(nèi)存地址,則使用let進(jìn)行聲明,否則使用const進(jìn)行聲明。
如下示例:
const num = 1;
num = num + 1;
由在上一小節(jié)中了解到的基本數(shù)據(jù)類(lèi)型的內(nèi)存分配方式,我們知道為變量num在棧內(nèi)存中分配了一個(gè)地址來(lái)保存對(duì)應(yīng)的值。
但是這里我們是使用const的方式來(lái)進(jìn)行聲明的,當(dāng)我們重新為變量num進(jìn)行賦值時(shí),JS嘗試為其分配新的內(nèi)存地址,那么這里也就是拋出錯(cuò)誤的地方,因?yàn)槲覀兠鞔_不允許對(duì)其進(jìn)行修改。
因此在控制臺(tái)中我們會(huì)看到對(duì)應(yīng)的報(bào)錯(cuò)信息。
再看如下示例:
const arr = [];
對(duì)于引用數(shù)據(jù)類(lèi)型,我們知道會(huì)在棧內(nèi)存上為其分配內(nèi)存地址,存儲(chǔ)的是堆中的內(nèi)存地址的值。
我們做如下操作:
arr.push(1);
arr.push(2);
arr.push(3);
執(zhí)行push操作實(shí)際上是將新值推入堆中的數(shù)組,內(nèi)存地址并沒(méi)有發(fā)生改變。這也就是為什么雖然使用const聲明變量,但是依舊沒(méi)有報(bào)錯(cuò)的原因。但是如果我們使用如下方式:
arr = 1;
arr = undefined;
arr = null;
arr = [];
arr = {};
這些方式都會(huì)修改原數(shù)組的內(nèi)存地址,const聲明是不允許修改內(nèi)存地址的,所以很明顯會(huì)拋出錯(cuò)誤。因此這里也是建議默認(rèn)情況下使用const聲明變量,除非需要修改內(nèi)存地址,const聲明的變量必須在聲明時(shí)進(jìn)行初始化,也方便了其他前端人員能一眼看出哪些變量是不可變的。
5、總結(jié)
在本篇中主要總結(jié)了一下JavaScript中的內(nèi)存模型,并針對(duì)基本數(shù)據(jù)類(lèi)型和引用數(shù)據(jù)類(lèi)型分別講述了其在JavaScript中的內(nèi)存分配方式,然后對(duì)let和const這兩種在代碼中的變量聲明方式進(jìn)行對(duì)比以了解其中的差異性,下篇基于內(nèi)存模型繼續(xù)講解JavaScript引擎中的垃圾回收機(jī)制以及在寫(xiě)代碼過(guò)程中的幾種有效避免內(nèi)存泄漏的方式,和大家一起了解JavaScript的底層細(xì)節(jié)。
另外有需要云服務(wù)器可以了解下創(chuàng)新互聯(lián)scvps.cn,海內(nèi)外云服務(wù)器15元起步,三天無(wú)理由+7*72小時(shí)售后在線,公司持有idc許可證,提供“云服務(wù)器、裸金屬服務(wù)器、高防服務(wù)器、香港服務(wù)器、美國(guó)服務(wù)器、虛擬主機(jī)、免備案服務(wù)器”等云主機(jī)租用服務(wù)以及企業(yè)上云的綜合解決方案,具有“安全穩(wěn)定、簡(jiǎn)單易用、服務(wù)可用性高、性價(jià)比高”等特點(diǎn)與優(yōu)勢(shì),專(zhuān)為企業(yè)上云打造定制,能夠滿足用戶豐富、多元化的應(yīng)用場(chǎng)景需求。