浅析JavaScript -原型链与继承

介绍原型链及继承

原型链

一些相关练习以及博客

引言

JS的面向对象是基于“原型对象”,在ES6中推出了新内容,用于定义类,即class,这个之后再讨论。

首先谈到的是为什么要使用js中的原型链。假设这样一个场景,你需要定义三个不同的猫猫,名字、颜色都各不相同,但是他们的叫声是一样的,

如果使用构造函数进行定义的话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Cat(name, color) {
this.name = name
this.color = color
this.meow = () => { console.log('meow~ meow~') }
}
// 调用方式如下
let CatA = new Cat('Alice', 'red')
let CatB = new Cat('Bob', 'black')
catA.name // Alice
catA.meow() // meow~ meow~
catB.meow() // meow~ meow~
catA.name === catB.name // false
catA.color === catB.color // false
catA.meow === catB.meow //false

catAcatB 就是直接使用构造函数生成的对象。缺点:无法共享属性,对系统资源的浪费。 meow() 作为每只猫都会的叫声,最好的方式是让这个属性可以被每只猫共享,而不是每只猫拥有各自的 meow() 属性。

这样即可以顺理成章地提到prototype了。

prototype的理念

对象的属性和方法,有可能是定义在自身,也有可能是定义在它的原型对象,由于原型本身也是对象,也有自己的原型,故就是一条原型链。

JS中所有对象都有构造函数,所有构造函数都有prototype属性(其实是所有函数都有prototype属性),所以所有对象都有自己的原型属性.

原型对象上的所有属性和方法,都能被派生对象共享。这就是JavaScript继承机制的基本设计。

也就是说,当实例对象本身没有某个属性或方法的时候,它会到构造函数的prototype属性指向的对象,去寻找该属性或方法。这就是原型对象的特殊之处。

原型链的覆盖

“原型链”的作用是,读取对象的某个属性时,JavaScript引擎先寻找对象本身的属性。当对象自身和它的原型,都定义了一个同名属性,则有限读取对象自身的属性。

原型链的尽头

任何属性和方法的null对象,而null对象没有自己的原型。
Object.getPrototypeOf(Object.prototype)
// null
上面代码表示,Object.prototype对象的原型是null,由于null没有任何属性,所以原型链到此为止。

原型链的各种拗口调用

此时应该上一张图!

简单描述一下:

上述例子中创建的 Cat() 就是一个构造函数,它本身有一个实例原型 Cat.prototype
而Cat.prototype的constructor就是Cat

CatA是由 Person 构造的,所以 Cat() 就是catA的构造函数。而 catA_proto_ 就是Cat.prototype

这一段还要重新组织一下语言。

使用原型链替代构造函数创建对象

还是使用之前的那个catA和catB的例子

1
2
3
4
5
6
7
8
9
10
11
12
function Cat(name, color) {
this.name = name
this.color = color
}
Cat.prototype = () => {
console.log(`meow~ I am ${this.name}`)
}
let catA = Cat('Alice', 'red')
let catB = Cat('Bob', 'black')
CatA.meow === CatB.meow // true

此时查看catA的meow属性,和catB的meow属性就是相等的了。

这其实就是意味着它们在堆内存是指向同一块内存空间。

请注意:当继承的函数被调用的时候,this 指向的是当前继承的对象,而不是继承的函数所在的原型对象。

1
2
3
4
5
6
7
8
9
10
11
12
function Cat(name, color, age) {
this.name = name
this.color = color
this.age = (age == undefined) ? 0 : age
}
Cat.prototype.showAge = function() {
console.log(`I am ${this.age} years old`)
}
let catA = new Cat('Alice', 'red')
let catB = new Cat('Bob', 'black', 2)

原型链间的验证

constructor

1
alert(catA.constructor == Cat) // true

instanceof, 验证原型对象与实例对象之间的关系下面这种是自执行的方式

1
alert(catA instanceof Cat) // true

isPrototypeOf(), 用来哦安段某个prototype对象和某个实例之间的关系

1
alert(Cat.prototype.ifPrototypeOf(catA)) // true

hasOwnPropertyOf(),每个实例对象都有这个方法,用来判断某一个属性到底是本地属性,还是继承自prototype的属性

1
2
alert(catA.hasOwnProperty('name')) // true
alert(catA.hasOwnProperty('meow')) // false

in,用来判断某个实例是否含有某个属性,不管是不是本地属性

1
2
alert('name' in catA) // true
alert('meow' in catA) // true

同时in还可以用来遍历某个对象的所有属性

1
2
3
for (let prop in catA) {
console.log(`catA[${prop}] = ${catA[prop]}`)
}

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person(){
this.age = 18;
}
Person.prototype.name="s";
var p1 = new Person();
for(var i in p1){
console.log(i)//age,name
}
console.log(p1.hasOwnProperty("age"));//实例属性
console.log(p1);//打印{age: 18, __proto__:{name:"s"}}
console.log(p1.name);//可以打印值
console.log(Object.keys(p1));//只有age(实例),可枚举的(是否可枚举是可以设置的)
console.log(Object.getOwnPropertyNames(p1))//只有age(实例),包括不可枚举的

原型链的继承方式

打个比方,现在有一个 Cat 还有一个 Dog,分别是两个构造函数,但是它们又是同属于一个物种就是 Animal,具有Animal的各种属性,那么如何让Cat和Dog继承Animal呢

1
2
3
4
// Animal的定义如下
function Animal() {
this.species = '哺乳动物'
}

此时就要用到原型链间的继承

一、构造函数绑定

就是红宝书中提到的 借用构造函数

1
2
3
4
5
6
7
8
9
function Cat(name, color) {
Animal.apply(this, arguments)
this.name = name
this.color = color
}
let catA = new Cat('Alice', 'red')
console.log(catA.species) // 哺乳动物

二、prototype模式

红宝书:原型链继承

使用prototype属性。如果 Cat 的prototype对象,指向一个 Animal 的实例,那么所有 Cat 的实例,就能继承 Animal

1
2
3
4
5
6
7
8
9
10
Cat.prototype = new Animal() // 相当于完全删除prototype对象原先的值,然后赋予一个新值
// 此时catA.constructor = Animal
// 应当手动纠正
Cat.prototype.constructor = Cat // 由于上面一行把Cat原本的prototype删掉了,现在重新绑定回来
console.log(catA.constructor == Cat.prototype.constructor) // true
let catA = new Cat('Alice', 'red')
console.log(catA.species) // 哺乳动物

请注意:当替换了prototype对象的时候,都应当为新的prototype对象加上constructor属性,并且指回原来的构造函数

这种继承的需要注意的是:不能在使用对象字面量定义原型链,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 这样就相当于重写了prototype,会覆盖之前
Cat.prototype = {
eatFish: function() {
console.log('eat fish')
},
eatFood: function() {
console.log('eat food')
}
}
// ✅
Cat.prototype.eatFish = function() {
console.log('eat fish')
}

组合继承就是将构造函数式与原型链继承进行结合。

三、直接继承prototype

首先需要对Animal进行修改,将不变的属性都放入Animal.prototype中。

1
2
3
4
5
6
7
8
9
10
function Animal() { }
Animal.prototype.species = '哺乳动物'
// 然后开始继承
Cat.prototype = Animal.prototype
Cat.prototype.constructor = Cat
let catA = new Cat('Alice', 'red')
console.log(catA.species) // 哺乳动物

这种方法的优点在于:在继承的时候不需要再创建一个新的 Animal 实例了,节省了内存。缺点是 Cat.prototypeAnimal.prototype 指向了同一个对象,所有对Cat.prototype的修改,都会反映在Animal.prototype上。

四、利用空对象作为中介

为了解决 直接继承 的缺点,所以此方法是利用一个空对象作为中介。

1
2
3
4
let f = function() { }
f.prototype = Animal.prototype
Cat.prototype = new f()
Cat.prototype.constructor = Cat

f 是空对象,几乎不占内存,此时修改Cat的prototype对象,也不会影响到Animal的prototype对象。

可以将上述方法封装成函数,

1
2
3
4
5
6
7
function extend(Child, Parent) {
let f = function() { }
f.prototype = Parent.prototype
Child.prototype = new f()
Child.prototype.constructor = Child
Child.uber = Parent.prototype // 此行只是为了实现继承的完备性,纯属备用性质。这等于在子对象上打开一条通道,可以直接调用父对象的方法
}

五、拷贝继承

将Animal所有不变的属性,放在prototype对象上

1
2
3
4
5
6
7
8
function extend2(Child, Parent) {
let p = Parent.prototype
let c = Child.prototype
for (let i in p) {
c[i] = p[i]
}
c.uber = p
}

这里做的是深拷贝,修改catA的species不会影响到其他的Cat实例

上述是学习阮一峰的博客中的内容,还有一篇也还不错的文章:JavaScript深入之继承的多种方式和优缺点

“非构造函数”的继承

1
2
3
4
5
6
let BosiCat = {
species: '波斯猫'
}
let American = {
nation: '美国'
}

如何让BosiCat继承American,注意,这两个都是普通对象,不能使用构造函数方法的继承

一、object() 方法

这种方法并没有使用严格意义上的构造函数,而是借助原型可以基于已有的对象创建新对象,同时还不比因此创建自定义类型。

红宝书:原型式继承,问题在于会共享引用类型的属性。

1
2
3
4
5
6
7
8
9
10
11
function object(o) {
function F() { }
F.prototype = o
return new F() // return一个实例
}
// 使用方式
let BosiCat = object(American) // 相当于将子对象的prototype指向父对象
BosiCat.species = '波斯猫'
console.log(BosiCat.nation) // 美国

二、浅拷贝

将父对象的属性,全部拷贝给子对象。

1
2
3
4
5
6
7
8
function extendCopy(p) {
let c = {}
for (let i in p) {
c[i] = p[i]
}
c.uber = p
return c
}

这和构造函数中的直接拷贝方法一样,子对象获得的只是一个内存地址,而不是真正的拷贝,父对象有 被篡改 的可能。

三、深拷贝

递归调用“浅拷贝”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function deepCopy(p, c) {
let c = c || {}
for (let i in p) {
if (typeof p[i] === 'object') {
c[i] = (p[i].constructor === Array) ? [] : {}
deepCopy(p[i], c[i])
} else {
c[i] = p[i]
}
}
return c
}
// 使用
American.birthPlaces = ['华盛顿','加州']
let BosiCat = deepCopy(American)
BosiCat.birthPlaces.push('旧金山')
console.log(BosiCat.birthPlaces) // 华盛顿 加州 旧金山
console.log(American.birthPlaces) // 华盛顿 加州

目前jQuery使用的就是这个方式

红宝书中的继承方式有:

  1. 原型链
  2. 借用构造函数
  3. 组合继承
  4. 原型式继承
  5. 寄生式继承
  6. 寄生组合式继承

红宝书中的剩下两种继承方式:

一、寄生式继承

红宝书

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

1
2
3
4
5
6
7
8
9
10
11
12
13
function object(o) {
var f = function() {}
f.prototype = o
return new f()
}
function createAnother(original) {
var another = object(original)
another.sayHi = function() {
console.log('hi')
}
return another
}

二、寄生组合式继承

即通过借用构造函数来继承属性,通过原型链的混成形式来集成方法,其背后的思路是:不必为了指定子类型的原型而调用超类型的构造函数,本质上,就是使用寄生式即成来继承超类型的原形,然后再将结果指定给子类型的原型。

基本模式如下:

1
2
3
4
5
function inheritPrototype(subType, superType) {
var prototype = object(superType.prototype) // 创建对象
prototype.constuctor = prototype // 增强对象
subType.prototype = 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
26
27
// 实现一个Human,输入输出如下
Human('Peter')
// I am Peter
Human('Peter').eat('apple')
// I am Peter
// Start to eat apple
Human('Peter').rest(10).eat('apple')
// I am Peter
// Starting rest 10s
// (等待十秒)
// Start to eat apple
Human('peter').go('home').rest(10).eat('apple')
// I am Peter
// Start to go home
// Starting rest 10s
// (等待十秒)
// Start to eat apple
Human('Peter').eat('apple').rest(10).go('home')
// I am Peter
// Start to eat apple
// Starting rest 10s
// (等待十秒)
// Start to go home

从等待十秒这部分来看的话,应该是需要使用异步进行操作。

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
36
37
38
39
40
41
// 还可以使用es6中的class进行实现。这里没写。
function Human(name) {
console.log(`I am ${name}`)
return this
}
Human.prototype.rest = function (time) {
this.promise = new Promise((resolve, reject) => {
console.log(`Starting rest ${time}s`)
setTimeout(() => {
resolve()
}, time * 1000)
})
return this // 此处堵塞
}
Human.prototype.eat = function (species) {
if (this.promise) {
this.promise.then(() => {
console.log(`Start to eat ${species}`)
})
this.promise = null
return this
} else {
console.log(`Start to eat ${species}`)
return this
}
}
Human.prototype.go = function (destination) {
if (this.promise) {
this.promise.then(() => {
console.log(`Start to go ${destination}`)
})
this.promise = null
return this
} else {
console.log(`Start to go ${destination}`)
return this
}
}
new Human('Peter').go('home').rest(4).eat('apple')

再看这样一个问题,来理解一下实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Foo() {
getName = function () {
console.log('1');
};
return this;
}
Foo.getName = function () {
console.log('2');
};
Foo.prototype.getName = function () {
console.log('3');
};
new Foo.getName();
new Foo().getName();
new new Foo().getName();

答案是 2 3 3

首先需要明白函数本身也是对象,而Foo.getName是添加了一个属性。而且应该明白JS中的运算符的优先级。其实是new (Foo.getName)()。Ys7

new Foo().getName()new Foo()创建了一个实例,且return了this,之后getName()函数是作用在this(也就是Foo()创建的那个实例上,但是由于Foo()里面的那个getName其实是一个全局的函数,所以根据原型链向上查找,找到了prototype上的getName(),因此输出3

new new Foo().getName()

如果修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Foo() {
this.getName = function () {
console.log('1');
};
return this;
}
Foo.getName = function () {
console.log('2');
};
Foo.prototype.getName = function () {
console.log('3');
};
new Foo.getName();
new Foo().getName();
new new Foo().getName();

答案是 2 1 1

实现 Inherit 函数

这道题想再谈谈继承的重写,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function A (name) {
this.name = name
// ...
}
A.prototype.say = function () {
alert(this.name)
}
const B = Inherit(A, {
hello () {
alert(`Hi, My name is ${this.name}!`)
}
})
const instanceB = new B('Faraway')
instanceB.say()
instanceB.hello()

我真的尝试了很多种的继承方式,最初首先用的是组合继承(即构造函数式继承 + 原型链继承)这样可以成功继承 fn的属性和方法,然后再使用原型式继承,也可以让child.prototype = object(a),但是问题在于两个都要修改prototype,会导致冲突,只能成功继承一个,使用比较传统的这种方式好像并不是非常行得通。

我最终还是使用的 拷贝继承,先通过构造函数式继承,得到 fn 所有的属性,再通过遍历得到prototype上所有的方法。

其实原型式继承,就是将构造函数的prototype绑定为 object(o)所得到的新对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Inherit(Func, a) {
function extend2(c, p) {
for (let i in p) {
c[i] = p[i]
}
c.uber = p
}
function Child() {
Func.apply(this, arguments)
}
extend2(Child.prototype, Func.prototype)
extend2(Child.prototype, a)
return Child
}

prototype.js