JS紅寶書共讀筆記 —— 8.物件

2022-01-12 11:00:02

本文內容:

  • 理解物件
  • 理解物件建立過程
  • 理解建構函式、原型物件和範例之間的關係
  • 理解繼承

物件基本

建立物件

// 構造範例方式
const person = new Object();
person.name = 'a';

// 宣告式
const person = {
	name: 'a'
};

物件屬性

為了標識某個標識為內部特性,ECMA-262 使用兩個中括號把特性名稱括起來。

資料屬性

  • [[Configurable]]
    • 屬性是否可被 delete
    • 是否可修改以下特性
    • 是否可以將其改為存取器屬性
    • 預設為 true
  • [[Enumerable]]
    • 是否可被 for…in… 檢索到
    • 預設為 true
  • [[Writable]]
    • 是否可被修改
    • 預設為 true
  • [[Value]]
    • 屬性實際的值
    • 預設為 undefined

修改資料屬性

Object.defineProperty(),該方法接收3個引數:目標物件屬性名稱描述符物件

const person = {};
Object.defineProperty(person, 'name', {
	writable: false,
	value: 'aa'
});

本例使用 Object.defineProperty() 給 person 建立了一個屬性 name,在非嚴格模式下給這個屬性重新賦值會被忽略;在嚴格模式下,嘗試修改唯讀屬性則會報錯。

當一個屬性被定義為不可設定後,就不能再變回可設定的了。此時呼叫 Object.defineProperty() 並修改任何非 writable 屬性都會導致錯誤。

在使用 Object.defineProperty() 時,如果不在第三個引數提供 configurableenumerablewritable

存取器屬性

  • [[Configurable]]
    • 是否可以被 delete 並重新定義
    • 是否可以修改本屬性的特性
    • 是否可以改為資料屬性
    • 預設為 true
  • [[Enumerable]]
    • 是否能被 for…in… 檢索到
    • 預設為 true
  • [[Get]]
    • 獲取函數
    • 在讀取屬性時呼叫,預設為 undefined
  • [[Set]]
    • 設定函數
    • 在寫入屬性時呼叫,預設為 undefined
const book = {
	year_: 2022,
	edition: 1
};

Object.defineProperty(book, 'year_', {
	get() {
		return this.year_;
	}
	set(v) {
		if (v > this.year_) {
			this.year_ = v;
			this.edition += v - 2022;
		}
	}
});

獲取函數和設定函數不是必須設定的,只設定獲取函數說明這個屬性是唯讀的。在非嚴格模式下,給唯讀屬性賦值會被忽略;在嚴格模式下則會報錯。

同時定義多個屬性

Object.defineProperties()

const book = {};
Object.defineProperties(book, {
	year_: {
		value: 2022
	},
	edition: {
		value: 1
	},
	year: {
		get() {
			return this.year_;
		}
		set(v) {
			if (v > 2022) {
				this.year_ = v;
				this.edition += v - 2022;
			}
		}
	},
});

讀取屬性的特性

Object.getOwnPropertyDescriptor()

這個方法接收兩個引數:目標物件目標屬性名

const book = {};
// 這裡呼叫上文所示的對 book 的屬性定義
// ...

const descriptor = Object.getOwnPropertyDescriptor(book, 'year');

console.log(descriptor.value); // undefined
console.log(descriptor.enumerable); // false
console.log(typeof descriptor.get); // "function"

提問:為什麼這裡的 enumerable 是 false ?(答案在「修改資料屬性「一節)

同時獲取目標物件的所有屬性的特徵

Object.getOwnPropertyDescriptors()

該靜態方法是在 ES2017 處新增的,只接收 目標物件 一個引數。

const book = {};
// 這裡呼叫上文所示的對 book 的屬性定義
// ...

console.log(Object.getOwnPropertyDescriptors(book));
/*
{
	edition: {
		configurable: false,
		enumerable: false,
		value: 1,
		writable: false
	},
	year: {
		configurable: false,
		enumerable: false,
		get: f(),
		set: f(v),
	},
	year_: {
		configurable: false,
		enumerable: false,
		value: 2022,
		writable: false
	},
}
*/

合併物件

Object.assign()

這個方法接收一個 目標物件一個或多個源物件

  • 尋找源物件上符合條件的屬性並將其複製到目標物件
    • 可列舉 Object.propertyIsEnumerable() 返回 true
    • 自有 Object.hasOwnProperty() 返回 true
  • 以字串和符號為鍵的屬性會被複制
  • 對每個符合條件的屬性
    1. 使用源物件的 [[Get]] 獲取屬性值
    2. 使用目標物件的 [[Set]] 設定屬性值

Object.assign() 執行的是淺複製。如果多個源物件有相同的屬性,則會用最後(書寫順序最靠右或最靠下)一個複製的值。

Object.assign() 會返回目標物件的參照。

const dest = {};

const res = Object.assign(dest, { id: 1, name: 'a' }, { id: 2 });

console.log(res === dest); // true
console.log(res); // { id: 2, name: 'a' }

如果在複製期間出錯,則操作會中止並退出,同時丟擲錯誤。此時 Object.assign() 只完成了部分複製,而這部分複製並不會回滾到原樣。因此 Object.assign() 只是一個盡力而為的操作。

物件表示和相等判定

在 ES6 以前一些棘手的情況

// === 符合預期的情況
console.log(true === 1); // false
console.log({} === {}); // false
console.log("2" === 2); // false

// 在不同的 JS 引擎中表現不同,但仍被認為相等
console.log(+0 === -0); // true
console.log(+0 === 0); // true
console.log(-0 === 0); // true

// 必須使用 isNaN() 來判定一個變數是否為 NaN
console.log(NaN === NaN); // false
console.log(isNaN(NaN)); // true

Object.is() 一定程度改善了上述棘手的情況。

console.log(Object.is(true, 1)); // false
console.log(Object.is({}, {})); // false
console.log(Object.is("2", 2)); // false

console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false

console.log(Object.is(NaN, NaN)); // true

ES6 增強物件語法

簡寫屬性值

const name = 'aa';
const person = { name };
console.log(person.name); // "aa"

動態命名屬性

const nameKey = 'name';
const person = { [nameKey]: 'aa' };
console.log(person); // { name: 'aa' }

在 [ ] 內可以寫任何 JS 表示式,比如說函數執行。

簡寫方法名

const functionKey = 'sayAge';
const person = {
	sayName() {
		console.log('aa');
	}
	[functionKey]() {
		console.log(18);
	}
};

物件解構

const person = {
	name: 'aa',
	age: 18
};

const {
	nane,
	age: personAge,
	some,
	hasDefault = 'default' // 不存在於源物件時使用預設值
} = person;

console.log(name); // "aa"
console.log(personAge); // 18
console.log(some); // undefined
console.log(hasDefault); // "default"
console.log(age); // Referrence Error

解構會在內部使用函數 ToObject() ( 不能在執行時環境中直接存取 ) 把後設資料解構轉換為物件。這意味著在物件解構的上下文中,原始值會被當成物件,而 nullundefined 不能被解構,否則會丟擲錯誤。

const { length } = 'fooBar';
console.log(length); // 6

const { constructor: c } = 4;
console.log(c === Number); // true

let { _ } = null; // TypeError
let { - } = undefined; // TypeError

基於以上認識,再來看一下 巢狀解構

const person = {
	info: {
		name: 'aa',
		age: 18
	},
};

const { info: { name, age: personAge } } = person;
console.log(name); // "aa"
console.log(age); // 18

// 當我們從一個 undefind(人為或本身沒有這個屬性) 或 一個 null 的屬性中解構時就會報錯
const { job: { desc } } = person; // TypeError

我們也可以利用物件解構這個特性給其他物件賦值

const person = {
	info: {
		name: 'aa',
		age: 18
	},
};
const copy = {};

{
	info: {
		name: copy.name,
		age: copy.age
	}
} = person;

console.log(copy); // { name: "aa", age: 18 }

此時,和 Object.assign() 類似,當解構賦值的過程中如果出現異常,這個過程是不能回滾的,就會出現部分賦值的情況。

函數引數使用解構

const person = {
	name: 'aa',
	age: 18
};

function print({ name, age: myAge }) {
	console.log(name, myAge);
}

print(person); // "aa" 18

建構函式、原型物件和範例

建構函式模式

建構函式和傳統的工廠函數對比有以下區別

  • 沒有顯示地建立物件 (使用 new 建立)
  • 屬性和方法直接賦值給 this
  • 沒有 return

使用 new 操作符時建構函式會執行以下操作:

  1. 在記憶體中建立一個新物件
  2. 這個新物件內部的 [[prototype]] 特性被賦值為建構函式的 prototype 屬性
  3. 建構函式內部的 this 被賦值給這個新物件 ( 即 this 指向新物件 )
  4. 執行建構函式內部的程式碼 ( 給新物件新增屬性 )
  5. 如果建構函式返回非空物件,則返回該物件;否則返回剛建立的新物件 ( 最好不要返回自定義物件,否則這個建構函式就沒有意義了 )

在範例化時,如果不想傳引數,那麼建構函式後面的括號可加可不加。

const Person = function() {
	this.name = 'aa';
	this.sayName = function() {
		console.log(this.name);
	};
};

const person1 = new Person();
const person2 = new Person;

console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true

需要注意的是,建構函式也是函數,在呼叫一個函數而沒有明確設定 this 的情況下,this 始終指向 Global 物件 ( 在瀏覽器中就是 window 物件 )。

// 作為函數呼叫
Person('aa'); // 新增到 window 物件
window.sayName(); // "aa"

// 在另一個物件的作用域中呼叫
const o = new Object();
Person.call(o, 'aa');
o.sayName(); // "aa"

建構函式的問題

上面定義的屬性在不同範例間是不共用的,如果要維護一些公共屬性,使用上面的做法會非常不方便。

原型模式

每個函數都會建立一個 prototype 屬性,這個屬性是指向一個物件的指標,該物件就是這個函數的原型物件,其包含應該由特定參照型別的範例共用的屬性和方法。

使用 原型物件 的好處是在上面定義的屬性和方法可以被物件範例共用。

假設現有一個 Person 建構函式和一個 person 範例,它們和 Object 及其原型物件的關係如下:
建構函式、原型物件和例項關係
從這個圖可以看出

  • 建構函式原型物件 是兩個概念,它們有自己的方式彼此通訊
  • 物件 上有一個隱藏屬性 [[prototype]] 用於和自己的建構函式的原型物件進行通訊,一般情況下是不能直接存取這個屬性的,但是有的瀏覽器( Firefox、Safari 和 Chrome 等 )會暴露 __proto__ 讓開發者來存取 [[prototype]]
    這也意味著我們不能在程式碼中直接使用 __proto__,因為如果程式碼執行在不支援 __proto__ 的瀏覽器時,__proto__ 的值就是 undefined
  • Object 建構函式及其原型物件基本上是 JS 的源頭了。像 NumberString 這些建構函式都繼承自 Object 原型物件

原型方法

  • instanceof 判定是否包含指定建構函式的原型

    console.log(person1 instanceof Person); // true
    console.log(person1 instanceof Object); // true
    console.log(Person.prototype instanceof Object); // true
    
  • isPrototypeOf()

    console.log(Person.prototype.isPrototypeOf(person1)); // true
    
  • Object.getPrototypeOf()
    返回引數內部特性 [[Prototype]] 的值

    console.log(Object.getPrototypeOf(person1) === Person.prototype); // true
    
    console.log(Object.getPrototypeOf(person1).name); // aa
    
  • Object.setPrototypeOf()
    可以像範例的私有特性 [[Prototype]] 寫入一個新值以重寫一個物件的原型繼承關係

    const biped = { numLegs: 2 };
    const person = { name: 'aa' };
    
    Object.setPrototypeOf(person, biped);
    
    console.log(person.name); // aa
    console.log(person.numLegs); // 2
    console.log(Object.getPrototypeOf(person) === biped); // true
    

    注意Object.setPrototypeOf() 可能會嚴重影響程式碼效能。Mozilla 檔案說得很清楚,」在所有瀏覽器和 JS 引擎中,修改繼承關係的影響都是微妙且深遠的。這種影響並不僅是 Object.setPrototypeOf() 語句那麼簡單,而是會涉及所有存取了修改 [[Prototype]] 的物件的程式碼「。

    可以通過 Object.create() 來建立一個新物件同時為其指定原型來避免 Object.setPrototypeOf() 可能造成的效能下降。

    const biped = { numLegs: 2 };
    const person = Object.create(biped);
    person.name = 'aa';
    
    console.log(person.name); // aa
    console.log(person.numLegs); // 2
    console.log(Object.getPrototypeOf(person) === biped); // true
    
  • Object.hasOwnProperty()
    當屬性存在於呼叫它的物件範例上時返回 true

    function Person() {
    	this.name = 'aa';
    }
    Person.sayName = function() {
    	console.log(this.name)
    };
    
    const p = new Person();
    console.log(p.hasOwnProperty('name')); // true
    console.log(p.hasOwnProperty('sayName')) // false
    

遍歷屬性

  • in 操作符 —— 當屬性存在於物件範例上或其原型鏈上時返回 true

    • 單獨使用 ( 常用於條件判斷 )
    • for…in…
      可遍歷範例屬性和原型屬性中所有可列舉的屬性
  • Object.keys() —— 獲得物件上所有可列舉的範例屬性

for...in...Object.keys() 的列舉順序是不確定的,取決於 JS 引擎,可能因瀏覽器而異。

物件迭代

  • Object.values()
    const o = { foo: 'foo', bar: 1, baz: {} };
    console.log(Object.values(o)); // ["foo", 1, {}]
    
    console.log(Object.values(o)[2]); // true
    
  • Object.entries()
    const o = { foo: 'foo', bar: 1, baz: {} };
    console.log(Object.entries(o)); // [["foo", "foo"], ["bar", 1], ["baz", {}]]
    
    console.log(Object.entries[o][2][1] === o.baz); // true
    

特性

  • 這兩個方法執行物件的淺複製
  • 符號屬性會被忽略

建構函式設計模式

盜用建構函式

核心思路是在子類建構函式中呼叫父類別建構函式,在呼叫的時候可以向父類別建構函式傳參。

function SuperType(name) {
	this.name = name;
}

function SubType() {
	// 繼承 SuperType 並傳參
	SuperType.call(this, 'aa');
	// 範例屬性
	this.age = 18;
}

const instance = new SubType();
console.log(instance.name); // "aa"
console.log(instance.age); // 18

盜用建構函式問題

函數不能重用,因為方法必須在建構函式中定義。另外,子類不能存取父類別原型上定義的方法,因此所有型別只能使用建構函式模式。

組合繼承

核心思路是使用原型鏈繼承原型上的屬性和方法,通過盜用建構函式繼承範例屬性

這樣即可把方法定義在原型上實現重用,又可以讓每個範例有自己的屬性。

function SuperType(name) {
	this.name = name;
}
function SubType(name, age) {
	// 繼承屬性
	SuperType.call(this, name);
	// 範例屬性
	this.age = age;
}

// 繼承方法
SubType.prototype = new SuperType();
// 在原型鏈上定義公共方法
SubType.prototype.sayAge = function() {
	console.log(this.age);
}

原型式繼承

Object.create() 接收兩個引數:作為新物件原型的物件和給新物件定義額外屬性的物件(可選)。

第二個個引數資料格式和 Object.defineProperties() 的第二個引數一樣。

const person = {
	name: 'a',
	friends: ['b', 'c']
};

// person 是 anotherPerson 的原型
const anotherPerson = Object.create(person);
anotherPerson.name = 'hh';
anotherPerson.friends.push('gg');

// person 是 yetAnotherPerson 的原型
const yetAnotherPerson = Object.create(person, {
	name: {
		value: 'yetAnotherPerson'
	}
});
yetAnotherPerson.friends.push('hh');

console.log(person.friends); // "b, c, hh, gg"
console.log(yetAnotherPerson.name); // yetAnotherPerson

寄生式繼承

核心思想是:建立一個實現繼承的函數,以某種方式增強物件,然後返回這個物件。

function createAnother(original) {
	const clone = Object.create(original); // 通過呼叫函數建立一個新物件
	clone.sayHi = function() { // 以某種方式增強這個物件
		console.log('hi');
	};
	return clone; // 返回這個物件
}

const person = { name: 'aa' };
const anotherPerson = createAnother(person);
anotherPerson.sayHi(); // "hi"

寄生式組合繼承

// 寄生式組合繼承的基本模式
function inheritPrototype(subType, superType) {
	const prototype = Object.create(superType.prototype); // 建立物件
	prototype.constructor = subType; // 增強物件
	subType.prototype = prototype; // 賦值物件
}

// 使用
function SuperType(name) {
	this.name = name;
	this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function() {
	console.log(this.name);
};

function SubType(name, age) {
	SuperType.call(this, name);
	this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
	console.log(this.age);
};

console.log(SuperType.sayAge); // undefined

寄生式組合繼承結果