图示关系
代码测试及运行结果
2、原型的作用
注释:
new 表示xiaoming对象是通过new 关键字调用Peopel()构造函数创建的
任何对象都默认包含构造函数的原型对象,通过__proto__
属性可以访问到对象中的原型
测试代码及运行结果
每个 JavaScript 对象都拥有一个[[Prototype]]对象。 访问一个对象的属性或方法(对象中属性值为function类型的属性称为方法)时首先会查找其自身,然后就是它的 [[Prototype]]对象,之后再搜索此[[Prototype]]对象的 [[Prototype]]对象,直到找到这个属性后返回属性值或者查找到原型链的终点。这个查找过程称为原型链查找。
###(2) 原型链中的属性及其它类型的属性介绍
图示关系
简单说明:xiaoming对象本身有三个属性:name,age,sex;nationality是People.prototype对象中的属性。
测试代码及运行结果
nationality单词的意思是国籍
. 是成员访问运算符,可以访问对象的成员,语法 对象.成员属性
。
从xiaoming对象的构造函数People可知,xiaoming对象本身并不包含nationality属性,因此xiaoming.nationality在xiaoming对象本身上并未查找到该属性,因此去xiaoming的原型对象(根据构造函数的prototype是实例对象的原型,可知xiaoming的原型对象是People.prototype)中继续查找,由于我们提前在People.prototype对象中添加了nationality属性,因此可以查找到并返回。如下图所示
而我们没有通过People构造函数添加属性address,因此会去People.prototype的原型中继续查找,People.prototype中也没有定义,从此,继续递归的去原型的原型中查找,直到原型链的顶端也没有查找到该属性 (下文会介绍People.prototype的原型,以及原型链的顶端的概念,不要着急。这里重点关注在访问对象中的属性时,如果没有找到,会去原型链中查找),因此返回undefined
【了解一下】
我使用的是Google Chrome浏览器,不同类型或者不同版本的浏览器关于原型对象在浏览器中的显示方式可能不同,但是本质上的原型对象没有任何区别。浏览器提供的访问原型对象的属性名为__proto__ 在Google浏览器的新旧版本中相同。这样看到话,旧版本原型对象属性名的控制台显示和访问方式(名字)相同。之所以要介绍这一点区别,是因为我在MDN网站(前端技术CSS、HTML、JavaScript的官方文档网站)上看到一些文章,在使用__proto__表示原型,而不是[[Prototype]],以免大家以后遇到产生疑惑。
遮蔽现象,如果对象本身定义了和原型中同名属性,会优先选择操作对象本身具有的属性。 这一特点,从原型链查找的概念中可以推断出。
测试代码及运行结果(本代码在上文代码的环境下继续书写)
访问了对象本身的nationality属性,而不是People.prototype中的nationality属性。
另外,可以看到,如果xiaoming.nationality如果出现在赋值运算符的左边,语义是给xiaoming对象添加属性,并不会进行原型链查找,进而修People.prototype.nationality。
我们可以对对象的属性进行增、删、改、查操作,对象.属性 出现在赋值运算符的左边,如果属性不在对象中,那么这时是添加属性。如果属性在对象中,那么这时是修改属性。删除的属性如果在对象中存在,则返回true,不存在则返回false;
从原型链查找的定义可知, 只有在访问属性的值时才会进行原型链查找。添加,删除,修改属性都是针对对象本身进行的,不会影响原型中的属性。
你也许可能产生过这样的疑惑:nationality属性定义在原型中,所有People类型的对象,都可以通过原型链查找访问到该属性。xiaoming对象设置它的国籍为“中国",如果在创建一个People类型的对象bob,它实际的国家是美国,于是修改People.prototype.nationality的属性为美国(开始为中国),那么xiaoming的国籍不也跟着修改了吗?的确会修改,这不符合现实啊。根据构造函数的prototype属性是它的实例对象的原型,可知同一构造函数创建的所有对象共享构造函数的原型,进而共享原型中的属性。nationality应该是每个对象“独享”的属性,而不是所有对象“共享”的属性。
因此,实例属性,属于每个实例对象,可以通过构造函数添加到对象。代码如下
function People(name, age, sex, nationality) {
this.name = name;
this.age = age;
this.sex = sex;
this.nationality = nationality;
}
哪么,什么样的属性适合添加到构造函数的原型中呢?答案很简单,需要被相同类型(构造函数所创建)的所有对象共享的属性。
举个例子,你开了一家公司,想为每个入职的员工分配一个编号,初始编号为1,后续有新人入职编号为最后一个入职人员的编号加一。
实现代码如下
function Employee(name, age, sex) {
this.name = name;
this.age = age;
this.sex = sex;
this.id = Employee.prototype.counter ++;
}
Employee.prototype.counter = 1 // id计数器
执行效果如下
另外,还可以在构造函数中添加属性,该属性被任意对象共享,通常是常量,不可以被修改。
这些属性也不是随便定义的,一般与构造函数存在一定联系,才放在该命名空间下(构造函数可以看作是一个类或者命名空间)。
举个例子,JavaScript的Math构造函数中包含PI属性(PI表示数学中的π),由于是常量习惯上将变量名大写
至此,学习了三种类型的属性
☕ 对比Java中的实现
JavaScript中的构造函数可以看作Java中的类,它们发挥着相似的作用:可以创建对象。在Java中,属于实例对象的属性称为实例属性(或实例变量),属于类的属性称为静态属性,静态属性具有共享性。另外,在ES6中也添加了class的语法,在此我们对比三种属性在Java中、JS ES6 class中、JS 构造函数中的使用方法,以加深大家对JavaScript语法的理解。
实例属性public class People { private String name; private int age; private String sex; public People(String name, int age, String sex) { this.name = name; this.age = age; this.sex = sex; } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
静态属性(原型对象中属性)
public class Employee { protected static int counter; }
- 1
- 2
- 3
// static 修饰符说明变量属于类
// protected 修饰符说明只有同类型对象可见,换句话说,限制了变量共享的范围为同类型对象常量静态属性(构造函数中的属性)
public class Math { public static final double PI = 3.14159265358979323846; }
- 1
- 2
- 3
上文我们主要介绍了属性在原型中的应用,但是这在原型应用中只能算是配角。我们使用原型最主要的目的是在原型中添加方法,来实现方法的共享和继承。由于对象方法的本质是值为funtion类型的属性,因此在前文中介绍的原型链查找,遮蔽现象等概念在这里依然适用。属性的遮蔽现象在方法中我们称为重写(override)
使用方法hasOwnProperty(name)
方法,参数name是字符串类型的属性名。
name属性来自xiaoming对象本身,函数返回true; counter属性来自原型,函数返回false;
另外,xiaoming对象之所以可以调用hasownProperty()方法,也是利用了原型链查找。过程如下:先在对象本身查找该方法,然后去原型(People.prototype)中查找,我并没有定义该方法,因此找不到。会继续去原型的原型中查找(People.prototype的原型是Object.prototype,下文原型链的顶端会介绍相关概念,现在有个印象即可),结果查找到了hasOwnProperty()方法就定义在Object.prototype中。
还有一个in操作符,它可以判断对象是否可以问到某个属性(包括对象自身和原型中的属性),语法\'属性名’ in 对象
。
首先,我们可以通过构造函数将方法添加到实例对象中
图示关系如下
测试代码及运行结果
从最后一行代码,可以看出,构造函数为不同构造函数添加的实例方法,并不全等,换句话说,不是同一函数对象。然而,它们所发挥的功能确实相同的:打印输出对象的信息。
通过构造函数直接把方法添加到实例对象上的缺点:每个实例对象分别在各自的身上存储了一份功能相同的函数,造成了内存的浪费。因此,实际中,虽然语法正确,但是不会通过构造函数给实例添加函数。
解决方法是把实例方法添加到原型中
图示关系
测试代码及运行结果
除了可以在原型中定义实例方法,还可以直接在实例对象中添加实例方法,为了和前者区分,我将在原型中添加的实例方法称为共享实例方法,直接在实例对象中添加的实例方法称为独享实例方法。
举个例子,扑克牌游戏。使用数组存储摸到的牌张,然后给数组对象添加本游戏想要使用的排序算法:插入排序,对牌张从小到大排序。由于其他数组对象可能并不需要排序算法,只是我们目前的业务场景需要而已,因此,非常适合添加独享实例方法。(由于只是为了说明语法,因此并没有限制相同的牌最多出现4次,以及大小王的问题)
代码如下
// 产生1~13的随机数,模拟拍张
function getCard() {
return Math.floor(Math.random() * (13 - 1 + 1) ) + 1;
}
// 数组用于存储所有摸到的牌
var cards = [];
//(***重点***) 在数组对象上添加插入排序算法
cards.insertSort = function () {
for (let i = 0; i < this.length; i ++) {
let temp = this[i];
let j;
for (j = i; j > 0 && temp < this[j - 1]; j --) {
this[j] = this[j - 1];
}
this[j] = temp;
}
}
// 开始游戏
function start() {
//清空
cards.length = 0;
// 随机发牌
for (let i = 0; i < 13; i ++) {
cards[i] = getCard();
}
// 初始牌序
console.log(cards);
//
cards.insertSort();
console.log(cards);
}
运行结果
最后,方法还可以添加到构造函数中,这种形式的方法主要用于完成某个特定功能,不操作任何实例属性,通常称为类方法或静态方法。
举个例子,JavaScript中Math.abs(),abs函数返回一个数字的绝对值。
运行结果
至此,学习了三种类型的方法
☕ 对比Java中的实现
Java中不能为对象动态的添加或者删除属性或者方法,一旦在类中定义好就无法修改了。因此Java中并不存在对象独享的实例方法
实例方法(共享实例方法)public class People { private String name; private int age; private String sex; public People(String name, int age, String sex) { this.name = name; this.age = age; this.sex = sex; } //实例方法 public void sayHello() { System.out.println("我是" + this.name + ", 今年" + this.age + "岁。"); }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
静态方法、类方法(构造函数对象的方法)
public class Math { private Math() {} // 静态方法 public static double abs() { // 实现功能的代码 }
- 1
- 2
- 3
- 4
- 5
- 6
Java 通过使用static关键字来区分实例方法和静态方法;JavaScript通过在定义的位置为区分实例方法和静态方法,即定义在构造函数原型中是实例方法,定义在构造函数对象中是静态方法。
图示关系
在文章的开始,我们知道People.prototype是object类型的对象,因此可以看作是通过new调用Object构造函数创建的对象。众所周知,构造函数的prototype属性是它的实例对象的原型,那么,Object的prototype属性就是People.prototype的原型。
测试代码及运行结果
xiaoming.hasOwnProperty(),xiaomingdui对象通过原型链查找,可以访问到原型链中的Object.prototype定义的hasOwnProperty方法(Object.prototype是原型链的一部分)
测试代码及运行结果
位于原型链上部的类型是下部类型的父类,Object类型是People类型的父类,反之,People类型是Object类型的子类。People子类的实例对象也可以认为是父类Object类型的实例。从语义上说Object类型是所有类型的父类,哪它有父类吗?可以通过访问Object.prototype的原型,来确定Object父类的类型,如果有点话。
测试代码及运行结果
由此可知, Object类型不存在父类型。Object.prototype是原型链中的最后一个原型对象,任何JavaScript对象都有原型,而Object类型的对象的原型为null
(了解一下:该图使用UML统一建模语言绘制,UML使用图形化的方式表示类,以及类之间的关系。此箭头表示继承关系)
我们以人和学生为例,People类具备的属性和方法Student类都有,Studnet类还扩展了一些属性和方法。Student”是一种“People,两类之间是”is a kind of“的关系。学生”是一种"人,除此之外,老师、保安、厨师也都”是一种“人。我们称Student类继承自People类。
继承描述了两个类之间的“is a kind of ”关系,被继承的类,例如People,称为父类(超类、基类);继承的类,例如Student,称为子类(派生类)。
父类更抽象化,一般化,而子类更具体化、更细化。
实现继承的关键在于:子类必须拥有父类属性的 全部属性和方法 ,同时子类还应该能定义自己特有的属性和方法。
可以利用原型链特性或者ES6 class语法实现继承。
默认情况下,新创建的构造函数的prototype指向Object类型的实例对象(实例、对象、实例对象这几个术语都是相同意思,指实例对象),如下图所示
Student类型(构造函数)的prototype默认指向Object类型的实例对象,Object类型的实例对象的原型又是Object类型(构造函数)的prototype,简言之,Student类型的prototype的原型指向Object类型的prototype。原型链体现了继承关系,Student是Object类型的子类。新创建的类型默认是Object类型的子类,符合现实语义。
那么,我们可以修改Student类型的prototype的指向为People类型的任意实例,从原型链上看,Student是People的子类,People是Object的子类。
代码如下,建立继承关系的关键 Student.prototype = new People();
Student.prototype修改前后对比,constructor属性及age,name,age不是我们关注的重点,在这里可以忽略。注意[[prototype]]之间的“层级关系”,它反映了在原型链中的位置,进而反应继承关系。
回顾一下继承的关键:子类必须拥有父类属性的全部属性和方法 ,同时子类还应该能定义自己特有的属性和方法。属性已经满足条件,现在我们在原型中添加共享实例方法。在People的prototype中添加sayHello方法(为了简洁,我省略了在前文中UML类图中给出的sleep方法),在Student的prototype中添加study方法。
Student类型的实例对象依据原型链查找特性,能够访问到在People以及Object的prototype中定义的方法,这样子类就拥有了父类的全部方法,进而完成了继承。
遮蔽现象,在子类中定义了和父类中同名的方法,会优先选择调用子类中的方法。这一现象我们称为重写。
在Student子类中重写People父类中的sayHello方法,如下图所示
现在还存在一个问题,子类Student中重复定义了父类中包含的属性,代码显得很“笨拙”。
借用构造函数:在子类构造函数的内部调用父类的构造函数,需要使用call()函数绑定上下,也称为伪造对象,或经典继承。
将方式一中的原型链继承和借用构造函数技术组合到一起,称为组合继承或者伪经典继承。
在Student和People构造函数的原型中添加实例方法的代码,这里就省略了。上文有提及。
自定义函数,兼容IE9以前的版本
“对象的继承”,没有构造函数或类的事。把一个已经有的对象,作为新对象的原型,从而达到扩展它的目的。
关系对比
Student只是语义上的类型,本质上,类型还是People。
三、特殊内置构造函数的关系
如果你有学习过ES6 class或者Java 类,那么,可以更便于理解下图的关系。
首先,任何对象(包括构造函数对象,构造函数的原型对象)都包含原型对象,可以通过__proto__访问,只有构造函数对象才有prototype属性。
构造函数原型对象之间的__proto_构成了原型链,体现继承关系。例如,Function.prototype指向Object.prototype,Function类型是Object类型的子类。普通实例对象与构造函数原型对象之间的__proto_,体现类与实例的关系,即实例的类型是什么。
Object和Function都是构造函数对象,任何对象都有原型对象,指向它的构造函数的原型对象。Object和Function是函数类型,因此Object和Function的__proto__指向函数类型的原型,即Function.prototype。
简化的关系图
练习题