本文深入解析ES6中Class类的继承机制,详细阐述extends关键字与super关键字的具体用法及区别。重点对比ES5与ES6在实例this创建顺序上的差异,解析prototype与__proto__两条继承链的原理。此外,文章探讨了继承Object、null等特殊情况,并重点说明ES6如何突破限制实现Array、Error等原生构造函数的正确继承。
ES6 Class类继承机制概述
本文通过具体实例,详细解析ES6中JavaScript Class类的继承用法,供读者参考学习。
Class基本继承用法与super关键字
1. 基本用法
Class类之间可通过extends关键字实现继承,相比ES5中通过修改原型链实现继承的方式,这种写法更加清晰且便捷。
class ColorPoint extends Point {}
上述代码定义了一个ColorPoint类,该类利用extends关键字继承了Point类的所有属性和方法。由于未添加额外代码,这两个类完全一致,相当于复制了一个Point类。接下来,我们在ColorPoint内部添加具体代码。
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y); // 调用父类的 constructor(x, y)
this.color = color;
}
toString() {
return this.color + ' ' + super.toString(); // 调用父类的 toString()
}
}
在上述代码中,constructor方法和toString方法内均出现了super关键字,它在此处代表父类的构造函数,用于创建父类的this对象。
子类必须在constructor方法中调用super方法,否则创建实例时会报错。原因在于子类自身没有this对象,而是继承自父类的this对象并对其进行加工。若不调用super方法,子类将无法获得this对象。
class Point { /* ... */ }
class ColorPoint extends Point {
constructor() {}
}
let cp = new ColorPoint(); // ReferenceError
上述代码中,ColorPoint继承了父类Point,但其构造函数未调用super方法,导致创建实例时抛出错误。
ES5的继承机制实质是先创造子类的实例对象this,再将父类的方法添加到this上(即Parent.apply(this))。而ES6的继承机制截然不同,它实质是先创造父类的实例对象this(因此必须先调用super方法),随后再用子类的构造函数修改this。
若子类未定义constructor方法,该方法会被默认添加,代码如下。这意味着无论是否显式定义,任何子类都必然包含constructor方法。
constructor(...args) {
super(...args);
}
另一个需注意的要点是,在子类的构造函数中,只有在调用super之后,才能使用this关键字,否则将报错。这是因为子类实例的构建基于对父类实例的加工,唯有super方法能返回父类实例。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
this.color = color; // ReferenceError
super(x, y);
this.color = color; // 正确
}
}
上述代码中,若子类的constructor方法在调用super之前使用this关键字,结果会报错;而将其置于super方法之后则是正确的做法。
以下是生成子类实例的代码示例。
let cp = new ColorPoint(25, 8, 'green');
cp instanceof ColorPoint // true
cp instanceof Point // true
上述代码中,实例对象cp同时是ColorPoint和Point两个类的实例,这与ES5的行为完全一致。
类的prototype与__proto__属性及继承链
2. 类的 prototype 属性和 __proto__ 属性
在大多数浏览器的ES5实现中,每个对象都拥有__proto__属性,指向对应构造函数的prototype属性。Class作为构造函数的语法糖,同时具备prototype属性和__proto__属性,因此存在两条继承链。
(1)子类的__proto__属性表示构造函数的继承,始终指向父类。 (2)子类prototype属性的__proto__属性表示方法的继承,始终指向父类的prototype属性。
class A {}
class B extends A {}
B.__proto__ === A // true
B.prototype.__proto__ === A.prototype // true
上述代码中,子类B的__proto__属性指向父类A,子类B的prototype属性的__proto__属性指向父类A的prototype属性。
产生上述结果的原因是,类的继承按照以下模式实现。
class A {}
class B {}
// B 的实例继承 A 的实例
Object.setPrototypeOf(B.prototype, A.prototype);
// B 继承 A 的静态属性
Object.setPrototypeOf(B, A);
《对象的扩展》一章中给出了Object.setPrototypeOf方法的实现。
Object.setPrototypeOf = function(obj, proto) {
obj.__proto__ = proto;
return obj;
}
由此便得到了上述结果。
Object.setPrototypeOf(B.prototype, A.prototype);
// 等同于
B.prototype.__proto__ = A.prototype;
Object.setPrototypeOf(B, A);
// 等同于
B.__proto__ = A;
这两条继承链可这样理解:作为一个对象,子类(B)的原型(__proto__属性)是父类(A);作为一个构造函数,子类(B)的原型(prototype属性)是父类的实例。
Object.create(A.prototype);
// 等同于
B.prototype.__proto__ = A.prototype;
Extends继承目标与特殊情况分析
3. Extends 的继承目标
extends关键字后方可以跟随多种类型的值。
class B extends A {}
上述代码中的A,只要是一个拥有prototype属性的函数,就能被B继承。由于函数均拥有prototype属性(除了Function.prototype函数),因此A可以是任意函数。
接下来,讨论三种特殊情况。
第一种特殊情况:子类继承Object类。
class A extends Object {}
A.__proto__ === Object // true
A.prototype.__proto__ === Object.prototype // true
在此情况下,A实际上是构造函数Object的复制,A的实例即为Object的实例。
第二种特殊情况:不存在任何继承。
class A {}
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === Object.prototype // true
在此情况下,A作为一个基类(即不存在任何继承),是一个普通函数,因此直接继承Funciton.prototype。然而,A调用后返回一个空对象(即Object实例),所以A.prototype.__proto__指向构造函数(Object)的prototype属性。
第三种特殊情况:子类继承null。
class A extends null {}
A.__proto__ === Function.prototype // true
A.prototype.__proto__ === undefined // true
此情况与第二种情况非常相似。A同样是一个普通函数,因此直接继承Funciton.prototype。但是,A调用后返回的对象不继承任何方法,所以其__proto__指向Function.prototype,即实质上执行了以下代码。
class C extends null {
constructor() {
return Object.create(null);
}
}
4. Object.getPrototypeOf()
Object.getPrototypeOf方法可用于从子类上获取父类。
Object.getPrototypeOf(ColorPoint) === Point
// true
因此,可利用此方法判断一个类是否继承了另一个类。
Object.getPrototypeOf与super关键字详解
5. super 关键字
super关键字拥有两种用法,含义各不相同。
(1)作为函数调用时(即super(...args)),super代表父类的构造函数。
(2)作为对象调用时(即super.prop或super.method()),super代表父类。需注意,此时super既可引用父类实例的属性和方法,也可引用父类的静态方法。
class B extends A {
get m() {
return this._p * super._p;
}
set m() {
throw new Error(' 该属性只读 ');
}
}
上述代码中,子类通过super关键字调用父类实例的_p属性。 由于对象总是继承其他对象,因此可在任意一个对象中使用super关键字。
var obj = {
toString() {
return "MyObject: " + super.toString();
}
};
obj.toString(); // MyObject: [object Object]
实例__proto__属性与原型链修改
6. 实例的 __proto__ 属性
子类实例的__proto__属性的__proto__属性,指向父类实例的__proto__属性。换言之,子类的原型的原型,即为父类的原型。
var p1 = new Point(2, 3);
var p2 = new ColorPoint(2, 3, 'red');
p2.__proto__ === p1.__proto__ // false
p2.__proto__.__proto__ === p1.__proto__ // true
上述代码中,ColorPoint继承了Point,导致前者原型的原型是后者的原型。
因此,通过子类实例的__proto__.__proto__属性,可以修改父类实例的行为。
p2.__proto__.__proto__.printName = function() {
console.log('Ha');
};
p1.printName() // "Ha"
上述代码在ColorPoint的实例p2上向Point类添加方法,结果影响了Point的实例p1。
原生构造函数的继承与ES6改进
原生构造函数的继承
原生构造函数是指语言内置的构造函数,通常用于生成数据结构。ECMAScript的原生构造函数大致包括以下这些。
Boolean()
Number()
String()
Array()
Date()
Function()
RegExp()
Error()
Object()
以往,这些原生构造函数是无法继承的,例如,无法自行定义Array的子类。
function MyArray() {
Array.apply(this, arguments);
}
MyArray.prototype = Object.create(Array.prototype, {
constructor: {
value: MyArray,
writable: true,
configurable: true,
enumerable: true
}
});
上述代码定义了一个继承Array的MyArray类。然而,该类的行为与Array完全不一致。
var colors = new MyArray();
colors[0] = "red";
colors.length // 0
colors.length = 0;
colors[0] // "red"
发生这种情况的原因是,子类无法获取原生构造函数的内部属性,通过Array.apply()或分配给原型对象均无效。原生构造函数会忽略apply方法传入的this,即原生构造函数的this无法绑定,导致无法获取内部属性。
ES5是先新建子类的实例对象this,再将父类的属性添加到子类上。由于父类的内部属性无法获取,导致无法继承原生的构造函数。例如,Array构造函数有一个内部属性[[DefineOwnProperty]],用于在定义新属性时更新length属性。该内部属性无法在子类中获取,导致子类的length属性行为异常。
下面的示例中,我们尝试让一个普通对象继承Error对象。
var e = {};
Object.getOwnPropertyNames(Error.call(e))
// [ 'stack' ]
Object.getOwnPropertyNames(e)
// []
上述代码中,我们试图通过Error.call(e)这种写法,使普通对象e具有Error对象的实例属性。然而,Error.call()完全忽略传入的第一个参数,而是返回一个新对象,e本身没有任何变化。这证明了Error.call(e)这种写法无法继承原生构造函数。
ES6允许继承原生构造函数定义子类,因为ES6是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的所有行为均可继承。以下是一个继承Array的例子。
class MyArray extends Array {
constructor(...args) {
super(...args);
}
}
var arr = new MyArray();
arr[0] = 12;
arr.length // 1
arr.length = 0;
arr[0] // undefined
上述代码定义了一个MyArray类,继承了Array构造函数,因此可以从MyArray生成数组的实例。这意味着,ES6可以自定义原生数据结构(如Array、String等)的子类,这是ES5无法做到的。
上述示例还表明,extends关键字不仅可用于继承类,还可用于继承原生的构造函数。因此,可以在原生数据结构基础上定义自己的数据结构。以下便是定义了一个带版本功能的数组。
class VersionedArray extends Array {
constructor() {
super();
this.history = [
[]
];
}
commit() {
this.history.push(this.slice());
}
revert() {
this.splice(0, this.length, ...this.history[this.history.length - 1]);
}
}
var x = new VersionedArray();
x.push(1);
x.push(2);
x // [1, 2]
x.history // [[]]
x.commit();
x.history // [[], [1, 2]]
x.push(3);
x // [1, 2, 3]
x.revert();
x // [1, 2]
上述代码中,VersionedArray结构会通过commit方法,将当前状态存入history属性;随后通过revert方法,可撤销当前版本,回到上一个版本。此外,VersionedArray依然是一个数组,所有原生数组方法均可在其上调用。
以下是一个自定义Error子类的示例。
class ExtendableError extends Error {
constructor(message) {
super();
this.message = message;
this.stack = (new Error()).stack;
this.name = this.constructor.name;
}
}
class MyError extends ExtendableError {
constructor(m) {
super(m);
}
}
var myerror = new MyError('ll');
myerror.message // "ll"
myerror instanceof Error // true
myerror.name // "MyError"
myerror.stack
// Error
// at MyError.ExtendableError
// ...
需注意,继承Object的子类存在一个行为差异。
class NewObj extends Object {
constructor() {
super(...arguments);
}
}
var o = new NewObj({
attr: true
});
console.log(o.attr === true); // false
上述代码中,NewObj继承了Object,但无法通过super方法向父类Object传参。原因在于ES6改变了Object构造函数的行为,一旦发现Object方法不是通过new Object()这种形式调用,ES6规定Object构造函数会忽略参数。
更多相关内容可查看本站专题:《ECMAScript6(ES6)入门教程》、《JavaScript数组操作技巧总结》、《JavaScript字符与字符串操作技巧总结》、《JavaScript数据结构与算法技巧总结》、《JavaScript错误与调试技巧总结》及《javascript面向对象入门教程》
希望本文所述对大家基于ECMAScript的程序设计有所帮助。