JS 底蘊之 變數、作用域和垃圾回收

Daniel_Yang發表於2018-05-23

JS 底蘊之 變數、作用域和垃圾回收

基本型別和引用型別

在 JavaScript 中,資料型別可分為基本型別和引用型別,

基本型別有六種:Null,Undefined,String,Boolean,Number,Symbol

而引用型別就是傳說中的 Object 了。

其中基本型別是按值傳遞,而引用型別的值是按引用訪問的,所以在操作物件時,實際上是在操作物件的引用而不是實際的物件 ( ps:在為物件新增屬性時,操作的是實際的物件 )。

關於基本型別和引用型別的不同,大概有以下幾點:

1、引用型別是動態的屬性,而基本型別不是。

對於引用型別,我們可以為其新增、刪除屬性和方法,但不能給基本型別的值新增屬性:

// 基本型別
var name = 'Fly_001';
name.age = 22;
alert(name.age); // undefined;

// 引用型別
var person = new Object();
person.name = 'Fly_001';
alert(person.name); // 'Fly_001';
複製程式碼

2、複製的方式不同。

如果從一個變數向另一個變數複製基本型別的值,會將值複製到為新變數分配的位置上:

var num1 = 5;
var num2 = num1;
複製程式碼

當使用 num1 的值來初始化 num2 時,num2 中也儲存了值5,但該值只是 num1 中 5 的一個副本,兩個變數不會互相影響。

當從一個變數向另一個變數複製引用型別的值時,傳遞的是一個指標,其指向儲存在堆中的一個物件,在複製結束後,兩個變數實際上將引用同一個物件,改變其中一個變數就會影響另一個變數:

var obj1 = new Object();
var obj2 = obj1;
obj1.name = 'Fly_001';
alert(obj2.name); // 'Fly_001';
複製程式碼

3、傳遞引數的特點。

這是一個容易困惑的點 ?。

ECMAScript 中所有函式的引數都是按值傳遞的。也就是說,把函式外部的值複製給函式內部的引數,就和把值從一個變數複製到另一個變數一樣。基本型別值的傳遞如同基本型別變數的複製一樣,而引用型別的傳遞,則如同引用型別變數的複製一樣,這一點確實會引起很多小夥伴的爭議,歡迎討論~

  • 在向引數傳遞基本型別的值時,被傳遞的值會被複制給一個區域性變數( 即 arguments 物件中的一個元素 )。

  • 在向引數傳遞引用型別的值時,會把這個值在記憶體中的地址複製給一個區域性變數,因此該區域性變數的變化會反映到函式的外部:

function addTen(num) {
    num += 10;
    return num;
}
var count = 20;
var result = addTen(count);
alert(count); // 20,木有變化;
alert(result); // 30

function setNmae(obj) {
    obj.name = 'Fly_001';
}
var person = new Object();
setName(person);
alert(person.name); // 'Fly_001';
複製程式碼

在上面程式碼中我們建立了一個物件,並將其儲存在了變數 person 中。然後,這個物件被傳遞到 setName () 函式中就被複制給了 obj,在這個函式內部,obj 和 person 引用的是同一個物件。

很多小夥伴會認為該引數是按引用傳遞的,為了證明物件是按值傳遞的,再看下這個修改過的程式碼:

function setName(obj) {
    obj.name = 'Fly_001';
    obj = new Object();
    obj.name = 'juejin';
}

var person  = new Object();
setName(person);
alert(person.name); // 'Fly_001';
複製程式碼

如果 person 是按引用傳遞的,那麼 person 就會自動被修改為指向其 name 屬性為 ‘juejin’ 的新物件。但接下來再訪問 person.name 時仍然顯示 ‘Fly_001’,這說明即使在函式內部修改了引數的值,但原始的引用仍保持不變。( 實際上,當在函式內部重寫 obj 時,這個變數引用的就是一個區域性物件了,其將在函式執行完畢後立即被銷燬。)

4、檢測型別的操作符不同。

  • 檢測基本型別適宜用 typeof 操作符
alert(typeof 'Fly_001'); // 'string';
alert(typeof []); // 'object';
複製程式碼

因為 typeof 操作符的返回值為 'undefined','string','boolean','number','symbol','object','function' 其中之一。

它可以很友好地指出某一具體基本型別,而對於引用型別則籠統地返回 'object'( typeof 對 陣列、正則、null 都會返回 'object' )。

  • 在檢測引用型別時更適合用 instanceof 操作符:
result = varible instanceof constructor;
複製程式碼

如果變數是給定引用型別的例項( 根據它的原型鏈來識別 ),那 instanceof 操作符將會返回 true。

執行環境及作用域

下面聊下 JavaScript 中很重要的一個概念 —— 執行環境

JS 中每個執行環境都有一個與之關聯的變數物件,在 Web 瀏覽器中,全域性執行環境是 window 物件,因此所有全域性變數和函式都是作為 window 物件的屬性和方法建立的。

某個執行環境中的所有程式碼執行完畢後,該環境將會被銷燬,儲存在其中的所有變數和函式定義也隨之銷燬,全域性執行環境直至網頁或瀏覽器關閉時才被銷燬( 如果存在閉包,情況又有所不同,會在後面幾篇提到 ?,多謝 吳hr 指正)。

每個函式都有自己的執行環境。當執行流進入一個函式時,函式的環境就會被推入一個環境棧中。而在函式執行之後,棧會將其環境彈出,把控制權返回給之前的執行環境。

var color = 'blue';

function changeColor() {
    var anotherColor = 'red';
    
    function swapColors() {
        var tempColor = anotherColor;
        anotherColor = color;
        color = tempColor;
        
        // 這裡可以訪問 color、anotherColor 和 tempColor;
    }
    
    swapColors();
    // 這裡可以訪問 color 和 anotherColor,但不能訪問 tempColor;
}

changeColor();
// 這裡只能訪問 color;
複製程式碼

以上程式碼共涉及 3 個執行環境:全域性環境、changeColor() 的區域性環境和 swapColor() 區域性環境。其中,內部環境可以通過作用域鏈訪問所有的外部環境,但外部環境不能訪問內部環境中的任何變數和函式。 這些環境之間的聯絡是線性的、有次序的。每個環境可以向上搜尋作用域鏈 ?,以查詢變數和函式名;但任何環境都不能通過向下搜尋作用域鏈而進入另一個執行環境。

  • 延長作用域鏈。

雖然執行環境的型別總共只有兩種 —— 全域性和區域性 (函式),但還是兩種辦法來延長作用域鏈~ 就是通過 try-catch 語句的 catch 塊和 with 語句。

這兩個語句都會在作用域鏈的前端新增一個變數物件。對 with 語句來說,會將指定的物件新增到作用域鏈中;對於 catch 語句來說,會建立一個新的變數物件,其中包含的是被丟擲的錯誤物件的宣告。

  • 沒有塊級作用域。

JavaScript 沒有塊級作用域經常會導致理解上的困惑 ?。在其它類 C 的語言中,由花括號封閉的程式碼塊都有自己的作用域,即執行環境,但在 JavaScript 中卻不是這樣:

if (true) {
    var color = 'blue';
}

alert(color); // 'blue';

for (var i = 0; i < 10; i ++) {
    // dosomething
}

alert(i); // 10;
複製程式碼

使用 var 宣告的變數會自動被新增到最接近的環境中。在函式內部,最接近的環境就是函式的區域性環境,若初始化變數時沒有使用 var 宣告,該變數會自動被新增到全域性環境。( 建立塊範圍區域性變數使用 let 關鍵字更方便 ):

function add(num1, num2) {
    var sum = num1 + num2;
    return sum;
}

var result = add(10, 20); // 30;
alert(sum); // 'sum is not defined';
複製程式碼

在上面程式碼中,雖然 sum 從函式中返回了,但在函式外部是訪問不到的。如果省略 var 關鍵字,這時 sum 是可以訪問到的( 不過在嚴格模式下,初始化未宣告的變數會報 'xxx is not defined' 錯 )。

  • 模仿塊級作用域。

雖然 js 沒有塊級作用域,但我們可以用匿名函式來模仿塊級作用域~,語法格式如下:

(function() {
    // 這裡是塊級作用域;
}) ();
複製程式碼

將函式宣告包含在一對圓括號裡,表示它實際上是一個函式表示式,而緊隨其後的圓括號會立即呼叫這個函式。實際上就相當於:

var someFunction() {
    // 這裡是塊級作用域;
};
someFunction();
複製程式碼

同時因為 JavaScript 將 function 關鍵字當作一個函式宣告的開始,後面不能直接跟圓括號,而函式表示式後面可以跟圓括號,所以將函式宣告加上圓括號轉換成函式表示式。

無論在什麼地方,只要臨時需要一些變數,就可以使用私有作用域:

function outputNumbers(count) {
    (function () {
        for (var i = 0; i < count; i ++) {
            alert(i);
        }
    }) ();
    
    alert(i); // 會導致錯誤,讀取不到 i;
}
複製程式碼

因為在匿名函式中定義的任何變數,都會在執行結束時立即銷燬,所以變數 i 只能在迴圈中使用。

  • 查詢識別符號。

當在某個環境中為了讀取或寫入而引用一個變數或函式名 ( 識別符號 ),必須通過搜尋來確定該它實際代表什麼。

搜尋過程從作用域的前端開始,向上逐級查詢,如果存在一個區域性的變數的定義,則停止搜尋,即同名區域性變數將覆蓋同名全域性變數:

var color = 'blue';

function getColor() {
    var color = 'red'; // 區域性變數;
    return color;
}

alert(getColor()); // 'red';
alert(window.color); // 'blue';
複製程式碼

垃圾收集。

JavaScript 具有自動垃圾收集機制,所以開發人員不必擔心記憶體使用問題,是不是很開森 ?,但最好還是瞭解下 ?。

首先我們來分析函式中區域性變數的正常生命週期:區域性變數只在函式執行的過程中存在,函式執行結束後就會釋放掉它們的記憶體以供將來使用。所以 垃圾收集器必須跟蹤哪些變數有用、哪些變數沒用,具體到瀏覽器的實現有兩個策略:標記清除和引用計數

  • 標記清除

此乃 JavaScript 中最常用的垃圾收集機制。

垃圾收集器在執行的時候會把儲存在記憶體中的所有變數都加上標記,然後去掉環境中的變數及被環境中的變數引用的變數的標記,

在此之後還有標記的變數將被視為準備刪除的變數,因為環境中的變數已經無法訪問到這些變數了。最後垃圾收集器完成記憶體清除工作,銷燬那些帶標記的值並回收它們所佔用的記憶體空間。

  • 引用計數

另一種出鏡率不高的垃圾收集策略是引用計數。

它主要跟蹤記錄每個值被引用的次數,當某個值的引用次數為 0 時,則說明沒有辦法再訪問這個值了,因此就可以將其佔用的記憶體空間回收。

但引用計數會存在一個迴圈引用的問題:

function problem() {
    var objA = new Object();
    var objB = new Object();
    
    objA.someOtherObject = objB;
    objB.anotherObject = objA;
}
複製程式碼

也就是說,在函式執行完之後,objA 和 objB 還將繼續存在,因此它們的引用次數永遠不會是 0,假如這個函式被重複多次呼叫,就會導致大量記憶體得不到回收 ?。

為了避免這樣的迴圈引用問題,最好在不使用它們的時候手動斷開連線:

objA.someOtherObject = null;
objB.anotherObject = null;
複製程式碼

當垃圾收集器下次執行時,就會刪除這些值並回收它們所佔用的記憶體。

Tips:一旦資料不再有用,最好將其設為 null。
複製程式碼

( 此條適合全域性變數和全域性物件的屬性,因為區域性變數會在它們離開執行環境時自動被解除引用 )。

ok,JavaScript 基礎的變數、作用域和垃圾回收我們就先講到這,下一篇會聊聊 JavaScript 物件導向的程式設計和函式表示式。

有贊我們再更吧,多謝多謝~ ?

相關文章