原型,原型链,继承与组装

原型 (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

我们的代码是不是已经优化到极致了?先别肯定得太早,让我们聊得再深入一点!在这个节骨眼我们已经知道了三件事:

  1. 如何创建一个构造函数;
  2. 如何添加方法到构造函数的原型;
  3. 如何使用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)呢?

  1. JS引擎首先查询charlie实例是否包含了eat方法,查询失败; 
  2. JS 引擎发现charlie是Dog的实例,所以它决定去Dog的原型(Dog.prototype)查询该方法,但也没有找到;
  3. 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),
  )
}

组装的思想,让我们从关注对象是什么,切换到做什么,从而从紧密耦合的继承关系中解脱。

知识共享署名4.0国际许可协议,转载请保留出处; 部分内容来自网络,若有侵权请联系我:前端学堂 » 原型,原型链,继承与组装

赞 (3) 打赏

评论 0

如果对您有帮助,别忘了打赏一下宝宝哦!

支付宝扫一扫打赏

微信扫一扫打赏