彻底理解js原型

461 阅读7分钟

问题

先来个问题,抛出来:

function Cat() {}
const c = new Cat();
Cat.prototype.name = '虹猫';
console.log(c.name); // 虹猫

Cat.prototype = {
	name: '虹猫他哥',
	age: 2,
};
console.log(c.name); // 虹猫
console.log(c.age); // undefined

如果你能准确说出上面的结果,那么你对原型就有一定的了解了,至少不会晕乎;如果没有理解答案为什么是这样,那请往下仔细看。

原型举例

function Cat() {}
Cat.prototype.name = '虹猫';
Cat.prototype.age = 1;
Cat.prototype.sayHi = function() {
    console.log('我是' + this.name);
}

const c1 = new Cat();
const c2 = new Cat();
console.log(c1.name); // 虹猫
console.log(c2.name); // 虹猫
c1.sayHi(); // 我是虹猫
c2.sayHi(); // 我是虹猫
console.log(c1.sayHi === c2.sayHi); // true

上面的比较容易理解,c1、c2 都是 Cat 对象的实例,而Cat 的属性 name、age等都是通过原型设置的,所以 c1、c2 的name,age等完全一样,包括对应的函数 sayHi都完全一样。

prototype,就是原型。每创建一个函数,就会有一个prototype属性,这个属性是一个指针。所有经过原型设置的对象的属性或者方法,在所有实例上都是共享的,也就是原型对象可以让所有对象实例共享他的属性和方法,所以才会有上面的 c1.name === c2.name, c1.sayHi === c2.sayHi。换句话说,就是 c1、c2 访问的属性、方法都是同一组属性和同一个方法 syaHi。

什么是原型对象

原型对象:创建一个函数的时候,会创建一个 prototype 属性,这个属性就是指向了函数的原型对象。可以理解为 Object.prototype。

所有原型对象都会有一个 constructor属性,即构造函数,这个属性包含一个 prototype 属性所在函数的指针,所以默认的就有:Cat.prototype.constructor === Cat,通过这个构造函数,可以为原型对象添加其他属性和方法.

当创建一个实例后,实例内部会有一个指针指向构造函数的原型对象,这个指针就是:Prototype,在 Chrome、Firefox、Safari 等浏览器中都支持一个属性:__proto__,它就是这个指针,注意,这个 __proto__ 是存在于实例与构造函数的原型对象之间,非实例与构造函数之间,所以就有:c1.__proto__ === Cat.prototype,即:实例c1内部的一个指针__proto__指向原型对象Cat.prototype。

图解

为了方便理解对象、原型对象、实例之间的关系,整理一个简单的图如下,还以Cat为例:

由上图可以得出几个结论:

  • Cat.prototype.constructor === Cat
  • c1.__proto__ === Cat.prototype

要非常清楚的知道上面这张图的含义。

实例属性覆盖原型属性

看个例子:

function Cat() {}
Cat.prototype.name = '虹猫';

const c1 = new Cat();
const c2 = new Cat();
c1.name = '虹猫他哥';
console.log(c1.name); // 虹猫他哥 ---来自实例
console.log(c2.name); // 虹猫 ---来自原型

原型对象中有个属性 name="虹猫",然后新建实例 c1、c2,此时他们都有属性值 name,且值为”虹猫“,然后 c1 重置了 name 值,改为”虹猫他哥“,此时再打印 c1.name、c2.name,发现值不一样了。

在这儿,给实例属性赋值,并不会覆盖原型中的值,如果在实例中添加了一个属性,而原型中已存在一个同名属性,则会在实例中创建该属性,该属性会屏蔽原型中那个属性。

也许到这儿一直有人有个疑问,为什么 c2.name 是“虹猫”,明明c2是直接 new Cat(),并没有构造函数赋值给name,此处就是查找对象属性的过程中来实现的。

当读取对象的某个属性时,会先从对象实例本身开始查找,如果实例中有此属性,则就去这个属性的值;如果实例中没有此属性,则会沿着它的原型链查找,即会在原型对象中查找。只有所有的原型对象中都没有此属性时,才返回undefined。

所以上面的 c2.name,首先会在c2上找是否有 name 属性,发现没有;然后沿着 c2 的原型上找,因为存在 Cat.prototype.name,所以找到了 此name。

至此,实例添加属性时,会屏蔽原型上的同名属性,即实例添加属性会阻止访问原型同名属性,但是不会修改那个属性,那么怎么能恢复呢?c1.name 怎么还能是”虹猫“呢?可以使用 delete 操作符,可以删除实例属性,重新访问到原型属性,如下:

function Cat() {}
Cat.prototype.name = '虹猫';

const c1 = new Cat();
c1.name = '虹猫他哥';
console.log(c1.name); // 虹猫他哥 ---来自实例
delete c1.name; // 删除实例属性
console.log(c1.name); // 虹猫 ---来自原型

判断原型方法

function Cat() {}
Cat.prototype.name = '虹猫';

const c1 = new Cat();

大家都知道 c1 跟 Cat 是有关系的,c1 是 Cat 的实例,但是怎么判断呢?可以通过如下方式判断:

  • c1.constructor === Cat
  • c1 instanceof Cat

那怎么判断实例与原型对象直接关系呢?可以通过 isPrototypeOf() 方法来判断是否存在关系:

console.log(Cat.prototype.isPrototypeOf(c1)); // true

当然也可以用:c1.__proto__ === Cat.prototype 来判断。 因为 c1 内部都有一个指向 Cat.prototype 的指针,所以都是返回 true。

还有一个方法,可以获得对象的原型,那就是 Object.getPrototypeOf(),如:

console.log(Object.getPrototypeOf(c1) === Cat.prototype); // true

这样,从实例中,就可以访问到原型对象的属性了。回到一开始的问题,加一个问题,怎么通过 c 访问到 name “虹猫”?

function Cat() {}
const c = new Cat();
Cat.prototype.name = '虹猫';
console.log(c.name); // 虹猫

Cat.prototype = {
	name: '虹猫他哥',
	age: 2,
};
console.log(c.name); // 虹猫
console.log(c.age); // undefined

答案就很简单了,就是:

Object.getPrototypeOf(c).name
// 或者
c.__proto__.name
// 或者
c.constructor.prototype.name

原型覆盖

通过上面几个例子,可以看出来,原型对象属性赋值比较麻烦,每次都需要写一遍 Cat.prototype,如:

function Cat(){}
Cat.prototype.name = '虹猫';
Cat.prototype.age = 1;
Cat.prototype.sex = '男';
...

其实有更简单的做法,就是直接对象字面量赋值给 Cat.prototype,来重写整个原型对象,如:

function Cat(){}
Cat.prototype = {
	name: '虹猫',
	age: 2,
	sex: '男',
	sayHi: function() {
		console.log('我是' + this.name);
	}
}

上面用一个对象字面量,直接赋值给原型了,这样可以一次性创建很多属性、方法。但是这样有个问题,每创建一个对象,会同时创建它的 prototype 对象,此对象也会有一个 constructor 属性,而上面的做法,直接重写了 prototype 对象,因此 constructor 也就变成了新对象的 constructor,即指向 Object 构造函数,此时 c.constructor !== Cat 了,如下:

const c1 = new Cat();
console.log(c1 instanceof Cat); // true,可以正常判断
console.log(c1 instanceof Object); // true,可以正常判断
console.log(c1.constructor === Cat); // false
console.log(c1.constructor === Object);  // true

一般情况,为了避免此情况,在用对象字面量复制的时候,显示的指定一下 constructor,如:

function Cat(){}
Cat.prototype = {
	constructor: Cat,
	name: '虹猫',
	age: 2,
	sex: '男',
	sayHi: function() {
		console.log('我是' + this.name);
	}
}

此时,就可以正常使用了。

原型的动态性

还是看例子:

function Cat() {}

const c1 = new Cat();
Cat.prototype.name = '虹猫';
console.log(c1.name); // 虹猫

上面的代码,先创建了一个实例 c1,然后才在原型对象中添加了 name 属性,但仍然可以访问到 c1.name,主要原因还是上面提到的查找属性过程,首先会查找实例 c1 上有没有 name,发现没有,然后继续搜索原型上是否有 name。

这就是原型的动态性,可以随时给原型添加属性和方法,并且能在所有实例中反映出来,但是如果重写原型,则就不一定了:

function Cat() {}

const c1 = new Cat();
Cat.prototype = {
	constructor: Cat,
	name: '虹猫',
};
console.log(c1.name); // undefined
console.log(c1.__proto__ === Cat.prototype); // false

可以看到,此时取不到 c1.name 的值了,并且此时 c1 的原型并不等于 Cat 的原型。因为调用构造函数时,会为实例添加一个执行最初原型的指针,把原型重写为一个对象后,就等于切断了构造函数与最初原型直接的联系,所以,重写原型对象切断了原型与之前对象实例之间的联系

此时回到一开始抛出的问题:

function Cat() {}
const c = new Cat();
Cat.prototype.name = '虹猫';
console.log(c.name); // 虹猫

Cat.prototype = {
	name: '虹猫他哥',
	age: 2,
};
console.log(c.name); // 虹猫
console.log(c.age); // undefined

上面在新建一个实例后,重写了 Cat 的原型,此时访问 c.name,因为实例创建时,会为实例添加一个执行最初原型的指针,所以 c.name 还是访问的 Cat.prototype.name = '虹猫',而 c.age ,因为 c 自身以及原型都没有 age 属性,则返回 undefined。

此时怎么通过 c 访问到”虹猫他哥“呢?答案是:c.constructor.prototype.name

如果在最后,在 new 一个新的对象,如:const c2 = new Cat();,此时 c2.name 就是“虹猫他哥”了。

总结

总之,原型这块儿可能是看一遍忘一遍,只要死死的记住以下几个就行了:

  • Cat.prototype.constructor === Cat
  • c1.constructor === Cat
  • c1.__proto__ === Cat.prototype

尤其上面三个

  • c1 instanceof Cat // true
  • Cat.prototype.isPrototypeOf(c1) // true
  • Object.getPrototypeOf(c1) === Cat.prototype

上面为 true 有个前提,就是实例化之后,不能重写原型。

记住:

  • c1 实例,只有 constructor、__proto__
  • Cat 函数,后面一般用prototype