系列文章推荐
JavaScript原型与原型链(基础篇)
JavaScript原型与原型链(进阶篇)
JavaScript原型与原型链(总结篇)
0 前言
原型机制一直都是JavaScript的核心之一,网上有人说理解了原型机制,也就理解了JavaScript的一半精髓了。网络上关于原型的文章大多数都是围绕着“原型是什么?”“原型如何使用?”这两大核心问题的,但是对于JavaScript初学者,尤其是有C++或Java基础的JavaScript初学者来讲,很难理解另一种非类(class
)的继承机制,出于这个问题,本文从语言设计者的角度,由浅入深、循序渐进的介绍JavaScript的继承机制,并引入原型的概念,希望对初学者有所帮助。
注意:了解ES6的读者可能知道JavaScript中有类(class
)这个概念,但只是个语法糖,本质上还是函数function
,所以文中出现“JavaScript没有类(class
)这个概念”这个结论时,读者默认或当作是ES6之前的JavaScript即可。
1 文章背景
1.1 JavaScript的诞生
1990年,世界上第一个浏览器“WorldWideWeb”诞生了;1993年,世界上第一个广泛流行的浏览器“Mosaic”诞生了;1994年10月,网景公司(Netscape Communications Corporation)发布了里程碑式的浏览器产品“Netscape Navigator”,进一步推动了互联网的发展,但当时的浏览器只能用于展示内容(即HTML内容和CSS样式),缺少交互的能力,如一张个人信息表单页面中,用户可能在年龄一栏输入了非数字字符,如输入了“ABC
”,此时用户提交表单,请求发送到服务器,服务器发现用户填写的表单年龄字段有误,然后返回错误信息给用户,用户重新输入,再次提交,整个过程浪费时间又占用服务器资源。
因此网景公司急需开发一个足够简单的脚本语言用来在浏览器端作数据验证,于是在1995年,JavaScript诞生了。
1.2 JavaScript的继承问题
JavaScript的设计目标之一就是足够简单,在1995年,正是C语言和Java盛行的时候,JavaScript的设计就是参考了C语言的基本语法以及Java的数据类型等特性。
与Java一样,在JavaScript中“万物皆对象”,但在当时JavaScript没有类(class
)这个概念,更没有继承这一说,JavaScript看起来不伦不类,所以最后决定给JavaScript设计一套继承模式,但若照搬Java的继承模式,那么JavaScript和Java就没啥区别了,所以还是为其设计了一套独特的继承方式,即原型机制。
2 创建一个对象
在面向对象语言中,把一组数据和处理它们的方法组成对象(object
),把相同行为的对象归纳为类(class
),所以创建一个对象必须得有一个类(class
),或者说有一个封装的对象模型,用于对象的实例化。
2.1 在Java中创建一个对象
使用class
关键字创建一个类,如下面例子中创建一个猫Cat
类:
public class Cat {
public String name;
public int age;
public Cat(String n, int a) {
name = n;
age = a;
}
}
使用new
操作符创建一个Cat
对象:
Cat kat = new Cat('kat', 2);
在使用new
操作符创建对象时,会自动调用构造函数并完成参数的初始化,最后返回对象实例,可见构造函数才是创建对象的真正执行者,在这个例子中,构造函数为:
public Cat(String n, int a) {
name = n;
age = a;
}
2.2 在JavaScript中创建一个对象
在JavaScript中是没有class
关键字的,所以不存在类(class
)这一概念,为了实现对象的创建,JavaScript跳过了类的声明,直接使用构造函数创建一个对象,下面声明一个Cat
构造函数:
function Cat(name, age) {
this.name = name
this.age = age
}
可以发现构造函数和普通的函数在形式上完全一样,在JavaScript中依然保留了new
操作符,创建对象的代码如下:
const kat = new Cat('kat', 2)
构造函数和普通函数的区别如下:
- 为了和普通函数区分,构造函数名字的第一个字母通常是大写的,但这不是必须的;
- 构造函数中有
this
关键字,代表实例对象本身; - 构造函数只能使用
new
操作符调用,其返回值有如下几种情况:- 若不指定
return
值,则返回的是创建的实例化对象; - 若指定
return
值,并且该值为非引用类型的值,则返回的是创建的实例化对象; - 若指定
return
值,并且该值为引用类型的值,则返回的是该引用类型的值。
- 若不指定
- 若不使用
new
操作符调用构造函数,其返回值有如下几种情况:- 若不指定
return
值,则返回的是undefine
; - 若指定
return
值,则返回的就是该值。
- 若不指定
3 原型
3.1 prototype
每个函数(普通和构造函数)都拥有一个prototype
属性,该属性为一个对象,该对象为使用构造函数创建的对象的原型,如下面例子:
function Cat(name, age) {
this.name = name
this.age = age
}
const kat = new Cat('kat', 2)
上述关系可以用如下图例表示:
总结一句话来说就是构造函数的prototype
属性为其实例化对象的原型。
3.2 __proto__
和Object.getPrototypeOf
上文中prototype
属性的作用是让构造函数获取其实例化对象的原型,那么让实例对象获取其原型的方法有__proto__
和Object.getPrototypeOf
两种。
3.2.1 __proto__
__proto__
为实例对象的一个属性,该属性为实例对象的原型,其使用方法如下:
function Cat(name, age) {
this.name = name
this.age = age
}
const kat = new Cat('kat', 2)
console.log(kat.__proto__ === Cat.prototype) // true
上述关系可以用如下图例表示:
这个例子中验证了一个重要的关系:
kat.__proto__ === Cat.prototype
读者可能在其他资料中了解到:
__proto__
并不是一个规范的属性,该特性已经从Web标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。但其实该属性已经在ES6中标准化了:https://262.ecma-international.org/6.0/#sec-additional-properties-of-the-object.prototype-object
3.2.2 Object.getPrototypeOf
标准推荐的获取对象原型的方法是Object.getPrototypeOf
,其使用方法如下:
function Cat(name, age) {
this.name = name
this.age = age
}
const kat = new Cat('kat', 2)
console.log(Object.getPrototypeOf(kat) === Cat.prototype) // true
上述关系可以用如下图例表示:
这个例子再次验证了这个重要的关系:
Object.getPrototypeOf(kat) === Cat.prototype
3.3 constructor
constructor
属性用于通过原型获取其构造函数,其使用方法如下:
function Cat(name, age) {
this.name = name
this.age = age
}
console.log(Cat === kat.constructor) // true
console.log(Cat === Cat.prototype.constructor) // true
上述关系可以用如下图例表示:
这个例子验证了另一个重要的关系:
Cat === kat.constructor
Cat === Cat.prototype.constructor
3.4 总结
prototype
、__proto__
和Object.getPrototypeOf
、constructor
描述的是构造函数、原型对象、实例对象间的关系,从上面例子中可以得到两个重要的关系:
- 构造函数的
prototype
属性为其实例化对象的原型;
kat.__proto__ === Cat.prototype
Object.getPrototypeOf(kat) === Cat.prototype
- 原型的
constructor
属性为其构造函数本身。
Cat === Cat.prototype.constructor
上述关系可以用如下图例表示:
4 原型的原型
前文中提到,原型本身也是一个对象,既然是一个对象,那么它也会有自己的原型,如下面例子:
function Cat(name, age) {
this.name = name
this.age = age
}
const kat = new Cat('kat', 2)
console.log(kat.__proto__) // Cat.prototype
在这个例子中kat.__proto__ === Cat.prototype
,那么使用kat.__proto__.__proto__
或Cat.prototype.__proto__
可以获取到对象kat
的原型的原型,那么再使用kat.__proto__.__proto__.constructor
或Cat.prototype.__proto__.constructor
即可获取到对象kat
的原型的原型的构造函数:
console.log(kat.__proto__.__proto__.constructor) // [Function: Object]
其结果如下:
可以发现kat.__proto__.__proto__.constructor
的结果是Object
函数。上述关系可以用如下图例表示:
为什么结果是Object
函数,这个问题将在5.1节中解答。
5 原型链
如在第4章中通过kat.__proto__.__proto__
这种获取原型的原型的这个连续过程就是原型链。
5.1 原型链的终点
根据第4章的例子,已知kat.__proto__.__proto__ === Object.prototype
,那么kat.__proto__.__proto__.__proto__
又会是什么呢?看下面例子:
function Cat(name, age) {
this.name = name
this.age = age
}
const kat = new Cat('kat', 2)
console.log(kat.__proto__.__proto__.__proto__ === null) // true
实验证明kat.__proto__.__proto__.__proto__ === null
,说明Object.prototype
的原型为null
,即Object.prototype
就是原型链的终点,把第4章的图例补充完整如下:
5.2 原型链的作用
当访问实例对象的属性或方法时,若该实例对象内部不存在这个属性或方法,那么就会在该实例对象的原型上找,若还找不到,则在原型的原型上找,直到找到原型的终点,即通过原型链,可以使实例对象能够获取到其原型上的属性和方法,如下面例子:
function Cat(name, age) {
this.name = name
this.age = age
}
Cat.prototype.showInfo = function() {
console.log(`name: ${this.name}`)
console.log(`age: ${this.age}`)
}
const kat = new Cat('kat', 2)
kat.showInfo()
在这个例子中,构造函数Cat
上是没有showInfo()
方法的,但是通过Cat.prototype.showInfo
给构造函数Cat
的原型上添加showInfo()
方法后,其实例化对象cat
能够调用showInfo()
方法,输出cat
对象如下:
原型链的这个特点,也就解释了为什么任何对象都能使用toString()
方法,因为任何对象都是Object
对象的实例,而toString()
是Object
对象上的方法,所以所有对象都能根据原型链找到这个方法。
6 原型链继承
原型链继承的核心就是重写原型对象,用一个新的对象取代原来的原型对象:
function Animal() {
this.description = 'An animal'
this.showDescription = function () {
console.log(this.description)
}
}
function Cat(name, age) {
this.name = name
this.age = age
this.showInfo = function () {
console.log(`name: ${name}, age: ${age}`)
}
}
Cat.prototype = new Animal() // 关键代码
const kat = new Cat('kat', 2)
kat.description = 'A cat' // 对非引用类型的数据进行修改
kat.showInfo() // name: kat, age: 2
kat.showDescription() // A cat
在这个例子中通过Cat.prototype = new Animal()
将Cat
的原型修改为Animal
,这样由Cat
构造函数实例化的对象kat
就能访问到Animal
中的属性和方法。上面的代码还可以优化,可以将构造函数中的方法定义在构造器的原型上,这样不必在每次实例化对象时都复制一份方法的副本。
function Animal() {
this.description = 'An animal'
}
Animal.prototype.showDescription = function () {
console.log(this.description)
}
使用原型链继承的缺点是引用属性会被所有实例对象共享,多个实例对象对引用类型的操作会被修改,如下面例子:
function Animal() {
this.description = ['An', 'animal']
}
Animal.prototype.showDescription = function () {
console.log(this.description)
}
function Cat(name, age) {
this.name = name
this.age = age
this.showInfo = function () {
console.log(`name: ${name}, age: ${age}`)
}
}
Cat.prototype = new Animal() // 关键代码
const kat = new Cat('kat', 2)
const yat = new Cat('yat', 3)
kat.description.push('kat') // 对引用类型的数据进行修改
kat.showDescription() // [ 'An', 'animal', 'kat' ]
yat.showDescription() // [ 'An', 'animal', 'kat' ]
在这个例子中实例化了两个对象kat
和yat
,使用kat
对Animal
中的引用类型数据description
进行修改后,另一个实例化对象yat
原型中的description
也会被修改。