原型,原型链,继承与组装
原型 (Prototype)
如果你不知道如何操作对象(objects),恐怕你在JavaScript这条路上走不了太远,因为对象是JS编程语言各个知识点的基础。而事实上,创建对象也许是你开始学习JS语言的第一件事。铺陈了这么多,我主要是想表达,为了最有效理解JS原型,我们需要唤醒内心的那个“技术小白”,回归到JS语言的最基础。
**对象即键值对(key/value)。**创建对象最常用的方式是使用大括号 { },然后我们再通过点符号向对象添加属性或方法。如下例所示,我们创建了一个animal对象。
let animal = {}
animal.name = 'Leo'
animal.energy = 10
animal.eat = function (amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
animal.sleep = function (length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
animal.play = function (length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
真简单!但如果我们的应用需要创建多个animal对象呢?我们自然而然会想到将这些逻辑封装到一个函数内,每当我们需要创建一个新的animal,就调用这个函数,我们称这种设计模式为函数实例化(Functional Instantiation)。我们称该函数为构造函数,因为它的职责是构造一个新的对象。animal对象的函数实例化代码如下。
function Animal (name, energy) {
let animal = {}
animal.name = name
animal.energy = energy
animal.eat = function (amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
animal.sleep = function (length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
animal.play = function (length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
return animal
}
const leo = Animal('Leo', 7)
const snoop = Animal('Snoop', 10)
这时候你也许正在想,我们不是在聊高阶JS语言吗?没错儿,我们接着往下看吧。现在当我们要创建一个animal对象(或实例),我们只需要调用Animal函数,并传入name和energy参数即可。简直简单到难以置信,**然而你意识到这种设计模式的软肋了吗?**这里最大的问题出在eat,sleep和play方法。这些方法应该是动态且通用的!我的意思是我们没必要每次实例化一个animal的时候,都把这些方法重新创建一遍,因为这实在太浪费内存了,且animal对象原本并不需要这么“占空间”。你能想到一个解决办法吗?我们可以把这些这些通用的方法封装到一个对象里,然后让所有的animal实例都引用这个对象。我们称这种设计模式为共享方法的函数实例化(Functional Instantiation with Shared Methods),好吧有点啰嗦,但是很形象。我们来看看它的实现。
const animalMethods = {
eat(amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
},
sleep(length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
},
play(length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
}
function Animal (name, energy) {
let animal = {}
animal.name = name
animal.energy = energy
animal.eat = animalMethods.eat
animal.sleep = animalMethods.sleep
animal.play = animalMethods.play
return animal
}
const leo = Animal('Leo', 7)
const snoop = Animal('Snoop', 10)
通过以上设计模式我们解决了资源浪费及animal对象过大的问题。**然而,并没有到此为止,使用Object.create可以再进一步优化原来的代码。**长话短说就是,Object.create 让我们创建一个对象且在对该对象查询失败时授权查询另一个对象。短话长说就是,通过 Object.create 的方式创建一个对象,会触发JS引擎的一个机制,即,当对该对象的某个属性(或key)查询失败的时候,JS引擎会自动去另一个与之关联的对象,看看这个对象是否包含该属性。
我们还是来看看下面的代码吧。
const parent = {
name: 'Stacey',
age: 35,
heritage: 'Irish'
}
const child = Object.create(parent)
child.name = 'Ryan'
child.age = 7
console.log(child.name) // Ryan
console.log(child.age) // 7
console.log(child.heritage) // Irish
从上面的例子可以看到,因为child是通过 Object.create(parent) 来创建的,所以当JS引擎对child对象的某个属性查询失败的时候,它会自动的去parent对象查询。这意味着尽管child没有heritage属性,而parent有,当我们在控制台输出 child.heritage 的时候,最终得到的值是parent的heritage属性值,Irish。
我们回到之前animal的例子,我说过使用Object.create可以实现进一步优化。
const animalMethods = {
eat(amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
},
sleep(length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
},
play(length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
}
function Animal (name, energy) {
let animal = Object.create(animalMethods)
animal.name = name
animal.energy = energy
return animal
}
const leo = Animal('Leo', 7)
const snoop = Animal('Snoop', 10)
leo.eat(10)
snoop.play(5)
当我们调用 leo.eat 方法时,JS引擎会首先从leo对象里查找该方法,查询失败,然后JS引擎会转而查询 animalMethods 对象,并从那儿找到了eat方法。
目前为止,咱们聊得还不错。**不过,上面的例子还不够完美,还有可以改进的地方。**为了使多个实例共享方法,我们创建了一个单独的对象(animalMethods),这种做法其实并不优雅。我倒认为,与其我们自己去想一个完美的解决之道,不如说这是一个成熟的语言本来就应该具备的基础能力,而这也是咱一直聊到现在的目的 - 原型(prototype)。
到底什么是JS原型?长话短说就是,**每一个JS函数都有一个prototype属性,该属性指向一个对象。**好吧,确实有儿点虎头蛇尾,咱还是用一段代码来测试一下。
function doThing () {}
console.log(doThing.prototype) // {}
基于这个知识点,我们想一想,与其我们自己创建一个对象(animalMethods)来保存需要共享的方法,为什么我们不把这些方法写进Animal函数的原型?我们所需要做的改进仅仅是使用Object.create,将我们之前所描述的JS引擎“失败查询”(failed lookup)机制指派给Animal.prototype对象,而不再是我们自己创建的animalMethods对象。我们称这种设计模式为原型实例化(Prototypal Instantiation),其具体实现如下。
function Animal (name, energy) {
let animal = Object.create(Animal.prototype)
animal.name = name
animal.energy = energy
return animal
}
Animal.prototype.eat = function (amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
Animal.prototype.sleep = function (length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
Animal.prototype.play = function (length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
const leo = Animal('Leo', 7)
const snoop = Animal('Snoop', 10)
leo.eat(10)
snoop.play(5)
**再次重申,原型是一个每一个JS函数都具有的属性,它“赋能”我们在某个函数的多个实例间共享方法。**你看,我们所有的功能都没变,但我们不再需要构造一个单独的对象来保存需要共享的方法了,我们用到的对象是Animal函数自带的属性 - Animal.prototype
我们的代码是不是已经优化到极致了?先别肯定得太早,**让我们聊得再深入一点!**在这个节骨眼我们已经知道了三件事:
- 如何创建一个构造函数;
- 如何添加方法到构造函数的原型;
- 如何使用Object.create方法触发JS引擎的失败查询机制,并且失败查询所关联的对象是当前函数的原型;
对于一门成熟的编程语言,这三件事儿都特别基础。难道JavaScript就那么“差劲”,竟然没有自带的能力来完成这些任务,非得我们一行一行代码来实现?我想你已经猜到了,JS语言当然具备这种能力,通过使用 new 关键字即可实现。
前面聊了那么多一直到现在,其实我们已经深度的理解了new关键字,每当我们使用new关键字的时候,我们准确的知道JS语言在背后到底做了什么。
回到我们的例子 - Animal构造函数。其中两行代码非常关键,即创建对象和返回对象。我们使用Object.create来创建对象,以触发JS引擎的失败查询机制,并将失败查询关联到该函数的原型。然后我们使用return语句,来获得创建的实例。
function Animal (name, energy) {
let animal = Object.create(Animal.prototype)
animal.name = name
animal.energy = energy
return animal
}
为什么我觉得new很酷呢?当你使用new关键字调用一个函数,JS引擎隐式的帮我们写了上面提到的两行代码,最后返回的实例,我们称之为this。下面的例子使用new关键字来调用Animal构造函数,我们用注释的代码表示JS引擎在背后所做的工作。
function Animal (name, energy) {
// const this = Object.create(Animal.prototype)
this.name = name
this.energy = energy
// return this
}
const leo = new Animal('Leo', 7)
const snoop = new Animal('Snoop', 10)
理解了这一点,我们最开始的例子便可以优化为:
function Animal (name, energy) {
this.name = name
this.energy = energy
}
Animal.prototype.eat = function (amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
Animal.prototype.sleep = function (length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
Animal.prototype.play = function (length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
const leo = new Animal('Leo', 7)
const snoop = new Animal('Snoop', 10)
需要注意的是,上面的代码之所以有效,是因为我们使用可new关键字。如果直接调用Animal,上面的例子就不会返回我们实例。
function Animal (name, energy) {
this.name = name
this.energy = energy
}
const leo = Animal('Leo', 7)
console.log(leo) // undefined
以上的设计模式,我们称为伪类实例化(Pseudoclassical Instantiation)。
类(Class)让我们创建一个对象的蓝图。当我们创建一个类的实例,这个实例会自动包含类中所定义的属性和方法。相信这个概念我们早已烂熟于心,实质上,我们上文所做的与Animal构造函数相关的一切就是对类的实现!我们只是没有使用Class关键字而已,相反我们用了普通又陈旧的JS函数,实现了与Class一样的功能。JS语言可没有停止发展,TC-39委员会在持续地完善它。2015年ES6便开始支持Class关键字,让我们来看看在新的语法下,我们的Animal构造函数的终极形式。
class Animal {
constructor(name, energy) {
this.name = name
this.energy = energy
}
eat(amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
sleep(length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
play(length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
}
const leo = new Animal('Leo', 7)
const snoop = new Animal('Snoop', 10)
非常干净的代码,对吧?如果这是新的创建类的方式,为什么我们之前耗费了大把时间介绍那么陈旧的实现方式?因为,Class关键字仅仅是一个语法糖而已,而其背后的原理正是我们前面所说过得伪类实例化模型。如果要从根本上理解ES6那些“方便”的语法,我们就必须懂得伪类模型。
最后说一个关于原型的小知识。无论这个对象是用哪种方式创建的,我们都能够通过 Object.getPrototypeOf 方法获得它的原型。我们接着上文的Animal构造函数示例,来看下面的代码。
const leo = new Animal('Leo', 7)
const prototype = Object.getPrototypeOf(leo)
console.log(prototype)
// {constructor: ƒ, eat: ƒ, sleep: ƒ, play: ƒ}
prototype === Animal.prototype // true
这里有两个非常重要的知识点,
- 第一,prototype对象不仅包含了 eat, sleep, play,还包含了一个constructor方法。我们发现,prototype 默认包含了constructor属性,该属性指向这个构造这个实例的函数或类。这意味着,我们能通过任何一个实例,直接访问到它的构造函数,语法是 instance.constructor。
- 第二,Object.getPrototypeOf(leo) === Animal.prototype 这行代码也成立,因为 Object.getPrototypeOf 让我们拿到一个实例的原型,而一个实例的原型等于其构造函数或类的原型。
继承和原型链
前面我们聊了如何创建Animal类,以及如何通过JS原型在一个类的多个实例间共享方法。本节我们要为特定的Animal创建类,比如,我们想要创建一些Dog实例,那么这些实例需要什么属性呢?好吧,跟Animal类相似地,我们可以给每一个dog一个name和energy属性,以及eat,sleep和play的能力(方法)。但Dog类也有其独特性,我们可以给它增加一个breed(品种)属性,当然还少不了bark(吠)的能力。我们用ES5实现如下。
function Dog (name, energy, breed) {
this.name = name
this.energy = energy
this.breed = breed
}
Dog.prototype.eat = function (amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
Dog.prototype.sleep = function (length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
Dog.prototype.play = function (length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
Dog.prototype.bark = function () {
console.log('Woof-Woof!')
this.energy -= .1
}
const charlie = new Dog('Charlie', 10, 'Goldendoodle')
好的,我们刚刚貌似把Animal又重新写了一遍,只不过增加了一些新的与Dog相关的属性。如果我们要创建Cat类,我们又得把Animal类重新复制一遍,然后增加一些与Cat相关的属性。事实上,我们每新增一种动物,我们就得再重复一次以前的代码。
这样的代码虽然也能够正常运行,但太浪费资源了。Animal类是一个完美的基类,因为它抽象出了每一种动物的共同属性。每当我们需要创建一个新的动物类型,我们是否可以利用现有Animal类呢?
回过头来研究一下我们最开始创建的Dog构造函数。首先,我们知道它有三个参数,name,energy 和breed。第二,我们使用了new关键字调用该函数,然后函数返回给我们this对象。第三,我们想要“利用”Animal函数,让每一个dog实例具备Animal所定义属性和方法。
前面两条,我们在说原型的时候已经讨论得非常充分了。现在我们要解决的是上述第三条。我们“利用”一个函数的方式就是调用它,所以我们需要在Dog函数里调用Animal函数。更准确地说就是,**我们要使用Dog的this关键字来调用Animal。**其调用结果是,Dog函数内的this将包含Animal的所有属性。
这里,你也许需要稍微复习一下**.call()**方法。**该方法允许我们调用任意JS函数,并指定其上下文。**这正是我们需要的,记得吗,我们要在Dog的上下文中调用Animal。
function Dog (name, energy, breed) {
Animal.call(this, name, energy)
this.breed = breed
}
const charlie = new Dog('Charlie', 10, 'Goldendoodle')
charlie.name // Charlie
charlie.energy // 10
charlie.breed // Goldendoodle
太好了,我们已经成功一半了。上面 Animal.call(this, name, energy) 这行代码让所有的dog实例具备了name和energy属性。然后,我们仅仅需要为Dog增加其特有的breed属性即可。
记得吗?我们希望所有的Dog实例具有Animal的所有属性及方法。但是现在,如果我们试图运行charlie.eat(10),系统会抛出一个错误。目前,Dog实例仅仅具备了Animal的属性,但并不具备其方法。
我们来想想解决的办法。我们知道Animal函数的所有方法都封装在其原型内(Animal.prototype)。这意味着,我们必须让所有的Dog实例都能访问到 Animal.prototype 内的方法。你是否还记得我们的好朋友Object.create?它让我们创建一个对象,且在对该对象查询失败时转而去查询另一个对象。回到我们的例子,我们现在要创建的对象是Dog的原型,当对Dog的原型查询失败时,我们希望JS引擎转而去查找Animal的原型。
function Dog (name, energy, breed) {
Animal.call(this, name, energy)
this.breed = breed
}
Dog.prototype = Object.create(Animal.prototype)
现在,当JS引擎对Dog实例(的某方法)查询失败的时候,它会转而去查询Animal的原型。如果你读到这儿有点困惑,可以再仔细回顾一下上一章节里我们对Object.create的描述。
下面是我们改造过后的完整代码。
function Animal (name, energy) {
this.name = name
this.energy = energy
}
Animal.prototype.eat = function (amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
Animal.prototype.sleep = function (length) {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
Animal.prototype.play = function (length) {
console.log(`${this.name} is playing.`)
this.energy -= length
}
function Dog (name, energy, breed) {
Animal.call(this, name, energy)
this.breed = breed
}
Dog.prototype = Object.create(Animal.prototype)
现在我们创建了基类(Animal),也创建了其子类(Dog)。现在让我们来看一看其背后的运行机制。
目前为止没啥特别的。但如果我们调用一个Animal函数内的方法(eat,sleep,play)呢?
- JS引擎首先查询charlie实例是否包含了eat方法,查询失败;** **
- JS 引擎发现charlie是Dog的实例,所以它决定去Dog的原型(Dog.prototype)查询该方法,但也没有找到;
- JS 引擎发现 Dog是Animal的子类,所以它决定去Animal的原型(Animal.prototype)查询该方法,找到了!然后eat方法被调用。
上述过程就是JavaScript原型链。
还有一个问题没说到的就是如何为Dog添加其特有的方法,比如bark?这一步比较简单,正如我们前面所做的,我们只需要在Dog的原型里定义bark方法,那么所有的Dog实例就能共享该方法啦。
还有一个小问题,如果你还记得,上一节我们提到,我们可以通过instance.constructor访问到一个实例的构造函数。我干嘛突然提到这一茬呢?原因是我们在上文中用Animal的原型重写了Dog的原型 - Dog.prototype = Object.create(Animal.prototype)
这意味着,现在所有Dog的实例,当我们企图输出instance.constructor的时候,我们得到的是Animal构造函数,而不是Dog构造函数。这个问题的解法其实也非常简单,我们仅仅需要在重写万Dog的原型后,为其添加一个对的constructor属性。
function Dog (name, energy, breed) {
Animal.call(this, name, energy)
this.breed = breed
}
Dog.prototype = Object.create(Animal.prototype)
Dog.prototype.bark = function () {
console.log('Woof Woof!')
this.energy -= .1
}
Dog.prototype.constructor = Dog
到这一步,如果我们此刻想要创建另一个子类,Cat. 我们只需要遵循上述的模式即可。这种子类具有基(父)类属性和方法的模式我们称为继承,它也是面向对象编程的核心。在ES6提出classes的概念之前,在JavaScript中实现继承是一个困难的任务,你不仅需要知道在何时何处使用继承,还需要熟练使用 .call, Object.create, this 以及 FN.prototype 等一系列比较高阶的JS特性。让我们看看使用ES6是如何实现继承的。
class Animal {
constructor(name, energy) {
this.name = name
this.energy = energy
}
eat(amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
sleep() {
console.log(`${this.name} is sleeping.`)
this.energy += length
}
play() {
console.log(`${this.name} is playing.`)
this.energy -= length
}
}
class Dog extends Animal {
constructor(name, energy, breed) {
super(name, energy) // calls Animal's constructor
this.breed = breed
}
bark() {
console.log('Woof Woof!')
this.energy -= .1
}
}
需要提到的是,在ES5中,为了让每一个Dog的实例都有name和energy属性,我们使用了.call函数,在Dog实例的上下文中调用Animal构造函数。这一步在ES6中更为简单直接,我们使用super函数在子类中调用基类的构造函数,并传入其所需参数即可。
组装 VS 继承
上一章我们讲到了继承,我们用下面的方式简单说明基类与子类的代码结构。
Animal
name
energy
eat()
sleep()
play()
Dog
breed
bark()
Cat
declawed
meow()
这是一种很好的模式,因为它既减少了代码重复,又增加了其可复用性。
现在我们假装要写一个“魔幻农场”的多人在线游戏。首先,我们需要创建用户(users),所以我们要将上面的类结构更新为:
User
email
username
pets
friends
adopt()
befriend()
Animal
name
energy
eat()
sleep()
play()
Dog
breed
bark()
Cat
declawed
meow()
可是现实总是难以预料,6个月后,出现了需求变更,因为我们的用户(users)希望在游戏中有更多真实人生的体验,目前,只有Animal才能eat,sleep和play,用户认为他们的游戏角色也应该具备同样的能力。好吧,这时候,我们需要重新修改一下类结构,将共同属性抽象到一个新的父类中,并增加一层继承。
FarmFantasy
name
play()
sleep()
eat()
User
email
username
pets
friends
adopt()
befriend()
Animal
energy
Dog
breed
bark()
Cat
declawed
meow()
我们成功了,但这种写法非常脆弱。甚至有黑粉将为这种设计模式命名为“上帝对象”(God Object).
我们看到了继承最大的弱点,我们基于我们想要构建的对象(User, Animal, Dog, Cat)”是什么(what they are?)”来构建它们的继承关系。但问题是,6个月后的User需求可能会发生改变。这是一个不可回避的现实,当我们现在的类结构在未来需要改变的时候,它们之间紧密耦合的继承关系就会崩溃。
所以,问题是,我们如何即实现同样的功能,且将其缺陷最小化呢?我们不如转换一下角度,与其去思考想要构建的对象是什么,不如想想它们做什么(what they do)?比如,狗会做什么,它会吃饭,睡觉,玩耍和吠叫,猫还会喵喵叫,用户会吃饭,睡觉,玩耍,收养动物和交朋友。现在我们将它们的这些能力转化为函数。
const eater = () => ({})
const sleeper = () => ({})
const player = () => ({})
const barker = () => ({})
const meower = () => ({})
const adopter = () => ({})
const friender = () => ({})
我们不再将这些方法定义在特定的类中了,而是将它们抽象为独立的函数,然后根据需要重新组装它们。
我们以eat函数为例,来看看具体实现:
eat(amount) {
console.log(`${this.name} is eating.`)
this.energy += amount
}
我们看到,eat通过传入的amount值,增加实例的energy属性值。但现在的问题是,我们决定将eat转化为独立的eater函数,那么它还怎么操作实例呢?好吧,如果我们将实例以state的形式传入eater函数呢?
const eater = (state) => ({
eat(amount) {
console.log(`${state.name} is eating.`)
state.energy += amount
}
})
我们用同样的方式实现剩余的函数:
const sleeper = (state) => ({
sleep(length) {
console.log(`${state.name} is sleeping.`)
state.energy += length
}
})
const player = (state) => ({
play() {
console.log(`${state.name} is playing.`)
state.energy -= length
}
})
const barker = (state) => ({
bark() {
console.log('Woof Woof!')
state.energy -= .1
}
})
const meower = (state) => ({
meow() {
console.log('Meow!')
state.energy -= .1
}
})
const adopter = (state) => ({
adopt(pet) {
state.pets.push(pet)
}
})
const friender = (state) => ({
befriend(friend) {
state.friends.push(friend)
}
})
现在,无论是Dog,Cat或User需要任何一种能力,我们将该对象与特定函数进行合并即可。比如,Dog,是sleeper,eater, player和barker的组合.
function Dog (name, energy, breed) {
let dog = {
name,
energy,
breed,
}
return Object.assign(
dog,
eater(dog),
sleeper(dog),
player(dog),
barker(dog),
)
}
const leo = Dog('Leo', 10, 'Goldendoodle')
leo.eat(10) // Leo is eating
leo.bark() // Woof Woof!
在Dog函数内,我们使用一个JS对象构造dog实例,然后使用Object.assign合并dog实例及其所需要的函数(行为)- 这里每一个函数定义了dog做什么,而不是是什么。
我们可以用同样的方式创建User。之前我们遇到一个问题是,当我们需要为User添加eat,sleep和play方法时,我们需要重构类结构及继承关系。现在我们将所有的方法从类层级中解耦了,要实现这个需求简直小事一桩。
function User (email, username) {
let user = {
email,
username,
pets: [],
friends: []
}
return Object.assign(
user,
eater(user),
sleeper(user),
player(user),
adopter(user),
friender(user),
)
}
为了再次验证我们的理论,让我们为所有的dog增加交朋友的能力。在组装模式下,实现这个需求非常简单。
function Dog (name, energy, breed) {
let dog = {
name,
energy,
breed,
friends: []
}
return Object.assign(
dog,
eater(dog),
sleeper(dog),
player(dog),
barker(dog),
friender(dog),
)
}
组装的思想,让我们从关注对象是什么,切换到做什么,从而从紧密耦合的继承关系中解脱。