理解原型&原型链

627 阅读7分钟

理解原型

  • constructor —— 构造函数
  • prototype —— 指向 原型对象 的属性
  • __proto__ —— 指向 实例内部[[Prototype]]特性 的属性

只要创建一个函数,就会为这个函数创建一个prototype属性(指向原型对象)。 注意是函数,不是实例

function Person() {}

Person.prototype.name = 'echo';
Person.prototype.sayName = function() {
    console.log(this.name);
}

const person = new Person();

console.log(person.prototype); // undefined
console.log(Person.prototype);
// {
//   name: "echo"
//   sayName: ƒ ()
//   constructor: ƒ Person()
//   [[Prototype]]: Object
// }

默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。

Person.prototype指向原型对象,而Person.prototype.constructor指回Person构造函数

console.log(Person.prototype.constructor);  // ƒ Person() {}

每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。

脚本中没有访问这个[[Prototype]]特性的标准方式,但FirefoxSafariChrome会在每个对象上暴露 __proto__属性,通过这个属性可以访问对象的原型。(在其他实现中,这个特性完全被隐藏了)。

console.log(person1.__proto__);
// {
//   name: "echo"
//   sayName: ƒ ()
//   constructor: ƒ Person()
//   [[Prototype]]: Object
// }

那么,接下来,让我们再看看他们之间的关系:

// 构造函数通过prototype属性链接到原型对象
Person.prototype.constructor === Person

// 实例通过__proto__链接到原型对象,它实际上指向隐藏特性[[Prototype]]
person1.__proto__ === Person.prototype

// 实例与构造函数没有直接联系,与原型对象有直接联系
person1.__proto__.constructor === Person

// 正常的原型链都会终止于Object的原型对象
Person.prototype.__proto__.constructor === Object

// 现在试着理解  通过对象的__proto__属性可以访问它自己的原型 这句话
Person.prototype.__proto__ === Object.prototype

// Object原型的原型是null
Person.prototype.__proto__.__proto__ === null

同一个构造函数创建的两个实例共享同一个原型对象
person1.__proto__ === person2.__proto__

// 通过Instanceof检查实例的原型链中是否包含指定构造函数的原型
person1 instanceof Person; // true
person1 instanceof Object; // true
Person.prototype instance Object; // true

实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有,简言之,构造函数,原型对象和实例是3个完全不同的对象

虽然不是所有实现都对外暴露了[[Prototype]],但可以使用isPrototypeOf()方法确定两个对象之间的这种关系。本质上,isPrototypeof()会在传入参数的[[Prototype]]指向调用它的对象时返回true

console.log(Person.prototype.isPrototypeof(person1)); // true

ECMAScriptObject类型有一个方法叫Object.getPrototypeOf(),返回参数的内部特性[[Prototype]]的值

console.log(Object.getPrototypeOf(person1) === Person.prototype); // true

也就是说,使用Object.getPrototypeOf()可以方便地取得一个对象的原型。

console.log(Object.getPrototypeOf(person1).name);

Object类型还有一个setPrototypeOf()方法,可以向实例的私有特性[[Prototype]]写入一个新值,也就是 重写一个对象的原型继承关系 的意思。

const a = {
    age: 12
}
const person = {
    name: 'echo'
}
Object.setPrototypeOf(person, a);

console.log(person.name); // 'echo'
console.log(person.age); // 12
console.log(Object.getPrototypeOf(person) === a); // true

警告Object.setPrototypeOf()可能会严重影响代码性能,这种影响并不仅是执行Object.setPrototypeOf()语句那么简单,而是会涉及所有访问了那些修改过[[Prototype]]的对象的代码

所以代码中常见的方式是通过Object.creat()来创建一个新对象,同时为其指定原型

const a = {
    age: 12
}
const person = Object.create(a);
person.name = 'echo';

console.log(person.name); // 'echo'
console.log(person.age); // 12
console.log(Object.getPrototypeOf(person) === a); // true

理解原型链

总结一下构造函数、原型和实例的关系:

  • 每个构造函数都有一个原型对象

  • 原型有一个属性指回构造函数

  • 而实例有一个内部指针指向原型

在通过对象访问属性时,会按照这个属性的名称开始搜索。

搜索开始于对象实例本身,如果没找到这个属性,则会沿着指针进入原型对象进行搜索。

比如在调用person1.sayName()时,会发生两步搜索:

  • JS引擎在person1实例上搜索sayName属性,未果,开始向上查找
  • person1实例的原型上有sayName属性,于是会返回保存在原型上的这个函数

最坏的结果是查找到最外层的Object,仍未找到,则会返回undefined,查找结束

这就是原型用于在多个对象实例间共享属性和方法的原理

虽然可以通过实例读取原型对象上的值,但不会通过实例重写这些值。在实例上设置同名属性,只是会让查找提前返回实例上对应的值(包括将属性值设置为null),而不会向上查找而已。

使用delete操作符可以完全删除实例上的这个属性,从而让查找过程上升到原型对象

ECMAScriptObject.getOwnPropertyDescriptor()方法只对实例属性有效。要取得原型属性的描述符,必须直接在原型对象上调用该方法

in操作符会在可以通过对象访问指定属性时返回true,包括实例和原型。

in操作符的另一种用法——for-in,用于遍历对象中(包括实例和原型链,可枚举和不可枚举属)的属性

hasOwnProperty()方法用于确定某个属性是不是属于实例自身。

function Person() {}
Person.prototype.name = 'echo';
let person1 = new Person();
console.log(person1.hasOwnProperty('name')); // false
console.log('name' in person1); // true
person1.name = 'Jon';
console.log(person1.hasOwnProperty('name')); // true
console.log('name' in person1); // true

修改原型的其他写法:

为了减少代码冗余,从视觉上更好地封装原型功能,使用以下方法来对原型进行重写。

function Person() {}

Person.prototype = {
    name: 'echo',
    age: 12,
    sayName() {
        console.log(this.name);
    }
}

那么,问题来了...

在以上代码中,Person.prototype等于被设置为一个新对象,这样重写之后,Person.prototypeconstructor属性就不指向Person了。

在创建函数时,也会创建它的prototype对象,同时会自动给这个原型的constructor属性赋值。

而上面的写法完全重写了默认的prototype对象,因此其constructor属性也指向了完全不同的新对象(Object构造函数),不再指向原来的构造函数。

此时,虽然instanceof操作符还能可靠地返回值,但已经不能再依靠constructor属性来识别类型了

let person = new Person();
console.log(person instanceof Person); // true
console.log(person.constructor === Person); // false

事实证明,虽然随时能给原型添加属性和方法,并能够立即反应在所有对象实例上,但这跟重写整个原型是两回事。

当然,我们可以在重写原型对象时,专门设置一下它的值,保证这个属性仍然包含恰当的值

...
Person.prototype = {
    constructor: Person,
    ...
}

但是要注意,以这种方式恢复constructor属性会创建一个[[Enumerable]]true的属性。而原生constructor属性默认是不可枚举的。可以使用Object.defineProperty()方法来定义constructor属性

...
Object.defineProperty(Person.prototype, 'constructor', {
    enumerable: false,
    value: Person
})

实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变

重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型

实例只有指向原型的指针,没有指向构造函数的指针。

function Person() {}
let person = new Person(); // 注意实例化Person的位置
Person.prototype = {
    constructor: Person,
    name: 'echo',
    age: 12,
    sayName() {
        console.log(this.name);
    }
}
let person2 = new Person();
person2.sayName(); // 'echo'
person.sayName(); // TypeError: person.sayName is not a function

以上代码,person实例是在重写原型对象之前创建的,在调用person.sayName()时会导致错误。这是因为person指向的原型还是最初的原型,而这个原型上并没有sayName属性。

原生对象的原型

原型模式之所以重要,不仅体现在自定义类型上,还因为它也是实现所有原生引用类型的模式。

所有原生引用类型的构造函数(包括ObjectArrayString等)都在原型上定义了实例方法。比如,数组实例的sort()方法就是Array.prototype上定义的,字符串包装对象的substring()方法也是String.prototype上定义的

通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。

不推荐在生产环境中修改原生对象原型,这样做很可能造成误会,而且可能引发命名冲突,另外还有可能意外重写原生的方法

推荐的做法是创建一个自定义的类,继承原生类型