傲娇大少之---【JS继承的几种方式,ES5,ES6继承的区别】

来giao一波JS的继承

毒鸡汤来啦:
你以为只要长得漂亮就有男生喜欢?你以为只要有了钱漂亮妹子就自己贴上来了?你以为学霸就能找到好工作?
我告诉你吧,这些都是真的!啦啦啦~

继承

继承,简单的说就是子类继承父类。在这详细说一下在JS中实现继承的几种方法。

注意:阅读本文之前需要理解JS的prototype、constructor、__proto__之间的关系。我上一篇也有写过,不懂的可以看下。

实现JS继承的几种方法

首先,先定义一个父类的构造函数。

 function ParentType () {
 }

接下来就是要实现一个继承的子类childType,有几种方法呢?

1. 原型链继承

大家都知道JS是通过原型实现继承的。
所以原型链继承的基本思想是:利用原型让一个引用类型继承另一个引用类型的属性和方法。
本质是:重写原型对象

简单回顾一下构造函数、原型和实例之间的关系:

  • 每个构造函数都有一个原型对象;(prototype)
  • 原型对象都包含一个指向构造函数的指针;(constructor)
  • 实例都包含一个指向原型对象的内部指针;(__proto__)

所以原型链继承的原理,是让子类的原型对象等于父类的实例。即子类原型对象包含一个指向父类原型的指针,而父类原型中又包含着指向父类构造函数的指针。
假如父类原型指向的是父类的父类的实例,那么上述关系依然成立,层层递进,构成了实例与原型之间的链条,即原型链。

上代码:

function ParentType () { // 父类
	this.attr = true;
}
ParentType.prototype.getAttr = function () { // 向父类原型增加方法
	return this.attr;
};
function ChildType () { // 定义子类
	this.a = false;
}
ChildType.prototype = new ParentType(); // 定义其原型对象等于父类的实例

let instance = new ChildType(); // 创建子类实例

console.log(instance.getAttr()); // true,调用父类原型中的方法,可以继承到

我们没有使用childType默认提供的原型,而是给它换了个新的原型,这个新的原型就是ParentType的实例。这样,新的原型不但具有父类的实例所拥有的偶有属性和方法,而且其内部还有一个指针,指向的父类的原型(ParentType.prototype)。根据我们上一篇文章讲到的,JS遍历属性和方法的顺序,在这个原型链上,子类的实例可以访问到父类的所有方法和属性。

原型链继承方式是JS中最传统的继承方式。大家知道所有的引用类型都继承了Object,这个继承方法就是通过原型链继承实现的,即所有构造函数的默认原型,都等于Object的实例。

此时要注意: instance.constructor指向的ParentType,这是因为原来的childType.prototype被重写了的缘故。

关于原型链继承:

谨慎:

  • 给子类添加原型的属性和方法需谨慎,必须放在替换原型的语句之后,否则会被覆盖掉。

优点:

  • 可以继承父类及其原型的全部属性和方法
  • 实例和子类和父类在一条原型链上

缺点:

  • 子类实例无法向父类传参
  • 因为是通过原型实现的继承,所以父类的实例属性会变成子类的原型属性,会导致包含引用类型值的原型属性会被所有的实例共享(基本类型没有该问题)。具体现象可以看下面这个例子:
function ParentType () { // 父类
	this.attr = [1, 2]; // 父类增加属性为引用类型
}
function ChildType () { // 定义子类
}
ChildType.prototype = new ParentType(); // 定义其原型对象等于父类的实例

let instance1 = new ChildType(); // 创建子类实例
instance1.attr.push(3);
console.log(instance1.attr); // [1, 2, 3] 修改了属性值

let instance2 = new ChildType(); // 创建另一个子类实例
console.log(instance2.attr); // [1, 2, 3] 其它子类实例被迫共享了引用类型的属性值

2. 借用构造函数继承

在解决原型中包含引用类型值所带来问题的过程中,开发人员开始使用一种叫做借用构造函数的技术,(有时也叫做伪造对象或经典继承)。

基本思想:在子类型构造函数的内部,调用父类型的构造函数。

别忘了,函数只不过是在特定环境中执行代码的对象,构造函数也是函数,因此通过使用apply和call方法也可以在新创建的对象上执行构造函数。

call() 和 apply() 方法的作用: 改变this的指向!

上代码:

function ParentType (name) { // 父类
	this.attr= [1, 2];
	this.name = name;
}
function ChildType (name) { // 定义子类
	ParentType.call(this, name); // 继承了父类
}

let instance1 = new ChildType('zhang'); // 创建子类实例
instance1.attr.push(3);
console.log(instance1.attr); // [1, 2, 3] 修改了属性值
console.log(instance1.name); // zhang, 实现了向父类传参

let instance2 = new ChildType('wang'); // 创建另一个子类实例
console.log(instance2.attr); // [1, 2] 没有被共享引用类型值
console.log(instance2.name); // wang, 实现了向父类传参 

这种继承方式,实际上是在创建子类的实例时,调用了父类的构造函数。每次创建实例的时候,都会执行一次父类的构造函数,所以可以解决引用类型值共享的问题。

关于借用构造函数:

谨慎:

  • 子类的私有属性,为防止被父类覆盖,应定义在call或apply之后

优点:

  • 可以向父类传参
  • 因为使用的call或apply的方式,所以可以实现多继承,即一个子类继承多个父类的属性和方法
  • 解决了引用数据类型值共享的问题

缺点:

  • 因为只是“借调”了构造函数,所以无法继承父类原型中的属性和方法
  • 父类的属性和方法都要在父类构造函数中定义才能继承到,每次创建子类实例都重新执行一次父类的构造函数,无法实现方法的复用。(所以在实际开发中,这种继承方式很少单独使用)

3. 组合继承

组合继承,有时候也叫作伪经典继承。指的是将原型链和借用构造函数的技术组合到一起,从而发挥二者之长的一种继承方式。

上代码:

function ParentType (name) { // 父类
	this.attr = [1, 2];
	this.name = name;
}
ParentType.prototype.getAttr = function () { // 父类原型的方法
	return this.attr;
};
function ChildType (name) { // 定义子类
	ParentType.call(this, name); // 继承属性
}
ChildType.prototype = new ParentType(); // 继承方法
ChildType.prototype.constructor = ChildType; // 强化继承

let instance1 = new ChildType('zhang'); // 创建子类实例
instance1.attr.push(3);
console.log(instance1.attr); // [1, 2, 3] 修改了属性值
console.log(instance1.name); // zhang, 实现了向父类传参

let instance2 = new ChildType('wang'); // 创建另一个子类实例
console.log(instance2.attr); // [1, 2] 没有被共享引用类型值
console.log(instance2.name); // wang, 实现了向父类传参

组合继承解决了原型链的引用类型值共享的问题,也解决了借用构造函数无法继承原型以及构造函数中的方法无法被复用的问题。所以是JavaScript中最常用的而继承方式。

谨慎:

  • 父类构造函数中用于定义属性,可防止引用类型值的被迫共享问题
  • 父类原型中用于定义方法,可以解决函数的复用问题

缺点:

  • 无论什么情况下,都会调用两次父类型的构造函数:一次是在创建子类型原型的时候(ChildType.prototype = new ParentType();),一次是在子类型构造函数内部(ParentType.call(this, name); )。

4. 原型式继承

道格拉斯·克罗克福德提出了原型式继承方式,实现思路:借助原型可以基于已有的对象创建新对象,同时还不必因此创建自定义类型。

原理如下:

function object(o) {
	function F() {} // 先创建一个临时的构造函数
	F.prototype = o; // 将传入的对象作为这个构造函数的原型
	return new F(); // 返回这个临时对象的实例
}

这样就实现对这个传入的对象的浅复制。所以原型式继承的基础就是必须有一个对象作为另一个对象的基础。

在ECMAScript 5 中新增了Object.create() 方法规范了原型式继承。这个方法接收两个参数:新对象原型的对象,新对象定义额外属性的对象。

即上面的object()函数等价于:Object.create(o);

现在来看一个原型式继承的例子吧,上代码:

let person = {
	name: 'Li',
	friends: ['zhao', 'qian']
}; // 用作新对象原型的对象(要浅继承的对象)

let p1 = Object.create(person); // 创建一个实例
p1.name = 'sun';
p1.friends.push('zhou');
console.log(p1.friends); // ["zhao", "qian", "zhou", "wu"]

let p2 = Object.create(person); // 创建另一个实例
p1.name = 'zheng';
p1.friends.push('wu');
console.log(p1.friends); // ["zhao", "qian", "zhou", "wu"]

console.log(person.friends); // ["zhao", "qian", "zhou", "wu"]

缺点:

  • 因为是重写了原型,所以与原型链继承方式一样,会存在引用类型值共享的问题。

注意:

  • 一般是在没有必要兴师动众的去创建一个子类的构造函数时,且不存在引用类型属性时,原型式继承是完全可以胜任的。

5. 寄生式继承

继承式继承于原型式继承是紧密相关的一种思路:创建一个仅用于封装过程的函数,该函数在内部以某种方式来增强对象,最后再返回对象。

上代码:

function createPerson (original) {
	let clone = Object.create(original); // 原型式继承
	clone.getName = function () { // 为对象添加方法
		return this.name;
	};
	return clone;
}

let person = { // 新对象原型的对象
	name: 'zhang',
	frinds: ['zhao', 'qian']
};

let instance = createPerson(person); // 新对象既拥有person对象的属性和方法,又拥有自己的方法
console.log(instance.getName()); // zhang,调用到了新对象自己的方法

谨慎:

  • 在主要考虑对象而不是自定义类型和构造函数的情况下,继承式继承也是一种有用的模式。
  • 里面的Object.create()不是必须的,只要是能返回新对象的函数都适用于次模式(比如new)。

缺点:

  • 为对象添加函数,无法做到函数的复用,降低效率,类似于借用构造函数方式。

6. 寄生组合式继承

上面说过组合式继承存在一个最大的问题就是会调用两次父类的构造函数。寄生组合式继承可以解决这个问题。

寄生组合式继承的基本思路:不必为了指定子类的原型而调用父类的构造函数。我们需要的无非就是父类原型的一个副本而已。

继承组合式继承的本质:使用寄生式继承来继承父类的原型,然后将结果指定给子类型的原型

上代码:

function ParentType (name) { // 父类
	this.attr = [1, 2];
	this.name = name;
}
ParentType.prototype.getAttr = function () { // 父类原型的方法
	return this.attr;
};
function ChildType (name) { // 定义子类
	ParentType.call(this, name); // 继承属性
}

let prototype = Object.create(ParentType.prototype); // 创建一个等于父类原型对象的对象
prototype.constructor = ChildType;// 增强对象,弥补因重写原型而失去的constructor属性
ChildType.prototype = prototype; // 完成了对父类的属性和方法的继承

let man1 = new ChildType('Li');
man1.attr.push(3);
console.log(man1.name); // Li,继承到父类的属性
console.log(man1.getAttr()); // [1,2,3] 继承到了父类的方法

let man2= new ChildType('zhao');
console.log(man2.name); // zhao
console.log(man2.getAttr()); // [1,2],引用类型值没有被共享

优点:(解决了上述所有的缺点)

  • 可以实现子类实例像父类传参
  • 引用类型值不会被共享
  • 实现了函数的复用
  • 只调用了一次父类的构造函数
  • 效率高

寄生组合式继承相比组合式继承,只调用了一次父类的构造函数,避免了在子类原型上创建不必要的、多余的属性(组合式继承会创建两次构造函数的属性)。
所以,开发人员们普遍认为继承组合方式是引用类型最理想的继承方式。

7. ES6通过class的extends实现继承

虽然一直说JS是面向对象的,但是在ES6之前,JS中对于类一直没有确定的概念。所以在ES6中提出了class的概念,用于定义类。
ECMAScript 2015 中引入的 JavaScript 类实质上是 JavaScript 现有的基于原型的继承的语法糖。类语法不会为JavaScript引入新的面向对象的继承模型。

类的定义: 类实际上是个“特殊的函数”,就像你能够定义的函数表达式和函数声明一样,类语法有两个组成部分:类表达式和类声明(两种创建类的方式)。

类声明和函数声明不同,函数声明可以提升,但类声明不行,意思就是创建实例之前,必须提前声明这个类。(即new 语句必须要在 class {} 定义之后)

类表达式:定义类的另一种方式,可以是具名的(let class1 = class className{}),也可以是匿名的(let class1 = class {})。

类体:一个大括号之内,包含构造函数,静态方法,原型方法,getter和setter都必须在严格模式下执行。

  • 构造函数: 也叫构造器,constructor方法是一个特殊的方法,这种方法用于创建和初始化一个由class创建的对象。一个类只能拥有一个名为 “constructor”的特殊方法。子类必须在constructor方法中调用super方法,否则新建实例时会报错。因为子类没有自己的this对象,而是继承父类的this对象,然后对其进行加工,如果不调用super方法,子类就得不到this对象。
  • 原型方法:
  1. 定义类的方法时,方法名前面不需要加上function关键字。方法之间不需要用逗号分隔,加了会报错。
  2. 这些定义的方法等同于在prototype中定义的方法。所以在类的实例上调用方法,实际上就是调用原型上的方法。
  3. 另外类的内部所有定义的方法,都是不可枚举的(non-enumerable)。(就是无法被for-in遍历查找)
  • 静态方法:使用static定义的函数。静态方法是指那些不需要对类进行实例化,使用类名就可以直接访问的方法。静态方法经常用来作为工具函数。静态方法不可以被实例继承,是通过类名直接调用的。但是,父类的静态方法可以被子类继承。
  • extends:extends关键字用于实现类之间的继承。子类继承父类,就继承了父类的所有属性和方法。 extends后面只可以跟一个父类。extends关键字不能用于继承一个对象,如果你想继承自一个普通的对象,你必须使用 Object.setPrototypeof ( )。

上代码:

class ParentType { // 父类
	constructor (param) { // 父类的构造函数
		this.param = param;
		this.attr = 'haha';
	}
	static showSomething (str) { // 父类的静态方法
		console.log(str);
	}
	getParam () { // 原型的方法
		return this.param;
	}
}

class ChildType extends ParentType{ // 定义子类继承父类
	constructor (param) {
		super(param); // 使子类获取到this对象
	}
	static showSth (str) {
		super.showSomething(str); // 通过super调用父类的函数
	}
}

let child = new ChildType('123456'); // 创建实例


ParentType.showSomething('lalala!'); // lalala! 静态方法的使用
// child.showSomething('child!'); // Uncaught TypeError: child.showSomething is not a function 实例无法继承静态方法。
ChildType.showSth('yeah!'); // yeah! 子类可以继承父类的静态方法。

console.log(child.getParam()); // 123456 调用到了父类的方法
console.log(ChildType.prototype.__proto__ === ParentType.prototype); // true
console.log(ChildType.__proto__ === ParentType); // true

ES6的继承实现方法,其内部其实也是ES5寄生组合继承的方式,通过call构造函数,在子类中继承父类的属性,通过原型链来继承父类的方法。

但是相比ES5的继承中,子类的__proto__属性指向的对应的构造函数的原型。ES6的Class定义的子类同时有prototype属性和__proto__属性,因此同时存在两条继承链:

  • 子类的__proto__属性,表示构造函数的继承,总是指向父类。
  • 子类prototype对象的__proto__属性,表示原型的继承,总是指向父类的prototype对象。

也就是,extends做了两件事情,一个是通过Object.create()把子类的原型赋值为父类的实例,实现了继承方法,子类.prototype.__proto__也自动指向父类的原型,一个是手动修改了子类的__proto__,修改为指向父类,(本来在es5 中应该是指向Function.prototype);

PS: extends也可以继承ES5的构造函数。

在这里插入图片描述

拓展一下下,有兴趣的童鞋看

一、JavaScript是通过原型链实现继承的,以上所有的继承方式得到的最后的子类实例的属性和方法,都是在它的原型链上向上查找的。所以要清楚prototype、constructor、__proto__的关系。

二、关于对象的属性:
ES5中规定了对象的两种属性:数据属性和访问器属性。

数据属性
数据属性包含一个数据值的位置,在这个位置可以读取和写入,数据属性有四个描述其行为的特性(这些特性不能直接访问,是内部值,所以放在两对儿方括号[[]]中):

  • [[ Configurable ]] : 表示能否通过delete删除,能否修改属性的特性,或者能否把属性修改为访问器属性。像那种直接定义在对象内部的属性,这个特性默认为true。
  • [[ Enumberable ]]:表示能否通过for-in循环访问属性,像那种直接定义在对象内部的属性,这个特性默认为true。
  • [[ Writable ]]:表示能否修改属性的值,像那种直接定义在对象内部的属性,这个特性默认为true。
  • [[ Value ]]:包含这个属性的数据值,读写属性的时候,都是在这个位置进行,这个特性默认为undefined。

也就是说{a: ‘123’},这个对象value特性被设置为‘123’,其他特性默认为true。但是这些特性呢,我们是访问不到的,这些特性知道就好,是为了实现JavaScript引擎用的。

如果要修改属性的默认特性,ES5里面提供了一个方法:Object.defineProperty([属性所在对象], [属性名字], [描述符对象])
例如:

let o = {
	attr: 123
}; // 随便定义一个对象的属性

// 改变属性为不可修改
Object.defineProperty(o, 'attr', {writable: false});

console.log(o.attr); // 123

o.attr = 9999;
console.log(o.attr); // 123 属性值未被修改。挺有意思的,大家有兴趣的可以了解下

访问器属性
访问器属性不包含数据值,它们包含一对儿getter和setter函数(不过,这两个函数都不是必须的)

  • [[ Configurable ]]:表示能否通过delete删除,能否修改属性的特性,或者能否把属性修改为访问器属性。像那种直接定义在对象内部的属性,这个特性默认为true。
  • [[ Enumberable ]]:表示能否通过for-in循环访问属性,像那种直接定义在对象内部的属性,这个特性默认为true。
  • [[ Get ]]:读取属性时调用的函数,默认值为undefined。
  • [[ Set ]]:写入属性时调用的函数,默认值为undefined。

访问器属性,不能直接定义,只能通过Object.defineProperty()来定义。

例子:

let o = {
	attr: 123
}; // 随便定义一个对象

Object.defineProperty(o, 'newAttr', {
	get: function () {
		return this.attr;
	},
	set: function (newValue) {
	 	this.attr += newValue;
	}
}); // 定义一个访问器属性

console.log(o.newAttr); // 123 执行了get方法
o.newAttr = 1; // 执行了set方法

console.log(o.newAttr); // 124
console.log(o.attr); // 124 设置了一个值,会导致其他属性发生变化

三、讲下创建对象的几种模式

工厂模式

function createObject (attr1, attr2) {
	let o = new Object();
	o.attr1 = attr1;
	o.attr2 = attr2;
	return o;
}
let p = createObject(1,2);

优点: 可以无数次的调用这个函数,创建多个同类型对象。
缺点:没有解决对象的识别问题(即对象的类型)

构造函数模式

function Person (attr1, attr2) {
	this.attr1 = attr1;
	this.attr2 = attr2;
}
let p = new Person(1,2);

与工厂模式的区别:

  • 没有显示的创建对象
  • 直接将属性和方法赋值给了this
  • 没有return语句

new的过程:

  • 创建一个新对象,即new Object()
  • 将构造函数的作用域赋值给新对象(this就指向了这个新对象)
  • 执行构造函数中的代码(为新对象添加属性)
  • 返回新对象

优点:构造函数可以当做函数使用,可以确定对象的类型。
缺点:构造函数内部的方法,每次创建实例都会被重新创建一遍,不能复用

原型模式

function Person () {
}
Person.prototype.attr1 = 1;
Person.prototype.attr2 = 2;
Person.prototype.func = function () {
	return this.attr1;
};
let p = new Person();

优点:实现了函数的复用
缺点:属性值会被所有实例共享

构造函数和原型组合使用

function Person (attr1, attr2) {
	this.attr1 = attr1;
	this.attr2 = attr2;
}
Person.prototype.func = function () {
	return this.attr1;
};
let p = new Person();

优点:集两者之长。

还有动态原型模式、寄生构造函数模式、稳妥构造函数模式。不过不大常用,这里不做一一说明了。

四、ES5和ES6的继承有什么区别?

本文上边所讲的前六种继承方式,都是ES5的继承方式,第七种是ES6的继承方式。

我们现在拿ES5的寄生组合式继承和ES6的class继承做下对比哈。

ES5:

function Parent (param) { // 定义父类构造函数
	this.param = param;
	this.attr = 'haha';
}
Parent.prototype.getParam = function () { // 定义父类原型方法
	return this.param;
};

function Child (param) { // 定义子类
	Parent.call(this, param); // 继承父类构造函数
}
Child.prototype = Object.create(Parent.prototype); // 寄生式继承父类原型
Child.prototype.constructor = Child; // 强化子类

let child = new Child(1234); // 创建实例

ES6:

class ParentType { // 父类
	constructor (param) { // 父类的构造函数
		this.param = param;
		this.attr = 'haha';
	}
	static showSomething (attr) { // 父类的静态方法
		alert(attr);
	}
	getParam () {
		return this.param;
	}
}

class ChildType extends ParentType{ // 定义子类继承父类
	constructor (attr) {
		super(attr); // 继承父类的构造函数
	}
}
let child = new ChildType(1234); // 创建实例

其实看一下extends的源码,会发现实现的逻辑很像我们的寄生组合继承方式。

总结一下区别

  1. 写法上:ES5通过构造函数的原型完成,ES6通过类的extends完成继承。
  2. 规范上:ES6类的定义,以及类体的写法规范更为严格。
  3. 继承机制:
    ES5的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。
    ES6的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。
    只有调用super之后,才可以使用this关键字,否则会报错。这是因为子类实例的构建,是基于对父类实例加工,只有super方法才能返回父类实例。
  4. 继承链:
    ES5,一条继承链: son._proto__ ⇒ parent.prototype ⇒ parent.prototype.construtor ⇒ parent
    ES6,两条继承链:
    son.
    _proto__ ⇒ parent // 继承属性
    son.prototype.__proto__ ⇒ parent.prototype // 继承方法

五、关于super

super关键字用于访问和调用一个对象的父对象上的函数。

基本概念大家都懂哈,但是具体用法,这里还是详细说明一下:

语法:

  • super([arguments]); // 调用 父对象/父类 的构造函数
  • super.functionOnParent([arguments]); // 调用 父对象/父类 上的方法

描述:

  • 在构造函数中使用时,super关键字需要单独使用,而且必须在使用this关键字之前使用。(所以说super在构造函数中的意义可以理解为改变this的指向,意义有点类似call和apply。)
  • super关键字也可以用来调用父对象上的函数。也可以用super调用父类的静态方法。(上面说过,实例无法继承父类的静态方法,但是使用super后,实例就可以使用了)
  • 当使用 Object.defineProperty 定义一个属性为不可写时,super将不能重写这个属性的值。(可以看本文拓展的第二个问题)

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值