JS实现继承

对象的原型

查看

每一个函数对象内都有一个prototype属性,其值是一个对象,这个对象就是原型对象。

获取原型对象的方式有两种:

  • 通过对象的__proto__属性可以获取到,但这个是浏览器早期自己添加的,存在兼容性问题。

  • 通过Object.getPrototypeOf()方法可以获取到,但其也存在老版本浏览器的兼容性问题。

通过new操作符创建的对象,会执行如下几步操作:

  1. 创建一个空的对象。

  2. 将this指向创建的空对象。

  3. 将构造函数的prototype属性赋值给被创建对象。

  4. 执行函数内的代码。

  5. 若返回值不是对象,则将创建的对象返回。

所以对象内存在与其对应的构造函数的prototype的相同引用,我们称之为在对象中为隐式原型对象,在函数中称之为显式原型对象。原型对象也是一个对象,如果在原型对象中找不到,会继续向原型对象的原型对象中寻找,直到找到或null为止,此链式查找也称为[隐式]原型链查找。

JS中的对象都有一个特殊的内置属性[[Prototype]],其指向父类的原型对象,但该属性是不可调用的,因为它仅仅是一个标准,通过此属性,可以查看对象的原型链关系。

Constructor

查看

原型对象上存在属性constructor,其指向当前的函数对象。

1
2
3
4
5
function Foo() {

}

console.log(Foo.prototype.constructor === Foo) // true

上面我们说可以通过原型链查找属性,那么方法也是可以的,通过在原型对象添加方法可以减少相同操作代码的编写,提高代码的复用性。

语法:Foo.prototype.变量名 = function(){}

但当我们需要添加多个方式时,一直重复上述操作有些麻烦,所有,我们可以重写原型对象来简化操作。

1
2
3
4
5
6
7
8
9
10
Foo.prototype = {
property: 'value',
method: function() {},
}

// 由于原型对象的constructor属性是不可枚举的,所以我们需要通过属性描述符对其进行单独操作。
Object.defineProperty(Foo.prototype, 'constructor', {
value: Foo,
enumerable: false
})

ES5实现继承

查看

前提:定义一个Person对象和Student对象。目标:实现Student对象继承Person对象。

方法一:通过原型链实现继承

通过Person构造方法创建一个person实例对象,改变Student构造函数对象的prototype指向person实例对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Person() {
this.name = '张三';
this.hobbies = ['看电影']
}
function Student() { }

var p = new Person()

Student.prototype = p
Student.prototype.studying = function () {
console.log(this.name + '正在学习')
}

var s1 = new Student()
var s2 = new Student()

s1.hobbies.push('看小说')
console.log(s1.hobbies, s2.hobbies) // (2) ['看电影', '看小说'] (2) ['看电影', '看小说']

弊端:

  • 有些属性是保存在实例对象p上的,通过直接打印实例对象s1,s2,看不到name、hobbies属性。

  • 虽然我们可以通过原型链访问到属性,但原型对象对于通过同一个构造函数创建的对象是共享的,意味如果修改原型对象上的引用属性,对其他的实例对象都会受影响。

  • 我们之所以要实现继承,是为了复用代码,但以上的情况,我们无法给Person传递参数,导致创建的s1,s2实例对象没有自己的属性,即无法进行定制化操作。

方法二:借用构造函数实现继承

constructor stealing:借用构造函数/经典继承/伪造对象

在Student构造函数内调用Person的构造函数,并通过call/apply改变this指向,实现继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Person(name, hobbies) {
this.name = name;
this.hobbies = hobbies;
}
function Student(name, hobbies, age) {
Person.call(this, name, hobbies)
this.age = age
}

Student.prototype = Person.prototype

var s1 = new Student('张三', ['看电影', '看小说'], 18)
var s2 = new Student('李四', ['玩游戏'], 20)

s1.hobbies.push('看小说')
console.log(s1.name, s2.name) // 张三 李四
console.log(s1.hobbies, s2.hobbies) // (3) ['看电影', '看小说', '看小说'] ['玩游戏']
console.log(s1.age, s2.age) // 18 20

弊端:

  • 直接把Person的原型对象赋值给Student的原型对象,这样会导致通过Student对原型对象的修改会直接影响到Peroson的原型对象。

方法三:组合借用继承

即将方法一和方法二结合起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Person(name, hobbies) {
this.name = name;
this.hobbies = hobbies;
}
function Student(name, hobbies, age) {
Person.call(this, name, hobbies)
this.age = age
}

var p = new Person()

Student.prototype = p

var s1 = new Student('张三', ['看电影', '看小说'], 18)
var s2 = new Student('李四', ['玩游戏'], 20)

s1.hobbies.push('看小说')
console.log(s1.name, s2.name) // 张三 李四
console.log(s1.hobbies, s2.hobbies) // (3) ['看电影', '看小说', '看小说'] ['玩游戏']
console.log(s1.age, s2.age) // 18 20

通过这种方法来实现继承已经问题不到了,但仍有不完美的地方,比如:

  • Person构造函数会被调用两次。

    • 第一次是在赋值给Student.prototype的时候。
    • 第二次是借用构造函数的时候。
  • Student实例对象上和其原型对象上会存在相同的属性,尽管值可能不同,但好在优先在对象本身上进行查找。

以上代码我们发现,实例化p仅仅是为了将其原型对象关联到Student.prototype上,那么是不是只要是个对象,然后将其隐式原型对象指向Person的显示原型对象即可?

方法四:原型式继承函数

原型式继承是由从道格拉斯·克罗克福德(Douglas Crockford,著名的前端大师,JSON的创立者)在2006年写的一篇文章说起: Prototypal Inheritance in JavaScript(在JavaScript中使用原型式继承)。

其实就是上述提出的创建一个对象,作为继承连接的原型对象桥梁。

1
2
3
4
5
function object(obj) {
function Func(){}
Func.prototype = obj
return new Func()
}

以上代码完全不必担心兼容性的问题,但我们也可以对其进行改造,原理是一样的,不过是调用了些API,可能会存在兼容性的问题。可以通过Object.setPrototypeOf(obj, prototype)Object.create(proto, propertiesObject)来实现。

Object.setPrototypeOf(obj, prototype):将obj的原型指向prototype。参数:
obj: 需要修改原型的对象。
prototype: 需要指向的原型对象。

通过该API可以将以上代码修改为:

1
2
3
4
5
function object(obj) {
var newObj = {}
Object.setPrototypeOf(newObj, obj)
return newObj
}

Object.create(proto, propertiesObject):创建一个对象,并将此对象的原型指向proto,并通过propertiesObject对此对象进行属性描述符操作,然后返回此对象。参数:
proto: 需要指向的原型对象
propertiesObject(可选): 需要添加的属性描述符对象。返回值:返回一个对象,返回对象的隐式原型对象指向传入的原型对象。

例子:

1
2
3
4
5
6
7
8
var obj = Object.create(person, {
name: {
value: '张三',
enumerable: true,
configurable: true,
writable: true
}
})

通过该API可以将以上代码修改为

1
2
3
4
function object(obj) {
var obj = Object.create(obj)
return obj
}

综上所看,通过原型式继承函数的代码可以为(最具兼容性的方式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Person(name, hobbies) {
this.name = name;
this.hobbies = hobbies;
}
function Student(name, hobbies, age) {
Person.call(this, name, hobbies)
this.age = age
}

function object(obj) {
function Func(){}
Func.prototype = obj
return new Func()
}
Student.prototype = object(Person.prototype)

方法五:寄生式继(Parasitic)承函数

什么是寄生式继承?

  • 寄生式继承式是与原型式继承紧密相连的一种思想,同样由道格拉斯·克罗克福德提出和推广。

  • 寄生式继承是结合原型式继承和工厂模式的一种方式

  • 即创建一个封装继承过程的函数,该函数在内部以某种方式来增强对象,最后将这个对象返回。

核心步骤:

1
2
3
4
5
6
7
8
9
10
11
function object(obj) {
function Func(){}
Func.prototype = obj
return new Func()
}

function createStudent(person) {
var newObj = object(person)
newObj.studying = function(){}
return newObj
}

完整实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Person(name, hobbies) {
this.name = name;
this.hobbies = hobbies;
}
function Student(name, hobbies, age) {
Person.call(this, name, hobbies)
this.age = age
}

function object(obj) {
function Func(){}
Func.prototype = obj
return new Func()
}

function createStudent(person) {
var newObj = object(person)
newObj.studying = function(){}
reuturn newObj
}

Student.property = createStudent(Person.prototype)

以上代码进一步的细分,实现一种函数实现特定功能,并在特定的函数中在原型对象上添加属性和方法。

最终方案: 寄生组合继承

结合前几种的思想,结合成最终继承方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function Person(name, hobbies) {
this.name = name;
this.hobbies = hobbies;
}
function Student(name, hobbies, age) {
// 借用构造函数
Person.call(this, name, hobbies)
this.age = age
}

// 原型式继承核心代码
function object(obj) {
function Func(){}
Func.prototype = obj
return new Func()
}

// 寄生式核心代码
function inherit(subType, superType) {
// 通过原型链实现
subType.prototype = object(superType.prototype)
subType.prototype.constructor = subType
}

inherit(Student, Person)

总结

通过以上多种实现继承的方案来看,在ES5时期,并没有一个统一的标准,可谓是百花齐放。

总结一下,ES5实现继承的方法有:

  1. 通过原型链实现继承:通过创建父类实例作为原型对象将子类与父类进行连接。

    缺陷:

    • 能访问到执行父类代码创建到原型对象的属性,但其在原型对象上,直接打印子类实例对象是看不到的。

    • 由于实现继承的目的是为了复用父类中的属性和方法,在此种情况下,无法定制化子类实例对象的属性和方法。即多个子类对象访问的属性都是相同的,且一旦存在属性为引用对象,则修改其中一个子类对象属性,则其他子类对象属性也会被修改。

  2. 借用构造函数实现继承:通过apply/call调用父类构造函数,将父类中的属性和方法添加到子类实例对象上。

    缺陷:

    • 此种方法是直接将子类的原型对象指向父类的原型对象,一旦通过子类操作原型对象会直接对父类原型对象造成影响。

  3. 组合借用继承:将前两种方法结合起来使用(已基本能够作为继承的方案)。

    缺陷:

    • 会调用两次父类构造函数,导致实例对象本身和其原型对象上存在相同的属性。

    • 且保露父类实例化在外部不太美观。

  4. 原型式继承:由道格拉斯·克洛克福德提出,将一个普通函数的原型对象指向父类原型对象,然后将此普通函数实例化返回作为子类原型对象。且可以通过新的API对其进行改造,如Object.create(proto, propertiesObject)Object.setPrototypeOf(obj, prototype),但有一定的兼容性问题。

  5. 寄生式继承:由道格拉斯·克洛克福德提出,在原型式继承的基础上,将为中间原型对象添加方法的操作封装到函数中,然后返回此原型对象作为子类原型对象。

  6. 寄生组合继承:总结以上方法的优点,借用构造函数实现子类的定制化,通过原型式继承函数,实现子类原型对象函数的创建,通过寄生式继承函数,在函数内通过原型继承实现子类原型对象的绑定,并将原型对象的constructor指向子类。

对象方法补充

查看
  • Object.prototype.hasOwnProperty():判断对象是否是自身属性,而不是原型对象上的属性。

  • in/for in 操作符:判断某个属性是否在某个对象或对象的原型上。

    • name in obj / for (var item in obj)
  • instanceof: 用于检测构造函数的prototype是否出现在某个实例对象的原型链上。

  • isPrototypeOf: 用于检测某个对象是否出现在某个实例对象的原型链上。

1
2
3
4
5
6
var obj = {}

var info = Object.create(obj)

console.log(obj.isPrototypeOf(info)) // true
console.log(info.isPortotypeOf(obj)) // false

手动实现instanceOf

1
2
3
4
5
6
7
8
9
10
11
function instanceof(o, c) {
var op = o.__proto__
while (op !== null) {
if (op === c.prototype) {
return true
} else {
op = op.__proto__
}
}
return false
}

原型继承关系图

ES6实现继承

查看

ES6新增了class关键字,用于创建类,通过类创建实例对象。

通过class创建对象的写法:

1
2
3
class Person {}

var Student = class {}

class与class通过extends实现继承。

1
2
class Person {}
class Student extends Person {}

class是个语法糖,本质上和构造函数是没有区别的,特别的是class定义的类是不能向构造函数一样调用的,内部做了调用判断。

而class的继承和ES5的寄生组合继承在核心上是一致的,只不过class加了更多的边界判断,且将子类的隐式原型指向父类,以便可以通过子类调用父类的类方法。在ES5可以通过Student.__proto__ = PersonObject.setPrototypeOf(Student, Person)实现。

额外知识点补充

查看

super关键字

Class中可以使用super关键字,通过super可以调用父类方法(实例方法和静态方法)和父类构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Person {
constructor(name, age) {
this.name = name;
this.age = age
}

static foo() {
console.log('foo')
}

eating() {
console.log('eating')
}
}
class Student extends Person {
constructor(name, age, hobbies) {
// 调用父类构造函数
super(name, age)
this.hobbies = hobbies
}

static oFoo(){
// 调用静态方法
super.foo()
}

oEating() {
// 调用父类方法
super.eating()
}
}
Student.oFoo()
const s = new Student('张三', 18, ['吃饭', '睡觉'])
console.log(s)
s.oEating()

注意:在子(派生)类的构造函数中使用this或返回默认对象之前,必须先通过super调用父类构造函数。 即如果在类中定义了constructor,必须在子类constructor中头行调用super,否则会报错。

类的混入

背景:

JS的类只支持单继承,如果在开发中我们需要在一个类中添加更多相似的功能,可以通过类的混入实现。

实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function mixinRunner(BaseClass) {
return class extends BaseClass {
running(){
console.log('running')
}
}
}

function mixinEater(BaseClass) {
return class extends BaseClass {
eating(){
console.log('eating')
}
}
}

class Person {}

class Student extends mixinEater(mixinRunner(Person)) {

}

const s = new Student()
s.eating()
s.running()

其实就是创建一个类继承需要扩展功能的类,如果有需求,再创建一个类继承前面创建的类,以此类推,知道所有需要扩展的功能都添加完,再交由其他类继承。

只不过上述代码对继承过程进行了封装然后将已继承的匿名类返回,一种功能封装一个函数,使代码结构更加清晰,增加了代码的可维护性,实现了代码复用,降低了耦合度,有利于模块化,便于管理和扩展等。

JS中的多态

面向对象的三大特征:封装、继承、多态。

对于JS来说,由于其的灵活性,其是否存在多态?

对于多态的定义:多态指为不同数据类型的实体提供统一的接口,或使用一个单一的符号来表示多个不同的数据类型。

从此定义来看,可以说JS中处处存在多态。

以如下代码为例:

1
2
3
function add(arg1, arg2) {
return arg1 + arg2
}

对于上述函数,其参数可以是任意类型,所以可以认为其存在多态。

对于单一的符号,通过var声明的变量,可以为其进行任意类型的复制,所以也可以认为其存在多态。