instanceof
的更多詳細資訊(進階)在這本書中,JavaScript 的物件導向程式(OOP)風格分為四個步驟介紹。本章涵蓋步驟 2-4,上一章涵蓋步驟 1。步驟如下(圖 9)
原型是 JavaScript 唯一的繼承機制:每個物件都有原型,原型可能是 null
或物件。在後者的情況下,物件會繼承原型中的所有屬性。
在物件文字中,你可以透過特殊屬性 __proto__
來設定原型
const proto = {
protoProp: 'a',
;
}const obj = {
__proto__: proto,
objProp: 'b',
;
}
// obj inherits .protoProp:
.equal(obj.protoProp, 'a');
assert.equal('protoProp' in obj, true); assert
由於原型物件本身也可以有原型,因此我們會得到一個物件鏈,也就是所謂的原型鏈。這表示繼承會讓我們覺得自己是在處理單一物件,但實際上我們處理的是物件鏈。
圖 10 顯示 obj
的原型鏈看起來像什麼。
未繼承的屬性稱為自有屬性。obj
有自有屬性 .objProp
。
有些運算會考慮所有屬性(自有和繼承的),例如取得屬性
> const obj = { foo: 1 };
> typeof obj.foo // own'number'
> typeof obj.toString // inherited'function'
其他運算只會考慮自有屬性,例如 Object.keys()
> Object.keys(obj)[ 'foo' ]
繼續閱讀,了解另一個只會考慮自有屬性的運算:設定屬性。
原型鏈的一個可能違反直覺的面向是,透過物件設定任何屬性(甚至是繼承的屬性)只會變更該物件本身,而不會變更任何原型。
考慮下列物件 obj
const proto = {
protoProp: 'a',
;
}const obj = {
__proto__: proto,
objProp: 'b',
; }
在下一段程式碼中,我們設定繼承的屬性 obj.protoProp
(A 行)。它會透過建立一個自有屬性來「變更」它:讀取 obj.protoProp
時,會先找到自有屬性,而其值會覆寫繼承屬性的值。
// In the beginning, obj has one own property
.deepEqual(Object.keys(obj), ['objProp']);
assert
.protoProp = 'x'; // (A)
obj
// We created a new own property:
.deepEqual(Object.keys(obj), ['objProp', 'protoProp']);
assert
// The inherited property itself is unchanged:
.equal(proto.protoProp, 'a');
assert
// The own property overrides the inherited property:
.equal(obj.protoProp, 'x'); assert
obj
的原型鏈如圖 11 所示。
__proto__
,除非在物件文字中我建議避免使用偽屬性 __proto__
:正如我們將在稍後看到的,並非所有物件都有它。
不過,物件文字中的 __proto__
不同。在那裡,它是一個內建功能,而且總是可用的。
取得和設定原型的建議方法是
取得原型的最佳方法是透過以下方法
.getPrototypeOf(obj: Object) : Object Object
設定原型的最佳方法是在建立物件時,透過物件文字中的 __proto__
或透過
.create(proto: Object) : Object Object
如果你必須這麼做,你可以使用 Object.setPrototypeOf()
來變更現有物件的原型。但這可能會對效能產生負面影響。
以下是這些功能的使用方式
const proto1 = {};
const proto2 = {};
const obj = Object.create(proto1);
.equal(Object.getPrototypeOf(obj), proto1);
assert
Object.setPrototypeOf(obj, proto2);
.equal(Object.getPrototypeOf(obj), proto2); assert
到目前為止,「p
是 o
的原型」總是表示「p
是 o
的直接原型」。但它也可以更廣泛地使用,表示 p
在 o
的原型鏈中。這種較鬆散的關係可以透過
.isPrototypeOf(o) p
例如
const a = {};
const b = {__proto__: a};
const c = {__proto__: b};
.equal(a.isPrototypeOf(b), true);
assert.equal(a.isPrototypeOf(c), true);
assert
.equal(a.isPrototypeOf(a), false);
assert.equal(c.isPrototypeOf(a), false); assert
考慮以下程式碼
const jane = {
name: 'Jane',
describe() {
return 'Person named '+this.name;
,
};
}const tarzan = {
name: 'Tarzan',
describe() {
return 'Person named '+this.name;
,
};
}
.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan'); assert
我們有兩個非常相似的物件。兩個物件都有兩個屬性,名稱分別為 .name
和 .describe
。此外,方法 .describe()
是相同的。我們如何避免重複這個方法?
我們可以將它移到一個物件 PersonProto
,並讓那個物件成為 jane
和 tarzan
的原型
const PersonProto = {
describe() {
return 'Person named ' + this.name;
,
};
}const jane = {
__proto__: PersonProto,
name: 'Jane',
;
}const tarzan = {
__proto__: PersonProto,
name: 'Tarzan',
; }
原型的名稱反映出 jane
和 tarzan
都是人。
圖 12 說明了這三個物件是如何連接的:底部的物件現在包含特定於 jane
和 tarzan
的屬性。頂部的物件包含它們之間共用的屬性。
當你呼叫方法 jane.describe()
時,this
指向那個方法呼叫的接收者 jane
(在圖表的左下角)。這就是為什麼這個方法仍然有效。tarzan.describe()
的運作方式類似。
.equal(jane.describe(), 'Person named Jane');
assert.equal(tarzan.describe(), 'Person named Tarzan'); assert
我們現在準備好來處理類別,它基本上是設定原型鏈的簡潔語法。在底層,JavaScript 的類別是不尋常的。但這是你在使用它們時很少會看到的東西。對於使用過其他物件導向程式語言的人來說,它們通常應該會感到熟悉。
我們之前使用過 jane
和 tarzan
,它們是代表人的單一物件。讓我們使用類別宣告來實作一個人的物件工廠
class Person {
constructor(name) {
this.name = name;
}describe() {
return 'Person named '+this.name;
} }
jane
和 tarzan
現在可以透過 new Person()
來建立
const jane = new Person('Jane');
.equal(jane.name, 'Jane');
assert.equal(jane.describe(), 'Person named Jane');
assert
const tarzan = new Person('Tarzan');
.equal(tarzan.name, 'Tarzan');
assert.equal(tarzan.describe(), 'Person named Tarzan'); assert
類別 Person
有兩個方法
.describe()
.constructor()
,在建立新執行個體後直接呼叫,並初始化該執行個體。它接收傳遞給 new
營運子(在類別名稱之後)的參數。如果你不需要任何參數來設定新執行個體,你可以省略建構函式。有兩種類別定義(定義類別的方法)
類別表達式可以是匿名的或命名的
// Anonymous class expression
const Person = class { ··· };
// Named class expression
const Person = class MyClass { ··· };
命名類別表達式的名稱作用類似於 命名函式表達式的名稱。
這是類別的初探。我們很快會探索更多功能,但首先我們需要了解類別的內部結構。
類別的內部結構有很多東西。讓我們看看 jane
的圖示(圖 13)。
類別 Person
的主要目的是設定右邊的原型鏈(jane
,接著是 Person.prototype
)。值得注意的是,類別 Person
內部的兩個建構(.constructor
和 .describe()
)都為 Person.prototype
建立了屬性,而不是 Person
。
這種有點奇怪的方法是因為向後相容性:在類別之前,建構函式(一般函式,透過 new
營運子呼叫)通常用作物件的工廠。類別大多是建構函式更好的語法,因此與舊程式碼保持相容。這說明了為什麼類別是函式
> typeof Person'function'
在這本書中,我互換使用術語建構函式(函式)和類別。
很容易將 .__proto__
和 .prototype
混淆。希望圖 13 能清楚說明它們的差異
.__proto__
是用於存取物件原型的偽屬性。.prototype
是因為 new
營運子如何使用它而特別的普通屬性。這個名稱並不理想:Person.prototype
沒有指向 Person
的原型,它指向 Person
所有執行個體的原型。Person.prototype.constructor
(進階)圖 13 中有一個細節我們還沒看過:Person.prototype.constructor
指回 Person
> Person.prototype.constructor === Persontrue
這種設定也是為了向後相容性而存在。但它還有兩個額外的好處。
首先,一個類別的每個實例都會繼承屬性 .constructor
。因此,給定一個實例,你可以使用它來建立「類似的」物件
const jane = new Person('Jane');
const cheeta = new jane.constructor('Cheeta');
// cheeta is also an instance of Person
// (the instanceof operator is explained later)
.equal(cheeta instanceof Person, true); assert
其次,你可以取得建立給定實例的類別名稱
const tarzan = new Person('Tarzan');
.equal(tarzan.constructor.name, 'Person'); assert
以下類別宣告主體中的所有建構都會建立 Foo.prototype
的屬性。
class Foo {
constructor(prop) {
this.prop = prop;
}protoMethod() {
return 'protoMethod';
}protoGetter() {
get return 'protoGetter';
} }
讓我們依序檢視它們
.constructor()
會在建立 Foo
的新實例後呼叫,以設定該實例。.protoMethod()
是個一般方法。它儲存在 Foo.prototype
中。.protoGetter
是個 getter,儲存在 Foo.prototype
中。以下互動使用類別 Foo
> const foo = new Foo(123);
> foo.prop123
> foo.protoMethod()'protoMethod'
> foo.protoGetter'protoGetter'
以下類別宣告主體中的所有建構都會建立所謂的靜態屬性,也就是 Bar
本身的屬性。
class Bar {
static staticMethod() {
return 'staticMethod';
}static get staticGetter() {
return 'staticGetter';
} }
靜態方法和靜態 getter 的使用方式如下
> Bar.staticMethod()'staticMethod'
> Bar.staticGetter'staticGetter'
instanceof
運算子instanceof
運算子會告訴你一個值是不是給定類別的實例
> new Person('Jane') instanceof Persontrue
> ({}) instanceof Personfalse
> ({}) instanceof Objecttrue
> [] instanceof Arraytrue
我們會在探討完子類別後,在稍後更詳細地探討 instanceof
運算子。
我推薦使用類別的原因如下
類別是物件建立和繼承的常見標準,現在廣泛受到各個框架(React、Angular、Ember 等)支援。這改善了過去的情況,當時幾乎每個框架都有自己的繼承函式庫。
它們協助 IDE 和型別檢查器等工具進行其工作,並在那裡啟用新功能。
如果你從其他語言轉到 JavaScript,而且習慣使用類別,那麼你可以更快上手。
JavaScript 引擎會最佳化它們。也就是說,使用類別的程式碼幾乎總是比使用自訂繼承函式庫的程式碼快。
你可以對內建建構函式(例如 Error
)進行子類別化。
這並不表示類別是完美的
有過度繼承的風險。
有將太多功能放入類別的風險(而有些功能通常比較適合放在函式中)。
它們在表面上和本質上的運作方式截然不同。換句話說,語法和語意之間存在脫節。以下是兩個範例:
C
內的方法定義會在物件 C.prototype
中建立一個方法。這種脫節的動機是為了向後相容。值得慶幸的是,這種脫節在實際上造成的問題很少;如果你順著類別假裝的樣子走,通常不會有問題。
練習:撰寫類別
exercises/proto-chains-classes/point_class_test.mjs
本節說明如何將物件的某些資料對外隱藏。我們在類別的脈絡中討論這些技術,但它們也適用於直接建立的物件,例如透過物件文字。
第一種技術是透過在屬性的名稱前面加上底線來讓屬性變為私有。這並不會以任何方式保護屬性;它只是對外傳達:「你不必知道這個屬性。」
在以下程式碼中,屬性 ._counter
和 ._action
是私有的。
class Countdown {
constructor(counter, action) {
this._counter = counter;
this._action = action;
}dec() {
this._counter--;
if (this._counter === 0) {
this._action();
}
}
}
// The two properties aren’t really private:
.deepEqual(
assertObject.keys(new Countdown()),
'_counter', '_action']); [
使用這種技術,你不會獲得任何保護,而且私有名稱可能會衝突。好處是它很容易使用。
另一種技術是使用弱映射。確切的運作方式已在 弱映射章節 中說明。以下是預覽
const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
constructor(counter, action) {
.set(this, counter);
_counter.set(this, action);
_action
}dec() {
let counter = _counter.get(this);
--;
counter.set(this, counter);
_counterif (counter === 0) {
.get(this)();
_action
}
}
}
// The two pseudo-properties are truly private:
.deepEqual(
assertObject.keys(new Countdown()),
; [])
這種技術能為你提供相當程度的防護,以避免外部存取,而且不會發生名稱衝突。但它也比較複雜。
本書說明了類別中私有資料最重要的技術。它可能也即將內建支援。請參閱 ECMAScript 提案 “類別公用執行個體欄位和私有執行個體欄位” 以取得詳細資訊。
在 探索 ES6 中說明了其他一些技術。
類別也可以子類化(「延伸」)現有的類別。以下的 Employee
類別就是 Person
的子類別,作為範例
class Person {
constructor(name) {
this.name = name;
}describe() {
return `Person named ${this.name}`;
}static logNames(persons) {
for (const person of persons) {
console.log(person.name);
}
}
}
class Employee extends Person {
constructor(name, title) {
super(name);
this.title = title;
}describe() {
return super.describe() +
` (${this.title})`;
}
}
const jane = new Employee('Jane', 'CTO');
.equal(
assert.describe(),
jane'Person named Jane (CTO)');
兩個註解
在 .constructor()
方法內,你必須透過 super()
呼叫超建構函式,才能存取 this
。這是因為在呼叫超建構函式之前,this
並不存在(這種現象是類別特有的)。
靜態方法也會被繼承。例如,Employee
繼承了靜態方法 .logNames()
> 'logNames' in Employeetrue
練習:子類別化
exercises/proto-chains-classes/color_point_class_test.mjs
前一節的類別 Person
和 Employee
由幾個物件組成(圖 14)。了解這些物件如何關聯的一個關鍵見解是,有兩個原型鏈
實例原型鏈從 jane
開始,並繼續使用 Employee.prototype
和 Person.prototype
。原則上,原型鏈在這裡結束,但我們得到另一個物件:Object.prototype
。此原型幾乎為所有物件提供服務,這就是為什麼它也包含在這裡
> Object.getPrototypeOf(Person.prototype) === Object.prototypetrue
在類別原型鏈中,Employee
排在第一位,Person
排在第二位。之後,鏈繼續使用 Function.prototype
,它只存在於此,因為 Person
是函式,而函式需要 Function.prototype
的服務。
> Object.getPrototypeOf(Person) === Function.prototypetrue
instanceof
(進階)我們尚未了解 instanceof
的實際運作方式。給定表達式
instanceof C x
instanceof
如何判斷 x
是否是 C
(或 C
的子類別)的實例?它透過檢查 C.prototype
是否在 x
的原型鏈中來執行此操作。也就是說,以下表達式是等效的
.prototype.isPrototypeOf(x) C
如果我們回到圖 14,我們可以確認原型鏈確實引導我們得出以下正確答案
> jane instanceof Employeetrue
> jane instanceof Persontrue
> jane instanceof Objecttrue
接下來,我們將利用對子類別化的了解來理解幾個內建物件的原型鏈。以下工具函式 p()
可協助我們進行探索。
const p = Object.getPrototypeOf.bind(Object);
我們萃取了 Object
的方法 .getPrototypeOf()
,並將其指定給 p
。
{}
的原型鏈讓我們從檢查一般物件開始
> p({}) === Object.prototypetrue
> p(p({})) === nulltrue
圖 15 顯示此原型鏈的圖表。我們可以看到 {}
確實是 Object
的實例,因為 Object.prototype
在其原型鏈中。
[]
的原型鏈陣列的原型鏈是什麼樣子?
> p([]) === Array.prototypetrue
> p(p([])) === Object.prototypetrue
> p(p(p([]))) === nulltrue
此原型鏈(在圖 16 中視覺化)告訴我們,陣列物件是 Array
的實例,而 Array
是 Object
的子類別。
function () {}
的原型鏈最後,一般函式的原型鏈告訴我們,所有函式都是物件
> p(function () {}) === Function.prototypetrue
> p(p(function () {})) === Object.prototypetrue
Object
實例的物件只有當 Object.prototype
在其原型鏈中時,物件才是 Object
的實例。透過各種文字建立的大多數物件都是 Object
的實例
> ({}) instanceof Objecttrue
> (() => {}) instanceof Objecttrue
> /abc/ug instanceof Objecttrue
沒有原型的物件不是 Object
的實例
> ({ __proto__: null }) instanceof Objectfalse
Object.prototype
結束大多數原型鏈。它的原型是 null
,這表示它也不是 Object
的實例
> Object.prototype instanceof Objectfalse
.__proto__
究竟如何運作?偽屬性 .__proto__
是由類別 Object
透過 getter 和 setter 實作。它可以像這樣實作
class Object {
__proto__() {
get return Object.getPrototypeOf(this);
}__proto__(other) {
set Object.setPrototypeOf(this, other);
}// ···
}
這表示您可以透過建立沒有 Object.prototype
在其原型鏈中的物件來關閉 .__proto__
(請參閱前一節)
> '__proto__' in {}true
> '__proto__' in { __proto__: null }false
讓我們檢查方法呼叫如何與類別一起運作。我們重新檢視早先的 jane
class Person {
constructor(name) {
this.name = name;
}describe() {
return 'Person named '+this.name;
}
}const jane = new Person('Jane');
圖 17 有 jane
的原型鏈圖表。
一般方法呼叫會被派送,方法呼叫 jane.describe()
會發生在兩個步驟中
派送:在 jane
的原型鏈中,找到第一個其鍵為 'describe'
的屬性,並擷取其值。
const func = jane.describe;
呼叫:呼叫該值,同時將 this
設定為 jane
。
.call(jane); func
這種動態尋找方法並呼叫它的方式稱為動態派送。
您可以直接進行相同的方法呼叫,而不需要調度
.prototype.describe.call(jane) Person
這次,我們透過 Person.prototype.describe
直接指向方法,而不是在原型鏈中搜尋它。我們也透過 .call()
以不同的方式指定 this
。
請注意,this
永遠指向原型鏈的開頭。這讓 .describe()
可以存取 .name
。
當您使用 Object.prototype
的方法時,直接方法呼叫會變得很有用。例如,Object.prototype.hasOwnProperty(k)
會檢查 this
是否有非繼承的屬性,其金鑰為 k
> const obj = { foo: 123 };
> obj.hasOwnProperty('foo')true
> obj.hasOwnProperty('bar')false
然而,在物件的原型鏈中,可能會有另一個金鑰為 'hasOwnProperty'
的屬性,會覆寫 Object.prototype
中的方法。那麼,調度的方法呼叫就不會起作用
> const obj = { hasOwnProperty: true };
> obj.hasOwnProperty('bar')TypeError: obj.hasOwnProperty is not a function
解決方法是使用直接方法呼叫
> Object.prototype.hasOwnProperty.call(obj, 'bar')false
> Object.prototype.hasOwnProperty.call(obj, 'hasOwnProperty')true
這種直接方法呼叫通常會縮寫如下
> ({}).hasOwnProperty.call(obj, 'bar')false
> ({}).hasOwnProperty.call(obj, 'hasOwnProperty')true
這個模式看起來可能效率不彰,但大多數引擎會最佳化這個模式,所以效能不應該是問題。
JavaScript 的類別系統只支援單一繼承。也就是說,每個類別最多只能有一個父類別。要解決這個限制的方法之一是透過稱為混入類別(簡稱:混入)的技術。
概念如下:假設我們想要類別 C
繼承自兩個父類別 S1
和 S2
。這將會是多重繼承,而 JavaScript 不支援這種繼承。
我們的解決方法是將 S1
和 S2
變成混入,也就是子類別的工廠
const S1 = (Sup) => class extends Sup { /*···*/ };
const S2 = (Sup) => class extends Sup { /*···*/ };
這兩個函式中的每個函式都會傳回一個類別,用來延伸給定的父類別 Sup
。我們建立類別 C
的方式如下
class C extends S2(S1(Object)) {
/*···*/
}
我們現在有一個類別 C
,它延伸自類別 S2
,而 S2
延伸自類別 S1
,而 S1
延伸自 Object
(大多數類別都隱含地延伸自 Object
)。
我們實作一個混入 Branded
,它有設定和取得物件品牌的輔助方法
const Branded = (Sup) => class extends Sup {
setBrand(brand) {
this._brand = brand;
return this;
}getBrand() {
return this._brand;
}; }
我們使用這個混入來實作類別 Car
的品牌管理
class Car extends Branded(Object) {
constructor(model) {
super();
this._model = model;
}toString() {
return `${this.getBrand()} ${this._model}`;
} }
以下程式碼確認混入有效:Car
有 Branded
的方法 .setBrand()
。
const modelT = new Car('Model T').setBrand('Ford');
.equal(modelT.toString(), 'Ford Model T'); assert
混入讓我們擺脫單一繼承的限制
原則上,物件是不排序的。排序屬性的主要原因是,列出項目、鍵或值的操作是確定性的。例如,這有助於測試。
測驗
請參閱測驗應用程式。