概述
在上篇文章中,我们使用ES5
通过构造函数和原型对象实现了「类」,通过原型链实现了「类」的继承。在ES6
中,新增class
和extend
实现了类和继承,提供了更接近传统语言的写法。
class
和大多数面向对象的语言不同,JavaScript
在诞生之初并不支持类,也没有把类继承作为创建相似或关联的对象的主要的定义方式。所以从ES1
到 ES5
这段时期,很多库都创建了一些工具,让JavaScript
看起来也能支持类。尽管一些JavaScript
开发者仍强烈主张该语言不需要类,但在流行库中实现类已成趋势,ES6
也顺势将其引入。但ES6
中的类和其他语言相比并不是完全等同的,目的是为了和JavaScript
的动态特性相配合。
定义
通过class
关键字,可以定义类。可以把class
看做一个语法糖,一个在ES5
中必须非常复杂才能完成的实现的封装。它使得定义一个类更加清晰明了,更符合面向对象编程的语法。
对比ES5
我们来对比一下:
// es5
function Person5 (name) {
this.age = 12
this.name = name
this.sayAge = function () {
return this.age
}
}
Person5.prototype.sayName = function () {
return this.name
}
let p1 = new Person5('zhu')
p1.age // 12
p1.sayName() // 'zhu'
// es6
class Person6 {
constructor (name) {
this.age = 12
this.name = name
this.sayAge = function () {
return this.age
}
}
sayName () {
return this.name
}
}
let p2 = new Person6('zhu')
p2.age // 12
p2.sayName() // 'zhu'
复制代码
类的原型对象的方法(sayName
),直接定义在类上即可。类的实例属性(name
)在constructor
方法里面定义。
两者相比,ES5
更能说请ECMAScript
通过prototype
实现类的原理,ES6
写法更加清晰规范。 而生成实例的方法还是一致的:通过new
命令。因为,class
只是定义类的语法糖。
原型对象的属性
至于类原型对象的属性的定义,目前还在提案阶段
// es5
function Person5 () {}
Person5.prototype.shareSex = 'man'
let p1 = new Person5()
p1.shareSex // 'man'
// es6
class Person6 {
shareSex = 'man'
}
let p2 = new Person6()
p2.shareSex // 'man'
复制代码
constructor
类的constructor
方法的行为模式完全与ES5的构造函数一样(关于构造函数可以参考{% post_link JavaScript高级程序设计第三版 %} 第6.2.2章节)。如果未定义,会默认添加。以下两个定义是等效的。
class Person {}
class Person {
constructor () {
return this
}
}
复制代码
表达式
上面的例子中,类的定义方式是声明式定义。与函数相似,类也有表达式定义的形式。
let Person = class {}
复制代码
虽然使用了声明变量,但是类表达式并不会提升。所以,声明式声明和表达式式声明除了写法不同,完全等价。
如果两种形式同时使用,声明式定义的名称可作为内部名称使用,指向类本身。但不能在外部使用,会报错。
let PersonMe = class Me {
constructor () {
Me.age = 12
}
sayAge () {
return Me.age
}
}
let p2 = new PersonMe()
p2.age // undefined
PersonMe.age // 12
p2.sayAge() // 12
Me.name // Uncaught ReferenceError: Me is not defined
PersonMe.name // Me
复制代码
我们看到PersonMe.name
的值是Me
,而不是PersonMe
。由此可知,变量PersonMe
只是存储了一个执行Me
这个类的指针。
而类名之所以可以在内部使用,是因为具名表达式实际是这样的:
let PersonMe = (function() {
const Me = function() {
Me.age = 12
}
Me.prototype.sayAge = function () {
return Me.age
}
return Me
})()
复制代码
也可以使用类表达式立即调用,以创建单例。
let p1 = new class {
constructor (name) {
this.name = name
}
sayName () {
return this.name
}
}('zhu')
复制代码
一级公民
在编程中,能被当做值来使用的就称为一级公民(first-class citizen)。这意味着它能做函数的参数、返回值、给变量赋值等。在ECMAScript
中,函数是一级公民;在ES6
中,类同样也是一级公民。
不可在内部重写类名
在类的内部,类名是使用const
声明的,所以不能在类的内部重写类名。但是在类的外部可以,因为无论是声明还是表达式的形式,在定义类的上下文中,函数名都只是存储指向类对象的指针。
区别
虽然我们说class
是语法糖,但是其某些地方表现与ES5
中也是有些区别的。
new.target
new
是从构造函数生成实例对象的命令。类必须使用new
调用,否则会报错,这点与ES5
中的构造函数不同。
// es5
function Person5 (name) {
return name
}
Person5('zhu') // zhu
// es6
class Person6 {
constructor (name) {
return name
}
}
Person6('zhu') // Uncaught TypeError: Class constructor Person6 cannot be invoked without 'new'
复制代码
而这正是通过new
命令在ES6
中新增的target
属性实现的。该属性一般用在构造函数之中,返回new
命令作用于的那个构造函数。如果构造函数不是通过new
命令调用的,new.target
会返回undefined,反之会返回作用的类。
class Person6 {
constructor () {
console.log(new.target)
}
}
Person6() // undefined
new Person6() // Person6
复制代码
值得注意的是,子类继承父类时,new.target
会返回子类。
class Father {
constructor () {
console.log(new.target)
}
}
class Son extends Father {}
new Son() // Son
复制代码
最后,我们使用new.target
在ES5
中模拟一下ES6
中class
的行为。
function Person5 () {
if(new.target === undefined) {
throw new TypeError("Class constructor Person6 cannot be invoked without 'new'")
}
console.log('success,', new.target === Person5)
}
Person5() // Uncaught TypeError: Class constructor Person6 cannot be invoked without 'new'
new Person5() // success, true
复制代码
类的方法不可枚举
ES6
中,在类上定义的方法,都是不可枚举的(non-enumerable)。在ES5
中是可以的。
// es5
function Person5 (name) {
this.age = 12
this.name = name
}
Person5.prototype.sayName = function () {
return this.name
}
// es6
class Person6 {
constructor (name) {
this.age = 12
this.name = name
}
sayName () {
return this.name
}
}
Object.getOwnPropertyDescriptor(Person5.prototype, 'sayName').enumerable // true
Object.getOwnPropertyDescriptor(Person6.prototype, 'sayName').enumerable // false
Object.keys(Person5.prototype) // ['sayName']
Object.keys(Person6.prototype) // []
Object.getOwnPropertyNames(Person5.prototype) // ["constructor", "sayName"]
Object.getOwnPropertyNames(Person6.prototype) // ["constructor", "sayName"]
复制代码
不存在变量提升
函数可以在当前作用域的任意位置定义,在任意位置调用。类不是函数,不存在变量提升。
// es5
new Person5()
function Person5 () {}
// es6
new Person6() // Uncaught ReferenceError: Person6 is not defined
class Person6 {}
复制代码
内部方法不是构造函数
类的静态方法、实例的方法内部都没有[[Construct]]
属性,也没有原型对象(没有prototype
属性)。因此使用new
来调用它们会抛出错误。
class Person6 {
sayHi () {
return 'hi'
}
}
new Person6.sayHi // Uncaught TypeError: Person6.sayHi is not a constructor
Person6.prototype.sayHi.prototype // undefined
复制代码
同样的,箭头函数(() => {}}
)也一样。
let Foo = () => {}
new Foo // Uncaught TypeError: Person6.sayHi is not a constructor
复制代码
这种不是构造函数的函数,在ES5
中,只有内置对象的方法属于这种情况。
Array.prototype.concat.prototype // undefined
复制代码
改进
除了区别,class
命令也有一些对ES5
构造函数的改进。比如,写法的改变,更加灵活、规范等等。
严格模式
在类和模块的内部,默认开启了严格模式,也就是默认使用了use strict
动态方法名
ES6
中,方法名可以动态命名。访问器属性也可以使用动态命名。
let methodName1 = 'sayName'
let methodName2 = 'sayAge'
class Person {
constructor (name) {
this.name = name
}
[methodName1] () {
return this.name
}
get [methodName2] () {
return 24
}
}
let p1 = new Person('zhu')
p1.sayName() // zhu
复制代码
访问器属性
在ES5
中,如果要将构造函数的实例属性设置成访问器属性,你要这样做:
function Person5 () {
this._age = 12
Object.defineProperty(this, 'age', {
get: function () {
console.log('get')
return this._age
},
set: function (val) {
console.log('set')
this._age = val
}
})
}
let p1 = new Person5()
p1.age // get 12
p1.age = 15 // set
p1.age // get 15
复制代码
在ES6
中我们有了更方便的写法:
class Person6 {
constructor () {
this._age = 12
}
get age () {
console.log('get')
return this._age
}
set age (val) {
console.log('set')
this._age = val
}
}
let p2 = new Person6()
p2.age // get 12
p2.age = 15 // set
p2.age // get 15
复制代码
静态属性和静态方法
类的静态属性和静态方法是定义在类上的,也可以说是定义在构造函数的。它们不能被实例对象继承,但是可以被子类继承。需要注意的是,静态属性如果是引用类型,子类继承的是指针。 在ES6
中,除了constructor
方法,在类的其他方法名前面加上static
关键字,就表示这是一个静态方法。
// es5
function Person5 () {}
Person5.age = 12
Person5.sayAge = function () {
return this.age
}
Person5.age // 12
Person5.sayAge() // 12
let p1 = new Person5()
p1.age // undefined
p1.sayAge // undefined
// 继承
Sub5.__proto__ = Person5
Sub5.age // 12
Sub5.sayAge() // 12
// es6
class Person6 {
static sayAge () {
return this.age
}
}
Person6.age = 12
Person6.age // 12
Person6.sayAge() // 12
let p2 = new Person5()
p2.age // undefined
p2.sayAge // undefined
// 继承
class Sub6 extends Person6 {}
Sub6.age // 12
Sub6.sayAge() // 12
复制代码
需要注意的是,静态方法里面的this
关键字,指向的是类,而不是实例。所以为了避免混淆,建议在静态方法中,直接使用类名。
class Person1 {
constructor (name) {
this.name = name
}
static getName () {
return this.name
}
getName () {
return this.name
}
}
let p1 = new Person1('zhu')
p1.getName() // 'zhu'
Person1.getName() // 'Person1'
class Person2 {
constructor (name) {
this.name = name
}
static getName () {
return Person2.name
}
getName () {
return this.name
}
}
let p2 = new Person2('zhu')
p2.getName() // 'zhu'
Person2.getName() // 'Person2'
复制代码
从上面的实例中我们可以看到,静态方法与非静态方法是可以重名的。
ES6
明确规定,Class
内部只有静态方法,没有静态属性。所以,目前只能在Class
外部定义(Person6.age = 12
)。 但是,现在已经有了相应的提案
class Person6 {
static age = 12
static sayAge () {
return this.age
}
}
复制代码
私有方法和私有属性
私有属性其实就在类中提前声明的,只能在类内部使用的属性。如下示例:
class PersonNext {
static x; // 静态属性;定义在类上,会被子类继承
public y; // 实例属性。一般简写为 [y;],忽略public关键字,定义在实例上。
#z; // 私有属性。类似于其他语言中的private。只能在类内部使用
}
复制代码
由于此写法还在提案阶段,本文暂不详细说明,有兴趣可以关注提案的进度
其他
this 的指向
Class
上实例方法中的this
,默认指向实例本身。但是使用解构赋值后,在函数指向时,作用域指向发生了改变,就有可能引起报错。虽说有解决的方法,但是还是尽量避免使用这种方式吧。
class Person6 {
constructor () {
this.name = 'zhu'
}
sayName () {
return this.name
}
}
let p1 = new Person6()
p1.sayName() // zhu
let { sayName } = p1
sayName() // Uncaught TypeError: Cannot read property 'name' of undefined
sayName.call(p1) // zhu
复制代码
babel
最后,我们看一下class
在babel
中如何转换成ES6的
let methodName = 'sayName'
class Person {
constructor (name) {
this.name = name
this.age = 46
}
static create (name) {
return new Person(name)
}
sayAge () {
return this.age
}
[methodName] () {
return this.name
}
}
复制代码
"use strict";
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
var methodName = "sayName";
var Person = (function() {
function Person(name) {
_classCallCheck(this, Person);
this.name = name;
this.age = 46;
}
Person.create = function create(name) {
return new Person(name);
};
Person.prototype.sayAge = function sayAge() {
return this.age;
};
Person.prototype[methodName] = function() {
return this.name;
};
return Person;
})();
复制代码
_classCallCheck
方法算是new.target
的polyfill。
继承
class继承主要就是添加了extends
关键字,相比与class
,extends
不仅仅是语法糖,还实现了许多ES5
无法实现的功能。也就是说,extends
是无法完全降级到ES5
的。比如,内置对象的继承。
extends
class
可以通过extends关键字实现继承,这比ES5
的通过修改原型链实现继承,要清晰和方便很多。 我们先来回顾下ES5
的实现:
function Father5 (name) {
this.name = name
this.age = 46
}
Father5.prototype.sayName = function () {
return this.name
}
Father5.prototype.sayAge = function () {
return this.age
}
Father5.create = function (name) {
return new this(name)
}
function Son5 (name) {
Father5.call(this, name)
}
Son5.prototype = Object.create(Father5.prototype, {
constructor: {
value: Son5,
enumerable: true,
writable: true,
configurable: true
}
})
Son5.__proto__ = Father5
Son5.prototype.setAge = function (age) {
this.age = age
}
var s1 = Son5.create('zhu')
s1.constructor // Son5
s1.sayName() // 'zhu'
s1.sayAge() // 46
s1.setAge(12)
s1.sayAge() // 12
复制代码
然后,我们看下class
和 extends
如何实现:
let Father6 = class Me {
constructor (name) {
this.name = name
this.age = 46
}
static create (name) {
return new Me(name)
}
sayName () {
return this.name
}
sayAge () {
return this.age
}
}
let Son6 = class Me extends Father6 {
constructor (name) {
super(name)
}
setAge (age) {
this.age = age
}
}
let s2 = Son6.create('sang')
s2.constructor // Son6
s2.sayName() // 'sang'
s2.sayAge() // 46
s2.setAge(13)
s2.sayAge() // 13
复制代码
我们看到extends
用super(name)
做了三件事:实例属性继承,原型对象继承,静态属性继承。接下来,我们就来说说super
。
super
在子类中,如果定义了constructor
,则必须在第一行调用super
。因为super
对子类的this
进行了封装,使之继承了父类的属性和方法。 如果在super
调用之前使用this
,会报错。
class Son extends Father {
constructor (name) {
this.name = name // Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
super(name)
this.name = name // 正常执行
}
}
复制代码
如果没有定义constructor
,则会默认添加。
class Son extends Father {}
// 等同于
class Son extends Father {
constructor (..arg) {
super(..arg)
}
}
复制代码
super
关键字必须作为一个函数或者一个对象使用,如果作为值使用会报错。
class Son extends Father{
constructor (name) {
super(name)
console.log(super) // Uncaught SyntaxError: 'super' keyword unexpected here
}
}
复制代码
作为函数调用时,只能在子类的constructor
函数中,否则也会报错。
作为对象使用时,在普通方法中,指向的是原父类的原型对象;在静态方法中,指向的是父类本身。
最后,由于对象总是继承其他对象的,所以可以在任意一个对象中,使用super关键字
var obj = {
toString() {
return "MyObject: " + super.toString();
}
};
obj.toString(); // MyObject: [object Object]
复制代码
唯一在
constructor
中可以不调用super
的情况是,constructor
显式的返回了一个对象。 不过,这种写法好像没什么意义。
没有继承
在ECMAScript
中,我们会经常使用字面量去「构造」一个基本类型数据。这其实是使用new
命令构造一个实例的语法糖。这往往让我们误以为在ECMAScript
中,一切函数都是构造函数,而一切对象都是这些构造函数的实例,而ECMAScript
也是一门面向对象的语言。
// 引用类型
var obj = {} // var obj = new Object()
var arr = [] // var arr = new Array()
// 值类型
var str = "" // var strObj = new String();var str = strObj.valueOf()
复制代码
但ECMAScript
并不是纯粹的面向对象语言,它里面也有函数式编程的东西。所以,并不是每个函数都有原型对象,都有constructor
。
比如原生构造函数的原型对象上面的方法(如Array.prototype.concat
、Number.prototype.toFixed
)都是没有prototype
属性的。还有,箭头函数也是没有prototype
属性的。所以,这些函数是不能是用new
命令的,如果用了会抛错。
new Array.prototype.concat() // Uncaught TypeError: Array.prototype.concat is not a constructor
复制代码
这些没有prototype
属性的方法,是函数式编程的实现,看起来也更纯粹。使用这些方法时,也建议使用lambda
的链式语法。
表达式继承
extends
后面能接受任意类型的表达式,这带来了巨大的可能性。例如,动态的决定父类。
class FatherA {}
class FatherB {}
const type = 'A'
function select (type) {
return type === 'A' ? FatherA : FatehrB
}
class Son extends select('A') {
constructor () {
super()
}
}
Object.getPrototypeOf(Son) === FatherA // true
复制代码
如果,想要一个子类同时继承多个对象的方法呢?我们也可以使用mixin
。
Mixin
Mixin
指的是多个对象合成一个新的对象,新对象具有各个组成成员的接口。下面示例,mixin
的返回对象的原型对象,是传入的几个对象的原型对象的合成。
const objA = {
sayA() {
return 'A'
}
}
const objB = {
sayB() {
return 'B'
}
}
const objC = {
sayC() {
return 'C'
}
}
function mixin (...args) {
const base = function () {}
Object.assign(base.prototype, ...args)
return base
}
class Son extends mixin(objA, objB, objC) {}
let s1 = new Son()
s1.sayA() // 'A'
s1.sayB() // 'B'
s1.sayC() // 'C'