原型链深入透彻全面释疑及应用

开始文章之前,大家先自查对原型链的认知到哪里?能否给自己讲清楚原型、构造函数、实例对象、原型链的概念以及他们彼此的联系,还有经常用的instanceof背后原理是否理解,等看完整篇文章后再回头看看这段话,看看对他们的理解是否有进一步加深

原型

什么是原型,我觉得可以这么通俗去理解,只要它是对象,它就有属于它的原型。用代码解释我觉得应该可以让大脑更容易理解并记忆

1
2
3
4
function Foo() {
this.name = 'foo'
}
var foo = new Foo()

这段代码再常见不过,我们定义了一个Foo函数,然后实例化,你能快速指出哪个是实例对象和构造函数器吗?

《在你不知道的JS》一书中,作者说了js其实不像其他oop语言一样会有构造函数,准确说应该是构造函数调用。

是的,我们通过new的方式创建对象,这时Foo()就是构造函数,而foo就是实例对象,你答对了吗?也许你有个疑问,为什么new就可以创建一个对象?这个问题我们后面再会解释

1.1 原型对象

bower_gulp

如上图,如果你看懂了,原型链这块你就已经掌握了,就没必要看下去了,或者你想挑刺也行

首先原型对象的好处在于它可以让所以的对象实例共享它所包含的属性和方法,换句话就是我们不用再构造函数中重复定义对象实例的信息

不推荐的代码呈上

1
2
3
4
5
6
7
8
9
10
11
function Cat(food) {
this.food = food
this.eat = eat
}
function eat() {
console.log(this.food)
}
function Dog(food) {
this.food = food
this.eat = eat
}

将eat函数添加到原型对象上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Animal() {}
Animal.prototype.eat = function() {
console.log(this.food)
}
function Cat(food) {
this.food = food
Animal.call(this, this.food)
}
function Dog(food) {
this.food = food
Animal.call(this, this.food)
}
Cat.prototype = new Animal()
Dog.prototype = new Animal()

如果看不懂或者因代码量变多而困惑也没关系,你只要先知道原型对象是干啥用就行,至于为什么这么写后面我们会详细讲解。

构造函数和实例是如何联系?

  • 通过new关键字创建对象

js引擎会给每个构造函数都添加一个prototype对象,我们通过Function.prototype就可以访问到原型对象,同理,实例对象也有一个访问原型对象的的属性__proto__,如前面那段代码

1
foo.__proto__ === Foo.prototype //true

如果对象有继承关系我们怎么去区分它的原型对象指向那个构造函数呢?constructor就是来做这件事

1.2 原型链原理

原型链常作为实现继承的主要方法,前面说过,每个构造函数都有自己的原型对象,原型对象都有一个指向构造函数的指针,而实例对象都包含一个(__proto__)指向原型对象的内部。那么如果让原型对象指向另一个类型的实例对象,很显然原型对象将包含一个指向另一个原型的指针,另一个原型也包含一个指向另一个构造函数的指针,层层递进就成为了原型链,注意,访问一个实例的时候, 如果在实例本身没有找到调用的属性或者方法, 它会通过__proto__向原型对象上找,如果找不到,继续往上一个原型对象里面的__proto__找,直到Object.prototype,如果找不到则原路返回 。

如下面代码,我们通过实例对象去访问category对象上的name,因为实例本身并没有name,所有它会通过原型链往上找

1
2
3
4
5
6
7
8
9
10
11
function Cat(food) {
this.food = food
}
Cat.prototype.eat = function() {
console.log(this.food)
}
Cat.prototype.category = {
name: "animal"
}
let cat = new Cat("fish")
cat.category.name

1.3 instanceof 原理

我们经常用instanceof去判断对象类型,实质上它是判断实例对象的__proto__跟构造函数.prototype 是不是同一个引用

创建对象

js有哪几种创建对象方式?给你3秒钟想想

时间到,答案是3种

  1. 字面量创建
  2. 构造函数
  3. Object.create()

2.1 字面量创建

通过直接声明对象的方式比较常见,这里直接上代码

1
2
var o1 = {name: 'o1'};
var o2 = new Object({name: 'o2'});

2.2 构造函数

前面我们提到到通过普通函数如何通过new关键字”处理”后它就升级为构造函数,那么new背后做了什么操作呢?

new原理:

  • 第一步: 创建一个空对象,[继承]构造函数的原型对象
  • 第二步: 执行构造函数,上下文(this)指向新的实例
  • 第三步: 判断是否有返回值,如果返回对象,这返回,如果不是,则返回关联构造函数对象

伪代码实现

1
2
3
4
5
6
7
8
9
var _new = function(cst) {
var obj = Object.create(cst.prototype)
var o = cst.call(obj)
if(o.constructor === Object) {
return o
} else {
return obj
}
}

2.3 Object.create()

Object.create()这种对象的方式我觉得你应该很少用,但你有想过它背后的工作原理,或者说它创建对象的过程都做什么?

1
aa = Object.create(a)
  • 释疑

Object.create会创建一个新对象的原型对象赋值给aa

1
aa.__proto__ === a

aa本身是个空对象,要访问a上的属性 也通过__proto__ 去访问

1
2
3
4
var p = {name: 'p'}
var o = Object.create(p)
o.__proto__.name // 'p'

类的继承

在原生js中,一般继承有两种方式

  1. 通过call/apply 改变this指针
  2. 通过原型链继承

3.1 借用构造函数实现继承

1
2
3
4
5
6
7
8
function Parent() {
this.type = 'one'
}
function Child() {
Parent.call(this)
}
new Child().type // 'one'

缺点: 只能继承构造函数上的属性和方法,不能访问父类原型对象上的属性或方法

1
2
3
4
5
6
7
8
9
10
11
function Parent() {
this.type = 'one'
}
Parent.prototype.group = function() {
console.log('group')
}
function Child() {
Parent.call(this)
}
let child = new Child()
child.hasOwnProperty('group') // false

3.2 继承原型链实现继承

代码呈上

1
2
3
4
5
6
7
8
9
10
11
12
function Parent() {
this.type = 'one'
}
Parent.prototype.group = function() {
console.log('group')
}
function Child() {
}
Child.prototype = new Parent()
let child = new Child()
child.type // 'one'
child.hasOwnProperty('group') //true

缺点: 原型链被污染

1
2
3
4
5
6
7
8
9
10
11
12
function Parent() {
this.type = 'one'
}
Parent.prototype.group = [1,2,3]
function Child() {
}
Child.prototype = new Parent()
let child1 = new Child()
let child2 = new Child()
child1.group.push('4') // [1, 2, 3, 4]
child2.group // [1, 2, 3, 4]

可以看到,我们分别实例化了两个对象child1和child2,我们只希望在child1添加一个元素,但child2的表现出乎我们意料.为什么呢? 因为new这个操作实际上this指向了被实例的对象上,所以从原型链的角度child1和child2都指向Child的原型对象上

3.3 组合方式

结合前面两种继承方式实现功能互补

代码呈上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Parent() {
this.type = 'one'
this.group = [1,2,3]
}
function Child() {
Parent.call(this)
}
Child.prototype = new Parent();
let child1 = new Child();
var child2 = new Child();
child1.group.push('4') ;
// Child {type: "one", group: Array(4)}
child2.group ;
// Child {type: "one", group: Array(3)}

似乎问题得到解决,我们通过实例化构造函数生成两个不同地址的实例,同时又拥有相同的实例属性。细心的你可能已经发现我把group声明在构造函数,那么如果将它添加到原型链上呢是否还可以呢? 你可以暂停阅读先自己去试试

同样,上面的写法也有缺点: 在子类构造函数执行父类构造函数,没法判断子类的构造函数

3.4 组合方式优化

上面那种组合方式,我们在new Child的时候,父类的构造函数被调用了两次,但其实这是多余的.

代码呈上

1
2
3
4
5
6
7
8
9
10
11
function Parent() {
this.type = 'one'
this.group = [1,2,3]
}
function Child() {
Parent.call(this)
}
Child.prototype = Parent.prototype;
var child1 = new Child();
var child2 = new Child();

恩,同样虽然解决多次(2次)调用父类构造函数问题后,它同样存在瑕疵: 没法判断子类的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Parent() {
this.type = 'one'
this.group = [1,2,3]
}
function Child() {
Parent.call(this)
}
Child.prototype = Parent.prototype;
let child1 = new Child();
let child2 = new Child();
child1 instanceof Child //true
child1 instanceof Parent //true

3.5 组合方式再优化

bower_gulp

不知道你对前面这张原型链还有没印象, 我们说通过constructor来区分原型对象的构造函数

代码呈上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Parent() {
this.type = 'one'
this.group = [1,2,3]
}
function Child() {
Parent.call(this)
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child; //弥补重写原型丢失的constructor
var child1 = new Child();
var child2 = new Child();
child1.__proto__.constructor === Child //true
child1.__proto__.constructor === Parent //false

有人把这种方式叫做寄生组合方式继承,它优点在于弥补前面几种继承方式的缺点,高效体现在只调用了一次父函数,避免了在子函数原型对象上创建多余属性,同时原型链又保持”干净”不变.

好了,原型链这块的所以知识点就这么多了,不知道你看完大脑有没有回路呢?

感谢您的阅读,本文由 lynhao 原创提供。如若转载,请注明出处:lynhao(http://www.lynhao.cn
{}+{} in Javascript
mvvm底层原理及伪代码实现