new
類別和子類別
class
Point
{
constructor
(
x
,
y
)
{
this
.
x
=
x
;
this
.
y
=
y
;
}
toString
()
{
return
`(
${
this
.
x
}
,
${
this
.
y
}
)`
;
}
}
class
ColorPoint
extends
Point
{
constructor
(
x
,
y
,
color
)
{
super
(
x
,
y
);
this
.
color
=
color
;
}
toString
()
{
return
super
.
toString
()
+
' in '
+
this
.
color
;
}
}
使用類別
> const cp = new ColorPoint(25, 8, 'green');
> cp.toString();
'(25, 8) in green'
> cp instanceof ColorPoint
true
> cp instanceof Point
true
在底層,ES6 類別並不是什麼新奇的事物:它們主要提供更方便的語法來建立舊式的建構函式。如果您使用 typeof
,您會看到這一點
> typeof Point
'function'
在 ECMAScript 6 中,類別定義如下
class
Point
{
constructor
(
x
,
y
)
{
this
.
x
=
x
;
this
.
y
=
y
;
}
toString
()
{
return
`(
${
this
.
x
}
,
${
this
.
y
}
)`
;
}
}
您使用這個類別就像 ES5 建構函式一樣
> var p = new Point(25, 8);
> p.toString()
'(25, 8)'
事實上,類別定義的結果是一個函式
> typeof Point
'function'
但是,您只能透過 new
呼叫類別,不能透過函式呼叫(稍後會說明這個背後的原理)
> Point()
TypeError: Classes can’t be function-called
類別定義的成員之間沒有分隔符號。例如,物件文字的成員以逗號分隔,而逗號在類別定義的頂層是不合法的。分號是允許的,但會被忽略
class
MyClass
{
foo
()
{}
;
// OK, ignored
,
// SyntaxError
bar
()
{}
}
分號是允許的,以準備可能包含以分號終止成員的未來語法。逗號是被禁止的,以強調類別定義與物件文字不同。
函式宣告會提升:進入作用域時,在其中宣告的函式會立即可用,與宣告發生在哪裡無關。這表示你可以呼叫稍後宣告的函式
foo
();
// works, because `foo` is hoisted
function
foo
()
{}
相反地,類別宣告不會提升。因此,類別只在執行到達其定義並評估後才會存在。事先存取它會導致ReferenceError
new
Foo
();
// ReferenceError
class
Foo
{}
此限制的原因是類別可以有一個extends
子句,其值是任意表達式。該表達式必須在適當的「位置」評估,其評估無法提升。
沒有提升比你想像的限制更少。例如,出現在類別宣告之前的函式仍然可以參照該類別,但你必須等到類別宣告經過評估後才能呼叫函式。
function
functionThatUsesBar
()
{
new
Bar
();
}
functionThatUsesBar
();
// ReferenceError
class
Bar
{}
functionThatUsesBar
();
// OK
與函式類似,有兩種類別定義,兩種定義類別的方法:類別宣告和類別表達式。
與函式表達式類似,類別表達式可以是匿名的
const
MyClass
=
class
{
···
};
const
inst
=
new
MyClass
();
也與函式表達式類似,類別表達式可以有僅在其內部可見的名稱
const
MyClass
=
class
Me
{
getClassName
()
{
return
Me
.
name
;
}
};
const
inst
=
new
MyClass
();
console
.
log
(
inst
.
getClassName
());
// Me
console
.
log
(
Me
.
name
);
// ReferenceError: Me is not defined
最後兩行顯示Me
不會成為類別外部的變數,但可以在類別內部使用。
類別主體只能包含方法,但不能包含資料屬性。原型擁有資料屬性通常被視為反模式,所以這只是強制執行最佳實務。
constructor
、靜態方法、原型方法 讓我們探討類別定義中常見的三種類別方法。
class
Foo
{
constructor
(
prop
)
{
this
.
prop
=
prop
;
}
static
staticMethod
()
{
return
'classy'
;
}
prototypeMethod
()
{
return
'prototypical'
;
}
}
const
foo
=
new
Foo
(
123
);
此類別宣告的物件圖如下。理解它的提示:[[Prototype]]
是物件之間的繼承關係,而prototype
是一個常規屬性,其值是一個物件。屬性prototype
僅相對於使用其值作為其建立的實例的原型的新運算子是特殊的。
首先,偽方法constructor
。此方法很特別,因為它定義表示類別的函式
> Foo === Foo.prototype.constructor
true
> typeof Foo
'function'
它有時稱為類別建構函式
。它具有常規建構函式函式沒有的功能(主要是透過super()
建構呼叫其超建構函式的功能,稍後會說明)。
其次,靜態方法。靜態屬性(或類別屬性)是 Foo
本身的屬性。如果您在方法定義前加上 static
,您將建立一個類別方法
> typeof Foo.staticMethod
'function'
> Foo.staticMethod()
'classy'
第三,原型方法。 Foo
的原型屬性是 Foo.prototype
的屬性。它們通常是方法,並由 Foo
的實例繼承。
> typeof Foo.prototype.prototypeMethod
'function'
> foo.prototypeMethod()
'prototypical'
為了及時完成 ES6 類別,它們被刻意設計成「極簡」。這就是為什麼您目前只能建立靜態方法、getter 和 setter,但不能建立靜態資料屬性。有一個建議將它們加入語言中。在該建議被接受之前,有兩個解決方法可以使用。
首先,您可以手動加入一個靜態屬性
class
Point
{
constructor
(
x
,
y
)
{
this
.
x
=
x
;
this
.
y
=
y
;
}
}
Point
.
ZERO
=
new
Point
(
0
,
0
);
您可以使用 Object.defineProperty()
來建立一個唯讀屬性,但我喜歡指定值的簡潔性。
其次,您可以建立一個靜態 getter
class
Point
{
constructor
(
x
,
y
)
{
this
.
x
=
x
;
this
.
y
=
y
;
}
static
get
ZERO
()
{
return
new
Point
(
0
,
0
);
}
}
在兩種情況下,您都會取得一個屬性 Point.ZERO
,您可以讀取它。在第一個情況中,每次都會傳回同一個實例。在第二個情況中,每次都會傳回一個新實例。
getter 和 setter 的語法就像 ECMAScript 5 物件字面值 中的一樣
class
MyClass
{
get
prop
()
{
return
'getter'
;
}
set
prop
(
value
)
{
console
.
log
(
'setter: '
+
value
);
}
}
您使用 MyClass
如下。
> const inst = new MyClass();
> inst.prop = 123;
setter: 123
> inst.prop
'getter'
如果您將方法名稱放在方括號中,您可以透過一個運算式來定義它。例如,下列定義 Foo
的方式都是等效的。
class
Foo
{
myMethod
()
{}
}
class
Foo
{
[
'my'
+
'Method'
]()
{}
}
const
m
=
'myMethod'
;
class
Foo
{
[
m
]()
{}
}
ECMAScript 6 中有幾個特殊方法的鍵是符號。運算方法名稱讓您可以定義此類方法。例如,如果一個物件有一個方法的鍵是 Symbol.iterator
,它就是可迭代的。這表示它的內容可以透過 for-of
迴圈和其他語言機制來迭代。
class
IterableClass
{
[
Symbol
.
iterator
]()
{
···
}
}
如果您使用星號 (*
) 作為方法定義的前置詞,它就會變成一個產生器方法。產生器在定義其金鑰為 Symbol.iterator
的方法時特別有用。以下程式碼示範其運作方式。
class
IterableArguments
{
constructor
(...
args
)
{
this
.
args
=
args
;
}
*
[
Symbol
.
iterator
]()
{
for
(
const
arg
of
this
.
args
)
{
yield
arg
;
}
}
}
for
(
const
x
of
new
IterableArguments
(
'hello'
,
'world'
))
{
console
.
log
(
x
);
}
// Output:
// hello
// world
extends
子句讓您可以建立現有建構函式的子類別 (可能已透過類別定義或尚未定義)
class
Point
{
constructor
(
x
,
y
)
{
this
.
x
=
x
;
this
.
y
=
y
;
}
toString
()
{
return
`(
${
this
.
x
}
,
${
this
.
y
}
)`
;
}
}
class
ColorPoint
extends
Point
{
constructor
(
x
,
y
,
color
)
{
super
(
x
,
y
);
// (A)
this
.
color
=
color
;
}
toString
()
{
return
super
.
toString
()
+
' in '
+
this
.
color
;
// (B)
}
}
同樣地,這個類別的用法符合您的預期
> const cp = new ColorPoint(25, 8, 'green');
> cp.toString()
'(25, 8) in green'
> cp instanceof ColorPoint
true
> cp instanceof Point
true
有兩種類別
Point
是基本類別,因為它沒有 extends
子句。ColorPoint
是衍生類別。有兩種使用 super
的方式
constructor
) 使用它就像函式呼叫 (super(···)
),以進行超建構函式呼叫 (A 行)。static
) 使用它就像屬性參考 (super.prop
) 或方法呼叫 (super.method(···)
),以參考超屬性 (B 行)。在 ECMAScript 6 中,子類別的原型是超類別
> Object.getPrototypeOf(ColorPoint) === Point
true
這表示靜態屬性會被繼承
class
Foo
{
static
classMethod
()
{
return
'hello'
;
}
}
class
Bar
extends
Foo
{
}
Bar
.
classMethod
();
// 'hello'
您甚至可以超呼叫靜態方法
class
Foo
{
static
classMethod
()
{
return
'hello'
;
}
}
class
Bar
extends
Foo
{
static
classMethod
()
{
return
super
.
classMethod
()
+
', too'
;
}
}
Bar
.
classMethod
();
// 'hello, too'
在衍生類別中,您必須先呼叫 super()
,才能使用 this
class
Foo
{}
class
Bar
extends
Foo
{
constructor
(
num
)
{
const
tmp
=
num
*
2
;
// OK
this
.
num
=
num
;
// ReferenceError
super
();
this
.
num
=
num
;
// OK
}
}
隱含地離開衍生建構函式而不呼叫 super()
也會導致錯誤
class
Foo
{}
class
Bar
extends
Foo
{
constructor
()
{
}
}
const
bar
=
new
Bar
();
// ReferenceError
就像在 ES5 中,您可以透過明確傳回物件來覆寫建構函式的結果
class
Foo
{
constructor
()
{
return
Object
.
create
(
null
);
}
}
console
.
log
(
new
Foo
()
instanceof
Foo
);
// false
如果你這樣做,this
是否已初始化並不重要。換句話說:如果你以這種方式覆寫結果,則不必在衍生建構函式中呼叫 super()
。
如果你未為基底類別指定 建構函式
,則會使用下列定義
constructor
()
{}
對於衍生類別,會使用下列預設建構函式
constructor
(...
args
)
{
super
(...
args
);
}
在 ECMAScript 6 中,你終於可以子類化所有內建建構函式(ES5 有 解決方法,但這些方法有很大的限制)。
例如,你現在可以建立自己的例外類別(在大部分引擎中,這些類別會繼承堆疊追蹤的功能)
class
MyError
extends
Error
{
}
throw
new
MyError
(
'Something happened!'
);
你也可以建立 Array
的子類別,其執行個體會正確處理 length
class
Stack
extends
Array
{
get
top
()
{
return
this
[
this
.
length
-
1
];
}
}
var
stack
=
new
Stack
();
stack
.
push
(
'world'
);
stack
.
push
(
'hello'
);
console
.
log
(
stack
.
top
);
// hello
console
.
log
(
stack
.
length
);
// 2
請注意,子類化 Array
通常不是最佳解決方案。通常最好建立你自己的類別(由你控制其介面),並委派給私有屬性中的 Array。
本節說明管理 ES6 類別私有資料的四種方法
建構函式
的環境中
方法 #1 和 #2 在 ES5 中已經很常見,用於建構函式。方法 #3 和 #4 是 ES6 中的新功能。讓我們透過每種方法實作相同的範例四次。
我們的執行範例是類別 Countdown
,它會在計數器(其初始值為 counter
)達到零時呼叫回呼 action
。兩個參數 action
和 counter
應儲存在私有資料中。
在第一個實作中,我們將 action
和 counter
儲存在類別建構函式的環境中。環境是內部資料結構,JavaScript 引擎會在每次進入新範圍(例如,透過函式呼叫或建構函式呼叫)時儲存參數和區域變數。以下是程式碼
class
Countdown
{
constructor
(
counter
,
action
)
{
Object
.
assign
(
this
,
{
dec
()
{
if
(
counter
<
1
)
return
;
counter
--
;
if
(
counter
===
0
)
{
action
();
}
}
});
}
}
使用 Countdown
的方式如下
> const c = new Countdown(2, () => console.log('DONE'));
> c.dec();
> c.dec();
DONE
優點
缺點
關於此技術的更多資訊:在「Speaking JavaScript」中的「建構函式環境中的私有資料(Crockford 隱私模式)」一節。
下列程式碼將私有資料保留在名稱以底線開頭的屬性中
class
Countdown
{
constructor
(
counter
,
action
)
{
this
.
_counter
=
counter
;
this
.
_action
=
action
;
}
dec
()
{
if
(
this
.
_counter
<
1
)
return
;
this
.
_counter
--
;
if
(
this
.
_counter
===
0
)
{
this
.
_action
();
}
}
}
優點
缺點
有一個涉及 WeakMaps 的巧妙技術,結合了第一種方法(安全性)和第二種方法(能夠使用原型方法)的優點。下列程式碼示範了此技術:我們使用 WeakMaps _counter
和 _action
來儲存私有資料。
const
_counter
=
new
WeakMap
();
const
_action
=
new
WeakMap
();
class
Countdown
{
constructor
(
counter
,
action
)
{
_counter
.
set
(
this
,
counter
);
_action
.
set
(
this
,
action
);
}
dec
()
{
let
counter
=
_counter
.
get
(
this
);
if
(
counter
<
1
)
return
;
counter
--
;
_counter
.
set
(
this
,
counter
);
if
(
counter
===
0
)
{
_action
.
get
(
this
)();
}
}
}
兩個 WeakMaps _counter
和 _action
各自將物件對應到它們的私有資料。由於 WeakMaps 的運作方式,這不會阻止物件被垃圾回收。只要您讓 WeakMaps 對外界隱藏,私有資料就是安全的。
如果您想更安全,您可以將 WeakMap.prototype.get
和 WeakMap.prototype.set
儲存在變數中,並呼叫它們(而不是方法,動態地)
const
set
=
WeakMap
.
prototype
.
set
;
···
set
.
call
(
_counter
,
this
,
counter
);
// _counter.set(this, counter);
如果惡意程式碼將這些方法替換為窺探我們私有資料的方法,您的程式碼就不會受到影響。但是,您僅受到在您的程式碼之後執行的程式碼的保護。如果它在您的程式碼之前執行,您無能為力。
優點
缺點
另一個私有資料儲存位置是金鑰為符號的屬性
const
_counter
=
Symbol
(
'counter'
);
const
_action
=
Symbol
(
'action'
);
class
Countdown
{
constructor
(
counter
,
action
)
{
this
[
_counter
]
=
counter
;
this
[
_action
]
=
action
;
}
dec
()
{
if
(
this
[
_counter
]
<
1
)
return
;
this
[
_counter
]
--
;
if
(
this
[
_counter
]
===
0
)
{
this
[
_action
]();
}
}
}
每個符號都是唯一的,這就是為什麼符號值屬性金鑰永遠不會與任何其他屬性金鑰衝突。此外,符號在某種程度上對外界隱藏,但並非完全如此
const
c
=
new
Countdown
(
2
,
()
=>
console
.
log
(
'DONE'
));
console
.
log
(
Object
.
keys
(
c
));
// []
console
.
log
(
Reflect
.
ownKeys
(
c
));
// [ Symbol(counter), Symbol(action) ]
優點
缺點
Reflect.ownKeys()
列出物件的所有屬性金鑰(包括符號!)。在 JavaScript 中使用子類別有兩個原因
instanceof
測試)也是超類別的實例。預期子類別實例會像超類別實例一樣運作,但可能會做更多的事情。類別對於實作繼承的用途有限,因為它們僅支援單一繼承(一個類別最多只能有一個超類別)。因此,不可能從多個來源繼承工具方法,它們都必須來自超類別。
那麼我們要如何解決這個問題?讓我們透過一個範例來探討解決方案。考慮一個企業的管理系統,其中 Employee
是 Person
的子類別。
class
Person
{
···
}
class
Employee
extends
Person
{
···
}
此外,還有用於儲存和資料驗證的工具類別
class
Storage
{
save
(
database
)
{
···
}
}
class
Validation
{
validate
(
schema
)
{
···
}
}
如果我們可以像這樣包含工具類別,那就太好了
// Invented ES6 syntax:
class
Employee
extends
Storage
,
Validation
,
Person
{
···
}
也就是說,我們希望 Employee
是 Storage
的子類別,而 Storage
應該是 Validation
的子類別,而 Validation
應該是 Person
的子類別。Employee
和 Person
將只會用於類別的其中一個鏈。但是 Storage
和 Validation
將會被多次使用。我們希望它們成為類別的範本,我們填入其超類別。此類範本稱為抽象子類別或混入。
在 ES6 中實作混入的一種方法是將其視為一個函式,其輸入值為超類別,其輸出值為延伸該超類別的子類別
const
Storage
=
Sup
=>
class
extends
Sup
{
save
(
database
)
{
···
}
};
const
Validation
=
Sup
=>
class
extends
Sup
{
validate
(
schema
)
{
···
}
};
在此,我們受益於 extends
子句的運算元不是固定的識別碼,而是一個任意表達式。使用這些混入,Employee
會像這樣建立
class
Employee
extends
Storage
(
Validation
(
Person
))
{
···
}
致謝。我所知道的這個技術的首次出現是 Sebastian Markbåge 的 Gist。
到目前為止,我們已經看過類別的基本要素。如果您有興趣了解底層是如何運作的,您只需要繼續閱讀。讓我們從類別的語法開始。以下是 ECMAScript 6 規格 A.4 節 中所示語法的略微修改版本。
ClassDeclaration:
"class" BindingIdentifier ClassTail
ClassExpression:
"class" BindingIdentifier? ClassTail
ClassTail:
ClassHeritage? "{" ClassBody? "}"
ClassHeritage:
"extends" AssignmentExpression
ClassBody:
ClassElement+
ClassElement:
MethodDefinition
"static" MethodDefinition
";"
MethodDefinition:
PropName "(" FormalParams ")" "{" FuncBody "}"
"*" PropName "(" FormalParams ")" "{" GeneratorBody "}"
"get" PropName "(" ")" "{" FuncBody "}"
"set" PropName "(" PropSetParams ")" "{" FuncBody "}"
PropertyName:
LiteralPropertyName
ComputedPropertyName
LiteralPropertyName:
IdentifierName /* foo */
StringLiteral /* "foo" */
NumericLiteral /* 123.45, 0xFF */
ComputedPropertyName:
"[" Expression "]"
兩個觀察
class
Foo
extends
combine
(
MyMixin
,
MySuperClass
)
{}
eval
或 arguments
;不允許重複的類別元素名稱;名稱 constructor
只可使用於一般方法,不可用於 getter、setter 或 generator 方法。TypeException
。
class
C
{
m
()
{}
}
new
C
.
prototype
.
m
();
// TypeError
類別宣告會建立(可變)let 繫結。下表說明與特定類別 Foo
相關的屬性屬性
可寫 | 可列舉 | 可設定 | |
---|---|---|---|
靜態屬性 Foo.*
|
true |
false |
true |
Foo.prototype |
false |
false |
false |
Foo.prototype.constructor |
false |
false |
true |
原型屬性 Foo.prototype.*
|
true |
false |
true |
備註
類別具有詞彙內部名稱,就像命名函式運算式一樣。
您可能知道命名函式運算式具有詞彙內部名稱
const
fac
=
function
me
(
n
)
{
if
(
n
>
0
)
{
// Use inner name `me` to
// refer to function
return
n
*
me
(
n
-
1
);
}
else
{
return
1
;
}
};
console
.
log
(
fac
(
3
));
// 6
命名函式運算式的名稱 me
會變成一個詞彙繫結變數,不受目前持有函式的變數影響。
有趣的是,ES6 類別也具有詞彙內部名稱,您可以在方法(建構函式方法和一般方法)中使用。
class
C
{
constructor
()
{
// Use inner name C to refer to class
console
.
log
(
`constructor:
${
C
.
prop
}
`
);
}
logProp
()
{
// Use inner name C to refer to class
console
.
log
(
`logProp:
${
C
.
prop
}
`
);
}
}
C
.
prop
=
'Hi!'
;
const
D
=
C
;
C
=
null
;
// C is not a class, anymore:
new
C
().
logProp
();
// TypeError: C is not a function
// But inside the class, the identifier C
// still works
new
D
().
logProp
();
// constructor: Hi!
// logProp: Hi!
(在 ES6 規範中,內部名稱是由 ClassDefinitionEvaluation 的動態語意 設定的。)
致謝:感謝 Michael Ficarra 指出類別具有內部名稱。
在 ECMAScript 6 中,子類別的建立方式如下。
class
Person
{
constructor
(
name
)
{
this
.
name
=
name
;
}
toString
()
{
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
;
}
toString
()
{
return
`
${
super
.
toString
()
}
(
${
this
.
title
}
)`
;
}
}
const
jane
=
new
Employee
(
'Jane'
,
'CTO'
);
console
.
log
(
jane
.
toString
());
// Person named Jane (CTO)
下一節將探討由前一個範例建立的物件結構。其後一節將探討 `jane` 如何配置和初始化。
前一個範例建立了以下物件。
原型鏈 是透過 [[Prototype]]
關係(一種繼承關係)連結的物件。在圖表中,您可以看到兩個原型鏈
衍生類別的原型是它所延伸的類別。此設定的理由是您希望子類別繼承其超類別的所有屬性
> Employee.logNames === Person.logNames
true
基底類別的原型是 Function.prototype
,它也是函式的原型
> const getProto = Object.getPrototypeOf.bind(Object);
> getProto(Person) === Function.prototype
true
> getProto(function () {}) === Function.prototype
true
這表示基底類別及其所有衍生類別(其原型)都是函式。傳統的 ES5 函式基本上是基底類別。
類別的主要目的是設定此原型鏈。原型鏈以 Object.prototype
結束(其原型為 null
)。這使得 Object
成為每個基底類別的隱含超類別(就實例和 instanceof
算子而言)。
此設定的理由是您希望子類別的實例原型繼承超類別實例原型的所有屬性。
順帶一提,透過物件文字建立的物件也具有原型 Object.prototype
> Object.getPrototypeOf({}) === Object.prototype
true
類別建構函式之間的資料流程不同於 ES5 中子類別建立的正規方式。在底層,它大致如下。
// Base class: this is where the instance is allocated
function
Person
(
name
)
{
// Performed before entering this constructor:
this
=
Object
.
create
(
new
.
target
.
prototype
);
this
.
name
=
name
;
}
···
function
Employee
(
name
,
title
)
{
// Performed before entering this constructor:
this
=
uninitialized
;
this
=
Reflect
.
construct
(
Person
,
[
name
],
new
.
target
);
// (A)
// super(name);
this
.
title
=
title
;
}
Object
.
setPrototypeOf
(
Employee
,
Person
);
···
const
jane
=
Reflect
.
construct
(
// (B)
Employee
,
[
'Jane'
,
'CTO'
],
Employee
);
// const jane = new Employee('Jane', 'CTO')
實例物件在 ES6 和 ES5 中是在不同的位置建立的
super()
呼叫超建構函式,這會觸發建構函式呼叫。new
的運算元中建立的,這是建構函式呼叫鏈中的第一個。透過函式呼叫呼叫超建構函式。前述程式碼使用了兩個新的 ES6 功能
new.target
是所有函式都具有的隱含參數。在建構函式呼叫鏈中,它的角色類似於在超方法呼叫鏈中的 this
。
new
直接呼叫(如 B 行),則 new.target
的值就是該建構函式。super()
呼叫(如 A 行),則 new.target
的值就是進行呼叫的建構函式的 new.target
。undefined
。這表示您可以使用 new.target
來判斷函式是以函式呼叫還是以建構函式呼叫(透過 new
)。new.target
指的是周圍非箭頭函式的 new.target
。Reflect.construct()
讓您可以進行建構函式呼叫,同時透過最後一個參數指定 new.target
。這種子類別化的方式有其優點,它讓一般程式碼可以對內建建構函式(例如 Error
和 Array
)進行子類別化。後續章節會說明為何需要不同的方法。
提醒一下,以下是您在 ES5 中進行子類別化的方式
function
Person
(
name
)
{
this
.
name
=
name
;
}
···
function
Employee
(
name
,
title
)
{
Person
.
call
(
this
,
name
);
this
.
title
=
title
;
}
Employee
.
prototype
=
Object
.
create
(
Person
.
prototype
);
Employee
.
prototype
.
constructor
=
Employee
;
···
this
原本在衍生建構函式中未初始化,表示如果它們在呼叫 super()
之前以任何方式存取 this
,就會擲回錯誤。this
初始化,呼叫 super()
會產生 ReferenceError
。這可以保護您避免重複呼叫 super()
。return
陳述式),結果就是 this
。如果 this
未初始化,就會擲回 ReferenceError
。這可以保護您避免忘記呼叫 super()
。undefined
和 null
),結果就是 this
(此行為必須保持與 ES5 及更早版本相容)。如果 this
未初始化,就會擲回 TypeError
。this
是否初始化並不重要。extends
子句 讓我們探討 extends
子句如何影響類別的設定方式 (規格第 14.5.14 節)。
extends
子句的值必須是「可建構的」(可透過 new
呼叫)。不過,null
是允許的。
class
C
{
}
C
的原型:Function.prototype
(如同一般函式)C.prototype
的原型:Object.prototype
(也是透過物件文字建立的物件的原型)
class
C
extends
B
{
}
C
的原型:B
C.prototype
的原型:B.prototype
class
C
extends
Object
{
}
C
的原型:Object
C.prototype
的原型:Object.prototype
請注意以下與第一個案例的細微差異:如果沒有 extends
子句,類別就是基礎類別,並配置執行個體。如果類別延伸 Object
,它就是衍生類別,而 Object
配置執行個體。產生的執行個體 (包括其原型鏈) 是相同的,但您到達的方式不同。
class
C
extends
null
{
}
C
的原型:Function.prototype
C.prototype
的原型:null
此類別讓您避免在原型鏈中使用 Object.prototype
。
在 ECMAScript 5 中,大多數內建建構函式無法進行子類別化 (有幾個解決方法)。
為了了解原因,讓我們使用正規的 ES5 模式對 Array
進行子類別化。正如我們很快就會發現的,這行不通。
function
MyArray
(
len
)
{
Array
.
call
(
this
,
len
);
// (A)
}
MyArray
.
prototype
=
Object
.
create
(
Array
.
prototype
);
不幸的是,如果我們實例化 MyArray
,我們會發現它無法正常運作:執行個體屬性 length
對於我們新增陣列元素並未產生反應
> var myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
0
有兩個障礙阻止 myArr
成為適當的陣列。
第一個障礙:初始化。您傳遞給建構函式 Array
的 this
(在 A 行) 會被完全忽略。這表示您無法使用 Array
來設定為 MyArray
建立的執行個體。
> var a = [];
> var b = Array.call(a, 3);
> a !== b // a is ignored, b is a new object
true
> b.length // set up correctly
3
> a.length // unchanged
0
第二個障礙:配置。由 Array
建立的執行個體物件是異國的 (ECMAScript 規格用來表示具有一般物件沒有的功能的物件的術語):它們的屬性 length
追蹤並影響陣列元素的管理。一般來說,異國物件可以從頭建立,但您無法將現有的正常物件轉換為異國物件。不幸的是,當在 A 行呼叫時,這是 Array
必須執行的動作:它必須將為 MyArray
建立的正常物件轉換為異國陣列物件。
在 ECMAScript 6 中,對 Array
進行子類別化如下所示
class
MyArray
extends
Array
{
constructor
(
len
)
{
super
(
len
);
}
}
這可行
> const myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
1
讓我們探討 ES6 子類別化方法如何移除先前提到的障礙
Array
無法設定實例,而 Array
回傳一個完全設定好的實例,便移除了這個障礙。與 ES5 相反,這個實例有子類別的原型。以下 ES6 程式碼在 B 行進行超方法呼叫。
class
Person
{
constructor
(
name
)
{
this
.
name
=
name
;
}
toString
()
{
// (A)
return
`Person named
${
this
.
name
}
`
;
}
}
class
Employee
extends
Person
{
constructor
(
name
,
title
)
{
super
(
name
);
this
.
title
=
title
;
}
toString
()
{
return
`
${
super
.
toString
()
}
(
${
this
.
title
}
)`
;
// (B)
}
}
const
jane
=
new
Employee
(
'Jane'
,
'CTO'
);
console
.
log
(
jane
.
toString
());
// Person named Jane (CTO)
為了了解超呼叫如何運作,讓我們看看 jane
的物件圖表
在 B 行,Employee.prototype.toString
進行超呼叫(B 行)至它已覆寫的方法(從 A 行開始)。讓我們將儲存方法的物件稱為該方法的起始物件。例如,Employee.prototype
是 Employee.prototype.toString()
的起始物件。
B 行的超呼叫包含三個步驟
toString
的方法。該方法可能在搜尋開始的物件中找到,或在原型鏈中之後找到。this
呼叫該方法。這樣做的原因是:超呼叫方法必須能夠存取相同的實例屬性(在我們的範例中,是 jane
的自有屬性)。請注意,即使您只取得(super.prop
)或設定(super.prop = 123
)超屬性(相對於進行方法呼叫),this
仍可能(在內部)在步驟 3 中扮演角色,因為可能會呼叫 getter 或 setter。
讓我們用三種不同的方式(但等效的方式)表達這些步驟
// Variation 1: supermethod calls in ES5
var
result
=
Person
.
prototype
.
toString
.
call
(
this
)
// steps 1,2,3
// Variation 2: ES5, refactored
var
superObject
=
Person
.
prototype
;
// step 1
var
superMethod
=
superObject
.
toString
;
// step 2
var
result
=
superMethod
.
call
(
this
)
// step 3
// Variation 3: ES6
var
homeObject
=
Employee
.
prototype
;
var
superObject
=
Object
.
getPrototypeOf
(
homeObject
);
// step 1
var
superMethod
=
superObject
.
toString
;
// step 2
var
result
=
superMethod
.
call
(
this
)
// step 3
變化 3 是 ECMAScript 6 處理超呼叫的方式。此方法由 兩個內部繫結 支援,而函式的環境具有這些繫結(環境提供儲存空間,即範圍中變數的所謂繫結)
[[thisValue]]
:此內部繫結也存在於 ECMAScript 5 中,並儲存 this
的值。[[HomeObject]]
:參照環境函式的起始物件。透過所有使用 super
的方法具有的內部槽 [[HomeObject]]
填入。繫結和槽在 ECMAScript 6 中都是新的。super
? 參照超屬性在涉及原型鏈時很方便,這就是為什麼您可以在物件文字和類別定義中的方法定義(包括產生器方法定義、getter 和 setter)中使用它的原因。類別可以是衍生的或非衍生的,方法可以是靜態的或非靜態的。
在函式宣告、函式表達式和產生器函式中,不允許使用 super
參照屬性。
super
的方法無法移動 您無法移動使用 super
的方法:此類方法具有內部插槽 [[HomeObject]]
,將其繫結到建立它的物件。如果您透過指定移動它,它將繼續參照原始物件的 superproperties。在未來的 ECMAScript 版本中,可能也有辦法傳輸此類方法。
ECMAScript 6 中已經讓內建建構函式的另一個機制變得可擴充:有時方法會建立其類別的新執行個體。如果您建立子類別,方法應該傳回其類別的執行個體,還是子類別的執行個體?幾個內建的 ES6 方法讓您可以透過所謂的species 模式來設定它們如何建立執行個體。
舉例來說,考慮 Array
的子類別 SortedArray
。如果我們在該類別的執行個體上呼叫 map()
,我們希望它傳回 Array
的執行個體,以避免不必要的排序。預設情況下,map()
傳回接收器 (this
) 的執行個體,但 species 模式讓您可以變更這個設定。
在以下三個區段中,我將在範例中使用兩個輔助函式
function
isObject
(
value
)
{
return
(
value
!==
null
&&
(
typeof
value
===
'object'
||
typeof
value
===
'function'
));
}
/**
* Spec-internal operation that determines whether `x`
* can be used as a constructor.
*/
function
isConstructor
(
x
)
{
···
}
標準 species 模式是由 Promise.prototype.then()
、Typed Arrays 的 filter()
方法和其他運算使用的。其運作方式如下
this.constructor[Symbol.species]
存在,請將其用作新執行個體的建構函式。Array
)。在 JavaScript 中實作,模式看起來會像這樣
function
SpeciesConstructor
(
O
,
defaultConstructor
)
{
const
C
=
O
.
constructor
;
if
(
C
===
undefined
)
{
return
defaultConstructor
;
}
if
(
!
isObject
(
C
))
{
throw
new
TypeError
();
}
const
S
=
C
[
Symbol
.
species
];
if
(
S
===
undefined
||
S
===
null
)
{
return
defaultConstructor
;
}
if
(
!
isConstructor
(
S
))
{
throw
new
TypeError
();
}
return
S
;
}
一般陣列實作 species 模式的方式略有不同
function
ArraySpeciesCreate
(
self
,
length
)
{
let
C
=
undefined
;
// If the receiver `self` is an Array,
// we use the species pattern
if
(
Array
.
isArray
(
self
))
{
C
=
self
.
constructor
;
if
(
isObject
(
C
))
{
C
=
C
[
Symbol
.
species
];
}
}
// Either `self` is not an Array or the species
// pattern didn’t work out:
// create and return an Array
if
(
C
===
undefined
||
C
===
null
)
{
return
new
Array
(
length
);
}
if
(
!
IsConstructor
(
C
))
{
throw
new
TypeError
();
}
return
new
C
(
length
);
}
Array.prototype.map()
透過 ArraySpeciesCreate(this, this.length)
建立它傳回的陣列。
Promise 使用種類模式的變體,用於靜態方法,例如 Promise.all()
let
C
=
this
;
// default
if
(
!
isObject
(
C
))
{
throw
new
TypeError
();
}
// The default can be overridden via the property `C[Symbol.species]`
const
S
=
C
[
Symbol
.
species
];
if
(
S
!==
undefined
&&
S
!==
null
)
{
C
=
S
;
}
if
(
!
IsConstructor
(
C
))
{
throw
new
TypeError
();
}
const
instance
=
new
C
(
···
);
這是屬性 [Symbol.species]
的預設 getter
static
get
[
Symbol
.
species
]()
{
return
this
;
}
這個預設 getter 由內建類別 Array
、ArrayBuffer
、Map
、Promise
、RegExp
、Set
和 %TypedArray%
實作。它會自動繼承至這些內建類別的子類別。
你可以透過兩種方式覆寫預設種類:使用你選擇的建構函式或 null
。
你可以透過靜態 getter (A 行) 覆寫預設種類
class
MyArray1
extends
Array
{
static
get
[
Symbol
.
species
]()
{
// (A)
return
Array
;
}
}
因此,map()
會傳回 Array
的執行個體
const
result1
=
new
MyArray1
().
map
(
x
=>
x
);
console
.
log
(
result1
instanceof
Array
);
// true
如果你沒有覆寫預設種類,map()
會傳回子類別的執行個體
class
MyArray2
extends
Array
{
}
const
result2
=
new
MyArray2
().
map
(
x
=>
x
);
console
.
log
(
result2
instanceof
MyArray2
);
// true
如果你不想使用靜態 getter,你需要使用 Object.defineProperty()
。你無法使用指定,因為已經有一個只有 getter 的相同金鑰屬性。這表示它是唯讀的,無法指定給它。
例如,這裡我們將 MyArray1
的種類設定為 Array
Object
.
defineProperty
(
MyArray1
,
Symbol
.
species
,
{
value
:
Array
});
null
如果你將種類設定為 null
,則會使用預設建構函式(使用哪一個建構函式取決於使用的種類模式變體,請參閱前幾節以取得更多資訊)。
class
MyArray3
extends
Array
{
static
get
[
Symbol
.
species
]()
{
return
null
;
}
}
const
result3
=
new
MyArray3
().
map
(
x
=>
x
);
console
.
log
(
result3
instanceof
Array
);
// true
類別在 JavaScript 社群中引起爭議:一方面,來自基於類別語言的人很開心他們不必再處理 JavaScript 的非傳統繼承機制。另一方面,許多 JavaScript 程式設計師認為 JavaScript 複雜的部分不是原型繼承,而是建構函式。
ES6 類別提供了一些明確的好處
讓我們看看一些關於 ES6 類別的常見抱怨。你會看到我同意它們中的大多數,但我認為類別的優點遠大於它們的缺點。我很高興它們在 ES6 中,我建議使用它們。
是的,ES6 類別確實模糊了 JavaScript 繼承的真實本質。類別的外觀(其語法)與其行為(其語意)之間存在不幸的脫節:它看起來像一個物件,但它是一個函式。我比較希望類別是建構函式物件,而不是建構函式。我在 Proto.js
專案 中透過一個小型函式庫(證明了這種方法有多麼合適)探討了這種方法。
然而,向下相容性很重要,這就是類別成為建構函式的另一個原因。這樣一來,ES6 程式碼和 ES5 就能有更好的互操作性。
語法和語意之間的脫節會在 ES6 及後續版本中造成一些摩擦。但你可以簡單地按字面意思理解 ES6 類別,過著舒適的生活。我不認為這個錯覺會讓你吃虧。新手可以更快入門,然後在之後閱讀幕後發生的事情(在他們對這門語言更熟悉之後)。
類別只提供單一繼承,這嚴重限制了您在物件導向設計方面的表達自由。然而,一直以來,它們的計畫便是成為多重繼承機制的基礎,例如特質。
然後,類別便成為可實例化的實體,以及您組裝特質的位置。在發生這種情況之前,如果您想要多重繼承,您將需要使用函式庫。
new
如果您想實例化類別,您被迫在 ES6 中使用 new
。這表示您無法在不變更呼叫站點的情況下,從類別切換到工廠函式。這確實是一個限制,但有兩個減輕因素
constructor
方法傳回物件,來覆寫 new
算子傳回的預設結果。new
轉換到函式呼叫將很簡單。顯然,如果您無法控制呼叫您程式碼的程式碼,例如函式庫,這對您沒有幫助。因此,類別在語法上確實會 有些 限制您,但是,一旦 JavaScript 具有特質,它們便不會在 概念上 限制您(關於物件導向設計)。
目前禁止使用函式呼叫類別。這樣做是為了讓未來保持選擇的彈性,最終新增一種透過類別處理函式呼叫的方法。
類別的 Function.prototype.apply()
類比是什麼?也就是說,如果我有一個類別 TheClass
和一個引數陣列 args
,我該如何實例化 TheClass
?
一種方法是透過展開運算子(...
)
function
instantiate
(
TheClass
,
args
)
{
return
new
TheClass
(...
args
);
}
另一個選項是使用 Reflect.construct()
function
instantiate
(
TheClass
,
args
)
{
return
Reflect
.
construct
(
TheClass
,
args
);
}
類別的設計座右銘是「極簡」。討論過幾個進階功能,但最後為了得到 TC39 一致通過的設計而捨棄。
ECMAScript 的後續版本現在可以延伸這個極簡設計 – 類別將提供特徵(或混入)、值物件(如果內容相同,不同的物件就是相等的)和 const 類別(產生不可變的實例)等功能的基礎。
下列文件是本章節的重要來源