我看JavaScript之美妙的“继承”

Posted 恪愚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了我看JavaScript之美妙的“继承”相关的知识,希望对你有一定的参考价值。

继承在各种编程语言中都充当着及其重要的角色,由于javascript“天生”的灵活性,使得JS在一些场景下急需一种可复用、规范性的解决方案,类和继承就这么自然而然的出现在了大众的视野当中。

随着不断的深入学习JavaScript,突然在某一天发现了好像很多人用于“继承”功能的代码是不同的,那么就有两个问题需要我们回答 ——

  1. JS 的继承到底有多少种实现方式?
  2. ES6 的 extends 关键字是用哪种继承方式实现的呢?

横空出世:es6之extends

在es6发布之后,Jser惊奇的发现JavaScript规范中多了一个“语法糖” —— class关键字,它很大程度上模仿了面向过程语言的规范,其中就包括了 类继承 要用到的关键字 extends
先来康康怎么用的:

class Person {
	constructor(name) {
		this.name = name
	}
	// 所谓函数,即原型方法
	// 下面等同于es5中的 Person.prototype.getName = function() { }
	// 也可以简写为 getName() {...}
	getName = function() {
		console.log('Person:', this.name)
	}
}
class Yxm extends Person {
	constructor(name, age) {
		// 子类中存在构造函数,则需要在使用“this”之前首先调用 super()。
		super(name)
		this.age = age
	}
}
const asuna = new Yxm('mxc', 18)
asuna.getName() // 成功访问到父类的方法

因为这是es6的代码,据说是有浏览器兼容性问题,如果遇到不支持 ES6 的浏览器,那么就得利用 babel 这个编译工具,将 ES6 的代码编译成 ES5。(现在也有很多在线es6转es5工具,很好用的)
我们也可以这么做,看看extends转义后究竟是什么:

function _possibleConstructorReturn (self, call) { 
		// ...
		return call && (typeof call === 'object' || typeof call === 'function') ? call : self; 
}
function _inherits (subClass, superClass) { 
    // 这里可以看到
	subClass.prototype = Object.create(superClass && superClass.prototype, { 
		constructor: { 
			value: subClass, 
			enumerable: false, 
			writable: true, 
			configurable: true 
		} 
	}); 
	if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; 
}

var Parent = function Parent () {
	// 验证是否是 Parent 构造出来的 this
	_classCallCheck(this, Parent);
};
var Child = (function (_Parent) {
	_inherits(Child, _Parent);
	function Child () {
		_classCallCheck(this, Child);
		return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
}
	return Child;
}(Parent));

OK,现在我们先略过其中的一丝丝细节,考虑文首提出的第一个问题:

几种js中实现继承的方式

  1. 第一种:原型链继承

原型链继承是最早,也是最常见的继承方式之一。其中涉及的构造函数、原型和实例,三者之间存在着一定的关系:即每一个构造函数都有一个原型对象,原型对象又包含一个指向构造函数的指针,而实例则包含一个原型对象的指针!

function Parent_one() {
	this.name = 'parent';
	this.play = [1, 2, 3]
}

function Child_one() {
	this.type = 'child2';
}
Child_one.prototype = new Parent_one();
console.log(new Child_one());

上面的代码看似没有问题,虽然父类的方法和属性都能够访问,但其实有一个潜在的问题,我再举个例子来说明这个问题。

var s1 = new Child_one();
var s2 = new Child_one();
s1.play.push(4);
console.log(s1.play, s2.play);

这段代码在控制台执行之后,可以看到结果如下:

明明我只改变了 s1 的 play 属性,为啥 s2 也跟着变了?原因很简单,因为两个实例使用的是同一个原型对象。它们的内存空间是共享的,当一个发生变化的时候,另外一个也随之进行了变化,这就是使用原型链继承方式的一个缺点。

  1. 第二种:构造函数继承(借助 call)

代码如下:

function Parent_one() {
	this.name = 'parent';
}

Parent_one.prototype.getName = function() {
	return this.name;
}

function Child_one() {
	Parent_one.call(this); // 重点!
	this.type = 'child'
}

let child = new Child_one();
console.log(child); // 没问题
console.log(child.getName()); // 会报错

执行上面的这段代码,可以得到这样的结果。

可以看到第一个打印的 child 在控制台显示,除了 Child_one 的属性 type 之外,也继承了 Parent_one 的属性 name。这样写的时候子类虽然能够拿到父类的属性值,解决了第一种继承方式的弊端.
但问题是,父类原型对象中一旦存在父类之前自己定义的(存在于原型链中的)方法,那么子类将无法继承这些方法。就像上图第二个打印的报错的那样。

因此,从上面的结果就可以看到构造函数实现继承的优缺点,它使父类的引用属性不会被共享,优化了第一种继承方式的弊端;但是随之而来的缺点也比较明显——只能继承父类的实例属性和方法,不能继承原型属性或者方法。

  1. 第三种:组合继承(将前两种方式组合起来)

这种方式结合了前两种继承方式的优缺点,结合起来的继承,代码如下。

function Parent_thr() {
	this.name = 'parent';
	this.play = [1, 2, 3];
}

Parent_thr.prototype.getName = function() {
	return this.name;
}

function Child_thr() {
	// 第二次调用 Parent_thr()
	Parent_thr.call(this);
	this.type = 'child';
}

// 第一次调用 Parent_thr()
Child_thr.prototype = new Parent_thr();
// 手动挂上构造器,指向自己的构造函数
Child_thr.prototype.constructor = Child_thr;
var s3 = new Child_thr();
var s4 = new Child_thr();
s3.play.push(4);
console.log(s3.play, s4.play); // 不互相影响
console.log(s3.getName()); // 正常输出'parent'
console.log(s4.getName()); // 正常输出'parent'

执行上面的代码,可以看到控制台的输出结果,之前方法一和方法二的问题都得以解决。

但是这里又增加了一个新问题:通过注释我们可以看到 Parent_thr 执行了两次,第一次是改变Child_thr 的 prototype 的时候,第二次是通过 call 方法调用 Parent_thr 的时候,那么 Parent_thr 多构造一次就多进行了一次性能开销,这是我们不愿看到的。

而且上面介绍的更多是围绕着构造函数的方式,那么对于 JavaScript 的普通对象,该怎么实现继承呢?

  1. 第四种:原型式继承

这里不得不提到的就是 ES5 里面的 Object.create 方法,这个方法接收两个参数:一是用作新对象原型的对象、二是为新对象定义额外属性的对象(可选参数)。

我们通过一段代码,看看普通对象是怎么实现的继承。

let parent_for = {
	name: "parent",
	friends: ["p1", "p2", "p3"],
	getName: function() {
		return this.name;
	}
};

let person_for = Object.create(parent_for);
person_for.name = "tom";
person_for.friends.push("yxm");

let person_fiv = Object.create(parent_for);
person_fiv.friends.push("mxc");

console.log(person_for.name);
console.log(person_for.name === person_for.getName());
console.log(person_fiv.name);
console.log(person_for.friends);
console.log(person_fiv.friends);

从上面的代码中可以看到,通过 Object.create 这个方法可以实现普通对象的继承,不仅仅能继承属性,同样也可以继承 getName 的方法,请看这段代码的执行结果。

第一个结果“tom”,比较容易理解,person_for 继承了 parent_for 的 name 属性,但是在这个基础上又进行了自定义。
第二个是继承过来的 getName 方法检查自己的 name 是否和属性里面的值一样,答案是 true。
第三个结果“parent”也比较容易理解,person_fiv 继承了 parent_for 的 name 属性,没有进行覆盖,因此输出父对象的属性。

最后两个输出结果是一样的,这个其实是关于引用数据类型“共享”的问题,大多数前端开发应该都知道 Object.create 方法是可以为一些对象实现浅拷贝的

但这种继承方式的缺点也很明显,多个实例的引用类型属性指向相同的内存,存在篡改的可能。

  1. 第五种:寄生式继承

使用原型式继承可以获得一份目标对象的浅拷贝,然后利用这个浅拷贝的能力再进行增强,添加一些方法,这样的继承方式就叫作寄生式继承。

虽然其优缺点和原型式继承一样,但是对于普通对象的继承方式来说,寄生式继承相比于原型式继承,还是在父类基础上添加了更多的方法:

let parent_fiv = {
	name: "parent",
	friends: ["p1", "p2", "p3"],
	getName: function() {
		return this.name;
	}
};

function clone(original) {
	let clone = Object.create(original);
	clone.getFriends = function() {
		return this.friends;
	};
	return clone;
}

let person_fiv = clone(parent_fiv);

console.log(person_fiv.getName());
console.log(person_fiv.getFriends());

通过上面这段代码,我们可以看到 person_fiv 是通过寄生式继承生成的实例,它不仅仅有 getName 的方法,而且可以看到它最后也拥有了 getFriends 的方法,结果如下图所示。

从最后的输出结果中可以看到,person_fiv 通过 clone 的方法,增加了 getFriends 的方法,从而使 person_fiv 这个普通对象在继承过程中又增加了一个方法,这样的继承方式就是寄生式继承。

  1. 第六种:寄生组合式继承(终极解决方案!)

结合第四种中提及的继承方式,解决普通对象的继承问题的 Object.create 方法,我们在前面这几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式,代码如下:

function clone(parent, child) {
	// 这里改用 Object.create 就可以减少组合继承中多进行一次构造的过程
	child.prototype = Object.create(parent.prototype);
	child.prototype.constructor = child;
}

function Parent_six() {
	this.name = 'parent';
	this.play = [1, 2, 3];
}
Parent_six.prototype.getName = function() {
	return this.name;
}

function Child_six() {
	Parent_six.call(this);
	this.friends = 'child';
}

clone(Parent_six, Child_six);

Child_six.prototype.getFriends = function() {
	return this.friends;
}

let person_six = new Child_six();
console.log(person_six);
console.log(person_six.getName());
console.log(person_six.getFriends());

通过这段代码可以看出来,这种寄生组合式继承方式,基本可以解决前几种继承方式的缺点,较好地实现了继承想要的结果,同时也减少了构造次数,减少了性能的开销,我们来看一下上面这一段代码的执行结果。

可以看到 person_six 打印出来的结果,属性都得到了继承,方法也没问题,可以输出预期的结果!

此时回过头来看上面extends转义后的代码,从源码中可以看到,它采用的也是寄生组合继承方式,那是不是也说明这种方式是较优的解决继承的方式?


可以看到:其实上面es6转义后的代码和「寄生组合」式的写法还是有所不同的 ——

  1. new 关键字校验:调用_classCallCheck方法判断当前函数调用前是否有new关键字
function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

构造函数执行前有new关键字,则在构造函数内部创建一个空对象,将构造函数的proptype指向这个空对象的proto,并将this指向这个空对象。如上,_classCallCheck中:this instanceof Parent 返回true。 若构造函数前面没有new则构造函数的proptype不会不出现在this的原型链上,返回false。

以上是关于我看JavaScript之美妙的“继承”的主要内容,如果未能解决你的问题,请参考以下文章

48个值得掌握的JavaScript代码片段(上)

Flask之模板之宏继承包含

深入浅出JavaScript之原型链&继承

JavaScript之面向对象学九(原型式继承和寄生式继承)

你好,JavaScript异步编程---- 理解JavaScript异步的美妙

JavaScript 原型继承原型链