《JavaScript 闖關記》之物件

劼哥stone發表於2019-03-01

物件是 JavaScript 的資料型別。它將很多值(原始值或者其他物件)聚合在一起,可通過名字訪問這些值,因此我們可以把它看成是從字串到值的對映。物件是動態的,可以隨時新增和刪除自有屬性。物件除了可以保持自有的屬性,還可以從一個稱為原型的物件繼承屬性,這種「原型式繼承(prototypal inheritance)」是 JavaScript 的核心特徵。

物件最常見的用法是建立(create)、設定(set)、查詢(query)、刪除(delete)、檢測(test)和列舉(enumerate)它的屬性。

屬性包括名字和值。屬性名可以是包含空字串在內的任意字串,但物件中不能存在兩個同名的屬性。值可以是任意 JavaScript 值,或者在 ECMAScript 5中可以是 gettersetter 函式。

除了名字和值之外,每個屬性還有一些與之相關的值,稱為「屬性特性(property attribute)」:

  • 可寫(writable attribute),表明是否可以設定該屬性的值。
  • 可列舉(enumerable attribute),表明是否可以通過 for-in 迴圈返回該屬性。
  • 可配置(configurable attribute),表明是否可以刪除或修改該屬性。

在 ECMAScript 5之前,通過程式碼給物件建立的所有屬性都是可寫的、可列舉的和可配置的。在 ECMAScript 5中則可以對這些特性加以配置。

除了包含屬性特性之外,每個物件還擁有三個相關的「物件特性(object attribute)」:

  • 物件的類(class),是一個標識物件型別的字串。
  • 物件的原型(prototype),指向另外一個物件,本物件的屬性繼承自它的原型物件。
  • 物件的擴充套件標記(extensible flag),指明瞭在 ECMAScript 5中是否可以向該物件新增新屬性。

最後,用下面術語來對 JavaScript 的「三類物件」和「兩類屬性」進行區分:

  • 內建物件(native object),是由 JavaScript 規範定義的物件或類。例如,陣列、函式、日期和正規表示式都是內建物件。
  • 宿主物件(host object),是由 JavaScript 直譯器所嵌入的宿主環境(比如 Web 瀏覽器)定義的。客戶端 JavaScript 中表示網頁結構的 HTMLElement 物件均是宿主物件。
  • 自定義物件(user-defined object),是由執行中的 JavaScript 程式碼建立的物件。
  • 自有屬性(own property),是直接在物件中定義的屬性。
  • 繼承屬性(inherited property),是在物件的原型物件中定義的屬性。

建立物件

可以使用物件字面量、new 關鍵字和 ECMAScript 5中的 Object.create() 函式來建立物件。

使用物件字面量建立物件(推薦)

建立物件最簡單的方式就是在 JavaScript 程式碼中使用物件字面量。物件字面量是由若干名值對組成的對映表,名值對中間用冒號分隔,名值對之間用逗號分隔,整個對映表用花括號括起來。屬性名可以是 JavaScript 識別符號也可以是字串直接量(包括空字串)。屬性的值可以是任意型別的 JavaScript 表示式,表示式的值(可以是原始值也可以是物件值)就是這個屬性的值。例如:

// 推薦寫法
var person = {
    name : "stone",
    age : 28
};

// 也可以寫成
var person = {};
person.name = "stone";
person.age = 28;複製程式碼

使用 new 關鍵字建立物件

new 關鍵字建立並初始化一個新物件。關鍵字 new 後跟隨一個函式呼叫。這裡的函式稱做建構函式(constructor),建構函式用以初始化一個新建立的物件。JavaScript 語言核心中的原始型別都包含內建建構函式。例如:

var person = new Object();
person.name = "stone";
person.age = 28;複製程式碼

其中 var person = new Object(); 等價於 var person = {};

使用 Object.create() 函式建立物件

ECMAScript 5定義了一個名為 Object.create() 的方法,它建立一個新物件,其中第一個引數是這個物件的原型。Object.create() 提供第二個可選引數,用以對物件的屬性進行進一步描述。Object.create() 是一個靜態函式,而不是提供給某個物件呼叫的方法。使用它的方法很簡單,只須傳入所需的原型物件即可。例如:

var person = Object.create(Object.prototype);
person.name = "stone";
person.age = 28;複製程式碼

其中 var person = Object.create(Object.prototype); 也等價於 var person = {};

原型(prototype)

所有通過物件字面量建立的物件都具有同一個原型物件,並可以通過 JavaScript 程式碼 Object.prototype 獲得對原型物件的引用。通過關鍵字 new 和建構函式呼叫建立的物件的原型就是建構函式的 prototype 屬性的值。因此,同使用 {} 建立物件一樣,通過 new Object() 建立的物件也繼承自 Object.prototype。同樣,通過 new Array() 建立的物件的原型就是 Array.prototype,通過 new Date() 建立的物件的原型就是 Date.prototype

沒有原型的物件為數不多,Object.prototype 就是其中之一。它不繼承任何屬性。其他原型物件都是普通物件,普通物件都具有原型。所有的內建建構函式(以及大部分自定義的建構函式)都具有一個繼承自 Object.prototype 的原型。例如,Date.prototype 的屬性繼承自 Object.prototype,因此由 new Date() 建立的 Date 物件的屬性同時繼承自 Date.prototypeObject.prototype

這一系列連結的原型物件就是所謂的「原型鏈(prototype chain)」。

屬性的查詢和設定

前面有提到過,可以通過點 . 或方括號 [] 運算子來獲取屬性的值。對於點 . 來說,左側應當是一個物件,右側必須是一個以屬性名稱命名的簡單識別符號。對於方括號來說 [] ,方括號內必須是一個計算結果為字串的表示式,這個字串就是屬性的名稱。例如:

// 推薦寫法
console.log(person.name);   // "stone"
console.log(person.age);    // "28"

// 也可以寫成
console.log(person["name"]);    // stone
console.log(person["age"]);     // 28複製程式碼

和獲取屬性的值寫法一樣,通過點和方括號也可以建立屬性或給屬性賦值,但需要將它們放在賦值表示式的左側。例如:

// 推薦寫法
person.name = "sophie"; // 賦值
person.age = 30;        // 賦值
person.weight = 38;     // 建立

// 也可以寫成
person["name"] = "sophie";  // 賦值
person["age"] = 30;         // 賦值
person["weight"] = 38;      // 建立複製程式碼

當使用方括號時,方括號內的表示式必須返回字串。更嚴格地講,表示式必須返回字串或返回一個可以轉換為字串的值。

屬性的訪問錯誤

查詢一個不存在的屬性並不會報錯,如果在物件 o 自身的屬性或繼承的屬性中均未找到屬性 x,屬性訪問表示式 o.x 返回 undefined。例如:

var person = {};
person.wife;    // undefined複製程式碼

但是,如果物件不存在,那麼試圖查詢這個不存在的物件的屬性就會報錯。nullundefined 值都沒有屬性,因此查詢這些值的屬性會報錯。例如:

var person = {};
person.wife.name;   // Uncaught TypeError: Cannot read property `name` of undefined.複製程式碼

除非確定 personperson.wife 都是物件,否則不能這樣寫表示式 person.wife.name,因為會報「未捕獲的錯誤型別」,下面提供了兩種避免出錯的方法:

// 冗餘但易懂的寫法
var name;
if (person) {
    if (person.wife) 
        name = person.wife.name;
}

// 簡練又常用的寫法(推薦寫法)
var name = person && person.wife && person.wife.name;複製程式碼

刪除屬性

delete 運算子用來刪除物件屬性,事實上 delete 只是斷開屬性和宿主物件的聯絡,並沒有真正的刪除它。delete 運算子只能刪除自有屬性,不能刪除繼承屬性(要刪除繼承屬性必須從定義這個屬性的原型物件上刪除它,而且這會影響到所有繼承自這個原型的物件)。

程式碼範例,請參見「變數和資料型別」-「資料型別」-「delete 運算子」

檢測屬性

JavaScript 物件可以看做屬性的集合,我們經常會檢測集合中成員的所屬關係(判斷某個屬性是否存在於某個物件中)。可以通過 in 運算子、hasOwnPreperty()propertyIsEnumerable() 來完成這個工作,甚至僅通過屬性查詢也可以做到這一點。

in 運算子的左側是屬性名(字串),右側是物件。如果物件的自有屬性或繼承屬性中包含這個屬性則返回 true。例如:

var o = { x: 1 }
console.log("x" in o);          // true,x是o的屬性
console.log("y" in o);          // false,y不是o的屬性
console.log("toString" in o);   // true,toString是繼承屬性複製程式碼

物件的 hasOwnProperty() 方法用來檢測給定的名字是否是物件的自有屬性。對於繼承屬性它將返回 false。例如:

var o = { x: 1 }
console.log(o.hasOwnProperty("x"));          // true,x是o的自有屬性
console.log(o.hasOwnProperty("y"));          // false,y不是o的屬性
console.log(o.hasOwnProperty("toString"));   // false,toString是繼承屬性複製程式碼

propertyIsEnumerable()hasOwnProperty() 的增強版,只有檢測到是自有屬性且這個屬性的可列舉性(enumerable attribute)為 true 時它才返回 true。某些內建屬性是不可列舉的。通常由 JavaScript 程式碼建立的屬性都是可列舉的,除非在 ECMAScript 5中使用一個特殊的方法來改變屬性的可列舉性。例如:

var o = inherit({ y: 2 });
o.x = 1;
o.propertyIsEnumerable("x");    // true:,x是o的自有屬性,可列舉
o.propertyIsEnumerable("y");    // false,y是繼承屬性
Object.prototype.propertyIsEnumerable("toString");  // false,不可列舉複製程式碼

除了使用 in 運算子之外,另一種更簡便的方法是使用 !== 判斷一個屬性是否是 undefined。例如:

var o = { x: 1 }
console.log(o.x !== undefined);              // true,x是o的屬性
console.log(o.y !== undefined);              // false,y不是o的屬性
console.log(o.toString !== undefined);       // true,toString是繼承屬性複製程式碼

然而有一種場景只能使用 in 運算子而不能使用上述屬性訪問的方式。in 可以區分不存在的屬性和存在但值為 undefined 的屬性。例如:

var o = { x: undefined }        // 屬性被顯式賦值為undefined
console.log(o.x !== undefined); // false,屬性存在,但值為undefined
console.log(o.y !== undefined); // false,屬性不存在
console.log("x" in o);          // true,屬性存在
console.log("y" in o);          // false,屬性不存在
console.log(delete o.x);        // true,刪除了屬性x
console.log("x" in o);          // false,屬性不再存在複製程式碼

擴充套件閱讀「JavaScript 檢測原始值、引用值、屬性」
shijiajie.com/2016/06/20/…

擴充套件閱讀「JavaScript 檢測之 basevalidate.js」
shijiajie.com/2016/06/25/…

列舉屬性

除了檢測物件的屬性是否存在,我們還會經常遍歷物件的屬性。通常使用 for-in 迴圈遍歷,ECMAScript 5提供了兩個更好用的替代方案。

for-in 迴圈可以在迴圈體中遍歷物件中所有可列舉的屬性(包括自有屬性和繼承的屬性),把屬性名稱賦值給迴圈變數。物件繼承的內建方法不可列舉的,但在程式碼中給物件新增的屬性都是可列舉的。例如:

var o = {x:1, y:2, z:3};            // 三個可列舉的自有屬性
o.propertyIsEnumerable("toString"); // false,不可列舉
for (p in o) {          // 遍歷屬性
    console.log(p);     // 輸出x、y和z,不會輸出toString
}複製程式碼

有許多實用工具庫給 Object.prototype 新增了新的方法或屬性,這些方法和屬性可以被所有物件繼承並使用。然而在ECMAScript 5標準之前,這些新新增的方法是不能定義為不可列舉的,因此它們都可以在 for-in 迴圈中列舉出來。為了避免這種情況,需要過濾 for-in 迴圈返回的屬性,下面兩種方式是最常見的:

for(p in o) {
   if (!o.hasOwnProperty(p)) continue;          // 跳過繼承的屬性
   if (typeof o[p] === "function") continue;    // 跳過方法
}複製程式碼

除了 for-in 迴圈之外,ECMAScript 5定義了兩個用以列舉屬性名稱的函式。第一個是 Object.keys(),它返回一個陣列,這個陣列由物件中可列舉的自有屬性的名稱組成。第二個是 Object.getOwnPropertyNames(),它和 Ojbect.keys() 類似,只是它返回物件的所有自有屬性的名稱,而不僅僅是可列舉的屬性。在ECMAScript 3中是無法實現的類似的函式的,因為ECMAScript 3中沒有提供任何方法來獲取物件不可列舉的屬性。

屬性的 gettersetter

我們知道,物件屬性是由名字、值和一組特性(attribute)構成的。在ECMAScript 5中,屬性值可以用一個或兩個方法替代,這兩個方法就是 gettersetter。由 gettersetter 定義的屬性稱做「存取器屬性(accessor property)」,它不同於「資料屬性(data property)」,資料屬性只有一個簡單的值。

當程式查詢存取器屬性的值時,JavaScript 呼叫 getter 方法。這個方法的返回值就是屬性存取表示式的值。當程式設定一個存取器屬性的值時,JavaScript 呼叫 setter 方法,將賦值表示式右側的值當做引數傳入 setter。從某種意義上講,這個方法負責「設定」屬性值。可以忽略 setter 方法的返回值。

和資料屬性不同,存取器屬性不具有可寫性(writable attribute)。如果屬性同時具有 gettersetter 方法,那麼它是一個讀/寫屬性。如果它只有 getter 方法,那麼它是一個只讀屬性。如果它只有 setter 方法,那麼它是一個只寫屬性,讀取只寫屬性總是返回 undefined。定義存取器屬性最簡單的方法是使用物件直接量語法的一種擴充套件寫法。例如:

var o = {
    // 普通的資料屬性
    data_prop: value,

    // 存取器屬性都是成對定義的函式
    get accessor_prop() { /*這裡是函式體 */ },
    set accessor_prop(value) { /* 這裡是函式體*/ }
};複製程式碼

存取器屬性定義為一個或兩個和屬性同名的函式,這個函式定義沒有使用 function 關鍵字,而是使用 getset。注意,這裡沒有使用冒號將屬性名和函式體分隔開,但在函式體的結束和下一個方法或資料屬性之間有逗號分隔。

序列化物件(JSON)

物件序列化(serialization)是指將物件的狀態轉換為字串,也可將字串還原為物件。ECMAScript 5提供了內建函式 JSON.stringify()JSON.parse() 用來序列化和還原 JavaScript 物件。這些方法都使用 JSON 作為資料交換格式,JSON 的全稱是「JavaScript 物件表示法(JavaScript Object Notation)」,它的語法和 JavaScript 物件與陣列直接量的語法非常相近。例如:

o = {x:1, y:{z:[false,null,""]}};       // 定義一個物件
s = JSON.stringify(o);                  // s是 `{"x":1,"y":{"z":[false,null,""]}}`
p = JSON.parse(s);                      // p是o的深拷貝複製程式碼

ECMAScript 5中的這些函式的本地實現和 github.com/douglascroc… 中的公共域ECMAScript 3版本的實現非常類似,或者說完全一樣,因此可以通過引入 json2.js 模組在ECMAScript 3的環境中使用ECMAScript 5中的這些函式。

JSON 的語法是 JavaScript 語法的子集,它並不能表示 JavaScript 裡的所有值。它支援物件、陣列、字串、無窮大數字、truefalsenull,可以序列化和還原它們。NaNInfinity-Infinity 序列化的結果是 null,日期物件序列化的結果是 ISO 格式的日期字串(參照 Date.toJSON() 函式),但 JSON.parse() 依然保留它們的字串形態,而不會將它們還原為原始日期物件。函式、RegExpError 物件和 undefined 值不能序列化和還原。JSON.stringify() 只能序列化物件可列舉的自有屬性。對於一個不能序列化的屬性來說,在序列化後的輸出字串中會將這個屬性省略掉。JSON.stringify()JSON.parse() 都可以接收第二個可選引數,通過傳入需要序列化或還原的屬性列表來定製自定義的序列化或還原操作。

關卡

請實現下面用來列舉屬性的物件工具函式:

/*
 * 把 p 中的可列舉屬性複製到 o 中,並返回 o
 * 如果 o 和 p 中含有同名屬性,則覆蓋 o 中的屬性
 */
function extend(o, p) {
    // 請實現函式體
}複製程式碼
/*
 * 將 p 中的可列舉屬性複製至 o 中,並返回 o
 * 如果 o 和 p 中有同名的屬性,o 中的屬性將不受影響
 */
function merge(o, p) {
    // 請實現函式體
}複製程式碼
/*
 * 如果 o 中的屬性在 p 中沒有同名屬性,則從 o 中刪除這個屬性
 * 返回 o
 */
function restrict(o, p) {
    // 請實現函式體
}複製程式碼
/*
 * 如果 o 中的屬性在 p 中存在同名屬性,則從 o 中刪除這個屬性
 * 返回 o
 */
function subtract(o, p) {
    // 請實現函式體
}複製程式碼
/*
 * 返回一個新物件,這個物件同時擁有 o 的屬性和 p 的屬性
 * 如果 o 和 p 中有重名屬性,使用 p 中的屬性值
 */
function union(o, p) { 
    // 請實現函式體
}複製程式碼
/*
 * 返回一個新物件,這個物件擁有同時在 o 和 p 中出現的屬性
 * 很像求 o 和 p 的交集,但 p 中屬性的值被忽略
 */
function intersection(o, p) { 
    // 請實現函式體
}複製程式碼
/*
 * 返回一個陣列,這個陣列包含的是 o 中可列舉的自有屬性的名字
 */
function keys(o) {
    // 請實現函式體
}複製程式碼

更多

關注微信公眾號「劼哥舍」回覆「答案」,獲取關卡詳解。
關注 github.com/stone0090/j…,獲取最新動態。

相關文章