理解對象及其創建過程、ES6 語法糖,原型模式、構造函數
理解原型鏈、盜用構造函數、組合式繼承以及最佳實踐等
理解對象#
ECMA-262 將對象定義為一組屬性的無序集合,每個屬性或方法都有一個名稱來表示,可將其想像為一張散列表,值可以為數據 / 函數
- 例 1
// 使用 new Object() 進行創建
let person = new Object();
person.name = "cosine";
person.age = 29
person.job = "Software Engineer";
person.sayName = function() {
console.log(this.name);
};
// 使用對象字面量創建
let person = {
name: 'cosine',
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
}
以上兩種對象是等價的,其屬性和方法都一樣
可以思考一下: new 的過程中做了什麼? 後面也會提到
屬性類型#
ECMA-262 使用一些內部特性來描述屬性的特徵。這些特性是由為 JavaScript 實現引擎的規範定義的。因此,開發者不能在 JavaScript 中直接訪問這些特性。為了將某個特性標識為內部特性,規範會用兩個中括號把特性的名稱括起來,比如[[Enumerable]]
屬性分兩種:數據屬性和 訪問器屬性
數據屬性#
包含一個保存數據值的位置。值會從這個位置讀取,也會寫入到這個位置。數據屬性有 4 個特性描述它們的行為
[[Configurable]]
可配置- 表示屬性是否可以通過
delete
刪除並重新定義 - 是否可以修改它的特性
- 是否可以把它改為訪問器屬性
- 默認情況下,所有直接定義在對象上的屬性的這個特性都是
true
- 表示屬性是否可以通過
[[Enumerable]]
可枚舉- 表示屬性是否可以通過
for-in
循環返回 - 默認:
true
- 表示屬性是否可以通過
[[Writable]]
可寫- 表示屬性的值是否可以被修改
- 默認:
true
[[Value]]
可寫- 包含屬性實際的值
- 這就是前面提到的那個讀取和寫入屬性值的位置
- 默認:
undefined
在像前面例子中那樣將屬性顯式添加到對象之後,[[Configurable]]
、[[Enumerable]]
和 [[Writable]]
都會被設置為 true,而 [[Value]]
特性會被設置為指定的值。
Object.defineProperty () 方法#
要修改屬性的默認特性,就必須使用 Object.defineProperty()
方法。這個方法接收 3 個參數:
obj
待添加屬性的對象prop
待定義或修改的屬性名稱或 Symboldescriptor
要定義或修改的屬性描述符
設置方法如下例
// Object.defineProperty()設置屬性
let person = {};
Object.defineProperty(person, "name", {
writable: false, // 看這兒! 不可修改
value: "cosine"
});
console.log(person.name); // cosine
person.name = "NaHCOx"; // 試圖修改
console.log(person.name); // 修改無效 print: cosine
創建了一個名為 name
的屬性並給它賦予了一個只讀的值,則這個屬性的值不能修改了
- 非嚴格模式下,嘗試給這個屬性重新賦值會被忽略。
- 嚴格模式下,嘗試修改只讀屬性的值會拋出錯誤
類似的規則也適用於創建不可配置的屬性
把 configurable
設置為 false
,意味著這個屬性不能從對象上刪除。
注意:一個屬性被定義為不可配置之後,就不能再變回可配置的了!!
再次調用
Object.defineProperty()
並修改任何非writable
屬性則會導致錯誤
在調用 Object.defineProperty()
時,configurable
、enumerable
和 writable
的值如果不指定,則都默認為 false
。
訪問器屬性#
訪問器屬性不包含數據值。相反,它們包含一個獲取(getter
)函數和一個設置(setter
)函數,不過這兩個函數不是必需的。
- 讀取訪問器屬性時,會調用
getter
函數,返回一個有效的值 - 寫入訪問器屬性時,會調用
setter
函數並傳入新值,這個函數必須決定對數據做出什麼修改
訪問器屬性有 4 個特性描述它們的行為:
[[Configurable]]
:表示屬性- 是否可以通過 delete 刪除並重新定義
- 是否可以修改它的特性
- 是否可以把它改為數據屬性
- 默認:
true
[[Enumerable]]
:表示屬性是否可以通過 for-in 循環返回。默認true
[[Get]]
:獲取函數,在讀取屬性時調用。默認值為undefined
[[Set]]
:設置函數,在寫入屬性時調用。默認值為undefined
注意,上述屬性的默認值都表示在直接定義對象上時的默認值,若用Object.defineProperty()
,則沒有定義的都為 undefined
下面是一個例子
// 訪問器屬性定義
// 定義一個對象,包含偽私有成員 year_和公共成員 edition
let book = {
year_: 2017,
edition: 1
};
Object.defineProperty(book, "year", {
get() {
return this.year_;
},
set(newVal) {
if(newVal > 2017) {
this.year_ = newVal;
this.edition += newVal - 2017;
}
}
});
book.year = 1999
console.log(book.year); // 2017
console.log(book.edition); // 1
book.year = 2018
console.log(book.year); // 2018
console.log(book.edition); // 2
對象 book 有兩個默認屬性:year_和 edition
year_中的下劃線常用來表示該屬性並不希望在對象方法的外部被訪問
另一個屬性 year 被定義為一個訪問器屬性,其中,
- getter 簡單返回 year_的值
- setter 會做一些計算以決定正確的版本(edition)
因此,把 year 屬性修改為 2018 會導致 year_變成 2018,edition 變成 2。而試圖修改為 1999 則不會有變化,這是訪問器屬性的典型使用場景,即設置一個屬性值會導致一些其他變化發生。
獲取函數和設置函數不一定都要定義。
- 只定義
getter
函數意味著屬性是只讀的,嘗試修改屬性會被忽略。在嚴格模式下,嘗試寫入只定義了獲取函數的屬性會拋出錯誤。 - 類似地,只有定義
setter
的屬性是不能讀取的,非嚴格模式下讀取會返回undefined
,嚴格模式下會拋出錯誤。
其他定義屬性函數#
其他還可通過 Object.defineProperties()
定義多個屬性,使用 Object.getOwnPropertyDescriptor()
方法可以取得指定屬性的屬性描述符 , Object.getOwnPropertyDescriptors()
方法可以取得每個自有屬性的屬性描述符並在一個新對象中返回。
合併對象#
把源對象所有的屬性一起複製到目標對象上,這種操作也被稱為 “混入”(mixin),因為目標對象通過混入源對象的屬性從而得到了增強。
Object.assign()
方法用於將所有可枚舉屬性 / 自有屬性的值從一個或多個源對象分配到目標對象,返回目標對象。
const target = { a: 1, b: 2 };
const source = { b: 4, c: 5 };
const returnedTarget = Object.assign(target, source);
console.log(target);
// expected output: Object { a: 1, b: 4, c: 5 }
console.log(returnedTarget);
// expected output: Object { a: 1, b: 4, c: 5 }
Object.assign()
實際上執行的是淺複製,只會複製對象的引用
- 若多個源對象都有相同的屬性,則用最後一個複製的值。
- 從源對象訪問器屬性取得的值,比如獲取函數,會作為一個靜態值賦給目標對象。也就是說:不能在兩個對象間轉移獲取函數和設置函數。
- 賦值期間出錯,則操作會中止並退出,同時拋出錯誤。沒有 “回滾” 之前賦值的概念,因此它是一個盡力而為、可能只會完成部分複製的方法。
對象標識及相等判定#
ES6 之前,有些情況利用 === 操作符進行判斷是無法成功的,如
// 這些情況在不同 JavaScript 引擎中表現不同,但仍被認為相等
console.log(+0 === -0); // true
console.log(+0 === 0); // true
console.log(-0 === 0); // true
// 要確定 NaN 的相等性,必須使用極為討厭的 isNaN()
console.log(NaN === NaN); // false
console.log(isNaN(NaN)); // true
ES6 規範改善了這類情況,新增了 Object.is()
方法用於判斷兩個值是否為同一個值
// 正確的 0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false
// 正確的 NaN 相等判定
console.log(Object.is(NaN, NaN)); // true
要檢查超過兩個值,可遞歸地利用相等性傳遞即可
function recursivelyCheckEqual(x, ...rest) {
return Object.is(x, rest[0]) &&
(rest.length < 2 || recursivelyCheckEqual(...rest));
}
ES6 語法糖#
ECMAScript 6 為定義和操作對象新增了很多極其有用的語法糖特性。這些特性都沒有改變現有引擎的行為,但極大地提升了處理對象的方便程度。
屬性名簡寫#
給對象添加變量的時候,經常會發現屬性名和變量名是一樣的。這個時候就可以使用變量名,不用再寫冒號,如果沒有找到同名變量,則會拋出 ReferenceError
。
例如:
let name = 'cosine';
let person = { name: name };
console.log(person); // { name: 'cosine' }
// 使用語法糖 等價於上面那個
let person = { name };
console.log(person); // { name: 'cosine' }
代碼壓縮程序會在不同作用域間保留屬性名,以防止找不到引用。
可計算屬性#
可在對象字面量中直接動態的命名屬性:
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let uniqueToken = 0;
function getUniqueKey(key) {
return `${key}_${uniqueToken++}`;
}
let person = {
[nameKey]: 'cosine',
[ageKey]: 21,
[jobKey]: 'Software engineer',
// 也可以是表達式!
[getUniqueKey(jobKey+ageKey)]: 'test'
};
console.log(person);
// { name: 'cosine', age: 21, job: 'Software engineer', jobage_0: 'test' }
簡寫方法名#
直接看:
let person = {
let person = {
// sayName: function(name) { // 舊
// console.log(`My name is ${name}`);
// }
sayName(name) { // 新
console.log(`My name is ${name}`);
}
};
person.sayName('Matt'); // My name is Matt
簡寫方法名對獲取函數和設置函數也是適用的,並且簡寫方法名與可計算屬性鍵相互兼容,也為後文的類打下了基礎
對象解構#
// 對象解構
let person = {
name: 'cosine',
age: 21
};
let { name: personName, age: personAge } = person;
console.log(personName, personAge); // cosine 21
// 讓變量直接使用屬性的名稱 定義默認值 若未定義默認值則不存在的則為undefined
let { name, age, job = 'test', score } = person;
console.log(name, age, job, score); // cosine 21 test undefined
解構在內部使用函數 ToObject()
(不能在運行時環境中直接訪問)把源數據結構轉換為對象。
這意味著在對象解構的上下文中,原始值會被當成對象。也就是說:null
和 undefined
不能被解構,否則會拋出錯誤
let { length } = 'foobar';
console.log(length); // 6
let { constructor: c } = 4;
console.log(c === Number); // true
let { _ } = null; // TypeError
let { _ } = undefined; // TypeError
想要給事先聲明的變量解構賦值,則賦值表達式必須包含在一對括號中
let personName, personAge;
let person = {
name: 'cosine',
age: 21
};
({name: personName, age: personAge} = person);
console.log(personName, personAge); // cosine 21
1、嵌套解構#
解構對於引用嵌套的屬性或賦值目標沒有限制。為此,可以通過解構來複製對象屬性 (淺複製)
let person = {
name: 'cosine',
age: 21,
job: {
title: 'Software engineer'
}
};
// 聲明 title 變量並將 person.job.title 的值賦給它
let { job: { title } } = person;
console.log(title); // Software engineer
在外層屬性沒有定義的情況下不能使用嵌套解構。無論源對象還是目標對象都一樣
2、部分解構#
涉及多個屬性的解構賦值是一個輸出無關的順序化操作。如果一個解構表達式涉及多個賦值,開始的賦值成功而後面的賦值出錯(對不能解構的 undefined || null 進行解構),則整個解構賦值只會完成一部分
3、參數上下文匹配#
在函數參數列表中也可以進行解構賦值。對參數的解構賦值不會影響 arguments 對象,但可以在函數簽名中聲明在函數體內使用局部變量:
let person = {
name: 'cosine',
age: 21
};
function printPerson(foo, {name, age}, bar) {
console.log(arguments);
console.log(name, age);
}
function printPerson2(foo, {name: personName, age: personAge}, bar) {
console.log(arguments);
console.log(personName, personAge);
}
printPerson('1st', person, '2nd');
// ['1st', { name: 'cosine', age: 21 }, '2nd']
// 'cosine' 21
printPerson2('1st', person, '2nd');
// ['1st', { name: 'cosine', age: 21 }, '2nd']
// 'cosine' 21
創建對象#
ES6 開始,正式支持了類和繼承,不過這種支持其實是封裝了 ES5.1 構造函數加原型繼承的語法糖。
工廠模式#
在設計模式那篇博客有提到一些設計模式 (前端設計模式應用筆記), 而工廠模式也是一種廣泛使用的設計模式,它提供了一種創建對象的最佳方式。在工廠模式中,我們在創建對象時不會對客戶端暴露創建邏輯,並且是通過使用一個共同的接口來指向新創建的對象。
function createPerson(name, age, job) {
let o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
console.log(this.name);
};
return o;
}
let person1 = createPerson("cosine", 21, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");
console.log(person1); // { name: 'cosine', age: 21, job: 'Software Engineer', sayName: [Function (anonymous)] }
person1.sayName(); // cosine
console.log(person2); // { name: 'Greg', age: 27, job: 'Doctor', sayName: [Function (anonymous)] }
person2.sayName(); // Greg
這種模式可以解決創建多個類似對象的問題,但沒有解決對象標識問題(即新創建的對象是什麼類型)
構造函數模式#
自定義構造函數,以函數的形式為自己的對象類型定義屬性和方法。
function Person(name, age, job){
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() {
console.log(this.name);
};
}
let person1 = new Person("cosine", 21, "Software Engineer");
person1.sayName(); // cosine
- 沒有顯式地創建對象
- 屬性和方法直接賦值給了 this
- 沒有 return
- 要創建 Person 的實例,應使用 new 操作符
new 過程中發生了什麼?#
劃重點,使用 new 調用構造函數會執行如下幾個操作:
- 在內存中創建一個新對象
- 將新對象內部的
[[Prototype]]
赋值為構造函數的 prototype 屬性。 - 構造函數內部的
this
指向這個新對象 - 執行構造函數內部的代碼(為對象添加屬性)
- 若構造函數返回非空對象,則返回該對象。否則,返回剛創建的新對象!
上個栗子的最後,person1 有一個 constructor 屬性指向 Person
console.log(person1.constructor) // [Function: Person]
console.log(person1.constructor === Person) // true
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
定義自定義構造函數可以確保實例被標識為特定類型,相比於工廠模式,這是一個很大的好處。
person1 之所以也被認為是 Object 的實例,是因為所有自定義對象都繼承自 Object(後文會提到)
注意以下幾點:
- 構造函數也是函數 :任何函數只要使用 new 操作符調用就是構造函數,而不使用 new 操作符調用的函數就是普通函數
- 構造函數的主要問題:其定義的方法會在每個實例上都創建一遍,因此不同實例上的函數雖然同名卻不相等,而因為都是做一樣的事,所以沒必要定義兩個不同的 Function 實例。
this 對象可以把函數與對象的綁定推遲到運行時,所以可以將函數定義轉移到構造函數外部。
這樣雖然解決了相同邏輯的函數重複定義的問題,但全局作用域也因此被搞亂了,因為那個函數實際上只能在一個對象上調用。如果這個對象需要多個方法,那麼就要在全局作用域中定義多個函數。這會導致自定義類型引用的代碼不能很好地聚集在一起。而這個新問題可以通過原型模式來解決
原型模式#
- 每個函數都會創建一個
prototype
屬性指向原型對象 - 在原型對象上定義的屬性和方法可以被所有對象實例共享
- 原來在構造函數中賦給對象實例的值,可以直接賦值給它們的原型
1、理解原型#
- 無論何時,只要創建一個函數,就會按照特定的規則為這個函數創建一個
prototype
屬性指向原型對象 - 所有原型對象會獲得一個名為
constructor
的屬性,指回與之關聯的構造函數- 如
Person.prototype.constructor
指向Person
- 如
- 然後,可通過構造函數給原型對象添加其他屬性和方法
- 原型對象默認只會獲得
constructor
屬性,其他的所有方法都繼承自Object
- 每次調用構造函數創建一個新實例,其的內部
[[Prototype]]
指針就會被賦值為構造函數的原型對象 - 腳本中沒有訪問這個
[[Prototype]]
特性的標準方式,但 Firefox、Safari 和 Chrome 會在每個對象上暴露__proto__
屬性,通過這個屬性可以訪問對象的原型
正常的原型鏈都會終止於 Object 的原型對象,而 Object
原型的原型為 null
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Person.prototype.__proto__.constructor === Object); // true
console.log(Person.prototype.__proto__.__proto__ === null); // true
構造函數、原型對象和實例是 3 個完全不同的對象:
console.log(person1 !== Person); // true
console.log(person1 !== Person.prototype); // true
console.log(Person.prototype !== Person); // true
實例通過 __proto__
鏈接到原型對象,構造函數通過 prototype
屬性鏈接到原型對象:
console.log(person1.__proto__ === Person.prototype); // true
conosle.log(person1.__proto__.constructor === Person); // true
同一個構造函數創建的實例共享同一個原型對象,而 instanceof
檢查實例的原型鏈中是否包含指定構造函數的原型:
console.log(person1.__proto__ === person2.__proto__); // true
console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
isPrototypeOf()
方法用於測試一個對象是否存在於另一個對象的原型鏈上。- 與
instanceof
運算符不同。在表達式object instanceof AFunction
中,object
的原型鏈是針對AFunction.prototype
進行檢查的,而不是針對AFunction
本身。
- 與
getPrototypeOf()
方法,返回參數的內部特性[[Prototype]]
的值- 使用它可以很方便的取得一個對象的原型,這在通過原型實現繼承時尤為重要。
setPrototypeOf()
方法,可以向實例的私有特性[[Prototype]]
寫入一個新值。- 使用它可以重寫一個對象的原型繼承關係
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Person.prototype.isPrototypeOf(person2)); // true
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true
console.log(Object.getPrototypeOf(person1).name); // "cosine"
Object.setPrototypeOf()
可能會嚴重影響代碼性能。Mozilla 文檔說得很清楚:“在所有瀏覽器和 JavaScript 引擎中,修改繼承關係的影響都是微妙且深遠的。這種影響並不僅是執行Object.setPrototypeOf()
語句那麼簡單,而是會涉及所有訪問了那些修改了[[Prototype]]
的對象的代碼。
為避免使用 Object.setPrototypeOf()
可能造成的性能下降,可以通過 Object.create()
來創建一個新對象,同時為其指定原型:
let biped = {
numLegs: 1
};
let person = Object.create(biped);
person.name = 'cosine';
console.log(person.name); // cosine
console.log(person.numLegs); // 1
console.log(Object.getPrototypeOf(person) === biped); // true
2、原型層級#
- 透過對象訪問屬性時,會按照該屬性名稱進行搜索。
- 如果在這個實例上發現了這個屬性,則返回該這個屬性對應值。
- 如果在該實例上沒有找到,則會進入原型對象,在原型對象上找到屬性後,返回對應的值。
- 如果原型對象上沒有找到,再到原型對象的原型對象上找…… 如此往復,直至找到
- 這既是原型用於在多個對象實例間共享屬性和方法的原理。
注意以下幾點:
- 雖然可以通過實例讀取原型對象上的值,但不可能通過實例重寫原型對象上的值
- 在實例上添加了一個與原型對象中同名的屬性,則會在實例上創建這個屬性,這個屬性會遮住原型對象上的屬性
- 使用
delete
操作符可以完全刪除實例上的這個屬性,從而使標識符解析過程能夠繼續搜索原型對象
hasOwnProperty()#
hasOwnProperty()
方法用於確定某個屬性是在實例上還是在原型對象上。這個方法會在屬性存在於調用它的對象實例上時返回 true
function Person() {}
Person.prototype.name = "cosine";
Person.prototype.age = 21;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
let person1 = new Person();
let person2 = new Person();
console.log(person1.hasOwnProperty("name")); // false
person1.name = "Khat"; // 添加了實例上的name, 遮蔽了原型上的name
console.log(person1.name); // "Khat",來自實例
console.log(person1.hasOwnProperty("name")); // true
console.log(person2.name); // "cosine",來自原型
console.log(person2.hasOwnProperty("name")); // false
delete person1.name;
console.log(person1.name); // "cosine",來自原型
console.log(person1.hasOwnProperty("name")); // false
3、原型和 in 操作符#
in
操作符有以下兩種使用方式:
- 單獨使用
in
操作符 - 在
for-in
循環中使用
單獨使用時,通過對象訪問指定屬性時返回 true
,無論該屬性是在實例上還是在原型上
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
person1.name = "cosine";
console.log(person1.name); // "Khat",來自實例
console.log(person1.hasOwnProperty("name")); // true
console.log("name" in person1); // true
console.log(person2.name); // "cosine",來自原型
console.log(person2.hasOwnProperty("name")); // false
console.log("name" in person2); // true
delete person1.name;
console.log(person1.name); // "cosine",來自原型
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true
如果要確定某個屬性是否存在於原型上,則可以同時使用 hasOwnProperty()
和 in
操作符
function hasPrototypeProperty(object, name){
return !object.hasOwnProperty(name) && (name in object);
} ;
在 for-in
循環中使用 in
操作符時,可以通過對象訪問且可以被枚舉的屬性都會返回,包括可枚舉的實例屬性、原型屬性(不包括被遮蔽的原型屬性和不可枚舉的屬性),除此之外,Object.keys()
方法也可以獲得對象上所有可枚舉的實例屬性,其返回一個包括該對象所有可枚舉屬性名稱的字符串數組
若想獲得所有實例屬性(包括不可枚舉),則可以用 Object.getOwnPropertyNames()
4、屬性枚舉順序#
- 枚舉順序不確定
for-in
循環Object.keys()
- 取決於 JavaScript 引擎,可能因瀏覽器而異。
- 枚舉順序確定
Object.getOwnPropertyNames()
Object.getOwnPropertySymbols()
Object.assign()
- 先以 升序 枚舉數值鍵,然後以 插入順序 枚舉字符串和符號鍵。
對象迭代#
ECMAScript 2017 新增了兩個靜態方法,用於將對象內容轉換為序列化的(可迭代的)格式。這兩個靜態方法 Object.values()
和 Object.entries()
接收一個對象,返回它們內容的數組。
Object.values()
返回對象值的數組Object.entries()
返回鍵 - 值對的數組- 非字符串屬性會被轉換為字符串輸出,符號屬性則會被忽略。這兩個方法執行的是對象的淺複製
1、 其他原型語法#
為了減少代碼冗余,從視覺上更好地封裝原型功能,直接通過一個包含所有屬性和方法的對象字面量來重寫原型成為了一種常見的做法
function Person() {}
Person.prototype = {
name: "cosine",
age: 21,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
但有一個問題:這樣重寫後,Person.prototype 的 constructor 屬性就不指向 Person 了。在創建函數時,也會創建它的 prototype 對象,同時會自動給這個原型的 constructor 屬性賦值。因此我們需要專門設置一下 constructor 的值
Person.prototype = {
constructor: Person, // 賦值
name: "cosine",
age: 21,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
以這種方式恢復 constructor
屬性會創建一個[[Enumerable]]
為 true
的屬性。而
原生 constructor
屬性默認是不可枚舉的。因此,如果你使用的是兼容 ECMAScript 的 JavaScript 引擎,那可能需要改為使用 Object.defineProperty()
方法來定義 constructor
屬性
2、 原型的動態性#
因為從 原型上搜索值 的過程是動態的,所以即使實例在修改原型之前已經存在,任何時候對原型對象所做的修改也會在實例上反映出來:
function Person() {}
let friend = new Person();
Person.prototype = {
constructor: Person,
name: "cosine",
age: 21,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
friend.sayName(); // 錯誤
3、 原生對象原型#
- 原型模式是實現所有原生引用類型的模式。
- 所有原生引用類型的構造函數(包括
Object
、Array
、String
等)都在原型上定義了實例方法。- 陣列實例的
sort()
等方法就是Array.prototype
上定義 - 字符串包裝對象的
substring()
等方法也是在String.prototype
上定義
- 陣列實例的
- 通過原生對象的原型可以取得所有默認方法的引用,也可以給原生類型的實例定義新的方法(但不建議這麼做 x)
4、 原型的問題#
- 弱化了向構造函數傳遞初始化參數的能力,會導致所有實例默認都取得相同的屬性值
- 最主要問題源自它的共享特性,一般來說,不同的實例應該有屬於自己的屬性副本,而原型模式新增的屬性會在不同實例上反映出來
繼承實現#
原型鏈#
- ECMA-262 將原型鏈定義為 ECMAScript 的 主要繼承方式
- 基本思想:通過原型,繼承多個引用類型的屬性與方法。
回顧一下:每個構造函數有一個原型對象,通過 prototype
指向原型對象,原型 dx 有一個屬性 constructor
指回構造函數,實例有一個內部指針 __proto__
指向原型。
那如果原型是另一種類型的實例呢?那就意味著這個原型本身有一個內部指針 __proto__
指向另一個原型對象。相應地另一個原型也有另一個指針 constructor
指向另一個構造函數,這樣就在實例和原型之間構造了一條原型鏈。
原型鏈擴展了前面描述的原型搜索機制。我們知道,在讀取實例上的屬性時,首先會在實例上搜索這個屬性。如果沒找到,則會繼承搜索實例的原型。在通過原型鏈實現繼承之後,搜索就可以向上,搜索原型的原型。直至原型鏈的末端
1、默認原型#
默認情況下,所有引用類型都繼承自 Object
,這也是通過原型鏈實現的。任何函數的默認原型都是一個 Object
的實例,這意味著這個實例有一個內部指針指向 Object.prototype
。這也是為什麼自定義類型能夠繼承包括 toString()
、valueOf()
在內的所有默認方法的原因。
2、原型與繼承關係#
原型與實例的關係可以通過兩種方式來確定。
- 使用
instanceof
操作符,如果一個實例的原型鏈中出現過相應的構造函數,則instanceof
返回true
console.log(instance instanceof Object); // true
console.log(instance instanceof SuperType); // true
console.log(instance instanceof SubType); // true
- 使用
isPrototypeOf()
方法。只要該實例原型鏈中包含這個原型,這個方法就返回 true
console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(SubType.prototype.isPrototypeOf(instance)); // true
3、原型鏈的問題#
- 在談到原型的問題時也提到過,原型中包含的引用值會在所有實例間共享,這也是為什麼屬性通常會在構造函數中定義而不會定義在原型上的原因。
- 在使用原型實現繼承時,原型實際上變成了另一種類型的實例。這意味著原先的實例屬性
function SuperType() {
this.colors = ["red", "blue", "green"];
}
function SubType() {}
// 繼承 SuperType
SubType.prototype = new SuperType();
let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green,black"
當 SubType
通過原型繼承 SuperType
後,SubType.prototype
變成了 SuperType
的一個實例,因而也獲得了自己的 colors
屬性。這類似於創建了 SubType.prototype.colors 屬性。最終結果是,SubType
的所有實例都會共享這個 colors
屬性,instance1.colors
上的修改也會反映到 instance2.colors上
- 第二個問題是,子類在實例化時不能給父類的構造函數傳參。事實上,我們無法在不影響所有對象實例的情況下把參數傳進父類的構造函數。再加上之前提到的原型中包含引用值的問題,就導致原型鏈基本不會被單獨使用
盜用構造函數#
基本思路:在子類構造函數中調用父類構造函數。
函數就是在特定上下文中執行代碼的簡單對象,所以可以使用 apply()
和 call()
方法以新創建的對象為上下文執行構造函數。
function SuperType() {
this.colors = ["red", "blue", "green"];
}
function SubType() {
// 繼承 SuperType !!
SuperType.call(this);
}
let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green"
通過使用 call()
(或 apply()
)方法,SuperType
構造函數在為 SubType
的實例創建的新對象的上下文中執行了。這相當於新的 SubType
對象上運行了 SuperType()
函數中的所有初始化代碼!這樣每個實例就有自己的屬性了
1、傳遞參數#
使用盜用構造函數可以在子類構造函數中向父類構造函數傳參。
function SuperType(name){
this.name = name;
}
function SubType() {
// 繼承 SuperType 並傳參
SuperType.call(this, "cosine");
// 實例屬性
this.age = 21;
}
let instance = new SubType();
console.log(instance.name); // "cosine";
console.log(instance.age); // 21
2、主要問題#
盜用構造函數的主要缺點,也是使用構造函數模式自定義類型的問題:
- 必須在構造函數中定義方法。因此函數不能重用
- 子類不能訪問父類原型上定義的方法,因此所有類型只能使用構造函數模式
由於存在這些問題,盜用構造函數基本上也不能單獨使用。
組合式繼承#
組合繼承綜合了原型鏈和盜用構造函數,將兩者的優點集中起來。
- 基本的思路是使用原型鏈繼承原型上的屬性和方法,而通過盜用構造函數繼承實例屬性
- 既可以把方法定義在原型上以實現重用,又可以讓每個實例都有自己的屬性
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;
}
// 繼承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() {
console.log(this.age);
};
let instance1 = new SubType("cosine", 21);
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "cosine";
instance1.sayAge(); // 21
let instance2 = new SubType("NaHCOx", 22);
console.log(instance2.colors); // "red,blue,green"
instance2.sayName(); // "NaHCOx";
instance2.sayAge(); // 22
原型式繼承#
適用情形:已有一個對象,想在它的基礎上再創建一個新對象。你需要把這個對象先傳給 object (),然後再對返回的對象進行適當修改。
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = Object.create(person, {
name: {
value: "Greg"
}
});
console.log(anotherPerson.name); // "Greg"
非常適合以下幾種情形:
- 不需要單獨創建構造函數
- 并且需要在對象間共享信息的場合
- 注意:屬性中包含的引用值始終會在相關對象間共享
寄生式繼承#
與原型式繼承比較接近的一種繼承方式是寄生式繼承(parasitic inheritance)
創建一個實現繼承的函數,以某種方式增強對象,然後返回這個對象。
基本的寄生繼承模式如下
function createAnother(original) {
let clone = Object.create(original); // 調用構造函數創建一個新對象
clone.sayHi = function() {
console.log(`Hi! I am ${this.name}`);
};
return clone; // 返回這個對象
}
let person = {
name: "cosine",
friends: ['NaHCOx', 'Khat']
};
let person2 = createAnother(person);
person2.name = 'CHxCOOH';
person2.sayHi(); // Hi! I am CHxCOOH
該例子通過 person 為源對象,返回一個增加了 sayHi 函數的新對象(進行了增強),主要適用於關注對象而不在乎構造函數和類型的場景
需要注意的一點是:
- 通過寄生式繼承給對象添加函數會導致函數難以重用,與構造函數模式類似
寄生式組合繼承#
組合繼承也有效率問題,比如父類構造函數始終會被調用兩次
- 在創建子類原型時調用
- 在子類構造函數中調用
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;
}
// 繼承方法
SubType.prototype = new SuperType(); // 第一次調用父類構造函數!
SubType.prototype.sayAge = function() {
console.log(this.age);
};
本質上,子類的原型最終是要包含父類對象的所有實例屬性,所以子類構造函數只需要在執行時 重寫 自己的原型就可以了。
寄生式組合繼承主要思路如下:
- 透過 盜用構造函數 繼承屬性
- 使用 混合式原型鏈 繼承方法
也就是說,將父類原型拿來,並用指向子類的 constructor 遮蔽原有 constructor,
function inheritPrototype(subType, superType) {
let prototype = Object.create(superType.prototype); // 創建父類原型的一個副本
prototype.constructor = subType; // 找回重寫原型導致丟失的constructor
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;
}
// 繼承方法
- SubType.prototype = new SuperType(); // 第一次調用父類構造函數!
+ inheritPrototype(SubType, SuperType); // 變成調用這個函數
SubType.prototype.sayAge = function() {
console.log(this.age);
};
避免了不必要的多次調用父類構造函數,也保證了原型鏈不變,可以算是引用類型繼承的最佳模式~