Skip to content

💤

《你不知道的 JavaScript》

行为委托

在第五章中,我们详细讨论了[[Prototype]]机制,和为什么对于描述“类”或“继承”来说它是那么使人糊涂和不合适。我们一路跋涉,不仅涉及了相当繁冗的语法(是代码凌乱的.prototype),还有各种陷阱(比如使人吃惊的.constructor解析和难看的假想多态语法)。我们探索了许多人试图用抹平这些粗糙的区域而使用的各种“mixin”方法。

这时一个常见的反应是,想知道为什么这些看起来如此简单的事情这么复杂。现在我们已经拉开帷幕看到了它是多么麻烦,这并不奇怪:大多数 JS 开发者从不探究得这么深,而将这一团糟交给一个“类”包去帮他们处理。

我希望到现在你不会甘心于敷衍了事并把这样的细节丢给一个“黑盒”库。我们现在来深入讲解我们如何与应当如何以一种比类造成的困惑简单得多而且更直接的方式来考虑 JS 中对象的[[Prototype]]机制

简单地复习一下第五章的结论,[[Prototype]]机制是一种存在于一个对象上的内部链接,它指向一个其他对象。

当一个属性/方法引用在一个对象上发生,而这样的属性/方法又不存在时,这个链接就会被使用。在这种情况下,[[Prototype]]链接告诉引擎去那个被链接的对象上寻找该属性/方法。接下来,如果那个对象也不能满足查询,就沿着它的[[Prototype]]查询,如此继续。这种对象间的一系列链接构成了所谓的“原型链”。

换句话说,对于我们能在 JS 中利用的功能的实际机制来说,其重要的实质全部在于被链接到其他对象的对象

这个观点是理解本章其余部分的动机和方法的重要基础!

迈向面向委托的设计

为了将我们的思想恰当地集中在如何用最直截了当的方法使用[[Prototype]],我们必须认识到它代表一种根本上与类不同的设计模式(见第四章)。

NOTE

某些面向类的设计依然是很有效的,所以不要扔掉你知道的每一件事(扔掉大多数就行了!)。比如,封装就十分强大,而且与委托是兼容的(虽然不那么常见)。

我们需要试着将我们的思维从类/继承的设计模式转变为行为代理设计模式。如果你已经使用你在教育/工作生涯中思考类的方式做了大多数或所有的编程工作,这可能感觉不舒服或不自然。你可能需要尝试这种思维过程好几次,才能适应这种非常不同的思考方式。

我将首先带你进行一些理论练习,之后我们会一对一地看一些更实际的例子来为你自己的代码提供实践环境。

类理论

比方说我们有一个相似的任务(“XYZ”,“ABC”,等等)需要在我们的软件中建模。

使用类,你设计这个场景的方式是:定义一个泛化的父类(基类)比如Task,为所有的“ 同类”任务定义共享的行为。然后,你定义子类XYZABC,它们都继承自Task,每个都分别添加了特化的行为来处理各自的任务。

重要的是,类设计模式将鼓励你发挥继承的最大功效,当你在XYZ任务中覆盖Task的某些泛化方法的定义时,你将会想利用方法覆盖(和多态),也许会利用super来调用这个方法的泛化版本,为它添加更多的行为。你很可能会找到几个这样的地方:可以“抽象” 到父类中,并在子类中特化(覆盖)的一般化行为

这是一些关于这个场景的假想代码:

javascript
class Task {
  id
  // `Task()` 构造器
  Task(ID) {
    id = ID
  }
  outputTask() {
    output(id)
  }
}
class XYZ inherits Task{
    label
    // `XYZ()` 构造器
    XYZ(ID, Label) {
        super(ID)
        label = Label
    }
    outputTask() {
        super()
        output(label)
    }
}
class ABC inherits Task{
    // ...
}

现在,你可以初始化一个或多个XYZ子类的拷贝,并且使用这些实例来执行XYZ任务。这些实例已经同时拷贝了泛化的Task定义的行为和具体的XYZ定义的行为。类似地,ABC类的实例将拷贝Task的行为和具体的ABC的行为。在构建完成之后,你通常仅会与这些实例交互(而不是类),因为每个实例都拷贝了完成计划任务的所有行为。

委托理论

但是现在让我们试着用行为委托代替来思考同样的问题。

你将首先定义一个称为Task对象(不是一个类,也不是一个大多数 JS 开发者想让你相信的function),而且它将拥有具体的行为,这些行为包含各种任务可以使用的(读作:委托至!)工具方法。然后,对于每个任务(“XYZ”,“ABC”),你定义一个对象来持有这个特定人物的数据/行为。你链接你的特定任务对象到Task工具对象,允许它们在必要的时候可以委托到它。

基本上,你认为执行任务“XYZ”就是从两个兄弟/对等的对象,(XYZTask)中请求行为来完成它。与其通过类的拷贝将它们组合在一起,我们可以将它们保持在分离的对象中,而且可以在需要的情况下允许XYZ对象委托到Task对象。

这里是一些简单的代码,示意你如何实现它:

javascript
var Task = {
  setID: function (ID) {
    this.id = ID
  },
  outputID: function () {
    console.log(this.id)
  },
}
// 使 `XYZ` 委托至 `Task`
var XYZ = Object.create(Task)

XYZ.prepareTask = function (ID, Label) {
  this.setID(ID)
  this.label = Label
}
XYZ.outputTaskDetails = function () {
  this.outputID()
  console.log(this.label)
}

// ABC = Object.create(Task)
// ABC ... = ...

在这段代码中,TaskXYZ不是类(也不是函数),它们仅仅是对象XYZ通过Object.create()创建,来[[Prototype]]委托到Task对象。

作为与面向类(也就是,OO —— object oriented)的对比,我称这种风格的代码为 “OLOO”(object linked to other objects(链接到其他对象的对象))。所有我们真正关心的是,对象XYZ委托到对象Task(对象ABC也一样)。

在 JS 中,[[Prototype]]机制将对象链接到其他对象。无论你多么想说服自己这不是真的,JS 没有像“类”那样的抽象机制。这就像逆水行舟:你可以做到,但你选择了逆流而上,所以很明显地,你会更困难达到目的地

OLOO 风格的代码中有一些需要注意的不同:

  1. 前一个类的例子中的idlabel数据成员都是XYZ上的直接数据属性(它们都不在Task上)。一般来说,当[[Prototype]]委托引入时,你想使状态保持在委托者上XYZABC),不是在委托上(Task)。

  2. 在类的设计模式中,我们故意在父类(Task)和子类(XYZ)上采用相同的命名outputTask,以至于我们可以利用覆盖(多态)。在委托的行为中,我们反其道而行之:我们尽一切可能避免在[[Prototype]]链的不同层级上给出相同的命名(称为 “遮蔽” —— 见第五章),因为这些命名冲突会导致尴尬/脆弱的语法来消除引用的歧义(见第四章),而我们想避免它。这种设计模式不那么要求那些倾向于被覆盖的泛化的方法名,而是要求针对每个对象的具体行为类型给出更具描述性的方法名。这实际上会产生更容易理解/ 维护的代码,因为方法名(不仅在定义的位置,而是扩散到其他代码中)变得更加明白(代码即文档)。

  3. this.setID(ID);位于对象XYZ的一个方法内部,它首先在XYZ上查找setID(..),但因为它不能在XYZ上找到这个名称的方法,[[Prototype]]委托意味着它可以沿着链接到Task来寻找setID(),这样当然就能找到了。另外,由于调用点的隐含this绑定规则(见第二章),当setID()运行时,即便方法是在Task上找到的,这个函数调用的this绑定依然是我们期望和想要的XYZ。我们在代码稍后的this.outputID()中也看到了同样的事情。换句话说,我们可以使用存在于Task上的泛化工具与XYZ互动,因为XYZ可以委托至Task

行为委托意味着:在某个对象(XYZ)的属性或方法没能在这个对象(XYZ)上找到时,让这个对象(XYZ)为属性和方法引用提供一个委托(Task)。

这是一个极其强大的设计模式,与父类和子类,继承,多态等有很大的不同。与其在你的思维中纵向地,从上面父类到下面子类地组织对象,你应当并列的,对等地考虑对象,而且对象间拥有方向性的委托链接。

NOTE

委托更适于作为内部实现的细节,而不是直接暴露在 API 接口的设计中。在上面的例子中,我们的 API 设计没必要有意地让开发者调用XYZ.setID(..) (当然我们可以!)。我们以某种隐藏的方式将委托作为我们 API 的内部细节,即XYZ.prepareTask(..)委托到Task.setID(..)。详细的内容,参照第五章的“链接作为候补?”中的讨论。

相互委托(不允许)

你不能在两个或多个对象间相互地委托(双向的)对方来创建一个循环。如果你使B链接到A,然后试着让A链接到B,那么你将得到一个错误。

这样的事情不被允许有些可惜(不是非常令人惊讶,但稍稍有些恼人)。如果你制造一个在任意一方都不存在的属性/方法引用,你就会在[[Prototype]]上得到一个无限递归的循环。但如果所有的引用都严格存在,那么B就可以委托至A,或相反,而且它可以工作。这意味着你可以为了多种任务用这两个对象相互委托至对方。有一些情况这可能会有用。

但它不被允许是因为引擎的实现者发现,在设置时检查(并拒绝!)无限循环引用一次,要比每次你在一个对象上查询属性时都做相同检查的性能要高。

调试

我们将简单地讨论一个可能困扰开发者的微妙的细节。一般来说,JS 语言规范不会控制浏览器开发者工具如何向开发者表示指定的值/结构,所以每种浏览器/引擎都自由地按需要解释这个事情。因此,浏览器/工具不总是意见统一。特别地,我们在要考察的行为就是在当前仅在 Chrome 的开发者工具中观察到的。

考虑到这段传统的“类构造器”风格的 JS 代码,正如它将在 Chrome 开发者工具控制台中出现的:

javascript
function Foo() {}
var a = new Foo()
a // Foo {}

让我们看一下这个代码段最后一行:对表达式a进行求知的输出,打印foo{}。如果你在 FireFox 中试用同样的代码,你很可能会看到Object {}。为什么会有不同?这些输出意味着什么?

Chrome 实质上在说“{}是一个由名为‘Foo’的函数创建的空对象”。FireFox 在说:“{}是一个由 Object 普通构建的空对象”。这种微妙的区别是因为 Chrome 在像一个内部属性一样,动态跟踪执行创建的实际方法的名称,而其他浏览器不会跟踪这样的附加信息。

试图用 JS 机制来解释它很吸引人:

javascript
function Foo() {}
var a = new Foo()
a.constructor // Foo(){}
a.constructor.name // "Foo"

那么,Chrome 就是通过简单地查看对象的.constructor.name来输出“Foo”的?令人费解的是,答案既是“是”也是“不”。

考虑下面的代码:

javascript
function Foo() {}
var a = new Foo()
Foo.prototype.constructor = function GotCha() {}
a.constructor // GotCha(){}
a.constructor.name // “GotCha”

a // Foo{}

即便我们将a.constructor.name合法地改变为其他的东西(“GotCha”),Chrome 控制台依旧使用名称“Foo”。那么,说明前面问题(它使用.constructor.name吗?)的答案是,它一定在内部追踪其他的什么东西。

但是,且慢!让我们看看这种行为如何与 OLOO 风格的代码一起工作:

javascript
var Foo = {}
var a = Object.create(Foo)
a // Object {}

Object.defineProperty(Foo, 'constructor', {
  enumerable: false,
  value: function GotCha() {},
})
a // GotCha {}

啊哈!GoaCha,Chrome 的控制台确实寻找并使用了.constructor.name。实际上,就在写这本书的时候,这个行为被认定为是 Chrome 的一个 Bug,而且就在你读到这里的时候,它可能已经被修复了。所以你可能已经看到了被修改过的a // Object{}

这个 Bug 暂且不论,Chrome 执行的(刚刚在代码段中展示的)“构造器名称”内部追踪(目前仅用于调试输出的目的),是一个仅在 Chrome 内部存在的扩张行为,它已经超出了 JS 语言规范要求的范围。

如果你不使用“构造器”来制造你的对象,就像我们在本章的 OLOO 风格代码中不鼓励的那样,那么你将会得到一个 Chrome 不会为其追踪内部“构造器名称”的对象,所以这样的对象将正确地仅仅被输出“Object{}”,意味着“从 Object()构建生成的对象”。

不要认为这代表一个 OLOO 风格代码的缺点,当你用 OLOO 编码而且用行为委托作为你的设计模式时,谁“创建了”(也就是,哪个函数被和new一起调用了?)一些对象是一个无关的细节。Chrome 特殊的内部“构造器名称”追踪仅仅在你完全接受“类风格”编码时才有用,而在你接受 OLOO 委托时是没有意义的。

思维模型比较

现在你至少在理论上可以看到“类”和“委托”设计模式的不同了,让我们看看这些设计模式在我们用来推导我们代码的思维模型上的含义。

我们将查看一些更加理论上的(“Foo”,“Bar”)代码,然后比较两种方法(OO vs. OLOO)的代码实现。第一段代码使用经典的(“原型的”) OO 风格:

javascript
function Foo(who) {
  this.me = who
}
Foo.prototype.identify = function () {
  return 'I am ' + this.me
}

function Bar(who) {
  Foo.call(this, who)
}
Bar.prototype = Object.create(Foo.prototype)
Bar.prototype.speak = function () {
  alert('Hello, ' + this.identify() + '.')
}

var b1 = new Bar('b1')
var b2 = new Bar('b2')

b1.speak()
b2.speak()

父类Foo,被子类Bar继承,之后Bar被初始化两次:b1b2。我们得到的是b1委托至Bar.prototypeBar.prototype委托至Foo.prototype。这对你来说应当看起来十分熟悉。没有太具开拓性的东西发生。

现在,让我们使用 OLOO 风格的代码实现完全相同的功能

javascript
var Foo = {
  init(who) {
    this.me = who
  },
  identity() {
    return 'I am' + this.me
  },
}

var Bar = Object.create(Foo)

Bar.speak = function () {
  alert('Hello,' + this.identify() + '.')
}

var b1 = Object.create(Bar)
b1.init('b1')
var b2 = Object.create(Bar)
b2.init('b2')

b1.speak()
b2.speak()

我们利用了完全相同的从BarFoo[[Prototype]]委托,正如我们在前一个代码段中b1Bar.prototype,和Foo.prototype之间那样,我们仍然有三个对象链接在一起

但重要的是,我们极大地简化了发生的所有其他事项,因为我们现在仅仅建立了相互链接的对象,而不需要所有其他讨厌且困惑的看起来像类(但动起来不像)的东西,还有构造器,原型和new调用。

问问你自己:如果我能用 OLOO 风格代码得到我用“类”风格代码得到的一样的东西,但 OLOO 更简单而且需要考虑的事情更少,OLOO 不是更好吗?

让我们讲解一下这两个代码段见设计的思维模型。

首先,类风格的代码段意味着这样的实体与它们的关系的思维模型:

OO-full

实际上,这有点儿不公平/误导,因为它展示了许多额外的,你在技术上一直不需要知道(虽然你需要理解它)的细节。一个关键是,它是一系列十分复杂的关系。但另一个关键是:如果你花时间来沿着这些关系的箭头走,在 JS 的机制中有数量惊人的内部统一性

例如,JS 函数可以访问call(..)apply(..)bind(..)(见第二章)的能力是因为函数本身就是对象,而函数对象还拥有一个[[Prototype]]链接,链到Function.prototype对象,它定义了那些任何函数对象都可以委托到的默认方法。JS 可以做这些事情,你也能!

好了,现在让我们看一个这张图的稍稍简化的版本,用它来进行比较稍微“公平”一点 —— 它仅展示了相关的实体与关系。

OO-simplify

仍然非常复杂,对吧?虚线描绘了当你在Foo.prototypeBar.prototype间建立“继承”时的隐含关系,而且还没有修复 丢失的.constructor属性引用(见第五章“复活构造器”)。即便将虚线去掉,每次你与对象链接打交道时,这个思维模型依然要变很多可怕的戏法。

现在,让我们看看 OLOO 风格代码的思维模型:

OLOO

正如你比较它们所得到的,十分明显,OLOO 风格的代码需要关心的东西少太多了,因为 OLOO 风格代码接受了事实:我们唯一需要真正关心的事情是连接到其他对象的对象

所有其他“类”的烂设计用一种令人费解而且复杂的方式得到相同的结果。去掉那些东西,事情就变得简单得多(还不会失去任何功能)。

Classes vs. Objects

我们已经看到了各种理论的探索和“类”与“行为委托”的思维模型的比较。现在让我们来看看更具体的代码场景,来展示你如何实时应用这些想法。

我们将首先讲解一种在前端网页开发中的典型场景:建造 UI 部件(按钮,下拉列表等等)。

widget “类”

因为你可能还不是如此地习惯于 OO 设计模式,你很可能会立即考虑这个问题:一个父类(也许称为Widget)拥有所有共同的基本部件行为,然后衍生的子类拥有具体的部件类型(比如Button)。

NOTE

为了 DOM 和 CSS 的操作,我们将在这里使用 JQuery,这仅仅是因为对于我们现在的讨论,它不是一个我们真正关心的细节。这些代码中你不关心你用哪个 JS 框架(JQuery,Dojo,YUI 等等)来解决如此无趣的问题。

让我们来看看,在没有任何“类”帮助库或语法的情况下,我们如何使用经典风格 JS 来实现“类”设计:

javascript
// 父类
function Widget(width, height) {
  this.width = width || 50
  this.height = height || 50
  this.$elem = null
}
Widget.prototype.render = function ($where) {
  if (this.$elem) {
    this.$elem
      .css({
        width: this.width + 'px',
        height: this.height + 'px',
      })
      .appendTo($where)
  }
}

// 子类
function Button(width, height, label) {
  Widget.call(this, width, height)
  this.label = label || 'Default'

  this.$elem = $('<button>').text(this.label)
}

// 使 `Button` 继承 `Widget`
Button.prototype = Object.create(Widget.prototype)

// 覆盖“继承来的” render(..)
Button.prototype.render = function ($where) {
  // "supper"调用
  Widget.prototype.render.call(this, $where)
  this.$elem.click(this.onClick.bind(this))
}

Button.prototype.onClick = function (evt) {
  console.log("Button '" + this.label + "' clicked!")
}

$(document).ready(function () {
  var $body = $(document.body)
  var btn1 = new Button(125, 30, 'Hello')
  var btn2 = new Button(150, 40, 'World')

  btn1.render($body)
  btn2.render($body)
})

OO 设计模式告诉我们要在父类中声明一个基础render(..)。之后在我们的子类中覆盖它,但不是完全替代它,而是用按钮特定的行为增强这个基础功能。

NOTE

显式假象多态的丑态,Widget.callWidget.prototype.render.call引用是为了伪装从“子类”方法得到“父类”基础方法支持的“super”调用。呃。

ES6 class语法糖

我们会在附录 A 中讲解 ES6 的class语法糖,但是让我们演示一下我们如何用class来实现相同的代码。

javascript
class Widget {
  constructor(width = 50, height = 50) {
    this.width = width
    this.height = height
    this.$elem = null
  }
  render($where) {
    if (this.$elem) {
      this.$elem
        .css({
          width: this.width + 'px',
          height: this.height + 'px',
        })
        .appendTo($where)
    }
  }
}

class Button extends Widget {
  constructor(width, height, label = 'Default') {
    super(width, height)
    this.label = label
    this.$elem = $('<button>').text(this.label)
  }
  render($where) {
    super.render($where)
    this.$elem.click(this.onClick.bind(this))
  }
  onClick(evt) {
    console.log("Button '" + this.label + "' clicked!")
  }
}

$(document).ready(function () {
  var $body = $(document.body)
  var btn1 = new Button(125, 30, 'Hello')
  var btn2 = new Button(150, 40, 'World')

  btn1.render($body)
  btn2.render($body)
})

毋庸置疑,通过使用 ES6 的class,许多前面经典方法中难看的语法被改善了。super(..)的存在看起来非常适宜(但当你深入挖掘它时,不全是好事!)

除了语法上的改进,这些都不是真正的,因为它们仍然工作在[[Prototype]]机制之上。它们仍然会受到思维模型不匹配的拖累,就像我们在第四,第五章中,和直到现在探索的那样。附录 A 将会详细讲解 ES6class语法和它的含义。我们将会看到为什么解决语法上的小问题不会实质上解决我们在 JS 中的类的困惑,虽然它做出了勇敢的努力假装解决了问题!

无论你是使用经典的原型语法还是新的 ES6 语法糖,你依然选择了使用“类”来对问题(UI 部件)进行建模。正如我们前面几章试着展示的,在 JS 中做这个选择会带给你额外的头疼和思维上的弯路。

委托Widget对象

这是我们更简单的Widget/Button例子,使用了OLOO风格委托:

javascript
var Widget = {
  init: function (width, height) {
    this.width = width || 50
    this.height = height || 50
    this.$elem = null
  },
  insert: function ($where) {
    if (this.$elem) {
      this.$elem
        .css({
          width: this.width + 'px',
          height: this.height + 'px',
        })
        .appendTo($where)
    }
  },
}

var Button = Object.create(Widget)

Button.setup = function (width, height, label) {
  // 委托调用
  this.init(width, height)
  this.label = label || 'Default'
  this.$elem = $('<button>').text(this.label)
}
Button.build = function ($where) {
  // delegated call
  this.insert($where)
  this.$elem.click(this.onClick.bind(this))
}
Button.onClick = function (evt) {
  console.log("Button '" + this.label + "' clicked!")
}

$(document).ready(function () {
  var $body = $(document.body)
  var btn1 = Object.create(Button)
  btn1.setup(125, 30, 'Hello')

  var btn2 = Object.create(Button)
  btn2.setup(150, 40, 'World')

  btn1.build($body)
  btn2.build($body)
})

使用这种 OLOO 风格的方法,我们不认为Widget是一个父类而Button是一个子类,Widget只是一个对象和某种具体类型的部件也许想要的代理到的工具的合集,而且Button也只是一个独立的对象(当然,带有委托至Widget的链接!)

从设计模式的角度来看,我们没有像类的方法建议的那样,在两个对象中共享相同的render(..)方法名称,而是选择了更能描述每个特定任务的不同的名称。同样的原因,初始化方法被称为init(..)setup(..)

不仅委托设计模式建议使用不同而且更具描述性的名称,而且在 OLOO 中这样做会避免难看的显式假象多态调用,正如你可以通过简单,相对的this.init(..)this.insert(..)委托调用看到的。

语法上,我们也没有任何构造器,.prototype或者new出现,它们事实上是不必要的设计。

现在,如果你再细心考察一下,你可能会注意到之前仅有一个调用(var btn1 = new Button(..)),而现在有了两个(var btn1 = Object.create(Button)btn1.setup(..))。这猛地看起来像是一个缺点(代码变多了)。

然而,即便是这样的事情,和经典原型风格比起来也是 OLOO 风格代码的优点。为什么?

用类的构造器,你“强制”(不完全是这样,但是被强烈建议)构建和初始化在同一个步骤中进行。然而,有许多种情况,能够将这两步分开做(就像你在 OLOO 中做的)更灵活。

举个例子,我们假定你在程序的最开始,在一个池中创建所有的实例,但你等到在它们被从池中找到并使用之前再用指定的设置初始化它们。我们的例子中,这两个调用紧挨在一起,当然它们也可以按需要发生在非常不同的时间和代码中非常不同的部分。

OLOO 对关注点分离原则有更好的支持,也就是创建和初始化没有必要合并在同一个操作中。

更简单的设计

OLOO 除了提供上面的更简单(而且更灵活!)的代码之外,行为委托作为一个模式实际上会带来更简单的代码架构。让我们讲解最后一个例子来说明 OLOO 是如何简化你的整体设计的。

这个场景中我们将讲解两个控制器对象,一个用来处理网页的登录 form(表单),另一个实际处理服务器的认证(通信)。

我们需要帮助工具来进行与服务器的 Ajax 通信。我们将使用 JQuery(虽然其他的框架都可以),因为它不仅为我们处理 Ajax,而且还返回一个类似 Promise 的应答,这样我们就可以在代码中使用.then(..)来监听这个应答。

NOTE

我们不会在这里讲到 Promise,但我们会在以后的你不知道的 JS系列中讲到。

根据典型的类的设计模式,我们在一个叫做Controller的类中将任务分解为基本功能,之后我们会衍生出两个子类,LoginControllerAuthController,它们都继承自Controller而且特化某些行为。

javascript
// 父类
function Controller() {
  this.errors = []
}
Controller.prototype.showDialog = function (title, msg) {
  // 细节
}
Controller.prototype.success = function (msg) {
  this.showDialog('Success', msg)
}
Controller.prototype.failure = function (err) {
  this.errors.push(err)
  this.showDialog('Error', err)
}
javascript
//子类
function LoginController() {
  Controller.call(this)
}
// 将子类链接到父类
LoginController.prototype = Object.create(Controller.prototype)
LoginController.prototype.getUser = function () {
  return document.getElementById('Login_username').value
}
LoginController.prototype.getPassword = function () {
  return document.getElementById('Login_password').value
}
LoginController.prototype.validateEntry = function (user, pw) {
  user = user || this.getUser()
  pw = pw || this.getPassword()
  if (!(user && pw)) {
    return this.failure('Please enter a username & password!')
  } else if (pw.length < 5) {
    return this.failure('Password must be 5+ characters!')
  }
  return true
}
// 覆盖来扩展基本的`failure()`
LoginController.prototype.failure = function (err) {
  Controller.prototype.failure.call(this, 'Login invalid:' + err)
}
javascript
// 子类
function AuthController(login) {
  Controller.call(this)
  // 除了继承外,我们还需要合成
  this.login = login
}
// 将子类链接到父类
AuthController.prototype = Object.create(Controller.prototype)
AuthController.prototype.server = function (url, data) {
  return $.ajax({
    url: url,
    data: data,
  })
}
AuthController.prototype.checkAuth = function () {
  var user = this.login.getUser()
  var pw = this.login.getPassword()
  if (this.login.validateEntry(user, pw)) {
    this.server('/check-auth', {
      user: user,
      pw: pw,
    })
      .then(this.success.bind(this))
      .fail(this.failure.bind(this))
  }
}
// 覆盖以扩展基本的`success()`
AuthController.prototype.success = function () {
  // "super"调用
  Controller.prototype.success.call(this, 'Authenticated!')
}
// 覆盖以扩展基本的`failure()`
AuthController.prototype.failure = function (err) {
  // "super"调用
  Controller.prototype.failure.call(this, 'Auth Failed:' + err)
}
javascript
var auth = new AuthController(new LoginController())
auth.checkAuth()

我们有所有控制器分享的基本行为,它们是success(..)failure(..)showDialog(..)。我们的子类LoginControllerAuthController覆盖了failure(..)success(..)来增强基本类的行为。还要注意的是,AuthController需要一个LoginController实例来与登录 form 互动,所以它变成了一个数据属性成员。

另外一件要提的事情是,我们选择一些合成散布在继承的顶端。AuthController需要知道LoginController,所以我们初始化它(new loginController()),并用一个称为this.login的类属性成员来引用它,这样AuthController才可以调用LoginController上的行为。

NOTE

这里可能会存在一丝冲动,就是使AuthController继承LoginController,或者反过来,这样的话我们就会通过继承链得到虚拟合成。但是这是一个非常清晰的例子,表明对这个问题来讲,将类继承作为模型有什么问题,因此AuthControllerLoginController都不特化对方的行为,所以它们之间的继承没有太大的意义,除非类是你唯一的设计模式。与此相反的是,我们在一些简单的合成中分层,然后它们就可以合作了,同时它俩都享有继承自父类Controller的好处。

如果你熟悉面向类(OO)的设计,这都应该看起来十分熟悉和自然。

去类化

但是,我们真的需要用一个父类,两个字类,和一些合成来对这个问题建立模型吗?有办法利用 OLOO 风格的委托得到简单得多的设计吗?有的!

javascript
var LoginController = {
  errors: [],
  getUser: function () {
    return document.getElementById('Login_username').value
  },
  getPassword: function () {
    return document.getElementById('Login_password').value
  },
  validateEntry: function (user, pw) {
    user = user || this.getUser()
    pw = pw || this.getPassword()
    if (!(user && pw)) {
      return this.failure('Please enter a username & password!')
    } else if (pw.length < 5) {
      return this.failure('Password must be 5+ characters!')
    }
    return true
  },
  showDialog: function (title, msg) {
    /*细节*/
  },
  failure: function (err) {
    this.errors.push(err)
    this.showDialog('Error', err)
  },
}
javascript
var AuthController = Object.create(LoginController)

AuthController.errors = []
AuthController.checkAuth = function () {
  var user = this.getUser()
  var pw = this.getPassword()

  if (this.validateEntry(user, pw)) {
    this.server('/check-auth', {
      user: user,
      pw: pw,
    })
      .then(this.accepted.bind(this))
      .fail(this.rejected.bind(this))
  }
}
AuthController.server = function (url, data) {
  return $.ajax({
    url: url,
    data: data,
  })
}
AuthController.accepted = function () {
  this.showDialog('Success', 'Authenticated!')
}
AuthController.rejected = function (err) {
  this.failure('Auth Failed:' + err)
}

因为AuthController只是一个对象(LoginController也是),我们不需要初始化(比如new AuthController())就能执行我们的任务。所有我们要做的是:

javascript
AuthController.checkAuth()

当然,通过 OLOO,如果你确实需要在委托链上创建一个或多个附加的对象时也很容易,而且仍然不需要任何像类实例化那样的东西:

javascript
var controller1 = Object.create(AuthController)
var controller2 = Object.create(AuthController)

使用行为委托,AuthControllerLoginController仅仅是对象,互相水平对等的,而且没有被安排或关联成面向类中的父与子。我们有些随意地让AuthController委托至LoginController —— 相反方向的委托也同样是有效的。

第二个代码段的重要要点是,我们只拥有两个实体(LoginControllerAuthController),而不是之前的三个

我们不需要一个基本的Controller类来在两个子类间“分享”行为,因为委托是一种可以给我们所需要功能的,足够强大的机制。同时,就像之前注意的,我们也不需要实例化我们的对象来使它们工作,因为这里没有类,只有对象自身。另外,这里不需要合成作为委托来给两个对象差异化地合作的能力。

最后,由于没有让名称success(..)failure(..)在两个对象上相同,我们避开了面向类的设计的多态陷阱:它将会需要难看的假想多态。相反,我们在AuthController上称它们为accepted(..)rejected(..) —— 对于它们的具体任务来说,稍稍更具描述性的名称。

结论:我们最终得到了相同的结果,但是用了(显著的)更简单的设计。这就是 OLOO 风格代码和行为委托设计模式的力量。

更好的语法

一个使 ES6class看似如此诱人的更好的东西是(见附录 A 来了解为什么要避开它!),声明类方法的速记语法:

javascript
class Foo {
  methodName() {}
}

我们从声明中扔掉了单词function,这使所有的 JS 开发者欢呼!

你可能已经注意到,而且为此感到沮丧:上面推荐的 OLOO 语法出现了许多function,这看起来像是对 OLOO 简化目标的诋毁。但它不是!

在 ES6 中,我们可以在任意字面对象中使用简约方法声明,所以一个 OLOO 风格的对象可以用这种方式声明(与class语法中相同的语法糖):

javascript
var LoginController = {
  errors: [],
  getUser() {}, // 看,没有`function`!
  getPassword() {},
  // ...
}

唯一的区别是字面对象的元素间仍然需要,分隔符,而class语法不必如此。这是在整件事情上很小的让步。

还有,在 ES6 中,一个你使用的更笨重的语法(比如AuthController的定义中):你一个一个地给属性赋值而不是用字面对象,可以改写为使用字面对象(于是你可以使用简约方法),而且你可以使用Object.setPrototypeOf(..)来修改[[Prototype]],像这样:

javascript
// 使用更好的字面对象语法和简写方式!
var AuthController = {
  errors: [],
  checkAuth() {},
  server(url, data) {},
  // ...
}
// 现在,链接`AuthController`委托至`LoginController`
Object.setPrototypeOf(AuthController, LoginController)

ES6 中的 OLOO 风格,与简明方法一起,变得比它以前有好的多(即便在以前,它也比经典的原型风格代码简单好看的多)。你不必非得选用类(复杂性)来得到干净漂亮的对象语法!

没有词法

简约方法确实有一个缺点,一个重要的细节。考虑这段代码:

javascript
var Foo = {
  bar() {},
  baz: function baz() {}, // 妈的,真神奇,几年了,第一次知道命名了属性名还可以再给函数一个名字(甚至函数名可以不同)
}

这是去掉语法糖后,这段代码将如何工作:

javascript
var Foo = {
  bar: function () {},
  baz: function baz() {},
}

看到区别了?bar()的速记变成了一个附着在bar属性上的匿名函数表达式function()..),因为函数对象本身没有名称标识符。和拥有词法名称标识符baz,附着在.baz属性上的手动指定的命名函数表达式function baz()..)做个比较。

那又怎么样?在“你不知道的 JS”系列的“作用域与闭包”这本书中,我们详细讲解了匿名函数表达式的三个主要缺点。我们简单地重复一下它们,以便于我们和简明方法相比较。

一个匿名函数缺少name标识符:

  1. 使调试时的栈追踪变得困难
  2. 使自引用(递归,事件绑定等)变得困难
  3. 是代码(稍微)变得难于理解

第一和第三条不适用于简明方法。

虽然去掉语法糖使用匿名函数表达式一般会使栈追踪中没有name。简明方法在语言规范中被要求去设置相应的函数对象内部的name属性,所以栈追踪应当可以使用它(这是依赖于具体实现的,所以不能保证)。

不幸的是,第二条仍然是简明方法的一个缺陷。它们不会有词法标识符用来自引用。考虑:

javascript
var Foo = {
  bar: function (x) {
    if (x < 10) {
      return Foo.bar(x * 2)
    }
    return x
  },
  baz: function baz(x) {
    if (x < 10) {
      return baz(x * 2)
    }
    return x
  },
}

在这个例子中上面的手动Foo.bar(x * 2)引用就足够了,但是在许多情况下,一个函数不一定能够这样做,比如使用this绑定,函数在委托中被分享到不同的对象,等等。你将会想要使用一个真正的自引用,而函数对象的name标识符是实现的最佳方式。

只要小心简明方法的这个注意点,而且如果当你陷入缺少自引用的问题时,仅仅为这个声明放弃简明方法语法,取代以手动的命名函数表达式声明形式:baz: function baz() {..}

自省

如果你花了很长时间在面向类的编程方式(不管是 JS 还是其他的语言)上,你可能会对类型自省很熟悉:自省一个实例来找出它是什么种类的对象。在类的实例上进行类型自省的主要目的是根据对象是如何创建的来推断它的结构/能力。

考虑这段代码,它使用instanceof(见第五章)来自省一个对象a1来推断它的能力:

javascript
function Foo() {}
Foo.prototype.something = function () {}
var a1 = new Foo()
// 稍后
if (a1 instanceof Foo) {
  a1.something()
}

因为Foo.prototype(不是Foo!)在a1[[Prototype]]链上(见第五章),instanceof操作符(使人困惑的)假装告诉我们a1是一个Foo“类”的实例。有了这个知识,我们假定a1Foo“类”中描述的能力。

当然,这里没有Foo类,只有一个普通的函数Foo,它恰好拥有一个引用指向一个随意的对象(Foo.prototype),而a1恰好委托连接至这个对象。通过它的语法,instanceof假装检查了a1Foo之间的关系,但它实际上告诉我们的是a1Foo.prototype(这个随意被引用的对象)是否有关联。

instanceof在语义上的混乱(和间接)意味着,要使用以instanceof为基础的自省来查询对象a1是否与讨论中的对象有关联,你不得不拥有一个持有对这个对象引用的函数 —— 你不能直接查询这两个对象是否有关联。

回想本章前面的抽象Foo/bar/b1例子,我们在这里缩写一下:

javascript
function Foo() {}
Foo.prototype.something = function () {}

function Bar() {}
Bar.prototype = Object.create(Foo.prototype)

var b1 = new Bar('b1')

为了在这个例子中的实体上进行类型自省,使用instanceof.prototype语义,这里有各种你可能需要实施的检查:

javascript
// `Foo`和`Bar`互相的联系
Bar.prototype instanceof Foo // true
Object.getPrototypeOf(Bar.prototype) === Foo.prototype // true
// isPrototypeOf()检查一个对象是否存在于另一个对象的原型链中。调用无关先后顺序
Foo.prototype.isPrototypeOf(Bar.prototype) // true

// `b1` 与 `Foo` 和 `Bar`的联系
b1 instanceof Foo // true
b1 instanceof Bar // true
Object.getPrototypeOf(b1) === Bar.prototype // true
Foo.prototype.isPrototypeOf(b1) // true
Bar.prototype.isPrototypeOf(b1) // true

可以说,其中有些烂透了。举个例子,直觉上(用类)你可能想说这样的东西Bar instanceof Foo(因为很容易混淆“实例”的意义认为它包含“继承”),但在 JS 中这不是一个合理的比较。你不得不说Bar.prototype instanceof Foo

另一个常见,但也许健壮性更差的类型自省模式叫做“duck typing(鸭子类型)”,比起instanceof来许多开发者都倾向于它。这个术语源自一则谚语,“如果它看起来像鸭子,叫起来像鸭子,那么它一定是一只鸭子”。

例如:

javascript
if (a1.something) {
  a1.something()
}

与其检查a1和一个持有可委托的something()函数的对象的关系,我们假设a1.something测试通过意味着a1有能力调用.something()(不管是直接在a1上直接找到方法,还是委托至其他对象)。就本身而言,这种假设没什么风险。

但是“鸭子类型”常常被扩展用于除了被测试关于对象能力以外的其他假设,这当然会在测试中引入更多风险(比如脆弱的设计)。

“鸭子类型”的另一个值得注意的例子来自于 ES6 的 Promise(就是我们前面解释过,将不会在本书内涵盖的内容)。

由于种种原因,需要判定任意一个对象引用是否是一个 Promise,但测试是通过检查对象是否恰好有then()函数出现在它上面来完成的。换句话说,如果任何对象恰好有一个then()方法,ES6 的 Promises 将会无条件地假设这个对象是“thenable”的,而且因此会期望它按照所有的 Promises 标准行为那样一致地动作。

如果你有任何非 Promise 对象,而却不管因为什么它恰好拥有then()方法,你会被强烈建议使它远离 ES6 的 Promise 机制,来避免破坏这种假设。

这个例子清楚地展现了“鸭子类型”的风险。你应当仅在可控的条件下,保守的使用这种方式。

再次将我们的注意力转向本章中出现的 OLOO 风格的代码,类型自省变得清晰多了。让我们回想(并缩写)本章的Foo/Bar/b1的 OLOO 示例:

javascript
var Foo = {}
var Bar = Object.create(Foo)
Bar.something = function () {}

var b1 = Object.create(Bar)

使用这种 OLOO 方式,我们所拥有的一切都是通过[[Prototype]]委托关联起来的普通对象,这是我们可能会用到的大幅简化后的类型自省

javascript
// `Foo` 和 `Bar` 互相的联系
Foo.isPrototypeOf(Bar) // true
Object.getPrototypeOf(Bar) === Foo // true

// `b1` 与 `Foo` 和 `Bar` 的联系
Foo.isPrototypeOf(b1) // true
Bar.isPrototypeOf(b1) // true
Object.getPrototypeOf(b1) === Bar // true

我们不再使用instanceof,因为它令人迷惑地假装与类有关系。现在,我们只需要(非正式地)问这个问题,“你是我的一个原型吗?”。不再需要用Foo.prototype或者痛苦冗长的Foo.prototype.isPrototypeOf(..)来间接地查询了。

我想可以说这些检查比起前面的一组自省检查,极大地减少了复杂性/杂乱。又一次,我们看到了在 JS 中 OLOO 要比类风格的编码简单(但有着相同的力量)

复习

在你的软件体系结构中,类和继承是你可以选用不选用的设计模式。多数开发者理所当然地认为类是组织代码的唯一(正确的)方法,但我们在这里看到了另一种不太常被提到的,但实际上十分强大的设计模式:行为委托

行为委托意味着对象彼此是对等的,在它们自己当中相互委托,而不是父类与子类的关系。JS 的[[Prototype]]机制的设计本质,就是行为委托机制。这意味着我们可以选择挣扎着在 JS 上实现类机制,也可以欣然接受[[Prototype]]作为委托机制的本性。

当你仅用对象设计代码时,它不仅能简化你使用的语法,而且它还能实际上引领更简单的代码结构设计。

OLOO(链接到其他对象的对象)是一种没有类的抽象,而直接创建和关联对象的代码风格。OLOO 十分自然地实现了基于[[Prototype]]的行为委托。