JS-超好懂的深入理解原型-看不懂来捶我😎

179 阅读10分钟

原型存在的意义

1. 来看一个问题

从实例化角度去看,函数,或者其他不变的东西可以放在原型上面,在不同实例之间共享。这样可以节省空间

我们来看代码

function Person(name) {
  this.name = name;
  this.likeColor = ['red', 'yellow'];
  this.logName = function(){
     console.log(this.name);
  }
}

var zenos = new Person('zenos');
var zora = new Person('zora');

上面我们实例化了两个对象,两个对象都有独立的name、likeColor、logName属性。

console.log(zenos.logName === zora.logName); // false

我们仔细看看logName这个函数,在两个实例上的作用都是一样的,没有必要定义两个不同的Function实例。定义一个Function实例,然后实例化对象的时候,让logName属性指向这个Function就好了

2. 相同的逻辑函数共享

我们可以这么做

function Person(name) {
    this.name = name;
    this.likeColor = ['red', 'yellow'];
    this.logName = printName;
}

function printName(){
    console.log(this.name);
}

var zenos = new Person('zenos');
var zora = new Person('zora');
console.log(zenos.logName === zora.logName); // true

printName函数的定义放在外面,实例对象里面的logName是指向外部同一个函数的指针,所以是相等的。但是这样虽然解决了相同逻辑的函数重复定义个问题,但是全局作用域也因此被搞乱了。如果这个对象需要多个方法,那么就需要在全局作用域定义多个函数。这会导致自定义类型引用的代码不能很好地聚集在一起。

自定义类型引用代码聚集在一起的好处是方便代码的阅读和维护

3. 用原型来解决共享问题

我们来看代码

function Person(name) {
    this.name = name;
    this.likeColor = ['red', 'yellow'];
}

Person.prototype.logName = function(){
     console.log(this.name);
}

var zenos = new Person('zenos');
var zora = new Person('zora');
console.log(zenos.logName === zora.logName); // true

我们知道,现在实例对象所访问的logName函数,是在原型对象上面的。而他们原型对象是同一个,所以logName也是同一个。这样做的好处是,即解决了相同逻辑函数重复定义得问题,也解决了全局作用域被搞乱和自定义类型的引用代码不能被很好地聚集在一起的问题。堪称完美

那么,是不是所有的对象属性都可以放在原型上面呢?

我们可以试试。下面我们将likeColor属性放在原型上面

function Person(name) {
    this.name = name;
}

Person.prototype.logName = function(){
     console.log(this.name);
}

Person.prototype.likeColor = ['red', 'yellow'];
var zenos = new Person('zenos');
var zora = new Person('zora');

console.log( zenos.likeColor);	// ['red', 'yellow']
console.log( zora.likeColor );	// ['red', 'yellow']
console.log( zenos.likeColor === zora.likeColor ); // true

现在两个实例对象的likeColor属性是共享同一个数组了,我们试着在其中一个对象上对数组做些修改

zenos.likeColor.push('blue');

console.log( zenos.likeColor);	// ['red', 'yellow','blue']
console.log( zora.likeColor );	// ['red', 'yellow','blue']
console.log( zenos.likeColor === zora.likeColor ); //true

结果虽然有些出乎意料,但还是可以理解的吧😁

在zenos实例对象上修改likeColor属性,就是在修改原型上面的likeColor,所以当zora访问likeColor的时候,拿到的数组是修改过后的数组

这就是将引用型变量放在原型上面的副作用,不知道你是否喜欢这样。当然,这个要看需求,如果你需要这样的效果,那就放在原型对象上面;如果你不需要,那就老老实实放在实例属性上面好了。实例属性在不同实例对象上是绝对独立的

4. 小结一下

只有需要共享的属性,才会放在原型对象上。

这就是我理解的原型对象存在的基本意义了,当然,你也可以提出你自己的看法

如何改变一个对象的[[prototype]]指向

改变一个对象的方法大概有四种。当然也有可能更多,你可以告诉我😁

1. Object.setPrototypeOf(o1, o2)

这个JS语法内置的API,我们来看用法

function Animal(name){
  this.name = name;
}

Animal.prototype.logName = function(){
  console.log('i am Animal: '+ this.name);
}

function Tiger(name){
  this.name = name;
}

var animal = new Animal();
Tiger.prototype = animal;

var tiger = new Tiger('tiger');

我们创建了Animal构造函数和Tiger构造函数,并且将Tiger构造函数的prototype属性指向Animal函数的实例化对象animal,这样的话,Tiger实例化出来的对象的原型对象就是animal,

所以会有这样的关系:

console.log( animal.__proto__ === Animal.prototype );  // true
console.log( tiger.__proto__ === Tiger.prototype );  // true
console.log( tiger.__proto__ === animal );  // true

要用到上一篇文章里讲的Object.getPrototypeOf(),还可以这么写

console.log( Object.getPrototypeOf(animal) === Animal.prototype );  // true
console.log( Object.getPrototypeOf(tiger) === Tiger.prototype );  // true
console.log( Object.getPrototypeOf(tiger) === animal );  // true

我画了一张图,清晰地呈现了Animal、Tiger、animal、tiger之间的关系:

看了这个图,是不是对原型链有更加直观的了解了呢😁

你发现没有,tiger对象的想要调用logName函数的时候,中间需要先查询animal对象,而不是直接查询animal的原型。我们可不可以跳过animal对象呢?emmm,答案当然是可以的,只要我们把tiger对象的__proto__直接指向animal的原型就好了

有种tiger实例对象不认爸爸( Tiger )的感觉,直接把爷爷( Animal )当作爸爸

上面的图中有个小问题,不知道你发现了没有。animal作为Tiger构造函数的原型,竟然没有constructor属性。animal是个很不称职的原型对象。让我们一起谴责它😒。

好在后面有办法解决这个问题,不慌

2. new

通过new一个构造函数就能实例化一个对象。实例化出来的对象的__proto__属性,会指向该构造函数的prototype指向的对象。

换个角度理解:如果你需要一个对象,它的原型对象能够访问某个构造函数的prototype所指向的对象,那就通过 new 这个构造函数来获取吧。

遗憾的是,你拿到这个对象之后,发现其中被构造函数夹带了私货(通过this添加),如果你只想要能够访问构造函数的prototype所指向的对象而已,而不想要其他的属性,这时候你就会觉得很烦。

嗯,我和你一样。那有没有办法解决这个问题呢?有的,往下看

实例化的过程,我在上一篇文章讲的很详细了,感兴趣的话,可以去看看😁

3. Object.create()

这是JS语法内置的API,作用是创建一个新的对象,新创建对象的__proto__会指向一个旧对象

我们来看用法

var littleTiger = Object.create(Animal.prototype);

littleTiger.logName();  //i am Animal: undefined

新创建了的littleTiger对象,它的__proto__是直接指向Animal.prototype的,没有掺杂任何的私货。

你知道为什么上面的打印结果是undefined吗😁

4. 手动修改

最后一种方法,也是最暴力的一种,直接手动修改对象的__proto__就好了

tiger.__proto__ = Animal.prototype;

tiger.logName(); //i am Animal: tiger

小结一下:我介绍了四种修改对象原型的方法,其中 Object.setPrototypeOf(o1, o2)和手动修改的方法,都是不提倡的。因为可能会严重影响代码的性能。

摘自《高程》:Mozilla文档说的很清楚:“在所有浏览器和javascript引擎中,修改继承关系的影响都是微妙且深远的。这种影响不仅仅是执行object.setpropertypeOf()语句那么简单,而是会涉及所有访问了那些修改过[[prototype]]的对象的代码

原型链

终于进入正题了

1. 什么是原型链

其实在上文和上篇文章中,我已经多次提到了原型链的概念,这里我概括性的总结一下:

每个对象都有隐藏属性__proto__,该属性指向对象的原型。在对象上查找某个属性的时候,如果没有找到就会往原型上面找;如果在原型上面没有找到,就在原型的__proto__所指的对象上找,也就是原型的原型;直到原型对象为空,或者找到为止。整个查找过程就像一个被__proto__链接起来的链条,我们把这个链条称为原型链

顺着原型链查找属性,只看__proto__,和prototype没有关系,切记

2. 实例属性与原型属性的区别

区别很简单,实例属性只存在于实例对象上,是实例对象独有的,非共享的。原型属性是存在于原型对象上面的,是实例对象之间共享的。

OK,概念很简单。我们来实操一下.

function Animal(name){
  this.name = name;
}

Animal.prototype.logName = function(){
  console.log('i am Animal: '+ this.name);
}

var animal = new Animal('Dog')

对于animal对象,现在只有一个实例属性name,和一个原型属性logName。现在我还想要实例化的对象带有一个size的实例属性。我可以怎么做呢?

function Animal(name){
  this.name = name;
}

var animal = new Animal('Dog');

animal.size = 'middle';
console.log(animal.size);  // middle

这个方法有点土诶🤮

function Animal(name,size){
  this.name = name;
  this.size = size;
}

var animal = new Animal('Dog','middle');

console.log(animal.size);  // middle

这个方法就好很多了😁

3. 什么属性应该作为实例属性,什么属性应该作为原型属性呢?

这个问题,在上面探讨原型的意义时,我已经回答过了

4. 引用型属性和非引用型属性

我们来看上面的一个例子

function Person(name) {
  this.name = name;
}

Person.prototype.logName = function(){
     console.log(this.name);
}
Person.prototype.likeColor = ['red', 'yellow'];
Person.age = 18;

var zenos = new Person('zenos');
var zora = new Person('zora');

zenos的原型上有三个属性,其中age是一个非引用型属性,logName和likeColor是引用型属性

下面我通过代码来阐释其两者之间的区别

1. 首先对likeColor做些修改,然后把它打印出来

console.log( zenos.likeColor);	// ['red', 'yellow']
console.log( zora.likeColor );	// ['red', 'yellow']
console.log( zenos.likeColor === zora.likeColor ); // true

zenos.likeColor.push('blue');

console.log( zenos.likeColor);	// ['red', 'yellow','blue']
console.log( zora.likeColor );	// ['red', 'yellow','blue']
console.log( zenos.likeColor === zora.likeColor ); //true

很好理解,zora访问到的likeColor也受到了影响

2. 再从zenos对象上对likeColor属性附上新的数组

zenos.likeColor = ['black','wihte'];

console.log( zenos.likeColor);	// ['black','wihte']
console.log( zora.likeColor );	// ['red', 'yellow','blue']
console.log( zenos.likeColor === zora.likeColor ); //false

结果为什么是这样的?what`s happened ?

解释一下

  • 在对zenos.likeColor赋值的时候,是LHS。JS引擎并不会执行RHS操作,也就不会查询到zenos原型身上。效果就是,zenos对象会增加一个新的属性likeColor。
  • 如果这个时候再去打印zenos.likeColor,JS引擎发现zenos对象上已经有了likeColor,那就不会去 zenos原型上找了。这个效果像实例属性覆盖了原型属性的查询,是的,我们这个现象叫做覆盖😁
  • 如果要打印zora.likeColor,JS引擎发现zora对象上没有likeColor属性,之后就去zora原型上找,找到了就返回给console.log()的形参。
  • 这就是为什么最后一行的打印是false,因为它们不再是一个数组了。

3. 如果从zenos对象上对age属性做修改,你猜会出现什么

答案是会在zenos上面生成一个age属性。

对非引用属性做修改,就一定会在实例对象上生成新的属性。这是非引用性属性和引用属性的区别

zenos.age = 19;

console.log( zenos.age);	// 19
console.log( zora.age );	// 18

不过,咱话也别说的这么满

zenos.__proto__.age = 19;

console.log( zenos.age);	// 19
console.log( zora.age );	// 19

是不是感觉我作弊了

5. 覆盖prototype之后,会发什么什么神奇的事情

function Person(name) {
  this.name = name;
}

Person.prototype.logName = function(){
     console.log(this.name);
}
Person.prototype.likeColor = ['red', 'yellow'];
Person.age = 18;

var zenos = new Person('zenos');
var zora = new Person('zora');

// true
console.log(Object.getPrototypeOf(zenos) === Object.getPrototypeOf(zora));

开始覆盖

var newZenos = {
  name: 'zenos'
}
 Object.setPrototypeOf(zenos, newZenos);

// false
console.log(Object.getPrototypeOf(zenos) === Object.getPrototypeOf(zora));

当zenos对象的__proto__属性被覆盖了之后,其原型就和zora半点关系也没有了。无论做什么操作都不会影响zora了

相当于叛出家门,认'贼'做父了🤔

6. 来一道面试题吧

想用这道面试题来考考你学得怎么样了

function Foo() {
  getName = function () {
    console.log(1)
  }
  return this;
}

Foo.getName = function () {
  console.log(2);
}

Foo.prototype.getName = function () {
  console.log(3);
}

var getName = function () {
  console.log(4);
}

function getName() {
  console.log(5);
}
console.log(Foo()); // window

Foo.getName(); // 2

getName(); // 1

Foo().getName(); // 1

getName(); // 1

var a34 = new Foo.getName(); // 2

var a37 = new Foo().getName(); // 3

var a40 = new new Foo().getName(); // 3

答案我已经写在后面了,说实话,刚开始我也被震住了。不过我会在后面的文章中,这个面试题讲明白的,保证让你听懂。

涉及到的知识点:变量提升,对象属性的查找顺序,对象实例化的过程,new操作符的优先级,this指向性

下一讲,我们去原型链的尽头看看

总结:

  1. 原型对象存在的意义
  2. 如何改变一个对象的原型
  3. 什么是原型链
  4. 实例属性和原型属性的区别
  5. 什么属性应该作为实例属性,什么属性应该作为原型属性呢
  6. 引用型属性和非引用型属性的区别
  7. 覆盖一个实例对象的__proto__,会发生什么
  8. 一个简单的面试题,试试你的水平
  9. 如果文中有哪些地方没写清楚,留言告诉我,谢谢🙏