變數、作用域與記憶體

kkk大魔王發表於2020-12-09

原始值與引用值

ECMAScript 變數可以包含兩種不同型別的資料:原始值和引用值。原始值(primitive value)就是最簡單的資料,引用值(reference value)則是由多個值構成的物件。
6 種原始值:Undefined、Null、Boolean、Number、String 和 Symbol。儲存原始值的變數是按值(by value)訪問的,因為我們操作的就是儲存在變數中的實際值。

引用值是儲存在記憶體中的物件。在操作物件時,實際上操作的是對該物件的引用(reference)而非實際的物件本身。為此,儲存引用值的變數是按引用(by reference)訪問的。

1.動態屬性

原始值和引用值的定義方式很類似,都是建立一個變數,然後給它賦一個值。不過,在變數儲存了這個值之後,可以對這個值做什麼,則大有不同。對於引用值而言,可以隨時新增、修改和刪除其屬性和方法。比如,看下面的例子:

let person = new Object(); 
person.name = "Nicholas"; 
console.log(person.name); // "Nicholas"

原始值不能有屬性,儘管嘗試給原始值新增屬性不會報錯。比如:

let name = "Nicholas"; 
name.age = 27; 
console.log(name.age); // undefined

原始型別的初始化可以只使用原始字面量形式。如果使用的是 new 關鍵字,則 JavaScript 會建立一個 Object 型別的例項,但其行為類似原始值。下面來看看這兩種初始化方式的差異:

let name1 = "Nicholas"; 
let name2 = new String("Matt"); 
name1.age = 27; 
name2.age = 26; 
console.log(name1.age); // undefined 
console.log(name2.age); // 26 
console.log(typeof name1); // string 
console.log(typeof name2); // object

2.複製值

在通過變數把一個原始值賦值到另一個變數時,原始值會被複制到新變數的位置。

let num1 = 5; 
let num2 = num1;

這兩個變數可以獨立使用,互不干擾。
在這裡插入圖片描述
在把引用值從一個變數賦給另一個變數時,儲存在變數中的值也會被複制到新變數所在的位置。區別在於,這裡複製的值實際上是一個指標,它指向儲存在堆記憶體中的物件。操作完成後,兩個變數實際上指向同一個物件,因此一個物件上面的變化會在另一個物件上反映出來,如下面的例子所示:

let obj1 = new Object(); 
let obj2 = obj1; 
obj1.name = "Nicholas"; 
console.log(obj2.name); // "Nicholas"

在這裡插入圖片描述

3.傳遞引數

function addTen(num) { 
 num += 10; 
 return num; 
} 
let count = 20; 
let result = addTen(count); 
console.log(count); // 20,沒有變化
console.log(result); // 30

這裡,函式 addTen()有一個引數 num,它其實是一個區域性變數。在呼叫時,變數 count 作為引數傳入。count 的值是 20,這個值被複制到引數 num 以便在 addTen()內部使用。在函式內部,引數 num的值被加上了 10,但這不會影響函式外部的原始變數 count。引數 num 和變數 count 互不干擾,它們只不過碰巧儲存了一樣的值。

function setName(obj) { 
 obj.name = "Nicholas"; 
} 
let person = new Object(); 
setName(person); 
console.log(person.name); // "Nicholas"

我們建立了一個物件並把它儲存在變數 person 中。然後,這個物件被傳給 setName()方法,並被複制到引數 obj 中。在函式內部,obj 和 person 都指向同一個物件。結果就是,即使物件是按值傳進函式的,obj 也會通過引用訪問物件。當函式內部給 obj 設定了 name 屬性時,函式外部的物件也會反映這個變化,因為 obj 指向的物件儲存在全域性作用域的堆記憶體上。

function setName(obj) { 
 obj.name = "Nicholas"; 
 obj = new Object(); 
 obj.name = "Greg"; 
} 
let person = new Object(); 
setName(person); 
console.log(person.name); // "Nicholas"

這個例子前後唯一的變化就是 setName()中多了兩行程式碼,將 obj 重新定義為一個有著不同 name的新物件。當 person 傳入 setName()時,其 name 屬性被設定為"Nicholas"。然後變數 obj 被設定為一個新物件且 name 屬性被設定為"Greg"。如果 person 是按引用傳遞的,那麼 person 應該自動將指標改為指向 name 為"Greg"的物件。可是,當我們再次訪問 person.name 時,它的值是"Nicholas",這表明函式中引數的值改變之後,原始的引用仍然沒變。當 obj 在函式內部被重寫時,它變成了一個指向本地物件的指標。而那個本地物件在函式執行結束時就被銷燬了。

4.確定型別

typeof 操作符最適合用來判斷一個變數是否為原始型別。更確切地說,它是判斷一
個變數是否為字串、數值、布林值或 undefined 的最好方式。如果值是物件或 null,那麼 typeof返回"object",typeof 雖然對原始值很有用,但它對引用值的用處不大。

ECMAScript 提供了 instanceof 操作符判斷物件的型別:

result = variable instanceof constructor

執行上下文與作用域

全域性上下文是最外層的上下文。根據 ECMAScript 實現的宿主環境,表示全域性上下文的物件可能不一樣。在瀏覽器中,全域性上下文就是我們常說的 window 物件,因此所有通過 var 定義的全域性變數和函式都會成為 window 物件的屬性和方法。使用 let 和 const 的頂級宣告不會定義在全視訊講解局上下文中,但在作用域鏈解析上效果是一樣的。上下文在其所有程式碼都執行完畢後會被銷燬,包括定義在它上面的所有變數和函式(全域性上下文在應用程式退出前才會被銷燬,比如關閉網頁或退出瀏覽器)。

每個函式呼叫都有自己的上下文。當程式碼執行流進入函式時,函式的上下文被推到一個上下文棧上。在函式執行完之後,上下文棧會彈出該函式上下文,將控制權返還給之前的執行上下文。ECMAScript程式的執行流就是通過這個上下文棧進行控制的。

程式碼執行時的識別符號解析是通過沿作用域鏈逐級搜尋識別符號名稱完成的。搜尋過程始終從作用域鏈的最前端開始,然後逐級往後,直到找到識別符號。

var color = "blue"; 
function changeColor() { 
 if (color === "blue") { 
 color = "red"; 
 } else { 
 color = "blue"; 
 } 
} 
changeColor();

函式 changeColor()的作用域鏈包含兩個物件:一個是它自己的變數物件(就是定義 arguments 物件的那個),另一個是全域性上下文的變數物件。這個函式內部之所以能夠訪問變數color,就是因為可以在作用域鏈中找到它。

此外,區域性作用域中定義的變數可用於在區域性上下文中替換全域性變數。看一看下面這個例子:

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

在這裡插入圖片描述
圖中矩形表示不同的上下文。內部上下文可以通過作用域鏈訪問外部上下文中的一切,但外部上下文無法訪問內部上下文中的任何東西。上下文之間的連線是線性的、有序的。每個上下文都可以到上一級上下文中去搜尋變數和函式,但任何上下文都不能到下一級上下文中去搜尋。swapColors()區域性上下文的作用域鏈中有 3 個物件:swapColors()的變數物件、changeColor()的變數物件和全域性變數物件。swapColors()的區域性上下文首先從自己的變數物件開始搜尋變數和函式,搜不到就去搜尋上一級變數物件。changeColor()上下文的作用域鏈中只有 2 個物件:它自己的變數物件和全域性變數物件。因此,它不能訪問 swapColors()的上下文。

1.作用域鏈增強

某些語句會導致在作用域鏈前端臨時新增一個上下文,這個上下文在程式碼執行後會被刪除。通常在兩種情況下會出現這個現象

  • try/catch 語句的 catch 塊
  • with 語句

這兩種情況下,都會在作用域鏈前端新增一個變數物件。對 with 語句來說,會向作用域鏈前端新增指定的物件;對 catch 語句而言,則會建立一個新的變數物件,這個變數物件會包含要丟擲的錯誤物件的宣告。

function buildUrl() { 
 let qs = "?debug=true"; 
 with(location){ 
 let url = href + qs; 
 } 
 return url; 
}

with 語句將 location 物件作為上下文,因此 location 會被新增到作用域鏈前端。buildUrl()函式中定義了一個變數 qs。當 with 語句中的程式碼引用變數 href 時,實際上引用的是location.href,也就是自己變數物件的屬性。在引用 qs 時,引用的則是定義在 buildUrl()中的那個變數,它定義在函式上下文的變數物件上。而在 with 語句中使用 var 宣告的變數 url 會成為函式上下文的一部分,可以作為函式的值被返回;

2.變數宣告

ES6 之後,JavaScript 的變數宣告經歷了翻天覆地的變化。直到 ECMAScript 5.1,var 都是宣告變數的唯一關鍵字。ES6 不僅增加了 let 和 const 兩個關鍵字,而且還讓這兩個關鍵字壓倒性地超越 var成為首選。

1.使用 var 的函式作用域宣告

在使用 var 宣告變數時,變數會被自動新增到最接近的上下文。在函式中,最接近的上下文就是函式的區域性上下文。在 with 語句中,最接近的上下文也是函式上下文。如果變數未經宣告就被初始化了,那麼它就會自動被新增到全域性上下文,如下面的例子所示:

function add(num1, num2) { 
 var sum = num1 + num2; 
 return sum; 
} 
let result = add(10, 20); // 30 
console.log(sum); // 報錯:sum 在這裡不是有效變數

如果省略上面例子中的關鍵字 var,那麼 sum 在 add()被呼叫之後就變成可以訪問的了。

在嚴格模式下,未經宣告就初始化變數
會報錯。

var 宣告會被拿到函式或全域性作用域的頂部,位於作用域中所有程式碼之前。這個現象叫作“提升”(hoisting)。提升讓同一作用域中的程式碼不必考慮變數是否已經宣告就可以直接使用。可是在實踐中,提升也會導致合法卻奇怪的現象,即在變數宣告之前使用變數。

var name = "Jake"; 
// 等價於:
name = 'Jake'; 
var name; 
下面是兩個等價的函式:
function fn1() { 
 var name = 'Jake'; 
} 
// 等價於:
function fn2() { 
 var name; 
 name = 'Jake'; 
}
//宣告的提升意味著會輸出 undefined 而不是
Reference Error
console.log(name); // undefined 
var name = 'Jake'; 
function() { 
 console.log(name); // undefined 
 var name = 'Jake'; 
}

2. 使用 let 的塊級作用域宣告

ES6 新增的 let 關鍵字跟 var 很相似,但它的作用域是塊級的。塊級作用域由最近的一對包含花括號{}界定。換句話說,if 塊、while 塊、function 塊,甚至連單獨的塊也是 let 宣告變數的作用域。

if (true) { 
 let a; 
} 
console.log(a); // ReferenceError: a 沒有定義
while (true) { 
 let b; 
} 
console.log(b); // ReferenceError: b 沒有定義
function foo() { 
 let c; 
} 
console.log(c); // ReferenceError: c 沒有定義
 // 這沒什麼可奇怪的
 // var 宣告也會導致報錯
// 這不是物件字面量,而是一個獨立的塊
// JavaScript 直譯器會根據其中內容識別出它來
{ 
 let d; 
} 
console.log(d); // ReferenceError: d 沒有定義

let 與 var 的另一個不同之處是在同一作用域內不能宣告兩次。重複的 var 宣告會被忽略,而重複的 let 宣告會丟擲 SyntaxError。

var a; 
var a; 
// 不會報錯
{ 
 let b; 
 let b; 
} 
// SyntaxError: 識別符號 b 已經宣告過了

3. 使用 const 的常量宣告

除了 let,ES6 同時還增加了 const 關鍵字。使用 const 宣告的變數必須同時初始化為某個值。一經宣告,在其生命週期的任何時候都不能再重新賦予新值。

const a; // SyntaxError: 常量宣告時沒有初始化
const b = 3; 
console.log(b); // 3 
b = 4; // TypeError: 給常量賦值
if (true) { 
 const a = 0; 
} 
console.log(a); // ReferenceError: a 沒有定義
while (true) { 
 const b = 1; 
} 
console.log(b); // ReferenceError: b 沒有定義
function foo() { 
 const c = 2; 
} 
console.log(c); // ReferenceError: c 沒有定義
{ 
 const d = 3; 
} 
console.log(d); // ReferenceError: d 沒有定義

賦值為物件的 const 變數不能再被重新賦值為其他引用值,但物件的鍵則不受限制。

const o1 = {}; 
o1 = {}; // TypeError: 給常量賦值
const o2 = {}; 
o2.name = 'Jake'; 
console.log(o2.name); // 'Jake'

4. 識別符號查詢

當在特定上下文中為讀取或寫入而引用一個識別符號時,必須通過搜尋確定這個識別符號表示什麼。搜尋開始於作用域鏈前端,以給定的名稱搜尋對應的識別符號。如果在區域性上下文中找到該識別符號,則搜尋停止,變數確定;如果沒有找到變數名,則繼續沿作用域鏈搜尋。(注意,作用域鏈中的物件也有一個原型鏈,因此搜尋可能涉及每個物件的原型鏈。)這個過程一直持續到搜尋至全域性上下文的變數物件。如果仍然沒有找到識別符號,則說明其未宣告。

var color = 'blue'; 
function getColor() { 
 return color; 
} 
console.log(getColor()); // 'blue'

第一步,搜尋 getColor()的變數物件,查詢名為 color 的識別符號。結果沒找到,於是繼續搜尋下一個變數物件(來自全域性上下文),然後就找到了名為 color 的識別符號。因為全域性變數物件上有 color的定義,所以搜尋結束。

var color = 'blue'; 
function getColor() { 
 let color = 'red'; 
 return color; 
} 
console.log(getColor()); // 'red'

如果區域性上下文中有一個同名的識別符號,那就不能在該上下文中引用父上下文中的同名識別符號

垃圾回收

JavaScript 是使用垃圾回收的語言,也就是說執行環境負責在程式碼執行時管理記憶體。通過自動記憶體管理實現記憶體分配和閒置資源回收。基本思路很簡單:確定哪個變數不會再使用,然後釋放它佔用的記憶體。這個過程是週期性的,即垃圾回收程式每隔一定時間(或者說在程式碼執行過程中某個預定的收集時間)就會自動執行。垃圾回收過程是一個近似且不完美的方案,因為某塊記憶體是否還有用,屬於“不可判定的”問題,意味著靠演算法是解決不了的。

我們以函式中區域性變數的正常生命週期為例。函式中的區域性變數會在函式執行時存在。此時,棧(或堆)記憶體會分配空間以儲存相應的值。函式在內部使用了變數,然後退出。此時,就不再需要那個區域性變數了,它佔用的記憶體可以釋放,供後面使用。這種情況下顯然不再需要區域性變數了,但並不是所有時候都會這麼明顯。垃圾回收程式必須跟蹤記錄哪個變數還會使用,以及哪個變數不會再使用,以便回收記憶體。在瀏覽器的發展史上,用到過兩種主要的標記策略:標記清理和引用計數。

1.標記清理

JavaScript 最常用的垃圾回收策略是標記清理(mark-and-sweep)。當變數進入上下文,比如在函式內部宣告一個變數時,這個變數會被加上存在於上下文中的標記。而在上下文中的變數,邏輯上講,永遠不應該釋放它們的記憶體,因為只要上下文中的程式碼在執行,就有可能用到它們。當變數離開上下文時,也會被加上離開上下文的標記。

給變數加標記的方式有很多種。比如,當變數進入上下文時,反轉某一位;或者可以維護“在上下文中”和“不在上下文中”兩個變數列表,可以把變數從一個列表轉移到另一個列表。標記過程的實現並不重要,關鍵是策略。

垃圾回收程式執行的時候,會標記記憶體中儲存的所有變數(記住,標記方法有很多種)。然後,它會將所有在上下文中的變數,以及被在上下文中的變數引用的變數的標記去掉。在此之後再被加上標記的變數就是待刪除的了,原因是任何在上下文中的變數都訪問不到它們了。隨後垃圾回收程式做一次記憶體清理,銷燬帶標記的所有值並收回它們的記憶體。

2.引用計數

其思路是對每個值都記錄它被引用的次數。宣告變數並給它賦一個引用值時,這個值的引用數為 1。如果同一個值又被賦給另一個變數,那麼引用數加 1。類似地,如果儲存對該值引用的變數被其他值給覆蓋了,那麼引用數減 1。當一個值的引用數為 0 時,就說明沒辦法再訪問到這個值了,因此可以安全地收回其記憶體了。垃圾回收程式下次執行的時候就會釋放引用數為 0 的值的記憶體。

3.效能

垃圾回收程式會週期性執行,如果記憶體中分配了很多變數,則可能造成效能損失,因此垃圾回收的時間排程很重要。尤其是在記憶體有限的移動裝置上,垃圾回收有可能會明顯拖慢渲染的速度和幀速率。開發者不知道什麼時候執行時會收集垃圾,因此最好的辦法是在寫程式碼時就要做到:無論什麼時候開始收集垃圾,都能讓它儘快結束工作。

4.記憶體管理

將記憶體佔用量保持在一個較小的值可以讓頁面效能更好。優化記憶體佔用的最佳手段就是保證在執行程式碼時只儲存必要的資料。如果資料不再必要,那麼把它設定為 null,從而釋放其引用。這也可以叫作解除引用。這個建議最適合全域性變數和全域性物件的屬性。區域性變數在超出作用域後會被自動解除引用。

function createPerson(name){ 
 let localPerson = new Object(); 
 localPerson.name = name; 
 return localPerson; 
} 
let globalPerson = createPerson("Nicholas"); 
// 解除 globalPerson 對值的引用
globalPerson = null;

解除對一個值的引用並不會自動導致相關記憶體被回收。解除引用的關鍵在於確保相關的值已經不在上下文裡了,因此它在下次垃圾回收時會被回收。

1.通過 const 和 let 宣告提升效能

ES6 增加這兩個關鍵字不僅有助於改善程式碼風格,而且同樣有助於改進垃圾回收的過程。因為 const和 let 都以塊(而非函式)為作用域,所以相比於使用 var,使用這兩個新關鍵字可能會更早地讓垃圾回收程式介入,儘早回收應該回收的記憶體。在塊作用域比函式作用域更早終止的情況下,這就有可能發生。

2. 隱藏類和刪除操作

Chrome ,使用 V8 JavaScript 引擎。V8 在將解釋後的 JavaScript程式碼編譯為實際的機器碼時會利用“隱藏類”。

執行期間,V8 會將建立的物件與隱藏類關聯起來,以跟蹤它們的屬性特徵。能夠共享相同隱藏類的物件效能會更好,V8 會針對這種情況進行優化,但不一定總能夠做到。

3. 記憶體洩漏

意外宣告全域性變數是最常見但也最容易修復的記憶體洩漏問題。

function setName() { 
 name = 'Jake'; 
}

此時,直譯器會把變數 name 當作 window 的屬性來建立(相當於 window.name = ‘Jake’)。可想而知,在 window 物件上建立的屬性,只要 window 本身不被清理就不會消失。這個問題很容易解決,只要在變數宣告前頭加上 var、let 或 const 關鍵字即可,這樣變數就會在函式執行完畢後離開作用域。

定時器也可能會悄悄地導致記憶體洩漏。

let name = 'Jake'; 
setInterval(() => { 
 console.log(name); 
}, 100);

只要定時器一直執行,回撥函式中引用的 name 就會一直佔用記憶體。

使用 JavaScript 閉包很容易在不知不覺間造成記憶體洩漏。

let outer = function() { 
 let name = 'Jake'; 
 return function() { 
 return name; 
 }; 
};

呼叫 outer()會導致分配給 name 的記憶體被洩漏。以上程式碼執行後建立了一個內部閉包,只要返回的函式存在就不能清理 name,因為閉包一直在引用著它。

4. 靜態分配與物件池

為了提升 JavaScript 效能,最後要考慮的一點往往就是壓榨瀏覽器了。此時,一個關鍵問題就是如何減少瀏覽器執行垃圾回收的次數。開發者無法直接控制什麼時候開始收集垃圾,但可以間接控制觸發垃圾回收的條件。理論上,如果能夠合理使用分配的記憶體,同時避免多餘的垃圾回收,那就可以保住因釋放記憶體而損失的效能。

瀏覽器決定何時執行垃圾回收程式的一個標準就是物件更替的速度。如果有很多物件被初始化,然後一下子又都超出了作用域,那麼瀏覽器就會採用更激進的方式排程垃圾回收程式執行。

function addVector(a, b) { 
 let resultant = new Vector(); 
 resultant.x = a.x + b.x; 
 resultant.y = a.y + b.y; 
 return resultant; 
}

呼叫這個函式時,會在堆上建立一個新物件,然後修改它,最後再把它返回給呼叫者。如果這個向量物件的生命週期很短,那麼它會很快失去所有對它的引用,成為可以被回收的值。假如這個向量加法函式頻繁被呼叫,那麼垃圾回收排程程式會發現這裡物件更替的速度很快,從而會更頻繁地安排垃圾回收。

該問題的解決方案是不要動態建立向量物件,比如可以修改上面的函式,讓它使用一個已有的向量物件:

function addVector(a, b, resultant) { 
 resultant.x = a.x + b.x; 
 resultant.y = a.y + b.y; 
 return resultant; 
}

在初始化的某一時刻,可以建立一個物件池,用來管理一組可回收的物件。應用程式可以向這個物件池請求一個物件、設定其屬性、使用它,然後在操作完成後再把它還給物件池。由於沒發生物件初始化,垃圾回收探測就不會發現有物件更替,因此垃圾回收程式就不會那麼頻繁地執行。

// vectorPool 是已有的物件池 
let v1 = vectorPool.allocate(); 
let v2 = vectorPool.allocate(); 
let v3 = vectorPool.allocate(); 
v1.x = 10; 
v1.y = 5; 
v2.x = -3; 
v2.y = -6; 
addVector(v1, v2, v3); 
console.log([v3.x, v3.y]); // [7, -1] 
vectorPool.free(v1); 
vectorPool.free(v2); 
vectorPool.free(v3); 
// 如果物件有屬性引用了其他物件
// 則這裡也需要把這些屬性設定為 null 
v1 = null; 
v2 = null; 
v3 = null;

如果物件池只按需分配向量(在物件不存在時建立新的,在物件存在時則複用存在的),那麼這個實現本質上是一種貪婪演算法,有單調增長但為靜態的記憶體。這個物件池必須使用某種結構維護所有物件,陣列是比較好的選擇。

相關文章