1. 原型和原型链
ECMAScript实现的是基于原型和原型链的继承。对于任何对象,都有一个__proto__
属性指向其原型对象,原型对象就是这个对象的构造函数的prototype
属性,同时原型对象的constructor
属性又指向构造函数,看下面这个例子:
function People(name) {
this.name = name
}
let xiaoming = new People('xiaoming')
xiaoming.__proto__ === People.prototype // true
xiaoming.__proto__
/* {constructor: ƒ}
constructor: ƒ People(name)
__proto__: Object*/
People.prototype.constructor === People // true
xiaoming.constructor === People // true
上面例子中,xiaoming.__proto__
就是xiaoming
这个对象的原型对象,也就是xiaoming
的构造函数People
的prototype
属性。原型对象的constructor
属性又指向了xiaoming
对象的构造函数People
。
由于原型对象本身也是一个对象,因此原型对象也有其自身的__proto__
属性,指向原型对象的构造函数的prototype
属性,这样形成了一条由构造函数的prototype
属性构成的原型链。
当访问对象属性或者调用对象方法时,首先在对象本身寻找;如果对象本身不具备所访问的属性或所调用的方法,就沿着原型链向上一级构造函数的prototype
属性(原型对象)中寻找,一直到Object.prototype
;如果Object.prototype
上也没有找到,就会报错。
Object.prototype
作为一个对象,其__proto__
属性就是null
,因此可以认为null
是原型链的终点:
Object.prototype.__proto__ === null // true
2. 继承
在理解了原型和原型链之后,再来看ECMAScript中的继承就比较容易清晰了。ES5中,标准的继承分为三步:
- 在子类(构造函数,为了便于理解也称为“类”,但ES本身没有类的概念)中调用父类的构造函数(通过
call
或apply
方法将构造函数中this
的指向子类) - 将子类的原型对象与父类的原型对象关联
- 将子类的构造函数与自身关联
下面逐步分析继承的过程。
2.1 基于构造函数的继承
首先是最简单的继承方式,也就是在子类中调用父类的构造函数:
// 最简单的继承:在子类中调用父类的构造函数
// 定义父类People
function People(name) {
this.name = name
this.say = function() {
console.log(`My name is ${this.name}`)
}
}
People.prototype.walk = () => {
console.log('I am walking ...')
}
// 定义子类Student
function Student(name, age) {
People.call(this, name)
this.age = age
this.study = () => {
console.log('I am studying ...')
}
}
// 实例化子类的对象
let xiaoming = new Student('xiaoming', 20)
xiaoming.name // "xiaoming"
xiaoming.age // 20
xiaoming.study() // I am studying ...
xiaoming.walk() // Uncaught TypeError: xiaoming.walk is not a function
这种方式虽然简单,但存在一个问题:子类只能继承父类的静态属性(如例子中父类People
的name
)和方法(如例子中People
中定义的say
),但不能继承父类原型对象上的属性和方法(如例子中People.prototype
上的walk
)。
2.2 基于构造函数和原型的继承
为了解决上述问题,还需要将子类和父类的原型相关联,可以通过以下2种方式实现:
// 让子类的prototype属性等于父类的prototype属性
Student.prototype = People.prototype
这种方式存在一个明显的问题:子类和父类共用一个原型对象,进而共用一个构造函数,就无法用instanceof
运算符判断一个对象到底是由子类还是父类的构造函数产生。
更合理的方式为:
// 通过一个父类的实例对象将子类和父类的prototype关联
Student.prototype = Object.create(People.prototype)
// 或者写成下面这种形式
//Student.prototype = new People()
到这里虽然解决了子类和父类共用一个原型对象的问题,但子类此时并没有自己的构造函数,因此还需要为子类指定构造函数:
Student.prototype.constructor = Student
这样就完成了标准的ECMAScript的继承。
2.3 ES6的类和继承
ES6新增了关键字class
和extends
,本质上是对ES5继承相关语法的语法糖:
class People {
constructor(name) {
this.name = name
}
eat() {
console.log('I am eating ...')
}
}
People.prototype.walk = () => {
console.log('I am walking ...')
}
class Student extends People {
constructor (name, age) {
super(name) // 调用父类构造函数,等价于People.call(this, name)
this.age = age
}
// 父类的静态方法自动被继承
study() {
console.log('I am studying ...')
}
}
let xiaoming = new Student('xiaoming', 20)
xiaoming.name // "xiaoming"
xiaoming.age // 20
xiaoming.study() // I am studying ...
// 父类的静态方法和原型方法都被子类继承
xiaoming.eat() // I am eating ...
xiaoming.walk() // I am walking ...
// 子类的构造函数也被重新指定为子类自身
xiaoming.constructor === People // false
xiaoming.constructor === Student // true
可以看到,ES6的语法更加简洁,但本质上还是基于构造函数和原型链的继承。
3. new运算符
ECMAScript中对象可以通过new
运算符作用在任意函数上产生。通常为了强调,将这些函数的首字母大写并称之为“构造函数”。new
运算符的执行过程也可以通过原型和原型链来加以理解:
- 第一步:创建一个空对象,并将空对象的原型对象(
__proto__
属性)与构造函数的原型对象(prototype
属性)关联,从而使得对象可以访问构造函数原型链上的属性和方法 - 第二步:执行构造函数,并将构造函数的
this
指向上一步创建的对象,从而使得对象可以访问构造函数的静态属性和方法 - 第三步:如果构造函数自身返回一个对象,就用返回的对象取代前两步创建的对象;否则,直接返回前两步创建的对象
可以将new
运算符用一个函数来模拟:
// 自定义函数模拟new执行过程
function myNew(constructor, [...rest]) {
// 第一步,创建空对象并指定__proto__属性
const obj = {}
obj.__proto__ = Object.create(constructor.prototype)
// 第二步,执行构造函数
const temp = constructor.apply(obj, rest)
// 第二步,判断构造函数有无返回值
if (temp && (typeof temp === 'object') && (temp instanceof Object)) {
obj = temp
obj.__proto__ = Object.create(constructor.prototype)
}
return obj
}
function People(name, age) {
this.name = name
this.age = age
this.eat = function() {
console.log('I am eating ...')
}
}
let xiaoming = myNew(People, ['xiaoming', 20])
xiaoming.name // "xiaoming"
xiaoming.age // 20
xiaoming.eat() // I am eating ...
这就实现了一个简单的模拟new
运算符的方法。