Skip to content

💤

《你不知道的 JavaScript》

混合(淆)“类”的对象

接着上一章对对象的探索,我们很自然地将注意力转移到“面向对象(object oriented)编程”,与“类(class)”。我们先将“面向类”作为设计模式来看看,之后我们再考察“类”的机制:“实例化(instantiation)”,“继承(inheritance)”与“(相对)多态(relative polymorphism)”。

我们将会看到,这些概念并不是非常自然地映射到 JS 的对象机制上,以及许多 JS 开发者为了克服这些挑战所做的努力(mixins 等)。

NOTE

这一章花了相当一部分时间(前一半!)在着重解释“面向对象编程”理论上。在后半部分讨论“Mixins(混合)”时,我们最终会将这些理论与真实且实际的 JS 代码联系起来。但是这里首先要蹚过许多概念和假想代码,所以可别跟丢了 —— 坚持下去!

类理论

“类/继承”描述了一种特定的代码组织和结构形式 —— 一种在我们的软件中对真实世界的建模方法。

OO 或面向类的编程强调数据和操作它的行为之间有固有的联系(当然,依数据的类型和性质不同而不同!),所以合理的设计是将数据和行为打包在一起(也称为封装)。这有时在正式的计算机科学中称为“数据结构”。

比如,表示一个单词或短语的一系列字符通常称为“string(字符串)”。这些字符就是数据。但你几乎从来不关心数据,你总是想对数据做事情,所以可以 数据实施的行为(计算它的长度,在末尾添加数据,检索,等等)都被设计成String类的方法。

任何给定的字符串都是这个类的一个实例,这个类是一个整齐的集合包装:字符数据和我们可以对它进行操作的功能。

类还隐含着一个对一个特定数据结构的一种分类方法。其做法是将一个给定的结构考虑为一个更加泛化的基础定义的具体种类。

让我们通过一个最常被引用的例子来探索这种分类处理。一辆可以被描述为一“类”更泛化的东西 —— 载具 —— 的具体实现。

我们在软件中通过定义Vehicle类和Car类来模型化这种关系。

Vehicle的定义可能会包含像动力(引擎等),载人能力等等,这些都是行为。我们在Vehicle中定义的都是所有(或大多数)不同类型的载具(飞机、火车、机动车)都共同拥有的东西。

在我们的软件中为每一种不同类型的载具一次又一次地重定义“载人能力”这个基本性质可能没有道理。反而,我们在Vehicle中把这个能力定义一次,之后当我们定义Car时,我们简单地指出它从基本的Vehicle定义中“继承”(或“扩展”)。于是Car的定义就被称为特化了更一般的Vehicle定义。

VehicleCar用方法的形式集约地定义了行为,另一方面一个实例中的数据就像一个唯一的车牌号一样属于一辆具体的车。

这样,类,继承,实例化就诞生了

另一个关于类的关键概念是多态(polymorphism),它描述这样的想法:一个来自于父类的泛化行为可以被子类覆盖,从而使它更加具体。实际上,相对多态允许我们在覆盖行为中引用基础行为。

类理论强调建议父类和子类对相同行为共享同样的方法名,以便子类(差异化地)覆盖父类。我们即将看到,在你的 JS 代码中这么做会导致种种困难和脆弱的代码。

类设计模式

你可能从没把类当作一种“设计模式”考虑过,因为最常见的是关于流行的“面向对象设计模式”的讨论,比如“迭代器(Iterator)”、“观察者(Observer)”、 “ 工厂(Factory)”、“ 单例(Singleton)”等等。当以这种方式表现时,几乎可以假定 OO 的类是我们实现所有(高级)设计模式的底层机制,好像对所有代码来说 OO 是一个给定的基础。

取决于你在编程方面接受过的正规教育的水平,你可能听过“过程式编程(procedural programming)”:一种不用任何高级抽象,仅仅由过程(也就是函数)调用其它函数构成的描述代码的方式。你可能被告知过,类是一个将过程式风格“面条代码”转换为结构良好,组织良好代码的恰当的方法。

当然,如果你有“函数式编程(functional programming)”的经验,你可能知道类只是几种常见设计模式中的一种。但是对于其他人来说,这可能是第一次你问自己,类是否真的是代码的根本基础,或者它们是在代码顶层上的选择性抽象。

有些语言(比如 Java)不给你选择,所以这根本没什么选择性 —— 一切都是类。其他语言如 C/C++或 PHP 同时给你过程式和面向类的语法,所以你可以选择。

JavaScript 的类

JS 拥有一些像类的语法元素(比如newinstanceof)有一阵子了,ES6 中还追加了class关键字。

但这并没有意味着 JS 有类。

由于类是一种设计模式,你可以,用相当的努力(正如本章接下来的部分),近似实现很多经典类的功能。JS 在通过提供看起来像类的语法,来努力满足用类进行设计的的极其广泛的渴望

虽然我们有了看起来像类的语法,但是 JS 机制好像在抵制你使用类设计模式,因为在底层,这些你正在上边工作的机制运行的十分不同。语法糖和(极其广泛使用的)JS “Class” 库废了很大力气来把这些真实情况对你隐藏起来,但你迟早会面对现实:你在其他语言中遇到的和你在 JS 中模拟的不同。

总而言之,类是软件设计中的一种可选模式,你可以选择 JS 中使用或不使用它。因为许多开发者对面向类的软件设计情有独钟,本章剩下部分探索一下,为了使用 JS 提供的东西维护类的幻觉要付出什么代价,和我们经历的痛苦。

类机制

在许多面向类语言中,“标准库”都提供一个叫“栈”(压栈,弹出等)的数据结构,用一个Stack类表示。这个类拥有一组变量来存储数据,还拥有一组可公开访问的行为(“方法”),这些行为使你的代码有能力与(隐藏的)数据互动(添加或移除数据等等)。

但是在这样的语言中,你不是直接在Stack上操作(除非制造一个静态的类成员引用,但这超出了讨论范围)。Stack类仅仅是任何的“栈” 都会做的事情的一个抽象解释,但它本身不是一个“栈”。为了得到一个可以对之进行操作的实在的数据结构,你必须实例化这个Stack类。

建筑物

传统的“类(class)”和“实例(instance)”的比拟源自于建筑物的建造。

一个建筑师会规划一栋建筑的所有性质:宽、高、在哪里有多少窗户、甚至墙壁和天花板用什么材料。在这个时候,她并不关心建筑物将会被建造在哪里,也不关心有多少这栋建筑的拷贝将被建造。

同时她也不关心这栋建筑的内容 —— 家具、墙纸、吊扇等等 —— 她仅关心建筑物含有何种结构。

她生产的建筑学上的蓝图仅仅是建筑物的“方案”。它们不实际构成我们可以实在进入其中并坐下的建筑物。为了这个任务我们需要一个建筑工人。建筑工人会拿走方案并精确地依照它们建造这栋建筑物。在真正的意义上,他是在将方案中意图的性质拷贝到物理建筑物中。

一旦完成,这栋建筑就是蓝图方案的一个物理实例,一个很可能实质完美的拷贝。然后建筑工人就可以移动到隔壁将它在重做一遍,建造另一个拷贝

建筑物与蓝图间的关系是间接的。你可以检视蓝图来了解建筑物是如何构造的,但对于直接考察建筑物的每一部分,仅有蓝图是不够的。如果你想打开一扇门,你不得不走进建筑物本身 —— 蓝图仅仅是为了用来表示门的位置而在纸上画的线条。

一个类就是一个蓝图。为了实际得到一个对象并与之互动,我们必须从类中建造(也就是实例化)某样东西。这种“建造”的最终结果是一个对象,通常称为一个 “实例”,我们可以按需要直接调用它的方法,访问它的公共数据属性。

这个对象是所有在类中被描述的特性的拷贝

你不太可能会指望走进一栋建筑之后发现,一份用于规划这栋建筑物的蓝图被裱起来挂在墙上,虽然蓝图可能在办公室的公共记录的文件中。相似地,你一般不会使用对象实例来直接访问和操作类,但是对于判定对象实例来自于哪个类至少是可能的。

与考虑对象实例与它源自的类的任何间接关系相比,考虑类和对象实例的直接关系更有用。一个类通过拷贝操作实例化为对象形式。

instantiate by copying

如你所见,箭头由左向右,从上至下,这表示着概念上和物理上发生的拷贝操作。

构造器(constructor)

类的实例由类的一种特殊方法构建,这个方法的名称通常与类名相同,称为“构造器(constructor)”。这个方法的具体工作,就是初始化实例所需的所有信息(状态)。

比如,考虑下面这个类的假想代码(语法是自创的):

javascript
class CoolGuy {
  specialTrick = nothing
  CoolGuy(trick) {
    specialTrick = trick
  }
  showOff() {
    output("Here's my trick: ", specialTrick)
  }
}

为了制造一个CoolGuy实例,我们需要调用类的构造器:

javascript
Joe = new CoolGuy('jumping rope')

Joe.showOff() // Here's my trick: jumping rope

CoolGuy类有一个构造器CoolGuy,它实际上就是在我们new CoolGuy(..)时调用的。我们从这个构造器拿回一个对象(类的一个实例),我们可以调用showOff()方法,来打印这个特定的CoolGuy的特殊才艺。

类的构造器属于哪个类,几乎总是和类同名。同时,构造器大多数情况下总是需要用new来调用,以便使语言的引擎知道你想要构建一个新的类的实例。

类继承

在面向类的语言中,你不仅可以定义一个能够初始化它自己的类,你还可以定义另一个类继承自第一个类。

这第二个类通常被称为“子类”,而第一个类被称为“父类”。这些名词显然来自于亲子关系的比拟,虽然这种比拟有些扭曲,就像你马上要看到的。

当一个家长拥有一个和他有血缘关系的孩子时,家长的遗传性质会被拷贝到孩子身上。明显地,在大多数生物繁殖系统中,双亲都平等地贡献基因进行混合。但是为了这个比拟的目的,我们假设只有一个亲人。

一旦孩子出现,他或她就从亲人那里分离出来。这个孩子受其亲人的继承因素的严重影响,但是独一无二。如果这个孩子拥有红色头发(玛莲妮亚?),这并不意味着他的亲人的头发曾经是红色,或者自动变成红色。

以相似的方式,一旦一个子类被定义,它就分离且区别于父类。子类含有一份从父类那里得来的行为的初始拷贝,但它可以覆盖这些继承的行为,甚至是定义新行为。

重要的是,要记住我们是在讨论父和子,而不是物理上的东西。这就是这个亲子比拟让人糊涂的地方,因此我们实际上应当说父类就是亲人的 DNA,而子类就是孩子的 DNA。我们不得不从两套 DNA 制造出(也就是“初始化”)人,用得到的物理上存在的人来与之进行谈话。

让我们把生物学上的亲子放到一边,通过一个稍稍不同的角度来看看继承:不同种类的载具。这是用来理解继承的最经典(也是争议不断)的比拟。

javascript
class Vehicle {
  engines = 1
  ignition() {
    output('Turning on my engine.')
  }
  drive() {
    ignition()
    output('Steering and moving forward!')
  }
}

class Car inherits Vehicle{
  wheels = 4
  drive() {
    inherited:drive()
    output('Rolling on all ', wheels, ' wheels!')
  }
}

class SpeedBoat inherits Vehicle{
  engines = 2
  ignition() {
    output('Turning on my ', engines, ' engines.')
  }
  pilot() {
    inherited:drive()
    output('Speeding through the water with ease!')
  }
}

为了简洁,类的构造器被省略了(我也不知道这是啥语言,啥语法)

我们定义Vehicle类,假定它有一个引擎,有一个打开打火器的方法,和一个行驶的方法。但你永远也不会制造一个泛化的“载具”,所以在这里它只是一个概念的抽象。

然后定义了两种具体的载具:CarSpeedBoat。他们都继承Vehicle的泛化性质,但之后它们都对这些性质进行了恰当的特化。一辆车有 4 个轮子,一艘快艇有两个引擎,意味着它需要在打火时特别注意要启动两个引擎。

多态(polymorphism)

Car定义了自己的drive()方法,它覆盖了从Vehicle继承来的同名方法。但是Cardrive()方法调用了inherited:drive(),这表示Car 可以引用它继承的,覆盖之前原版drive()SpeedBoatpilot()方法也引用了它继承的drive()拷贝。

这种技术称为“多态(polymorphism)”,或“虚拟多态(virtual polymorphism)”。对我们当前的情况更加具体一些,我们称之为“相对多态(relative polymorphism)”

多态这个话题比我们可以在这里谈到的的内容要宽泛得多,但是我们当前的“相对”意味着一个特殊层面:任何方法都可以引用位于继承层级上更高一层的其他(同名或不同名)方法。我们说“相对”,因为我们不绝对定义我们想访问的哪一层(也就是类),而实质上用“向上一层”来相对地引用。

在许多语言中,在这个例子中出现inherited:的地方使用了super关键字,它基于这样的想法:一个“超类(super class)”是当前类的父级/祖先。

多态的另一个方面是,一个方法名可以在继承链的不同层级上有多种定义,而且在解析哪个方法在被调用时,这些定义可以适当地被自动选择。

如上例我们看到这种行为发生了两次:drive()VehicleCar中定义,而ignition()VehicleSpeedBoat中定义。

NOTE

另一个传统面向类语言通过super给你的能力,是从子类的构造器中直接访问父类构造器。这很大程度上是对的,因为对真正的类来说,构造器属于这个类。然而在 JS 中,这是相反的 —— 实际上认为“类”属于构造器(Foo.prototype...类型引用)更恰当。因为在 JS 中,父子关系仅存在于它们各自的构造器的两个.prototype对象间,构造器本身不直接关联,而且没有简单的方法从一个中相对引用另一个。

可以从ignition()中具体看出多态的一个有趣的含义。在pilot()内部,一个相对多态引用指向了(被继承的)Vehicle版本的drive()。而这个drive()仅仅通过名称(不是相对引用)来引用ignition()方法。

语言的引擎会使用哪一个版本的ignition()?是Vehicle的还是SpeedBoat的?它会使用SpeedBoat版本的ignition()。如果你初始化Vehicle类本身,并且调用它的drive(),那么语言引擎将会使用Vehicleignition()定义。

换句话说,ignition()方法的定义,根据你引用的实例是哪个类(继承层级)而多态(改变)。

这看起来过于深入学术细节了。不过为了好好地与 JS 的[[Prototype]]机制的类似行为进行对比,理解这些细节还是很重要的。

如果类是继承而来的,对这些类本身(不是由它们创建的对象!)有一个方法可以相对地引用它们继承的对象,这个相对引用通常称为super

记得刚才这幅图:

instantiate by copying

注意对于实例化(a1a2b1b2继承(Bar),箭头如何表示拷贝操作。

从概念上讲,看起来子类Bar可以使用相对多态引用(也就是super)来访问它父类Foo的行为。然而在现实中,子类不过是被给予了一份它从父类继承来的行为的拷贝而已。如果子类“覆盖”一个它继承的方法,原版的方法和覆盖版的方法实际上都是存在的,所以它们都是可以访问的。

不要让多态把你搞糊涂,使你认为子类是链接到父类上的。子类得到一份它需要从父类继承的东西的拷贝。类继承意味着拷贝

多重继承(multiple inheritance)

有些面向类的语言允许你指定一个以上的“父类”来进行“继承”。多重继承意味着每个父类的定义都被拷贝到子类中。

表面上看来,这是对面向类的一个强大的加成,给我们能力去将更多功能组合在一起。然而,这无疑会产生一些复杂的问题。如果两个父类都提供了drive()的方法,在子类的drive()引用将会解析为哪个版本?你会总是不得不手动指明哪个父类的drive()是你想要的,从而失去一些多态继承的优雅之处吗?

还有另外一个所谓的“钻石问题”:子类“D”继承自两个父类(“B”和“C”),它们两个又继承自共通的父类“A”。如果“A”提供了方法drive(),而“B” 和“C”都覆盖(多态地)了这个方法,那么当“D”引用drive()时,它应当使用哪个版本呢?(B:drive()还是C:drive()

The Diamond Issue

事情会比我们这样窥豹一斑能看到的复杂得多。我们在这里将它们提出来,只是便于我们可以将它和 JS 机制的工作方式比较。

JS 更简单:它不为“多重继承”提供原生机制。许多人认为这是件好事,因为省去的复杂性要比“减少”的功能多得多。但是这并不能阻挡开发者们用各种方法模拟它。

混合(mixin)

当你“继承”或是“实例化”时,JS 的对象机制不会自动地执行拷贝行为。很简单,在 JS 中没有“类”可以拿来实例化,只有对象,而且对象也不会被拷贝到另一个对象中,而是被链接在一起

因为在其他语言中观察到的类的行为意味着拷贝,让我们看看 JS 开发者们如何在 JS 中模拟这种缺失的类的拷贝行为:mixin(混合)。我们会看到两种“mixin”:明确的(explicit)隐含的(implicit)

明确的 mixin(explicit mixins)

让我们再次回顾前面的VehicleCar的例子。因为 JS 不会自动地将行为从Vehicle拷贝到Car,我们可以建造一个工具来手动拷贝。这样的工具经常被许多库/框架称为extend(..),但为了便于说明,我们且称之为mixin(..)

javascript
// 大幅简化的`mixin(..)示例`:
function mixin(sourceObj, targetObj) {
  for (let key in sourceObj) {
    // 仅拷贝非既存内容
    if (!(key in targetObj)) {
      targetObj[key] = sourceObj[key]
    }
  }
  return targetObj
}

const Vehicle = {
  engines: 1,
  ignition: function () {
    console.log('Turning on my engine.')
  },
  drive: function () {
    this.ignition()
    console.log('Steering and moving forward!')
  },
}

const Car = mixin(Vehicle, {
  wheels: 4,
  drive: function () {
    Vehicle.drive.call(this)
    console.log('Rolling on all ', this.wheels, ' wheels!')
  },
})

NOTE

重要的细节:我们谈论的不再是类,因为 JS 中没有类。VehicleCar分别只是我们实施拷贝的源和目标对象。

Car现在拥有了一份从Vehicle得到的属性和函数的拷贝。技术上讲,函数实际上没有被复制,而是指向函数的引用被复制了。所以,Car现在有一个称为ignition的属性,它是一个ignition()函数引用的拷贝;而且它还有一个称为engines的属性,持有从Vehicle拷贝来的值1

Car已经有了drive属性(函数),所以这个属性引用没有覆盖。

重温“多态(polymorphism)”

我们来考察一下这个语句:Vehicle.drive.call(this)。我们将称之为“显式假想多态(explicit pseudo-polymorphism)”。回想一下,我们前一段假想代码的这一行是我们称之为“相对多态(relative polymorphism)”的inherited:drive()

JS 没有能力实现相对多态(ES6 之前)。所以,因为CarVehicle都有一个名为drive()的函数,为了在它们之间区别调用,我们必须使用绝对(不是相对)引用。我们明确地用名称指出Vehicle对象,然后在它上面调用drive()

但如果我们说Vehicle.drive(),那么这个函数调用的this绑定将会是Vehicle对象,而不是Car对象,那不是我们想要的。所以,我们使用call(this)来保证drive()Car对象的环境中被执行。

NOTE

如果Car.drive()的函数名称标识符没有与Vehicle.drive()的重叠(也就是“遮蔽(shadowed)”),我们不会有机会演示“方法多态(method polymorphism)”。因为那样的话,一个指向Vehicle.drive()的引用会被mixin(..)调用拷贝,而我们可以使用this.drive()直接访问它。被选用的标识符重叠遮蔽就是为什么我们不得不使用更复杂的显式假想多态(explicit pseudo-polymorphism) 的原因。

在拥有相对多态的面向类的语言中,CarVehicle之间的连接在类定义的顶端被建立一次,那里是维护这种关系的唯一场所。

但由于 JS 的特殊性,显式假想多态(因为遮蔽!)在每一个你需要这种(假想)多态引用的函数中建立了一种脆弱的手动/显式链接。这可能会显著地增加维护成本。而且,虽然显式假想多态可以模拟“多重继承”的行为,但这只会增加复杂性和代码脆弱性。

这种方法的结果通常是更加复杂,更难读懂,而且更难维护的代码。应当尽可能地避免使用显式假想多态,因为在大部分层面上它的代价要高于利益。

混合拷贝(mixing copies)

回忆一下上边的mixin(..)工具:

javascript
// 大幅简化的`mixin()`示例:
function mixin(sourceObj, targetObj) {
  for (let key in sourceObj) {
    if (!(key in targetObj)) {
      targetObj[key] = sourceObj[key]
    }
  }
  return targetObj
}

现在,我们考察一下mixin(..)如何工作。它迭代sourceObj(在我们的例子中是Vehicle)的所有属性,如果在targetObj(在我们的例子中是Car)中没有名称与之匹配的属性,它就进行拷贝。因为我们是在初始对象存在的情况下进行拷贝,所以我们要小心不要将目标属性覆盖掉。

如果在指明Car的具体内容之前,我们先进行拷贝,那么我们就可以省略对targetObj检查,但是这样做有些笨拙且低效,所以通常不优先选用:

javascript
// 另一种mixin,对覆盖不太“安全”
function mixin(sourceObj, targetObj) {
  for (let key in sourceObj) {
    targetObj[key] = sourceObj[key]
  }
  return targetObj
}
const Vehicle = {
  // ...
}
// 首先,创建一个空对象,将整个`Vehicle`拷贝到其中
const Car = mixin(Vehicle, {})
// 然后,将`Car`的特定内容添加到`Car`中
mixin(
  {
    wheels: 4,
    drive: function () {
      // ...
    },
  },
  Car
)

不论哪种方法,我们都明确地将Vehicle中的非重叠内容拷贝到Car中。“mixin”这个名称来自于解释这个任务的另一种方法:Car混入Vehicle的内容,就像你把巧克力碎片混入你最喜欢的曲奇饼面团。

这个拷贝操作的结果,是Car将会独立于Vehicle运行。如果你在Car上添加属性,他不会影响到Vehicle,反之亦然。

NOTE

这里有几个小细节被忽略了。仍然有一些微妙的方法使两个对象在拷贝完成后还能互相“影响”对方,比如它们共享一个共通对象(比如数组)的引用。

由于两个对象还共享他们的共通函数的引用,这意味着即便手动将函数从一个对象拷贝(也就是混入)到另一个对象中,也不能 实际上模拟发生在面向类的语言中的从类到实例的真正的复制

JS 函数不能真正意义上地(以标准,可靠的方式)被复制,所以你最终得到的是同一个共享的函数对象(函数是对象)的被复制的引用。举例来说,如果你在一个共享的函数对象(比如ignition())上添加属性来修改它,VehicleCar都会通过这个共享的引用而受“影响”。

在 JS 中明确的 mixin 是一种不错的机制。但是它们显得言过其实。和将一个属性定义两次相比将属性从一个对象拷贝到另一个对象并不会产生多少实际的好处。而且由于我们刚才提到的函数对象引用的微妙之处,这显得尤为正确。

如果你明确地将两个或更多对象混入你的目标对象,你可以某种程度上模拟“多重继承”的行为,但是在将方法或属性从多于一个源对象那里拷贝过来时,没有直接的办法可以解决名称的冲突。有些开发者/库使用“延迟绑定(late binding)”和其他诡异的替代方法来解决问题,但从根本上讲,这些“技巧”通常得不偿失(而且低效!)。

要小心的是,仅在明确的 mixin 能够实际提高代码可读性时使用它,而如果你发现它使代码变得更难追溯,或在对象间建立了不必要或笨重的依赖性时,要避免使用这种模式。

如果正确使用 mixin 使你的问题变得比以前 困难,那么你可能应当停止使用 mixin。事实上,如果你不得不使用复杂的库/工具来处理这些细节,那么这可能标志着你正走在更困难,也许没必要的道路上。第六章中,我们将试着提取一种更简单的方法来实现我们期望的结果,同时免去这些周折。

寄生继承(parasitic inheritance)

明确的 mixin 模式的一个变种,在某种意义上是明确的而某种意义上是隐含的,称为“寄生继承(parasitic inheritance)”,它主要由 Douglas Crockford 推广的。

javascript
// “传统的 JS 类” `Vehicle`
function Vehicle() {
  this.engines = 1
}
Vehicle.prototype.ignition = function () {
  console.log('Turning on my engine.')
}
Vehicle.prototype.drive = function () {
  this.ignition()
  console.log('Steering and moving forward!')
}
// “寄生类” `Car`
function Car() {
  // 首先,`car`是一个`Vehicle`
  const car = new Vehicle()
  // 接着,我们对`car`进行定制
  car.wheels = 4
  // 保存一个`Vehicle::drive()`的引用
  const vehDrive = car.drive
  // 重写(覆盖)`Vehicle::drive()`
  car.drive = function () {
    vehDrive.call(this)
    console.log('Rolling on all ', this.wheels, ' wheels!')
  }
  return car
}

var myCar = new Car()
myCar.drive()
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 wheels!

如你所见,我们一开始从“父类”(对象)Vehicle制造了一个定义的拷贝,之后将我们的“子类”(对象)定义混入其中(按照需要保留父类的引用),最后将组合好的对象car作为子类实例传递出去。

NOTE

当我们调用new Car时,一个新对象被创建并被Carthis所引用。但是由于我们没有使用这个对象,而是返回我们自己的car对象,所以这个初始化创建的对象就被丢弃了。因此,Car()可以不用new关键字调用,就可以实现和上面代码相同的功能,而且还可以省去对象的创建和回收。

隐含的 mixin(implicit mixins)

隐含的 mixin 和前面解释的显式假想多态是紧密相关的。所以它们需要注意相同的事项。

javascript
const Something = {
  cool: function () {
    this.greeting = 'Hello World'
    this.count = this.count ? this.count + 1 : 1
  },
}
Something.cool()
Something.greeting // 'Hello World'
Something.count // 1

const Another = {
  cool: function () {
    // 隐式的将`Something`混入`Another`
    Something.cool.call(this)
  },
}

Another.cool()
Another.greeting // 'Hello World'
Another.count // 1 (不会和 `Something` 共享状态)

Something.cool.call(this)既可以在“构造器”调用中使用(最常见的情况),也可以在方法的调用中使用(如上所示),我们实质上“ 借用” 了Something.cool()函数并在Another环境下,而非Something环境下调用它(通过this绑定)。结果是,Something.cool()中进行的赋值被实施到了Another对象而非Something对象。

那么,这就是说我们将Something的行为“混入”了Another

虽然这种技术看起来有效利用了this再绑定的功能,也就是生硬地调用Something.cool.call(this),但是这种调用不能被作为相对(也更灵活的)引用,所以你应当提高警惕。一般来说,应当尽量避免使用这种结构以保持代码干净且易于维护。

复习

类是一种设计模式。许多语言提供语法来启用自然而然的面向类的软件设计。JS 也有类似的语法,但是它的行为和你在其他语言中熟悉的工作原理有很大不同

类意味着拷贝

当一个传统的类被实例化时,就发生了类的行为向实例中拷贝。当类被继承时,也发生父类的行为向子类的拷贝。

多态(在继承链的不同层级上拥有同名的不同函数)也许看起来意味着一个从子类回到父类的相对引用链接,但是它仍然只是拷贝行为的结果。

JS不会自动地(像类那样)在对象间创建拷贝。

mixin 模式常用于在某种程度上模仿类的拷贝行为,但是这通常导致显式假想多态那样(OtherObj.methodName.call(this, ...))难看而且脆弱的语法,这样的语法又常导致更难懂和更难维护的代码。

明确的 mixin 和类拷贝又不完全相同,因为对象(和函数!)仅仅是共享的引用被复制,不是对象/函数本身被复制。不注意这样的微小之处通常是各种陷阱的根源。

一般来讲,在 JS 中模拟类通常会比解决当前真正的问题埋下更多的坑。