JavaScript中的原型与继承

JavaScript是基于原型的语言,对象和它的原型对象之间只是建立了关联,这与基于类的语言完全不同。在基于类的语言中,会把一个对象(“类”)的属性和方法复制到另一个对象(“实例”)中,因为在编译期间要确定这个实例所占内存大小。但是JavaScript又提出了构造函数、new运算符,想往基于类的语言特征上靠,这反而让刚从别的语言过来的开发者感到迷糊。例如,本来想按照类的特征去设计代码,发现没办法设置私有属性。更加让人迷惑的是,铺天盖地的资料跟你讲的是原型范围内的东西,用的术语确是类中的,加大了理解难度。ES6中增加了class,规范了对类的使用。class中的各种特性依然是基于原型的,并没有真的把基于类的语言中的那一套搬过来。

Douglas Crockford 在《JavaScript语言精粹》这本书中讲的特别好,一语中的:

JavaScript是一门弱类型语言,从不需要类型转换。对象继承关系变得无关紧要。对于一个对象来说重要的是它能做什么,而不是它从哪里来。

把重点转移到一个对象能做什么,如何复用其他对象的功能上,一切问题思考起来都变得简单很多。JavaScript中对象和函数的灵活性,使得有很多方式可以达到代码重用的目的。

JavaScript中的原型

在基于原型的语言中,对象可以从它的原型对象“继承”属性和方法。在JavaScript中,对象从它的原型对象“继承”的属性和方法,在原型对象的prototype属性中。又因为JavaScript中的对象都是通过函数“构造”出来的,但JavaScript没有区分哪种函数可以用来“构造”对象,哪种不能。所以每个函数都有一个prototype属性,它的值是一个对象。

ES6提出的箭头函数没有prototype属性,而且不能用作构造器函数,用new运算符调用时会报错。

1
2
3
4
5
6
7
function f() {
}
f.prototype.a = 1
f.prototype.b = 2
f.prototype.print = function(sth) {
console.log(sth)
}

f.prototype的值中,除了我们手动添加的属性和方法,还多了两个属性constructor__proto__,这两个属性会一起被函数“构造”出来的对象“继承”。constructor属性的值就是当前函数本身。而__proto__是每个对象都会有的一个私有属性,正是__proto__建立起了对象和它的原型对象之前的联系,__proto__会指向原型对象的prototype属性。

现在就用f“构造”一个新的对象,和以往直接调用函数不同,需要在函数调用前面加上new运算符。前面加上new运算符才去调用的函数,叫做构造函数

1
let o = new f()

可以看到,o.__proto__的属性和方法就是f.prototype的属性和方法。实际上o.__proto__就是指向f.prototype的一个引用。我们可以说构造函数f就是对象o原型对象

1
console.log(o.__proto__ === f.prototype)    // true

通过constructor可以确定对象的原型对象是哪个构造函数,将__proto__层层展开,会发现o.__proto__.__proto__最终指向了Objectprototype,后面再没有__proto__了,这其实构成了对象o原型链

1
o ------> f.prototype ------> Object.prototype

再回到函数f,既然函数也是对象,那我们来看看它的原型链是怎样的:

可以看到,函数f的原型链是这样的:

1
f ------> Function.prototype -------> Object.prototype

再拓展一点,JavaScript中所有函数本身的原型链,最后两个原型对象都是FunctionObject。可以做个测试:

1
2
3
4
5
6
console.log(Object.__proto__ === Function.prototype)              // true
console.log(Object.__proto__.__proto__ === Object.prototype) // true
console.log(Function.__proto__ === Function.prototype) // true
console.log(Function.__proto__.__proto__ === Object.prototype) // true
console.log(String.__proto__ === Function.prototype) // true
console.log(String.__proto__.__proto__ === Object.prototype) // true

JavaScript提供了两种方式可以判断一个对象是否在另一个对象的原型链上。

第一种:prototypeObj.isPrototypeOf(object)

1
2
3
4
5
6
7
8
console.log(f.prototype.isPrototypeOf(o))                   // true
console.log(Object.prototype.isPrototypeOf(o)) // true
console.log(Object.prototype.isPrototypeOf(Function)) // true
console.log(Function.prototype.isPrototypeOf(Function)) // true
console.log(Object.prototype.isPrototypeOf(Object)) // true
console.log(Function.prototype.isPrototypeOf(Object)) // true
console.log(Function.prototype.isPrototypeOf(String)) // true
console.log(Object.prototype.isPrototypeOf(String)) // true

第二种:object instanceof constructor

1
2
3
4
5
6
7
8
console.log(o instanceof f)                                 // true
console.log(o instanceof Object) // true
console.log(f instanceof Function) // true
console.log(f instanceof Object) // true
console.log(Object instanceof Object) // true
console.log(Function instanceof Function) // true
console.log(Object instanceof Function) // true
console.log(Function instanceof Object) // true

也可以先获取对象原型的prototype属性,然后再做比较。

尽管通过__proto__可以获取对象的原型,但这个属性早已经被废弃了,最好不要在生成代码中直接使用。

上面有提到o.__proto__.constructor指向的就是“构造”对象o的函数,所以可以通过constructor获取对象的原型:

1
2
let proto = o.constructor.prototype
proto === f.prototype

此外,Object提供了一个getPrototypeOf()方法可以获取对象的原型:

1
2
let proto = Object.getPrototypeOf(o)
proto === f.prototype

何时把属性或方法定义在原型上呢?

对象可以直接访问它原型链上的属性和方法。也可以在函数中往this上添加属性和方法,这样函数“构造”出来的对象,本身就拥有了这些属性和方法。当试图访问对象的属性和方法时,会先在对象自身查找,如果没有找到,就会沿着对象的原型链查找,直到搜索到原型链的末尾还没有找到,就返回undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let f = function() {
this.b = 3
this.c = 4
}
f.prototype.a = 1
f.prototype.b = 2
f.prototype.print = function(sth) {
console.log(sth)
}

let o = new f()
console.log(o.a) // 1
console.log(o.b) // 3
console.log(o.c) // 4
console.log(o.d) // undefined

this上的属性和方法每个对象单独拥有一份,一个对象修改了函数中的属性或方法,不会影响到其他对象的。原型上的属性和方法是所有“继承”自该原型对象共享的,如果一个对象该了原型上的属性值或方法,其他对象的也会改变。所以定义在函数中的this上,会比定义在原型上占更多的内存空间。通常我们把对象的方法定义在原型上,一般方法对于所有对象都一样。

我们把定义在原型上的属性或方法,叫做原型属性或原型方法,把定义在函数内this上的属性或方法,就叫做自身的属性或自身的方法吧。

既然函数也是对象,那也可以直接将属性和方法定义在函数本身上,像下面这样:

1
2
3
4
f.e = 20
f.test = function() {}
console.log(f.e) // 20
console.log(o.e) // undefined

这有点像基于类的语言中使用static定义的属性和方法,这里我们也可以把它叫做函数的静态属性或静态方法,只能通过函数自身去访问。

如何判断对象的属性还是原型属性还是自身的属性呢?

在用for...in遍历一个对象时,会遍历出所有的属性,包括原型属性和实例属性,可以使用hasOwnProperty来判断属性是否是自身的属性。

1
2
3
4
5
for (const prop in o) {
if (o.hasOwnProperty(prop)) {
console.log(prop)
}
}

也可以用Object.getOwnPropertyNames获取一个对象所有的自身属性。

继承

要实现JavaScript中的继承,需要重点关注的是对象,如何让一个新对象可以继承一个旧对象的属性。本节会主要讨论两种实现继承的方式,基于原型的继承和Douglas Crockford 在《JavaScript语言精粹》提倡的完全使用函数实现的继承。

基于原型的继承

翻了很多讲JavaScript中实现继承的文章,总结下来基于原型的继承有两种思路。一种是用已有的对象创建一个新对象,然后再给新对象增加功能,这种继承也叫“差异化继承”。还有一种是拓展对象的原型链,模仿类的实现。

先来看第一种,可以使用Object.create(proto)基于传入的对象,创建一个新的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const animal = {
foods: [],
desc: "I'm an animal",
eat: function() {
console.log("nom nom nom")
}
}

const bird = Object.create(animal)
bird.desc = "I'm a bird"
bird.fly = function() {
console.log("jiu jiu jiu")
}
bird.eat()
bird.foods.push('bug')

为什么说这种方式是基于原型的呢,因为Object.create(proto)的内部实现大概是这样的:

1
2
3
4
5
function createObject(proto) {
function F() {}
F.prototype = proto
return F
}

父类整个对象成为了子类的原型,父类中的属性和方法会被它的所有子类所共享,这导致了一个子类改动了父类的属性或方法,其他子类访问到的内容也会受影响,像这里的bird.foods.push('bug')就会影响animal.foodsbird.descbird.fly没有影响父类,是因为在bird本身又多了descfly两个属性。

这种方式也会导致子类继承了父类多余的属性和方法。

这种方式有一个好处就是避免了使用new运算符。

再来看第二种,拓展原型链,模仿类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Animal(name) {
this.name = name
}
Animal.prototype.get_name = function() {
return this.name
}
Animal.prototype.eat = function() {
console.log("nom nom nom")
}

function Bird(name) {
this.name = name
}
Bird.prototype = Object.create(Animal.prototype)
Bird.prototype.constructor = Bird

let bird = new Bird('Kitty')
bird.eat()
bird.get_name()

再进一步,可以将上面原型赋值的过程抽出来一个通用的inherits方法:

1
2
3
4
5
6
7
8
Function.prototype.inherits = function(Parent) {
const proto = Object.create(Parent.prototype)
proto.constructor = this
this.prototype = proto
return this
}

Bird.inherits(Animal)

现在Bird只继承了Animal原型上的属性和方法,构造函数内定义的属性和方法不会被继承。如果要复用Animal的构造函数中定义的属性和方法可以在Bird中调用一次Animal函数。

1
2
3
function Bird(name) {
Animal.call(this, name)
}

用这种方式也可以使用instanceof正确判断bird所在的原型链。

1
2
console.log(bird instanceof Animal)              // true
console.log(bird instanceof Bird) // true

使用函数化的方式继承

上面看到的继承方式都有一个弱点,就是没法保护隐私,对象的所有属性都是对外可见的。这其实给了别人修改对象内部细节的机会。利用闭包实现的模块,就可以很好地隐藏私有变量。而且不需要使用构造函数,也就不必用new运算符调用函数。

为什么要尽量避免使用new运算符呢?

因为JavaScript没有区分普通函数和构造函数的,是开发者约定俗成的将构造函数名称的首字母大写,以便于辨别。如果一个本意是想当构造函数用的函数,被直接调用,那么this不会被绑定到对象上,更糟糕的是this会指向全局对象,污染了全局环境。

下面用函数化的方式再实现一下上面的继承关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function animal(spec) {
var that = {}

that.get_name = function() {
return spec.name
}

that.eat = function() {
console.log('eating food: ' + spec.food)
}

return that
}

function bird(spec) {
spec.food = 'bug'

var that = animal(spec)

that.fly = function() {
console.log("jiu jiu jiu")
}

return that
}

let myBird = bird({name: 'Kitty'})
myBird.eat()
myBird.fly()

因为不需要使用构造函数,所以也不需要使用new运算符。还可以再进一步封装一个superior方法,让子类能够在重写父类的方法时,调用父类中的对应方法。

1
2
3
4
5
6
7
Object.prototype.superior = function(name) {
var that = this,
method = that[name]
return function() {
return method.apply(that, arguments)
}
}

再创建一个对象coolbird继承bird,然后测试一下superior的作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
function coolbird(spec) {
var that = bird(spec)
super_get_name = that.superior('get_name')

that.get_name = function() {
return 'like '+ super_get_name() + ' baby'
}
return that
}

let myCoolBird = coolbird({name: 'Kitty'})
console.log(myCoolBird.get_name())
// like Kitty baby

构造对象的函数,除了传入spec参数用来构造对象的过程中使用,还可以再传入一个my参数,用来存放想要分享给其他对象的内容,其他对构造对象的函数也可以通过my参数来分享给别人内容。

这里是一个函数化构造器的伪代码模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var constructor = function(spec, my) {

var that, 其他的私有实例变量
// that 即是最终返回的对象

my = my || {}

把共享的变量和函数添加到my

that = 一个新对象

// 有很多方式可以构造一个对象。可以构造一个对象字面量,或者用 new 运算符 去调用一个构造器函数,或者可以使用Object.create方法去构造一个已经存在的对象的新实例,或者调用任意一个会返回对象的函数,也可以用 new 运算符创建一个`class`的对象。

添加给 that 的特权方法
// 因为添加到 that 上的方法可以访问到私有变量,所以是拥有特权的方法

return that
}

上面的实现方式,是不是有点像Node.js中模块化的实现方式。利用闭包实现模块,我们要牢记于心,也许它就是未来我们解决某个问题的利器。

如果通过这种方式创建的对象的所有方法都不实用thisthat,那么这个对象就是不可变的。一个不可变的对象就是一个简单功能函数的集合。

本文作者:意林
本文链接:http://shinancao.cn/2019/09/10/JS-Prototype/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!