你不懂js系列学习笔记-this与对象原型- 06

本文探讨了JavaScript中行为委托的概念及其与传统类设计模式的对比。通过具体示例展示了对象链接到其他对象(OLOO)的设计模式如何简化代码结构,提高代码可维护性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第六章: 行为委托

原文:You-Dont-Know-JS

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

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

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

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

1. 迈向面向委托的设计

1.1 类理论

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

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

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

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

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 的行为。在构建完成之后,你通常仅会与这些实例交互(而不是类),因为每个实例都拷贝了完成计划任务的所有行为。

1.2 委托理论

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

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

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

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

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 —— 面向对象)的对比,我称这种风格的代码为 “OLOO”(objects-linked-to-other-objects(链接到其他对象的对象))。所有我们 真正 关心的是,对象 XYZ 委托到对象 Task(对象 ABC 也一样)。

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

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)。

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

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

相互委托(不允许)

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

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

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

调试

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

function Foo() {}

var a1 = new Foo();

a1; // Foo {}
复制代码

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

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

考虑下面的代码:

function Foo() {}

var a1 = new Foo();

Foo.prototype.constructor = function Gotcha() {};

a1.constructor; // Gotcha(){}
a1.constructor.name; // "Gotcha"

a1; // Foo {}
复制代码

即便我们将 a1.constructor.name 合法地改变为其他的东西(“Gotcha”),Chrome 控制台依旧使用名称“Foo”。

1.3 思维模型比较

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

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

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 风格的代码 实现完全相同的功能:

var Foo = {
  init: function(who) {
    this.me = who;
  },
  identify: function() {
    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 风格代码接受了 事实:我们唯一需要真正关心的事情是 链接到其他对象的对象。

2.更简单的设计

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

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

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

注意: 我们不会再这里讲到 Promise,但我们会在以后的 你不懂 JS 系列中讲到。

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

// 父类
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);
};
复制代码
// 子类
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) {
  // "super"调用
  Controller.prototype.failure.call(this, "Login invalid: " + err);
};
复制代码
// 子类
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);
};
复制代码
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 上的行为。

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

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

去类化

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

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", "Login invalid: " + err);
  }
};
复制代码
// 链接`AuthController`委托到`LoginController`
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())就能执行我们的任务。所有我们要做的是:

AuthController.checkAuth();
复制代码

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

var controller1 = Object.create(AuthController);
var controller2 = Object.create(AuthController);
复制代码

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

第二个代码段的主要要点是,我们只拥有两个实体(LoginController and AuthController),而 不是之前的三个。

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

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

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

3. 更好的语法

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

class Foo {
  methodName() {
    /* .. */
  }
}
复制代码

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

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

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

var LoginController = {
  errors: [],
  getUser() {
    // 看,没有 `function`!
    // ...
  },
  getPassword() {
    // ...
  }
  // ...
};
复制代码

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

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

// 使用更好的字面对象语法 w/ 简约方法!
var AuthController = {
  errors: [],
  checkAuth() {
    // ...
  },
  server(url, data) {
    // ...
  }
  // ...
};

// 现在, 链接 `AuthController` 委托至 `LoginController`
Object.setPrototypeOf(AuthController, LoginController);
复制代码

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

没有词法

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

var Foo = {
  bar() {
    /*..*/
  },
  baz: function baz() {
    /*..*/
  }
};
复制代码

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

var Foo = {
  bar: function() {
    /*..*/
  },
  baz: function baz() {
    /*..*/
  }
};
复制代码

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

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

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

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

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

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

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

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(){..}

4. 自省

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

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

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 例子,我们在这里缩写一下:

function Foo() { /* .. */ }
Foo.prototype...

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

var b1 = new Bar( "b1" );
复制代码

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

// `Foo` 和 `Bar` 互相的联系
Bar.prototype instanceof Foo; // true
Object.getPrototypeOf(Bar.prototype) === Foo.prototype; // true
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 来许多开发者都倾向于它。这个术语源自一则谚语,“如果它看起来像鸭子,叫起来像鸭子,那么它一定是一只鸭子”。

例如:

if (a1.something) {
  a1.something();
}
复制代码

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

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

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

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

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

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

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

var Foo = { /* .. */ };

var Bar = Object.create( Foo );
Bar...

var b1 = Object.create( Bar );
复制代码

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

// `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(..) 来间接地查询了。

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

复习

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

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

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

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

转载于:https://juejin.im/post/5ae92732f265da0b9f4014d5

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值