4 變量、作用域和內存問題

本章內容

  • 理解基本類型和引用類型的值
  • 理解執行環境
  • 理解垃圾收集

由于不存在定義某個變量必須要保存何種數據類型值的規則,變量的值及其數據類型可以在腳本的生命周期內改變。

4.1 基本類型和引用類型的值

基本類型值指的是簡單的數據段,而引用類型值指那些可能由多個值構成的對象。
在將一個值賦給變量時,解析器必須確定這個值是基本類型值還是引用類型值。第3章討論了5種基本數據類型:UndefinedNullBooleanNumberString。這5種基本數據類型是按值訪問的,因為可以操作保存在變量中的實際的值。
引用類型的值是保存在內存中的對象。與其他語言不同,JavaScript不允許直接訪問內存中的位置,也就是說不能直接操作對象的內存空間。在操作對象時,實際上是在操作對象的引用而不是實際的對象。為此,引用類型的值是按引用訪問的。

在很多語言中,字符串以對象的形式來表示,因此被認為是引用類型的。ECMAScript放棄了這一傳統。

4.1.1 動態的屬性

對于引用類型的值,我們可以為其添加屬性和方法,也可以改變和刪除其屬性和方法。

var person = new Object();
person.name = "Nicholas";
alert(person.name);  //"Nicholas"

但是,我們不能給基本類型的值添加屬性,盡管這樣做不會導致任何錯誤。

var name = "Nicholas";
name.age = 27;
alert(name.age);  //undefined

4.1.2 復制變量值

如果從一個變量向另一個變量復制基本類型的值,會在變量對象上創建一個新值,然后把該值復制到為新變量分配的位置上。

var num1 = 5;
var num2 = num1;

當從一個變量向另一個變量復制引用類型的值時,同樣也會將存儲在變量對象中的值復制一份放到為新變量分配的空間中。不同的是,這個值的副本實際上是一個指針,而這個指針指向存儲在堆中的一個對象。復制操作結束后,兩個變量實際上將引用同一個對象。因此,改變其中一個變量,就會影響另一個變量。

var obj1 = new Object();
var obj2 = obj1;
obj1.name = "Nicholas";
alert(obj2.name);  //"Nicholas"

4.1.3 傳遞參數

ECMAScript中所有函數的參數都是按值傳遞的。訪問變量有按值和按引用兩種方式,而參數只能按值傳遞。
在向參數傳遞基本類型的值時,被傳遞的值會被復制給一個局部變量(即命名參數,或者用ECMAScript的概念來說,就是arguments對象中的一個元素)。在向參數傳遞引用類型的值時,會把這個值在內存中的地址復制給一個局部變量,因此這個局部變量的變化會反映在函數的外部。

function addTen(num) {
  num += 10;
  return num;
}

var count = 20;
var result = addTen(count);
alert(count);  //20, no changes
alert(result);  //30

這里的函數addTen()有一個參數num,而參數實際上是函數的局部變量。在調用這個函數時,變量count作為參數被傳遞給函數。在函數內部,參數num的值被加上了10,但這一變化不會影響函數外部的count變量。假如num是按引用傳遞的話,那么變量count的值也將變成30。如果使用對象:

function setName(obj) {
  obj.name = "Nicholas";
}

var person = new Object();
setName(person);
alert(person.name);  //"NIcholas"

對象person被傳遞到setName()函數中之后就被復制給了obj。即使這個對象是按值傳遞的,obj也會按引用來訪問同一個對象。當在函數內部為obj添加name屬性后,函數外部的person也將有所反映;有很多開發人員錯誤地認為:在局部作用域中修改的對象會在全局作用域反映出來,就說明參數是按引用傳遞的。為了證明對象是按值傳遞的,例子:

function setName(obj) {
  obj.name = "Nicholas";
  obj = new Object();
  obj.name = "Greg";
}

var person = new Object();
setName(person);
alert(person.name);  //"Nicholas"

如果person是按引用傳遞的,那么person就會自動被修改為指向name屬性值為Greg的新對象。但是person.name顯示的值仍然是Nicholas。這說明即使在函數內部修改了這個參數的值,但原始的引用仍然保持未變。實際上,當在函數內部重寫obj時,這個變量引用的就是一個局部對象了。而這個局部對象會在函數執行完畢后立即被銷毀。

可以把ECMAScript函數的參數想象成局部變量。

4.1.4 檢測類型

typeof操作符是確定一個變量是字符串、數值、布爾值,還是undefined的最佳工具。如果變量的值是一個對象或null,則typeof操作符會像下面例子中所示的那樣返回object

var s = "Nicholas";
var a = true;
var i = 22;
var u;
var n = null;
var o = new Object();

alert(typeof s);  //string
alert(typeof i);  //number
alert(typeof a);  //boolean
alert(typeof u);  //undefined
alert(typeof n);  //object
alert(typeof o);  //object

通常,我們并不是想知道某個值是對象,而是想知道它是什么類型的對象。為此,ECMAScript提供了instanceof操作符。

result = variable instanceof constructor

如果變量是給定引用類型的實例,那么instanceof操作符就會返回true

alert(person instanceof Object);  //變量person是Object嗎?
alert(colors instanceof Array);  //變量colors是Array嗎?
alert(pattern instanceof RegExp);  //變量pattern是RegExp嗎?

根據規定,所有引用類型的值都是Object的實例。在檢測一個引用類型值和Object構造函數時,instanceof操作符始終會返回true。當然,如果使用instance操作符檢測基本類型的值,則該操作符會返回false,因為基本類型不是對象。

4.2 執行環境及作用域

執行環境是JavaScript中最為重要的一個概念。執行環境定義了變量或函數有權訪問的其他數據,決定了它們各自的行為。每個執行環境都有一個與之關聯的變量對象,環境中定義的所有變量和函數都保存在這個對象中。雖然我們編寫的代碼無法訪問這個對象,但解析器在處理數據時會在后臺使用它。
全局執行環境是最外圍的一個執行環境。根據ECMAScript實現所在宿主環境不同,表示執行環境的對象也不一樣。在Web瀏覽器中,全局執行環境被認為是window對象,因此所有全局變量和函數都是作為window對象的屬性和方法創建的。某個執行環境中的所有代碼執行完畢后,該環境被銷毀,保存在其中的所有變量和函數定義也隨之銷毀(全局執行環境直到應用程序退出——例如關閉網頁或瀏覽器——時才會被銷毀)。
每個函數都有自己的執行環境。當執行流進入一個函數時,函數的環境就會被推入一個環境棧中。而在函數執行之后,棧將其環境彈出,把控制權返回給之前的執行環境。
當代碼在一個環境中執行時,會創建變量對象的一個作用域鏈,用來保證對執行環境有權訪問的所有變量和函數的有序訪問。作用域鏈的前端,始終都是當前執行的代碼所在環境的變量對象。全局執行環境的變量對象始終都是作用域鏈中的最后一個對象。
標識符解析是沿著作用域鏈一級一級地搜素標識符的過程。搜索過程始終從作用域鏈的前端開始,然后逐級地向后回溯,直至找到標識符為止。

var color = "blue";

function changeColor() {
  if (color === "blur") {
    color = "red";
   } else {
    color = "blur";
  }
}
changeColor();
alert("Color is now " + color);

函數changeColor()的作用域鏈包含兩個對象:它自己的變量對象和全局環境的變量對象。可以在函數內部訪問變量color,就是因為可以在這個作用域鏈中找到它。
此外,在局部作用域中定義的變量可以在局部環境中與全局變量互換使用,如下所示:

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

以上代碼共涉及3個執行環境:全局環境、changeColor()的局部環境和swapColors()的局部環境。全局環境中有一個變量color和一個函數changeColor()changeColor()的局部環境中有一個名為anotherColor的變量和一個名為swapColors()的函數,但它也可以訪問全局環境中的變量colorswapColors()的局部環境中有一個變量tempColor,該變量只能在這個環境中訪問到。無論全局環境還是changeColor()的局部環境都無權訪問tempColor。然而,在swapColors()內部則可以訪問其他兩個環境中的所有變量,因為那兩個環境是它的父執行環境。

函數參數也被當作變量來對待,因此其訪問規則與執行環境中的其他變量相同。

4.2.1 延長作用域鏈

當執行流進入下列任何一個語句時,作用域鏈就會得到加長:

  • try-catch語句的catch塊;
  • with語句。

這兩個語句都會在作用域鏈的前端添加一個變量對象。對with語句來說,會將指定的對象添加到作用域鏈中。對catch語句來說,會創建一個新的變量對象,其中包含的是被拋出的錯誤對象的聲明。

function buildUrl() {
  var qs = "?debug=true";

  with(location) {
    var url = href + qs;
  }
  return url;
}

4.2.2 沒有塊級作用域

JavaScript沒有塊級作用域經常會導致理解上的困惑。

  1. 聲明變量
    使用var聲明的變量會自動被添加到最接近的環境中。如果初始化變量時沒有使用var聲明,該變量會自動被添加到全局環境。
function add(num1, num2) {
  var sum = num1 + num2;
  return sum;
}
var result = add(10, 20);  //30
alert(sum);  //由于sum不是有效的變量,因此會導致錯誤
function add(num1, num2) {
  sum = num1 + num2;
  return sum;
}
var result = add(10, 20);  //30
alert(sum);  //30
  1. 查詢標識符
    當在某個環境中為了讀取或寫入而引用一個標識符時,必須通過搜索來確定該標識符實際代表什么。搜索過程從作用域鏈的前端開始,向上逐級查詢與給定名字匹配的標識符。如果在局部環境中找到了該標識符,搜索過程停止,變量就緒。如果在局部環境中沒有找到該變量名,則繼續沿作用域鏈向上搜索。搜索過程將一直追溯到全局環境的變量對象。如果在全局環境中也沒有找到這個標識符,則意味著該變量尚未聲明。
    在這個搜索過程中,如果存在一個局部的變量的定義,則搜索會自動停止,不再進入另一個變量對象。換句話說,如果局部環境中存在著同名標識符,就不會使用位于父環境中的標識符。
var color = "blue";
function getColor() {
  var color = "red";
  return color;
}
alert(getColor());  //"red"
alert(window.color);  //"blue"

變量查詢也并不是沒有代價的。很明顯,訪問局部變量要比訪問全局變量更快,因為不用向上搜索作用域鏈。

4.3 垃圾收集

JavaScript具有自動垃圾收集機制。這種垃圾收集機制的原理其實很簡單:找出那些不再繼續使用的變量,然后釋放其占用的內存。為此,垃圾收集器會按照固定的時間間隔周期性地執行這一操作。

4.3.1 標記清除

JavaScript中最常用的垃圾收集方式是標記清除。當變量進入環境時標記為“進入環境”,離開環境時標記“離開環境”。
垃圾收集器在運行的時候會給存儲在內存中的所有變量都加上標記。然后,去掉環境中的變量以及被環境中的變量引用的變量的標記。而在此之后再被加上標記的變量將被視為準備刪除的變量。

4.3.2 引用計數

另一種不太常見的垃圾手機策略叫做引用計數。存在循環引用問題。

4.3.3 性能問題

在有的瀏覽器中可以觸發垃圾收集過程,但我們不建議這樣做。

4.3.4 管理內存

確保占用最少的內存可以讓頁面獲得更好的性能。一旦數據不再有用,最好通過將其值設置為null來釋放其引用——這個做法叫做解除引用。這一做法適用于大多數全局變量和全局對象的屬性。局部變量會在它們離開執行環境時自動被解除引用。

function createPerson(name) {
  var localPerson = new Object();
  localPerson.name = name;
  return localPerson;
}

var globalPerson = createPerson("Nicholas");
//手工解除globalPerson的引用
globalperson = null;

解除引用的真正作用是讓值脫離執行環境,以便垃圾收集器下次運行時將其回收。

4.4 小結

JavaScript變量可以用來保存兩種類型的值:基本類型和引用類型。基本類型和引用類型值具有以下特點:

  • 基本類型值在內存中占據固定大小的空間,因此被保存在棧內存中;
  • 從一個變量向另一個變量復制基本類型的值,會創建這個值的一個副本;
  • 引用類型的值是對象,保存在堆內存中;
  • 包含引用類型值的變量實際上包含的并不是對象本身,而是一個指向該對象的指針;
  • 從一個變量向另一個變量復制引用類型的值,復制的其實是指針,因此兩個變量最終都指向同一個對象;
    確定一個值是那種基本類型可以使用typeof操作符,而確定一個值是哪種引用類型可以使用instanceof操作符。

所有變量都(基本類型和引用類型)存在于一個執行環境(也稱作用域)當中,這個執行環境決定了變量的生命周期,以及那一部分代碼可以訪問其中的變量。

  • 執行環境有全局執行環境和函數執行環境之分;
  • 每次進入一個新執行環境,都會創建一個用于搜素變量和函數的作用域鏈;
  • 函數的局部環境不僅有權訪問函數作用域中的變量,而且有權訪問其包含環境,乃至全局環境;
  • 全局環境只能訪問在全局環境中定義的變量和函數,而不能直接訪問局部環境中的任何數據;
  • 變量的執行環境有助于確定應該何時釋放內存。

JavaScript是一門具有自動垃圾收集機制的編程語言。

  • 離開作用域的值將被自動標記為可以回收,因此將在垃圾收集期間被刪除。
  • “標記清除”是目前主流的垃圾收集算法,思想是給當前不使用的值加上標記,然后再回收其內存。
  • 另一種是“引用計數”,思想是跟蹤記錄所有值被引用的次數。JavaScript引擎目前都不再使用這種算法。
  • 當代碼中存在循環引用現象時,“引用計數”算法就會導致問題。
  • 解除變量的引用不僅有助于消除循環引用現象,而且對垃圾收集也有好處。應該及時解除不再使用的全局對象、全局對象屬性以及循環引用變量的引用。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容