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
仅在 new
运算符使用其值作为其创建的实例的原型时才特殊。
**首先,伪方法 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
()
{}
对于派生类,使用以下默认构造函数
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
进行子类化通常不是最佳解决方案。通常最好创建自己的类(您可以控制其接口),并将委托给私有属性中的数组。
本节介绍四种为 ES6 类管理私有数据的方法
constructor
的环境中方法 #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
();
}
}
}
优点
缺点
有一种使用 WeakMap 的巧妙技术,它结合了第一种方法(安全性)和第二种方法(能够使用原型方法)的优点。以下代码演示了此技术:我们使用 WeakMap _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
)();
}
}
}
两个 WeakMap _counter
和 _action
中的每一个都将对象映射到其私有数据。由于 WeakMap 的工作方式,这不会阻止对象被垃圾回收。只要您对外部世界隐藏 WeakMap,私有数据就是安全的。
如果您想更加安全,可以将 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()
列出对象的 all 属性键(包括符号!)。在 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 或生成器方法。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
跟踪并影响数组元素的管理。通常,可以从头开始创建奇异对象,但不能将现有的普通对象转换为奇异对象。不幸的是,这就是 Array
在 A 行中调用时必须做的事情:它必须将为 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
对其重写的方法(从 A 行开始)进行超调用(B 行)。让我们将存储方法的对象称为该方法的*宿主对象*。例如,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]]
,将其绑定到创建它的对象。如果通过赋值移动它,它将继续引用原始对象的超属性。在未来的 ECMAScript 版本中,可能也会有办法转移此类方法。
ECMAScript 6 中内置构造函数的另一个机制已经可以扩展:有时方法会创建其类的新实例。如果您创建一个子类,该方法应该返回其类的实例还是子类的实例?一些内置的 ES6 方法允许您通过所谓的*物种模式*来配置它们如何创建实例。
例如,考虑 Array
的子类 SortedArray
。如果我们在该类的实例上调用 map()
,我们希望它返回 Array
的实例,以避免不必要的排序。默认情况下,map()
返回接收器 (this
) 的实例,但物种模式允许您更改这一点。
在接下来的三节中,我将在示例中使用两个辅助函数
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
)
{
···
}
标准物种模式由 Promise.prototype.then()
、类型化数组的 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
;
}
普通数组以略微不同的方式实现物种模式
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 的未来版本现在可以扩展这种最小化设计——类将为特征(或混合)、值对象(如果具有相同内容,则不同对象相等)和常量类(产生不可变实例)等功能奠定基础。
以下文档是本章的重要来源