前言
从本人之前的文章里,可以对于原型和原型链的理解有些许浅显的理解
但其中更多地从对象视角看这个问题,不够详尽也不够通俗易懂
这几天阅过红宝书的相关内容后想对原型这个知识有一定的补充
原型?
每个函数都会创建一个 prototype
属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。
翻译一下:通过调用构造函数而创建的对象,我们定义他的原型就是他的构造函数身上的
prototype
属性
没错,原型是什么这个问题就已经解释完了。
如何去理解?
我们只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype
属性,指向原型对象。默认情况下,所有原型对象自动获得一个名为 constructor
的属性,指回与之关联的构造函数。
比如这个例子里:a.prototype.constructor
指向 a。
不同的构造函数会给原型对象添加其他属性和方法。在自定义构造函数时,原型对象默认只会获得 constructor
属性,其他的所有方法都继承自Object
。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]
指针就会被赋值为构造函数的原型对象。
这里要补充一下:脚本中没有访问这个
[[Prototype]]
特性的标准方式,我们可以在浏览器的控制台看到[[Prototype]]
属性,而想要访问他们则需要利用Firefox
、Safari
和Chrome
暴露的__proto__
属性。
在这里先停一下,我们先理清楚一个事情:
-
对象test 的构造函数Test有个自己的属性
prototype
-
对象test 本身有个属性
__proto__
或者叫[[prototype]]
-
两者相同
明白了这一点之后我们可以知道:
实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
借用红宝书的一个经典案例来加深印象:其中,Person
是自定义函数,person1
和person2
分别是Person
构造出来的
可以看到:Person
的prototype
、person1
的[[prototype]]
、person2
的[[prototype]]
都指向了同一个地方,而且person1
和person2
跟他们的构造函数没有直接的关系
带来的意义?
按照我们之前的经验知道:
在通过对象访问属性时,会按照这个属性的名称开始搜索。
搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有发现这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。
比如在上面那个红宝书的案例里面:
调用 person1.sayName()
时,会发生两步搜索:
首先,JavaScript
引擎会问:“person1
实例有 sayName
属性吗?”
答案是没有。
然后,继续搜索并问:“person1
的原型有 sayName
属性吗?”
答案是有。
于是就返回了保存在原型上的这个函数。在调用 person2.sayName()
时,会发生同样的搜索过程,而且也会返回相同的结果。
这就是原型用于在多个对象实例间共享属性和方法的原理
OK,我们得到了一个很重要的性质,顺着这个性质,我们做以下操作:
person1.name = "Greg";
console.log(person1.name); // "Greg",来自实例
console.log(person2.name); // "Nicholas",来自原型
用前面的结论我们可以知道person2.name
的结果,主要是探讨一下person1.name
只要给对象实例添加一个属性,这个属性就会遮蔽(shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。
既然我能在实例身上找到这个属性,为啥还要往原型对象上找对吧!
即使在实例上把这个属性设置为 null
,也不会恢复它和原型的联系。不过,使用 delete
操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。
delete person1.name;
console.log(person1.name); // "Nicholas",来自原型
再理解
1.原型语法的优化
既然我们知道原型用于在多个对象实例间共享属性和方法的原理,我们也知道可以用设定构造函数的prototype
属性的形式来共享,比如前面的Person
函数:
function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成为了一种常见的做法
function Person() {}
Person.prototype = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
需要强调的是:这样重写之后,Person.prototype
的 constructor
属性就不指向 Person
了。上面的写法完全重写了默认的 prototype
对象,因此其constructor
属性也指向了完全不同的新对象(Object
构造函数),不再指向原来的构造函数。这适用于constructor
属性不重要的情况下。
2.原型的动态性
C语言的指针学过吧,那么就很好理解下面这句话:
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。
红宝书给出了这么一个例子:
let friend = new Person();
Person.prototype.sayHi = function() {
console.log("hi");
};
friend.sayHi(); // "hi",没问题!
虽然 friend
实例是在添加方法之前创建的,但它仍然可以访问这个方法。主要原因是实例与原型之间松散的联系。实例和原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上找到 sayHi
属性并返回这个属性保存的函数。
实例的[[Prototype]]
指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。
不同的是:重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。
function Person() {}
let friend = new Person();
Person.prototype.sayHi = function() {
console.log("hi");
};
Person.prototype = {
...//此处省略其他属性
sayName() {
console.log(this.name);
}
};
friend.sayName(); // friend.sayName is not a function
friend.sayHi(); // Hi
实例只有指向原型的指针,没有指向构造函数的指针
再次印证了上面的观点:
实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有
3.原生对象原型
原型模式的重要性不仅体现在我们自己写的自定义类型上,所有原生引用类型的构造函数(包括 Object
、Array
、String
等)都在原型上定义了实例方法,以至于我们能够用到很多来自他们身上具有独特闪光点的方法。
虽然理论上可以像修改自定义对象原型一样修改原生对象原型。但是并不推荐在产品环境中修改原生对象原型。
这样做很可能造成误会,而且可能引发命名冲突(比如一个名称在某个浏览器实现中不存在,在另一个实现中却存在),还有可能意外重写原生的方法。
红宝书推荐的做法是创建一个自定义的类,继承原生类型。
4.问题
原型模式它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。这会带来不便,但还不是原型的最大问题。
原型的最主要问题源自它的共享特性。
题外话:共享单车知道吧,共享单车的模式实践到今天,已经触及过很多问题,这是共享经济这个概念都会遇到的通病。
原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,还记得遮蔽吧:可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性。来看下面的例子:
function Person() {}
Person.prototype = {
...//此处省略其他属性
friends: ["Shelby", "Court"],
};
let person1 = new Person();
let person2 = new Person();
person1.friends.push("Van");
console.log(person1.friends); // "Shelby,Court,Van"
console.log(person2.friends); // "Shelby,Court,Van"
console.log(person1.friends === person2.friends); // true
如果这是有意在多个实例间共享数组,那没什么问题。
但一般来说,不同的实例应该有属于自己的属性副本,如果不同实例之间有一种不想要的共享,势必会污染我们的参数。这就是实际开发中通常不单独使用原型模式的原因。
参考文献
本文大部分思路和举例来自于《JavaScript高级程序设计》(第四版)
即文中提到的 “红宝书”