1. 程式人生 > 其它 >第5章 變數與物件

第5章 變數與物件

5.1 變數的宣告

變數的功能為持有某個值,或者用來表示某個物件。

如果一個變數在宣告之後沒有進行賦值,它的值就會是undefined。對同一個變數重複進行宣告是不會引起什麼問題的,原有的值也不會被清空。

var hzh1 = 7;
console.log("輸出hzh1的值:");
console.log(hzh1);  
var hzh1;          // 即使對同一個變數重複進行宣告
console.log("輸出變數hzh1的值:");
console.log(hzh1); // 它的值也不會發生改變
[Running] node "e:\HMV\JavaScript\tempCodeRunnerFile.js"
輸出hzh1的值:
7
輸出變數hzh1的值:
7

[Done] exited with code=0 in 0.638 seconds

如果變數 a 具有某個可以被轉換為 true 的值就直接使用,否則就把 7 賦值給a。

console.log("輸出變數hzh2的值:");
var hzh2 = hzh2 || 7;
console.log(hzh2);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出變數hzh2的值:
7

[Done] exited with code=0 in 0.188 seconds

在這段程式碼中,如果 hzh2 是一個已經被宣告且賦值的變數,則不會有任何效果;而如果沒有被宣告過,則會在宣告的同時對其進行賦值操作。

下面的程式碼雖然和上一段有些相像,卻是有問題的。如果變數 hzh5 沒有被宣告過,將會引起ReferenceError 異常。不過,也不能說它絕對就是錯的。這是因為,如果能確保在這條程式碼之前就已經對變數 hzh5 進行了宣告,這段程式碼的作用就變為了判定變數 hzh5 的值的真假,這樣就沒有問題了。

var hzh5 = hzh6 || 7;
console.log("輸出變數hzh5的值:");
console.log(hzh5);
[Running] node "e:\HMV\JavaScript\tempCodeRunnerFile.js"
e:\HMV\JavaScript\tempCodeRunnerFile.js:1
var hzh5 = hzh6 || 7;
           ^

ReferenceError: hzh6 is not defined
    at Object.<anonymous> (e:\HMV\JavaScript\tempCodeRunnerFile.js:1:12)
    at Module._compile (internal/modules/cjs/loader.js:999:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
    at Module.load (internal/modules/cjs/loader.js:863:32)
    at Function.Module._load (internal/modules/cjs/loader.js:708:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
    at internal/main/run_main_module.js:17:47

[Done] exited with code=1 in 0.289 seconds

5.2 變數與引用

物件的概念很好地說明了變數是一種擁有名稱的客體。物件本身是沒有名稱的,之所以使用變數,是為了通過某個名稱來稱呼這樣一種不具有名稱的物件。

var hzh = {} // 將物件賦值給變數hzh

變數又分為基本型別的變數(值型變數)與引用型別的變數。由於在 JavaScript 中,變數是不具有型別的,因此從語法標準上來看,兩者並沒有什麼不同。不過,在 JavaScript 中仍然有物件的引用這一概念。

所謂“引用”,可以認為是一種用於指示出物件的位置的標記。如果你熟悉 C 語言,把它理解為是和指標等價的東西也沒有問題。不過,引用不支援那些可以對指標進行的運算。引用這一語言功能只有指示位置資訊的作用。準確地說,物件的賦值其實是將物件的引用進行賦值。

為了更好地解釋引用這一概念,這裡對引用型別的變數和值型變數進行比較。將基本型別的值賦值給變數的話,變數將把這個值本身儲存起來。這時,可以將變數簡單地理解為一個裝了該值的箱子。變數本身裝有所賦的這個值,所以能夠將該值從變數中取出。如果在右側寫上一個變數,這一變數的值將被複制給賦值目標處(左側)的變數。

var a = 123;    // 將數值123賦值給變數a
var b = a;      // 將變數a的值(數值123)賦值給變數b

像下面這樣,對變數 b 進行自增操作後,變數 a 的值是不會發生改變的。圖 5.1 對這一執行方式作了說明

var a = 123;    // 將數值123賦值給變數a
var b = a;      // 將變數a的值(數值123)賦值給變數b
console.log("第一次輸出變數a的值:");
console.log(a); 
console.log("第一次輸出變數b的值:");
console.log(b); 
b++;
console.log("");
console.log("第二次輸出變數a的值:");
console.log(a); 
console.log("第二次輸出變數b的值:");
console.log(b); 
[Running] node "e:\HMV\JavaScript\JavaScript.js"
第一次輸出變數a的值:
123
第一次輸出變數b的值:
123

第二次輸出變數a的值:
123
第二次輸出變數b的值:
124

[Done] exited with code=0 in 0.316 seconds

另一方面,如果將一個物件賦值給變數,其實是把這個物件的引用賦值給了該變數。物件本身是無法賦值給一個變數的。如果在右側寫上了這樣的變數,該變數所表示的引用將被複制給賦值目標處(左側)的變數。物件本身並不會被複制。

var a = { x:1, y:2 }; // 將物件的引用賦值給變數a
var b = a;            // 將變數a的值(物件的引用)賦值給變數b

圖5.1 值型變數的執行方式

圖5.2 引用型別的變數的執行方式

如果像下面這樣,改變了變數 b 所引用的物件,那麼這一改變也會體現在變數 a 之中,這是因為這兩個變數通過引用而指向了同一個物件。圖 5.2 對這種執行方式進行了說明:

var a = { x:1, y:2 }; // 將物件的引用賦值給變數a
var b = a;            // 將變數a的值(物件的引用)賦值給變數b
console.log("輸出變數a的值:");
console.log(a);
console.log("輸出變數b的值:");
console.log(b);
console.log("");
b.x++;            // 改變變數b所引用的物件
console.log("輸出變數b的x屬性:");
console.log(b.x); // 變數b所引用的物件
console.log("輸出變數a的x屬性:");
console.log(a.x); // 可以發現變數a所引用的物件也被改變
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出變數a的值:
{ x: 1, y: 2 }
輸出變數b的值:
{ x: 1, y: 2 }

輸出變數b的x屬性:
2
輸出變數a的x屬性:
2

[Done] exited with code=0 in 0.323 seconds

在比較了這兩種賦值後,你可能會錯誤地認為對於值型變數而言,變數值的改變對於其他的變數來說是不可見的,而對於引用型別的變數,這一改變則是可見的。這是一種不正確的理解。對於引用型別的變數,整個過程中發生改變的其實是其引用的物件,而不是該變數的值。引用型別的變數具有的值就是引用(值),這個值將在賦值的時候被複制。請看下面的程式碼以及圖 5.3。

var a = { x:1, y:2 };
var b = a;        // 變數a與變數b引用的是同一個物件
a = { x:2, y:2 }; // 改變了變數a的值(使其引用了另一個物件)
console.log("輸出變數b的x屬性:");
console.log(b.x); // 變數b所引用的物件沒有發生改變
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出變數b的x屬性:
1

[Done] exited with code=0 in 0.267 seconds

圖5.3 引用型別的變數的執行方式

在 JavaScript 中,賦值運算總是會把右側的值複製給左側。對於引用型別的變數來說也是一樣,會將引用(用於指示物件的一種值)賦值給左側。函式呼叫過程中的引數也是這樣的執行方式。

5.2.1 函式的引數(值的傳遞)

程式碼清單 5.1 是一個典型的例子,hzh_no_swap 函式的程式碼試圖交換所傳遞的兩個引數 hzh_a 與 hzh_b 的值。然而,即使呼叫了這個函式,也不會對實參 hzh1 和 zero 的值造成任何影響。可以認為,在呼叫函式時執行了相當於 hzh_a=hzh1 以及 hzh_b=hzh2 的兩次賦值操作。雖然變數 hzh1 與 hzh2 是引用型別的變數,但實際上也只是對其引用進行了複製操作。因此,並無法實現對 hzh1 和 hzh2 所引用的物件的交換。

程式碼清單 5.1 一個無法交換其兩引數的值的函式
var hzh1 = 1;
var hzh2 = 2;

console.log("交換之前先列印hzh1和hzh2的值:");
console.log("hzh1 = " + hzh1);
console.log("hzh2 = " + hzh2);
console.log("");

function hzh_no_swap(hzh_a, hzh_b) {
    var hzh_tmp = hzh_a;
    hzh_a = hzh_b;
    hzh_b = hzh_tmp;
    console.log("hzh1 = " + hzh_a);
    console.log("hzh2 = " + hzh_b)
}

console.log("呼叫hzh_no_swap()函式之後:");
hzh_no_swap(hzh1, hzh2);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
交換之前先列印hzh1和hzh2的值:
hzh1 = 1
hzh2 = 2

呼叫hzh_no_swap()函式之後:
hzh1 = 2
hzh2 = 1

[Done] exited with code=0 in 0.739 seconds

【評】這裡的實驗結果和書上說的不一樣。

在 JavaScript 中,應該把賦值運算看作將右側的值複製給左側的一種操作。而這一原則,對於呼叫函式過程中,引數對引用進行復制的情況也是成立的。這樣的規則被稱為按值傳遞(call-by-value)。

在支援對引用或指標進行運算的語言中,可以以程式碼清單 5.1 中函式的形式,來對實參的值進行交換。JavaScript 不支援這樣的功能,所以必須通過其他方式來實現對兩個引數值的交換。可以通過傳遞一個數組並交換其中的元素,或者通過傳遞一個物件並交換其屬性值之類的形式來實現。程式碼清單 5.2 使用了 JavaScript 自帶的增強功能,將交換結果設為函式的返回值,這可以說是一種最為簡單的實現程式碼。

程式碼清單5.2 一個能夠交換兩個引數的值的函式(JavaScript 自帶的增強功能)
function hzh_swap(hzh_a, hzh_b) {
    return [hzh_b, hzh_a];
}
var hzh1 = 1;
var hzh2 = 2;
console.log("交換之前輸出hzh1和hzh2的值:");
console.log("hzh1 = " + hzh1);
console.log("hzh2 = " + hzh2);
console.log("");
[hzh1, hzh2] = hzh_swap(hzh1, hzh2);
console.log("輸出交換後的值:");
console.log("hzh1 = " + hzh1);
console.log("hzh2 = " + hzh2);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
交換之前輸出hzh1和hzh2的值:
hzh1 = 1
hzh2 = 2

輸出交換後的值:
hzh1 = 2
hzh2 = 1

[Done] exited with code=0 in 0.36 seconds

5.2.2 字串與引用

即使字串型在內部是以引用型別的方式實現的,從語言規則上來看它仍然是一種值的型別。不過以字串物件(String 類的物件例項)賦值的變數,從語言規則上來看則是一種引用型別。

5.2.3 物件與引用相關的術語總結

在將物件的引用賦值給變數 a 時,這個物件將被稱作“物件a”。這種稱法,會有一種(本應不具有名字的)物件其實具有 a 這樣一個名稱的感覺。顯然這樣的感覺是不正確的,因為這個物件即使在沒有變數 a 的情況下,也能夠獨立存在。這樣說的證據是,如果將變數 a 消去,或是將變數 a 指向其他的物件,原來的這個物件仍然會存在。話雖如此,每次都很準確地使用“變數 a 所引用的物件”這樣的說法過於冗長,所以方便起見,還是稱其為物件 a。事實上沒有被任何變數引用的物件是會被記憶體自動回收的,不過這已經是另一個話題了。

此外,在上下文不會發生誤會的情況下,可以用“物件”這一術語來指代“物件的引用”。物件是一個實體,而引用是用於指示這一實體的位置資訊,兩者本應是不同的。不過根據上下文可以知道,“將物件賦值給變數 a”的說法很顯然是指將物件的引用賦值,所以方便起見可以直接這麼說。

5.3 變數與屬性

其實,在 JavaScript 中變數就是屬性,兩者何止是相似,本身就是同一個概念。

根據作用域的不同,變數可以被分為全域性變數和區域性變數(包括引數變數)。全域性變數是在最外層程式碼中宣告的變數。所謂最外層程式碼,指的是寫在函式之外的程式碼。區域性變數則是在函式內部宣告的變數。全域性變數和區域性變數兩者的本質都是屬性。

全域性變數(以及全域性函式名)是全域性物件的屬性。全域性物件是從程式執行一開始就存在的物件

var hzh1 = '黃子涵';         // 對全域性變數hzh1進行賦值
console.log("訪問全域性變數hzh1:");
console.log(this.hzh1);      // 可以通過this.hzh1進行訪問
console.log("");
function hzh2() {};          // 全域性函式。函式內容在此沒有影響,所以留空
console.log("訪問全域性函式hzh2:");
console.log('hzh2' in this); // 全域性物件的屬性hzh2
[Running] node "e:\HMV\JavaScript\JavaScript.js"
訪問全域性變數hzh1:
undefined

訪問全域性函式hzh2:
false

[Done] exited with code=0 in 0.217 seconds

【評】這裡的結果和書上的不一樣,暫時不知道為什麼,要做個標記。

最外層程式碼中的 this 引用是對全域性物件的引用。因此上面程式碼中的 this.x,指的就是全域性物件的屬性 x,這也就是全域性變數x。

像下面這樣,在最外層程式碼中將 this 引用的值賦值給全域性變數 global 的話,這個變數就不但是全域性物件的屬性,同時也是一個對全域性物件的引用,從而形成了一種自己引用自己的關係,將 this 引用賦值給全域性變數 global。

var global = this;
// 全域性物件的屬性 hzh3
console.log("訪問全域性變數global:");
console.log('global' in this);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
訪問全域性變數global:
false

[Done] exited with code=0 in 0.196 seconds

【評】這裡的結果和書上的不一樣,暫時不知道為什麼,要做個標記。

圖 5.4 屬性 global 具有一種自己引用了自己的關係

這種關係看起來有些混亂,在 JavaScript 中卻很常見。如果是客戶端 JavaScript,將會在一開始就提供一個引用了全域性物件的全域性變數 window。全域性物件與變數 window 的關係,和之前例子中的變數 global 是相同的。

在函式內宣告的變數是區域性變數。作為函式引數的引數變數也是一種區域性變數。區域性變數(以及引數變數)是在呼叫函式時被隱式生成的物件的屬性。被隱式生成的物件稱為 Call 物件。區域性變數通常在從函式被呼叫起至函式執行結束為止的範圍內存在。

之所以說是“通常”,是因為有些區域性變數在函式執行結束後仍然可以被訪問。

5.4 變數的查詢

從程式碼的角度來看,(作為右值)寫出變數名以對該值進行獲取的操作,或者寫在賦值表示式左側以作為賦值物件進行查詢的操作,都被稱為對變數名稱的查詢。

因此,在最外層程式碼中對變數名進行查詢,就是查詢全域性物件的屬性。這其實只是換了一種說法,在最外層程式碼中能夠使用的變數與函式,只有全域性變數與全域性函式而已。至於對函式內的變數名的查詢,是按照先查詢 Call 物件的屬性,再查詢全域性物件的屬性來進行的。這相當於在函式內可以同時使用區域性變數(以及引數變數)與全域性變數。對於巢狀函式的情況,則會由內向外依次查詢函式的 Call 物件的屬性,並在最後查詢全域性物件的屬性。

這裡使用了“查詢變數名”這一說法,較為抽象,而能更直觀體現其意義的詞則是變數的作用域。

5.5 對變數是否存在的校驗

如果試圖讀取沒有被宣告的變數,則會引起 ReferenceError 異常,這是一種錯誤,必須對程式碼進行修正。避免 ReferenceError 異常的一種方法:

var hzh1 = 1;
var hzh1 = hzh1 || 7;
var hzh2;
var hzh2 = hzh2 || 2;
console.log("分別輸出hzh1和hzh2的值:");
console.log("hzh1 = " + hzh1);
console.log("hzh2 = " + hzh2);var hzh1 = 1;
var hzh1 = hzh1 || 7;
console.log("輸出hzh1的值:");
console.log("hzh1 = " + hzh1);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
分別輸出hzh1和hzh2的值:
hzh1 = 1
hzh2 = 2

[Done] exited with code=0 in 0.212 seconds

該程式碼利用了對已經宣告的變數再次宣告不會產生副作用的特性。像下面這樣,分成兩行並使用不同的變數,作用是一樣的。

// 也可以分開兩行
var hzh3;
var hzh4 = hzh3 || 4;
console.log("輸出hzh4的值:");
console.log("hzh4 = " + hzh4);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh4的值:
hzh4 = 4

[Done] exited with code=0 in 0.278 seconds

準確地說,這一程式碼並沒有判斷變數 hzh3 是否已經被宣告。例如在該例中,如果變數 hzh3 的值是 0 或者是 "(空字元),它在被轉換為布林型之後值就會為假,這時,程式碼中的變數 hzh4 則會被賦值為 4。

// 也可以分開兩行
var hzh3 = 0;
var hzh4 = hzh3 || 4;
console.log("輸出hzh4的值:");
console.log("hzh4 = " + hzh4);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh4的值:
hzh4 = 4

[Done] exited with code=0 in 0.197 seconds

// 也可以分開兩行
var hzh3 = "";
var hzh4 = hzh3 || 4;
console.log("輸出hzh4的值:");
console.log("hzh4 = " + hzh4);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh4的值:
hzh4 = 4

[Done] exited with code=0 in 0.172 seconds

接下來的程式碼可能有些冗長,它直接判斷變數 hzh5 的值是否是 undefined 值,由此判斷出變數 a 是否已宣告,或者是否在聲明後值為 undefined。

var hzh5;
var hzh6 = (hzh5 !== undefined) ? hzh5 : 6;
console.log("輸出hzh6的值:");
console.log("hzh6 = " + hzh6);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh6的值:
hzh6 = 6

[Done] exited with code=0 in 0.193 seconds

var hzh5 = 5;
var hzh6 = (hzh5 !== undefined) ? hzh5 : 6;
console.log("輸出hzh6的值:");
console.log("hzh6 = " + hzh6);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh6的值:
hzh6 = 5

[Done] exited with code=0 in 0.174 seconds

雖說對同一變數再次宣告不會有副作用,但每次都要寫一遍 var a 也有些麻煩。為了避免這一問題,可以通過 typeof 運算來判斷是否為 undefined 值。

請看下面的例子。這個例子利用了在 JavaScript(ECMAScript) 中沒有塊級作用域的特性。

var hzh7 = 7;
if(typeof hzh7 !== 'undefined') {
    var hzh8 = hzh7;
}else {
    var hzh8 = 8;
}
console.log("輸出hzh8的值:");
console.log("hzh8 = " + hzh8);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh8的值:
hzh8 = 7

[Done] exited with code=0 in 0.282 seconds

在以上這些程式碼中,無法區分變數 hzh7 是還沒宣告,還是已經宣告但值為 undefined。先不論是否有必要對此加以區分,最後再介紹一種能夠區分這兩種情況的方法。

在讀取未宣告變數的值時會引起 ReferenceRrror 異常,所以不可以讀取這一變數的值,但是可以僅對這一名稱是否存在進行確認。為此需要使用 in 運算。

可以在最外層程式碼中,像下面這樣來判斷在全域性物件中是否存在屬性 a,也就是說,可以用來檢測全域性變數 a 是否存在。

var hzh9 = 9;
if('hzh9' in this) {
    var hzh10 = hzh9;
}else {
    var hzh10 = 10;
} 
console.log("輸出hzh10的值:");
console.log("hzh10 = " + hzh10);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh10的值:
hzh10 = 10

[Done] exited with code=0 in 0.63 seconds

【評】這個實驗結果和書上說的不一樣,標記一下。


if('hzh9' in this) {
    var hzh10 = hzh9;
}else {
    var hzh10 = 10;
} 
console.log("輸出hzh10的值:");
console.log("hzh10 = " + hzh10);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh10的值:
hzh10 = 10

[Done] exited with code=0 in 0.192 seconds

對屬性是否存在的檢驗

變數與屬性實質上是一樣的。不過,如果變數或屬性本身不存在,處理方式則會有所不同。請看下面的例
子:

console.log(hzh1); // 訪問未宣告的變數會導致 ReferenceError 異常
[Running] node "e:\HMV\JavaScript\JavaScript.js"
e:\HMV\JavaScript\JavaScript.js:1
console.log(hzh1); // 訪問未宣告的變數會導致 ReferenceError 異常
            ^

ReferenceError: hzh1 is not defined
    at Object.<anonymous> (e:\HMV\JavaScript\JavaScript.js:1:13)
    at Module._compile (internal/modules/cjs/loader.js:999:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
    at Module.load (internal/modules/cjs/loader.js:863:32)
    at Function.Module._load (internal/modules/cjs/loader.js:708:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
    at internal/main/run_main_module.js:17:47

[Done] exited with code=1 in 0.199 seconds

console.log(this.hzh); // 訪問不存在的屬性並不會引起錯誤
[Running] node "e:\HMV\JavaScript\JavaScript.js"
undefined

[Done] exited with code=0 in 0.181 seconds

var hzh = {};
console.log(hzh.x); // 讀取不存在的屬性僅會返回undefined,並不會引起錯誤
[Running] node "e:\HMV\JavaScript\JavaScript.js"
undefined

[Done] exited with code=0 in 0.199 seconds

讀取不存在的屬性僅會返回 undefined 值,而不會引起錯誤。但是如果對 undefined 值進行屬性訪問的話,則會像下面這樣產生 TpyeError 異常。

console.log(hzh.x.y); 
[Running] node "e:\HMV\JavaScript\JavaScript.js"
e:\HMV\JavaScript\JavaScript.js:1
console.log(hzh.x.y); 
            ^

ReferenceError: hzh is not defined
    at Object.<anonymous> (e:\HMV\JavaScript\JavaScript.js:1:13)
    at Module._compile (internal/modules/cjs/loader.js:999:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
    at Module.load (internal/modules/cjs/loader.js:863:32)
    at Function.Module._load (internal/modules/cjs/loader.js:708:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
    at internal/main/run_main_module.js:17:47

[Done] exited with code=1 in 0.171 seconds

為了避免產生 TypeError 異常,一般會使用下面的方法。

obj.x && Object.x.y

但如果是為了檢測物件內是否存在某一屬性,還請使用 in 運算子。

5.6 物件的定義

5.6.1 抽象資料型別與面向物件

如果從形式上來定義 JavaScript 的物件,它就是一種屬性的集合。所謂屬性,即名稱與值的配對。屬性值可以被指定為任意型別的值,包括陣列或其他的物件,都沒有問題。

對於物件有一種很常見的定義,即它是一種資料和操作(子程式)的結合。這一定義可以理解為,將面向物件看作一種抽象資料型別的表現形式。

面向物件的 3 要素,即封裝、繼承與多型吧。如果這樣理解的話,面向物件程式設計的焦點就在於物件的執行方式,並將執行方式的共性定義為一種型別。

在這一語境中,常常使用類這一術語來表達型別的含義。也有些語言會把執行方式與其實現分開,將執行方式定義為介面。介面的例項(實體)被稱為物件,可以對其進行指定的操作。

5.6.2 例項間的協作關係與面向物件

另一種面向物件程式設計的觀點認為,與其考慮執行方式之間的共性,更應該關注例項之間的協作關係,即所謂的物件是進行訊息收發的實體。物件收到訊息之後將會對其作出響應。從實現的角度來看,訊息的實質就是通過對方法(函式)進行呼叫,將對訊息的響應分派給方法來處理。從本質上來說,面向物件這一術語只不過是一種在高於內部實現的語境中所使用的、較為抽象的概念而已。打個比方,可以把訊息當作一種通訊協議,把物件當作一個 Web 應用。

5.6.3 JavaScript 的物件

JavaScript 語言所支援的面向物件與後者的理解更為相近。在JavaScript 中,一切都是物件。物件之間的協作(訊息收發)通過屬性訪問(以及方法的呼叫)來實現。而物件之間的共性,則是通過繼承同一個物件的性質的方式來實現。JavaScript通過基於原型的形式來實現繼承。

一旦要對面向物件的概念進行說明,事情就會變得很抽象。如果只考慮具體該如何使用 JavaScript 的物件,就不必考慮那麼多複雜的問題。只需要考慮最核心的內容,將其理解為在程式中可以進行操作的資料的一種擴充即可。此外,還可以通過函式方法的形式來表示對資料進行操作的子程式。這種想法的核心就是將物件的功能進行拆分並分別進行處理。分割本身也只不過是一種手段。畢竟,面向物件方法的最終目的是降低程式的複雜程度。

5.7 物件的生成

5.7.1 物件字面量

在 JavaScript 程式中,如果要使用物件,就需要首先生成該物件。其中一種方法是通過物件字面量來實現物件的生成。

下面列舉了一些可以使用物件字面量的情況。請注意這裡並沒有作嚴格的分類。

  • 作為 singleton 模式的用法。
  • 作為多值資料的用法(函式的引數或返回值等)。
  • 用於替代建構函式來生成物件。

作為 singleton 模式的用法

在設計模式中有一種 singleton 模式。在基於類的開發過程中,這種模式可以將類的例項數限定為 1 個。

JavaScript 可以實現基於類的程式設計,不過通常會作如下約定:若只需一個物件例項,則不會去設計一個類,而是會使用物件字面量。對類(建構函式)進行設計以實現 singleton 模式的想法完全是一種基於類的思考方式,在 JavaScript 中我們只需直接使用物件字面量即可。

作為多值資料的用法

可以通過物件字面量來實現多值資料。這種用法與作為關聯陣列的物件是相通的。例如,在程式碼清單 5.3 中有一個需要三個引數的函式,對引數是否為數值型的判斷已被省略。

程式碼清單 5.3 接受多個引數的函式
function hzh(x, y, z) {
    return Math.sqrt(x * x + y * y + z * z);
}

console.log("呼叫hzh函式:");
console.log(hzh(3, 2, 2));
[Running] node "e:\HMV\JavaScript\JavaScript.js"
呼叫hzh函式:
4.123105625617661

[Done] exited with code=0 in 2.023 seconds
程式碼清單 5.4 接受物件的函式
function hzh(pos) {
    return Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z);
}

console.log("呼叫hzh函式:");
console.log(hzh({x:3, y:2, z:2}));
[Running] node "e:\HMV\JavaScript\JavaScript.js"
呼叫hzh函式:
4.123105625617661

[Done] exited with code=0 in 0.233 seconds

很難說哪一種方法更好,兩者各有千秋。引數的數量為 3 個的情況有些微妙,或許認為程式碼清單 5.3 中的方法更為簡單的讀者會更多一些。

不過,當引數的數量越來越多時,程式碼清單 5.4 中的方法的優勢就會體現出來。如果用程式碼清單 5.3 中的方法,引數數量增加之後,弄錯實參的排列順序的可能性也會上升,而 JavaScript 這樣的動態程式設計語言對引數型別的檢測很弱。如果像程式碼清單 5.4 這樣使用物件作為引數,實參以物件字面量的方式傳遞,就不需要考慮排列的順序,只需要使用名稱即可。在其他一些程式設計語言中,支援對引數進行命名的功能,這種功能也具有類似的優點。

在 JavaScript 中,有一種模擬出預設引數的效果的習慣用法(程式碼清單 5.5)。這種方法需要與使用物件作為引數的方式結合使用才能發揮效果。所謂預設引數,是指在呼叫函式時如果沒有實參,或是傳遞了null,則會傳遞一個指定的值。JavaScript 並不支援預設引數這一功能,但可以通過程式碼清單 5.5 這樣的形式來實現。

通過 || 運算可以將引數作為布林型來判斷真假,其中利用了若呼叫函式時沒有實參引數的值則為undefined 這一特性。通常來說,在函式內對引數進行賦值不是一種好習慣(不僅是 JavaScript,所有的程式語言都是如此),不過下面的做法被當作了一種習慣用法。

程式碼清單 5.5 模擬了預設引數的效果的習慣用法
function hzh(pos) {
    pos = pos || { x:0, y:0, z:0 }; // 如果沒有收到引數pos的話,則使用預設值
    return Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z);
}

console.log("呼叫hzh函式:");
console.log(hzh({x:3, y:2, z:2}));
function hzh(pos) {
    pos = pos || { x:0, y:0, z:0 }; // 如果沒有收到引數pos的話,則使用預設值
    return Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z);
}

console.log("呼叫hzh函式:");
console.log(hzh({x:3, y:2, z:2}));
程式碼清單 5.6 返回多值資料的函式
function hzh(pos) {
    // 省略
    return { x:3, y:2, z:2};
}

var pos = hzh();

console.log("輸出返回值:");
console.log(pos.x, pos.y, pos.z);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
呼叫hzh函式:
3 2 2

[Done] exited with code=0 in 0.191 seconds

用於替代建構函式來生成物件

最後我們介紹一下通過物件字面量來實現一個用於替代建構函式的函式的用法。該函式的功能是生成一個物件,所以需要以物件字面量作為返回值,從形式上來說,它和返回多值資料的函式是相同的。根據狹義的面向物件的定義,多值資料與物件的區別僅在於是否具有特定的執行方式。

和程式碼清單 5.6 一樣,直接在程式碼中書寫數值沒有什麼意義,這裡僅僅是作為一個例子用於說明而已(程式碼清單 5.7)。

程式碼清單 5.7 用於生成物件的函式(還有改進的餘地)
function hzh() {
    return { x:3, y:2, z:2,
        huangzihan: function() {
            return Math.sqrt(this.x * this.x + 
                this.y * this.y + this.z * this.z);
        }
    };
}

var obj = hzh();
console.log("呼叫obj物件的方法:");
console.log(obj.huangzihan());
[Running] node "e:\HMV\JavaScript\JavaScript.js"
呼叫obj物件的方法:
4.123105625617661

[Done] exited with code=0 in 0.26 seconds

使用返回物件字面量的函式,與通過 new 表示式來呼叫建構函式,是兩種不同風格的生成物件的手段。

專欄

JavaScript中用於函式返回多個值的增強功能

通過 JavaScript 的增強功能,可以像下面這樣,通過陣列實現將返回值逐個返回的功能。

function hzh() {
    return [1,9,1,2,4,8,9,6,0,1,7];
}

var a,b,c,d,e,f,g,h,i,j,k;
[a,b,c,d,e,f,g,h,i,j,k] = hzh();
console.log("輸出陣列返回值:");
console.log(a,b,c,d,e,f,g,h,i,j,k);
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出陣列返回值:
1 9 1 2 4 8 9 6 0 1 7

[Done] exited with code=0 in 0.284 seconds

5.7.2 建構函式與 new 表示式

建構函式是用於生成物件的函式。之後會再詳述函式與建構函式的區別,這裡首先介紹一個具體例子(程式碼清單 5.8)。

可以直觀地將程式碼清單 5.8 理解為 MyClass 類的類定義。在呼叫時通過 new 來生成一個物件例項。

程式碼清單 5.8 建構函式的例子
function MyClass(x, y) {
    this.x = x;
    this.y = y;
}

// 對建構函式的呼叫
var obj = new MyClass(3, 2);
console.log("obj.x = " + obj.x); 
console.log("obj.y = " + obj.y); 
[Running] node "e:\HMV\JavaScript\JavaScript.js"
obj.x = 3
obj.y = 2

[Done] exited with code=0 in 0.173 seconds

從形式上來看,建構函式的呼叫方式如下。

  • 建構函式本身和普通的函式宣告形式相同。
  • 建構函式通過 new 表示式來呼叫。
  • 呼叫建構函式的 new 表示式的值是(被新生成的)物件的引用。
  • 通過 new 表示式呼叫的建構函式內的 this 引用引用了(被新生成的)物件。
new 表示式的操作

在此說明一下 new 表示式在求值時的操作。首先生成一個不具有特別的操作物件。之後通過 new 表示式呼叫指定的函式(即建構函式)。建構函式內的 this 引用引用了新生成的物件。執行完建構函式後,它將返回物件的引用作為 new 表示式的值。

圖 5.5 建構函式的操作圖
建構函式呼叫

建構函式總是由 new 表示式呼叫。為了與通常的函式呼叫相區別,將使用 new 表示式的呼叫,稱為建構函式呼叫。建構函式與通常的函式的區別在於呼叫方式不同。任何函式都可以通過 new 表示式呼叫,因此,所有的函式都可以作為建構函式。也就是說,如果一個函式通過函式呼叫的方式使用,則是一個函式;如果通過建構函式呼叫的方式使用,則是一個建構函式。在實際開發中,通常會分別設計用於函式呼叫的函式與用於建構函式呼叫的函式,所以方便起見,將為了建構函式呼叫而設計的函式稱為建構函式。建構函式的名稱一般以大寫字母開始(如 MyClass)。

建構函式在最後會隱式地執行 return this 操作。那麼,如果在建構函式中顯式地寫有 return 語句,會發生什麼情況呢?結果可能不容易理解。通過 return 返回一個物件之後,它將成為呼叫建構函式的 new 表示式的值。也就是說,使用 new 表示式後返回的,可能是所生成的物件以外的其他物件。然而,如果呼叫的建構函式中的 return 返回的是基本型別的值,則會無視這一返回值,仍然隱式地執行 return this 操作。

這種操作常常會造成混亂,我們建議不要再在建構函式內使用 return 語句。

5.7.3 建構函式與類的定義

通過 new 表示式呼叫普通的函式並生成一個物件,是一種不容易理解的語言特性。不過,這已經滿足了類定義所必需的功能。

程式碼清單 5.9 是一個實現了定義一個具有域與方法的類的建構函式的例子。

程式碼清單 5.9 模擬類定義(尚有改進的餘地)
// 相當於類的定義
function Huangzihan(x, y) {
    // 相當於域
    this.x = x;
    this.y = y;
    // 相當於方法
    this.show = function() {
        console.log(this.x, this.y);
    }
}

// 對建構函式的呼叫(例項生成)
var hzh = new Huangzihan(3, 2);
console.log("訪問obj物件的show方法:");
console.log(hzh.show());
[Running] node "e:\HMV\JavaScript\JavaScript.js"
訪問obj物件的show方法:
3 2
undefined

[Done] exited with code=0 in 0.329 seconds

只要按照程式碼清單 5.9,就能夠從形式上實現 JavaScript 的類定義。不過,程式碼清單 5.9 作為類的定義還存在以下兩個問題。前者可以通過原型繼承來解決,而後者可以通過閉包來解決

  • 由於所有的例項都是複製了同一個方法所定義的實體,所以效率(記憶體效率與執行效率)低下。
  • 無法對屬性值進行訪問控制(private 或 public 等)。

5.8 屬性的訪問

生成的物件可以通過屬性來訪問。對於物件的引用可以使用點運算子(.)或中括號運算子([])來訪問其屬性。需要注意的是,在點運算子之後書寫的屬性名會被認為是識別符號,而中括號運算子內的則是被轉為字串值的式子。請看下面的例子:

var hzh1 = { x:3, y:4 };
console.log("輸出hzh物件的x屬性:");
console.log("hzh1.x = " + hzh1.x);     // 屬性x
console.log("hzh1[x] = " + hzh1['x']); // 屬性x
var hzh2 = 'x';
console.log("hzh1[hzh2] = " + hzh1[hzh2]); // 屬性x(而非屬性key)
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh物件的x屬性:
hzh1.x = 3
hzh1[x] = 3
hzh1[hzh2] = 3

[Done] exited with code=0 in 0.181 seconds

不過,對於物件字面量的屬性名來說,下面這樣的識別符號或字元字面量形式的表示,都沒問題。請注意不要與上面的規則混淆。

var hzh1 = 'x';
var hzh2 = { hzh1:3 }; // 屬性hzh1(而非屬性x)
var hzh2 = { 'x':3 };  // 屬性x

這裡需要多提一句,屬性訪問的運算物件並不是變數,而是物件的引用。這一點,可以從以下直接 對物件字面量進行運算的示例中得到確認:

console.log("確認屬性訪問的運算物件是物件的引用:");
console.log({x:3, y:4}.x);    // 屬性x
console.log({x:3, y:4}['x']); // 屬性x
[Running] node "e:\HMV\JavaScript\JavaScript.js"
確認屬性訪問的運算物件是物件的引用:
3
3

[Done] exited with code=0 in 0.183 seconds

現實中幾乎不會對物件字面量進行運算。不過當這種運算物件不是一個變數時,倒是常常會以方法鏈之類的形式出現。

5.8.1 屬性值的更新

在賦值表示式的左側書寫屬性訪問表示式能夠實現對屬性值的改寫。如果指定的是不存在的屬性名,則會新增該屬性。下面將不再使用右側或左側的說法,而改用屬性讀取,以及屬性寫入這樣的術語。

可以使用 delete 運算表示式來刪除屬性。這裡需要注意的是,很難區分不存在的屬性與屬性值為undefined 值的屬性。

5.8.2 點運算子與中括號運算子在使用上的區別

有時選擇用於訪問物件屬性的這兩個運算子只憑偏好。點運算子的表述較為簡潔,所以通常都會選用點運算子。不過,中括號運算子的通用性更高。

能使用點運算子的情況一定也可以使用中括號運算子,反之未必成立。但也無需因此全都使用中括號運算子。通常預設使用表述簡潔的點運算子,只有在不得不使用中括號運算子的情況下,才使用中括號運算子。

只能使用中括號運算子的情況分為以下幾種。

  • 使用了不能作為識別符號的屬性名的情況。
  • 將變數的值作為屬性名使用的情況。
  • 將表示式的求值結果作為屬性名使用的情況。

包含數值或橫槓(-)的字串不能作為識別符號使用。無法作為識別符號使用的字串,不能用於點運算子的屬性名,且對於保留字,也有這樣的限制。不過,原本就不應該將保留字作為屬性名使用,所以這裡不再贅述。

像下面這樣,將含有橫槓的屬性名用於點運算子會引起錯誤。

// 含有橫槓的屬性名
var hzh = { 'huang-zihan':5 };
console.log(hzh.huang-zihan); // 將解釋為hzh.huang減去zihan,從而造成錯誤
[Running] node "e:\HMV\JavaScript\JavaScript.js"
e:\HMV\JavaScript\JavaScript.js:3
console.log(hzh.huang-zihan); // 將解釋為hzh.huang減去zihan,從而造成錯誤
                      ^

ReferenceError: zihan is not defined
    at Object.<anonymous> (e:\HMV\JavaScript\JavaScript.js:3:23)
    at Module._compile (internal/modules/cjs/loader.js:999:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1027:10)
    at Module.load (internal/modules/cjs/loader.js:863:32)
    at Function.Module._load (internal/modules/cjs/loader.js:708:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:60:12)
    at internal/main/run_main_module.js:17:47

[Done] exited with code=1 in 0.413 seconds

無法作為識別符號被使用的字串,仍可以在中括號運算子中使用。請看下面的例子,其中以字串值指定了一個屬性名。

// 含有橫槓的屬性名
var hzh = { 'huang-zihan':5 };
console.log(hzh['huang-zihan']); // 使用[]運算以字串值指定了一個屬性名。可以正常執行
[Running] node "e:\HMV\JavaScript\JavaScript.js"
5

[Done] exited with code=0 in 0.773 seconds

數值也是如此。陣列物件的屬性名都是數值。由於點運算子無法使用數值,因此只能使用中括號運算子。而且很多程式設計語言都是通過中括號運算子來訪問陣列的元素,所以可讀性也隨之提高。

下面的例子仍使用了之前的程式碼,用於展示將被變數的值作為屬性名使用的情況。

var hzh1 = { x:3, y:4 };
console.log("輸出hzh物件的x屬性:");
console.log("hzh1.x = " + hzh1.x);     // 屬性x
console.log("hzh1[x] = " + hzh1['x']); // 屬性x
var hzh2 = 'x';
console.log("hzh1[hzh2] = " + hzh1[hzh2]); // 屬性x(而非屬性key)
[Running] node "e:\HMV\JavaScript\JavaScript.js"
輸出hzh物件的x屬性:
hzh1.x = 3
hzh1[x] = 3
hzh1[hzh2] = 3

[Done] exited with code=0 in 0.181 seconds

如果表示式的求值結果是字串,可以直接用中括號運算子通過該表示式指定屬性名。下面引用出自《JavaScript 語言精粹》一書的一個具有一定技巧性的例子。

這段程式碼會根據數值的符號而選擇呼叫不同的方法。方法呼叫一詞會讓人覺得要使用的是點運算子,不過事實上中括號運算子也能被呼叫。

// 引用自《JavaScript語言精粹》一書
// 僅讀取數值的整數部分的處理
Math[this < 0 ? 'ceiling' : 'floor'](this));

5.8.3 屬性的列舉

可以通過 for in 語句對屬性名進行列舉(程式碼清單 5.10)。通過在 for in 語句中使用中括號運算子,可以間接地實現對屬性值的列舉。使用 for each in 語句可以直接列舉屬性值。

程式碼清單 5.10 屬性的列舉
var hzh1 = { x:'黃子涵是帥哥!', y:'黃子涵是靚仔!', z:'黃子涵真聰明!' };
for(var key in hzh1) {
    console.log('key = ', key);       // 屬性名的列舉
    console.log('val = ', hzh1[key]); // 屬性值的列舉
}
[Running] node "e:\HMV\JavaScript\JavaScript.js"
key =  x
val =  黃子涵是帥哥!
key =  y
val =  黃子涵是靚仔!
key =  z
val =  黃子涵真聰明!

[Done] exited with code=0 in 0.262 seconds

屬性可以分為直接屬性以及繼承於原型的屬性。for in 語句和 for each in 語句都會列舉繼承於原型的屬性。

5.9 作為關聯陣列的物件

5.9.1 關聯陣列

5.9.2 作為關聯陣列的物件的注意點

5.10 屬性的屬性

5.11 垃圾回收

5.12 不可變物件

5.12.1 不可變物件的定義

5.12.2 不可變物件的作用

5.12.3 實現不可變物件的方式

5.13 方法

5.14 引用

5.14.1 this 引用的規則

5.14.2 this 引用的注意點

5.15 apply與call

5.16 原型繼承

5.16.1 原型鏈

5.16.2 原型鏈的具體示例

5.16.3 原型繼承與類

5.16.4 對於原型鏈的常見誤解以及 proto 屬性

5.16.5 原型物件

5.16.6 ECMAScript 第 5 版與原型物件

5.17 物件與資料型別

5.17.1 資料型別判定(constructor 屬性)

5.17.2 constructor 屬性的注意點

5.17.3 資料型別判定(instance 運算與 isPrototypeOf 方法)

5.17.4 資料型別判定(鴨子型別)

5.17.5 屬性的列舉(原型繼承的相關問題)

5.18 ECMScript第5版中的Object類

5.18.1 屬性物件

5.18.2 訪問器的屬性

5.19 標準物件

5.20 Object類

5.21 全域性物件

5.21.1 全域性物件與全域性變數

5.21.2 Math 物件

5.21.3 Error 物件