文章目录
前言
对于使用过基于类的语言 (如 Java 或 C++) 的开发人员来说,JavaScript 有点令人困惑,因为它是动态的,并且本身不提供一个 class 实现。(在 ES2015/ES6 中引入了 class 关键字,但那只是语法糖,JavaScript 仍然是基于原型的)。
当谈到继承时,JavaScript 只有一种结构:对象。每个实例对象( object )都有一个私有属性(称之为 proto )指向它的构造函数的原型对象(prototype )。该原型对象也有一个自己的原型对象( proto ) ,层层向上直到一个对象的原型对象为 null。根据定义,null 没有原型,并作为这个原型链中的最后一个环节。
几乎所有 JavaScript 中的对象都是位于原型链顶端的 Object 的实例。
尽管这种原型继承通常被认为是 JavaScript 的弱点之一,但是原型继承模型本身实际上比经典模型更强大。例如,在原型模型的基础上构建经典模型相当简单。
1、原型链继承
基本原理:
通过原型继承多个引用类型的属性和方法。
每个构造函数都有一个原型对象,原型有一个属性指向构造函数,因此当我们改变了这个属性呢,那么这个函数的原型就会指向另一个对象,这样一层一层的就构成了一条原型链,而我们的函数方法可以顺着这个链条访问他原型链上的属性和方法,这就是原型链的基本思想。
function People() {
this.name = 'zzzz'
this.color = 'red'
}
People.prototype.sayHello = function () {
console.log('Hello Word!')
}
function Student() {
this.gender = '男'
}
Student.prototype = new People();
Student.prototype.read = function () {
console.log(`${this.name}正在认真读书!`)
}
var a = new Student();
console.log(a.name) //zzzz
console.log(a.color) //red
a.sayHello() //Hello Word!
console.log(a.gender) //男
a.read() //zzzz正在认真读书
这个例子中我们定义了两个函数,People 和 Student,然后我们替换了Student的默认原型到People的实例。我们知道,在读取实例上的属性时,首先会在实例上搜索这个属性,如果没找到,则会继续搜索实例的原型,如果原型上不存在,搜索原型的原型,逐步向上,直达顶层。故此处都正确打印出了相应结果。
默认原型
实际上,原型链中海油一环。默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。任何函数都有一个默认的Object实例,这意味着这个实例内部有一个默认指针指向Object.prototype 。这也就是一般函数都默认有toString() 的由来。
此处我们修改了Student的默认原型,使其指向了People,故People中的方法,在Student的实例中都能访问。
如何确定继承关系
原型与实例可通过两种方式来确定关系。
- instanceof
console.log(a instanceof Student) //true
console.log(a instanceof People) //true
console.log(a instanceof Object) //true
- isPrototypeOf
console.log(Student.prototype.isPrototypeOf(a)) //true
console.log(People.prototype.isPrototypeOf(a)) //true
console.log(Object.prototype.isPrototypeOf(a)) //true
可见,Student,People,Object都在a的实例的原型上。
关于方法
你可能已经注意到了,我们替换了子类的原型,那么你如果在替换原型之前在原型上定义了方法,那么这些方法都将被丢弃,只有在替换原型之后,添加才有用。
原型链的问题
原型的引用值会在所有实例间共享,这样,当一处修改了这些值之后,他就将是最新的原型属性。
子类在实例化之时,不能向父类传参。
2、盗用构造函数
基本原理:
在子类构造函数中调用父类构造函数。因为函数毕竟仅是在特定上下文中执行代码的简单对象。我们可以通过call或者apply这些方法以新创建的对象作为父类构造函数的上下文执行环境。
下面看个例子:
无参调用
function People(name='ccc',color='red') {
this.name = name
this.color = color
}
function Student() {
//继承代码
People.call(this)
this.gender = '男'
}
var a = new Student();
console.log(a.name) //ccc
console.log(a.color) //red
console.log(a.gender) //男
上面调用call的代码即为我们盗用构造函数的核心。通过call和apply这些函数,我们把父类的构造函数的执行环境改变了,就是改变了this指向,指向了我们创建Student的对象,这里即为a这个对象,那么父类构造函数里的name和color的this指向也是a,子类构造函数的this也是这个a对象,就相当于在a这个对象中执行了所有构造函数的初始化,那么所有这种子类的实例化都会有自己的name和color属性。
传递参数
function People(name='ccc',color='red') {
this.name = name
this.color = color
}
function Student(name='aaa',color='blue',gender='女') {
People.call(this,name,color)
this.gender = '男'
}
var a = new Student('zzz','white','女');
console.log(a.name) //zzz
console.log(a.color) //white
console.log(a.gender) //女
传递参数也很简单,只需在子类的构造函数的call方法中传递就行,因为call方法第一个参数之后的参数都是传到函数里的参数。
注意:为避免父类构造函数属性影响子类构造函数,一般在call后再给子类实例添加属性。
盗用构造函数的问题
盗用构造函数不能访问原型上的方法,因此所有要继承的方法都需卸载构造函数里。且原型和父类没有任何联系,故也不存在属性共享等情况。故盗用构造函数一般不单独使用。
3、组合继承
基本原理:
通过调用父类构造函数,继承父类的属性并保留传参的优点,然后通过将父类实例作为子类原型,实现函数复用。
综合了原型链和盗用构造函数,将两者的优点集中了起来。
下面看例子:
function People() {
this.name = 'ccc'
this.list = ['ccc', 'ddd']
}
People.prototype.sayHello = function () {
console.log('Hello Word!')
}
function Student() {
People.call(this) //第二次调用
this.gender = '男'
}
Student.prototype = new People() //第一次调用
var a = new Student();
console.log(a.name) //ccc
console.log(a.list) //["ccc", "ddd"]
console.log(a.gender) //男
a.sayHello()
在这个例子中,People函数定义了两个属性,name和list ,在原型上定义了一个sayHello 方法。在Student的构造函数中调用了父类的构造函数,然后定义了自己的属性gender ,然后替换Student的原型。
此时我们实例化Student这个对象,我们发现这个对象拥有了父类的所有属性和方法。
注意:我们换Student的原型时调用了一次父类的构造函数,在子类的构造函数中又调用了一次构造函数,故一共调用了两次构造函数。
组合继承弥补了原型链继承和盗用构造函数的不足,是js中使用最多的继承模式,不过上面的注意值得我们留意。
4、原型式继承
基本原理:
借助原型可以基于已有的对象创建新对象,同时还不必须因此创建自定义的类型,以此实现对象之间的信息共享。
下面给出基本函数:
function creatObject(o) {
function F() { }
F.prototype = o;
return new F();
}
这个creatObject函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质上,就是对传入的对象实现一个 浅复制 。
下面看例子
var People={
name: 'ccc',
list : ['ccc', 'ddd']
}
var a = creatObject(People);
var b = creatObject(People);
console.log(a.name); //ccc
console.log(b.name); //ccc
console.log(a.list); //["ccc", "ddd"]
console.log(b.list); //["ccc", "ddd"]
a.list.push('aaa');
console.log(a.list); //["ccc", "ddd", "aaa"]
b.list.push('bbb');
console.log(b.list); //["ccc", "ddd", "aaa", "bbb"]
原型式继承适用于:你有一个对象,你想在它的基础上创建新的对象。此时你可以把这个对象传给creatObject函数,然后这个函数会给你返回一个对象,你可以在这个对象上做适当修改。
在该例中,我们的原始对象为People,我们基于它创建了两个对象,a和b。此时我们访问a和b对象的属性,你会发现他们正确继承了People的属性(即包括原始值属性又包括原始引用属性值)而且是共享的。纠其原因,主要就是我们a和b对象的原型是People对象,我们访问a和b继承的属性时,它在它本地的属性中找不到,就去原型上面找,所以会这样。
注意一个问题,当我们直接为a和b对象上的继承属性赋值时,因为这些属性是继承的属性,其本身并没有,访问是访问的原型上的属性,但是只要赋值,就会为对象本身没有的属性做添加,所以此时你打开浏览器查看他的原型链时,可能发现他自身和原型上同时存在了相同的属性名,这是正常的。
ES6通过一个新API(Object.create())方法将原型式继承的概率规范化了。所以以后创建原型式继承没必要上面的那个creatObject函数了,直接调用Object.create()这个API就行,这个API接受两个参数,第一个是作为新对象原型的对象,就是上面的People;另一个是增加额外属性的对象(你需要继承的对象没有一些属性,另一些对象上有,这个另一些对象就是我们作为额外属性的对象(可选))。在只有一个参数时,效果和creatObject一样。
下面看例子:
var a = Object.create(People)
var b = Object.create(People)
console.log(a.name); //ccc
console.log(b.name); //ccc
console.log(a.list); //["ccc", "ddd"]
console.log(b.list); //["ccc", "ddd"]
a.list.push('aaa');
console.log(a.list); //["ccc", "ddd", "aaa"]
b.list.push('bbb');
console.log(b.list); //["ccc", "ddd", "aaa", "bbb"]
和上面的creatObject的方法结果是一样的。
当传第二个参数时(和原型链上属性同名时会屏蔽原型上的方法(访问就近原则)):
var a = Object.create(People,{
name:{value:'第二个参数'}
})
console.log(a.name) //第二个参数
原型式继承非常适合不需要单独创建对象,但又需要在对象间共享属性的情况。
注意:
属性是所有子对象共享的,和原型链继承一样。
5、寄生式继承
基本原理:
创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象:
基本的模式如下:
function creatAnother(o) {
var obj = Object.create(o);
obj.sayHello = function () {
console.log('Hello Word!')
}
return obj;
}
看到这里你可能感到很熟悉,没错,这个和原型式继承所做的操作基本一致,不过它在我们原型式继承的基础上,对原型对象做了增强,比如这里的sayHello () 这个方法。
注意这里使用Object.create(o)创建的新对象的方式不是一定的,任何可以返回新对象的方式都是可行的。
下面看例子:
var a = creatAnother(People);
a.sayHello();// Hello Word!
这个例子基于People创建了一个新对象,这个新对象含有People的所有属性和方法,还有一个新方法sayHello()。
从这里你就可以看出来了,寄生式继承就是在原型式继承的基础上做了增强,你可以添加一些别的方法属性等(不影响原始对象)。
寄生式继承适用于主要关注对象,不在乎类型和构造函数的场景,其较于原型式对象做了增强,其他和原型式对象基本一致。
6、寄生式组合继承
基本原理:
通过盗用构造函数来实现属性继承,通过父类原型实现原型继承。
寄生式组合继承是组合继承的优化版,组合继承,共调用了两次构造函数,父类的属性在设置为子类的原型时调用了一次,然后在子类的构造函数时调用了一次。如果你此时打开浏览器,应该会发现父类的属性在子类实例本身和原型上都存在了一份,不过子类的属性屏蔽了父类的属性。
寄生式组合继承就是为解决这个问题产生的。
基本模式如下:
function inheritPrototype(a, b) {
var prototype = Object.create(b.prototype);
prototype.constructor = a;
a.prototype = prototype;
}
这个函数即是寄生式组合继承的核心逻辑。其接受两个参数,子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本,然后给返回的prototype 对象的constructor 赋值为子类构造函数,解决重新原型导致的constructor问题,然后把子类的原型设置为这个副本,就大功告成了。
下面看例子:
function People() {
this.name = 'ccc'
this.list = ['ccc', 'ddd']
}
function Student() {
People.call(this)
this.gender = '男'
}
inheritPrototype(Student,People);
var a=new Student();
console.log(a)
这里只调用了一次父类的构造函数,避免了Student.prototype上不必要的属性浪费,节约了内存,且原型链保持不变。
其是组合继承的最近实践。
7、es6 中的class 通过extends 实现继承
在ES6中,class (类)作为对象的模板被引入,可以通过 class 关键字定义类。
此时我们可以写出类似java等语言的继承代码
class People {
constructor(name = '', color = '') {
this.name = name
this.color = color
}
sayHello() {
console.log('Hello Word!')
}
}
class Student extends People{
constructor(name = '', color = '', gender = '') {
super(name, color);
this.gender = gender
}
read() {
console.log(`${this.name}正在认真读书!`)
}
}
var a = new Student('zzz','blue','女');
console.log(a.name) //zzz
console.log(a.color) //blue
a.sayHello() //t.html:19 Hello Word!
console.log(a.gender) //女
a.read() //zzz正在认真读书!
console.log(Student.prototype.isPrototypeOf(a)) //true
console.log(People.prototype.isPrototypeOf(a)) //true
console.log(Object.prototype.isPrototypeOf(a)) //true
这里我们定义了一个People的父类和一个Student的子类,父类定义了name和color两个属性,在构造函数(接受name和color传参)中初始化,然后定义了一个人sayHello方法。子类中定义了一个gender属性,然后调用super传递name和color给父类,并定义了一个read方法。最后打印结果符合我们的预期。
如果你感兴趣,你可以打印下这个对象,在浏览器中看下他的原型,你会发现他和我们的寄生式组合继承的结构基本一模一样。
class 的本质是 function,父类的属性会复制到子类,父类的方法会加到父类的原型中。
它可以看作一个语法糖,让对象原型的写法更加清晰、更像面向对象编程的语法。
注意:
super关键字需在this前调用,里面的参数即是需要传递到父类的值。