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