你不知道的JavaScript之行为委托
1.面向委托的设计
JavaScript中委托机制的本质就是对象之间的关联关系。
var Task = { setID: function(ID) {this.id = ID;}, outputID: function() {console.log(this.id);} }; 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); };
在这段代码中,Task和XYZ并不是类(或者函数),它们是对象。XYZ通过Object.create(..)创建,它的[[Prototype]]委托了Task对象。、
相比于面向类(或者说面向对象),我会把这种编码风格称为“对象关联”(OLOO,objects linked to other objects)。
对象关联风格的代码还有一些不同之处:
①. 在上面的代码中,id和label数据成员都是直接存储在XYZ中(而不是Task)。通常来说,在[[Prototype]]委托中最好把状态保存在委托者(XYZ)而不 是委托目标(Task)上。
②. 在类设计模式中,我们故意让父类和子类都有outputTask方法,这样就可以利用重写(多态)的优势。在委托行为中则恰好相反:我们会尽量避免在 [[Prototype]]链的不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法来消除引用歧义(显示伪多态)。
这个设计模式要求尽量少使用容易被重写的方法名,提倡使用更有描述性的方法名,尤其要写清相应对象行为的类型。这样做实际上可以创建出更容易 理解和维护的代码,因为方法名(不仅在定义的位置,而是贯穿整个代码)更加清晰(自文档)。
③. this.setID(ID);XYZ中的方法首先会寻找XYZ自身是否有setID(..),但是XYZ中并没有这个方法名,因此会通过[[Prototype]]委托关联到Task继续寻找, 这时就可以找到setID(..)方法。此外,由于调用位置出发了this的隐式绑定规则,因此虽然setID(..)方法在Task中,运行时this仍然会绑定到XYZ,这正是 我们想要的。
换句话说,我们和XYZ进行交互时可以使用Task中的通用方法,因为XYZ委托了Task。
委托行为因为这某些对象在找不到属性或者方法引用时会把这个请求委托给另一个对象。这是一种极其强大的设计模式,和父类、子类、继承、多态等概念完全不同。在你的脑海中对象并不是按照父类到子类的关系垂直组织的,而是通过任意方向的委托关联并排组织的。
在API接口的设计中,委托最好在内部实现,不要直接暴露出去。
禁止互相委托!你无法在两个或两个以上互相(双向)委托的对象之间创建循环委托。如果你把B关联到A然后试着把A关联到B,就会出错。之所以要禁止互相委托,是因为引擎的开发者们发现在设置时检查(并禁止!)一次无限循环引用要更加高效,否则每次从对象中查找属性时都需要进行检查。
通常来说,JavaScript规范并不会控制浏览器中开发者工具对于特定值或者结构的表示方式,浏览器和引擎可以自己选择合适的方式进行解析,因此浏览器和工具的解析结果并不一定相同。
当你使用对象关联风格来编写代码并使用行为委托设计模式时,并不需要关注是谁“构造了”对象(就是使用new调用的那个函数)。只有使用类风格来编写代码时Chrome内部的“构造函数名称”跟踪才有意义,使用对象关联时这个功能不起任何作用。
2.比较思维模型
比较两个不同风格的代码:
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.crreate(Foo.prototype); Bar.prototype.speak = function() { alert('Hello, ' + this.identify() + '.'); }; var b1 = new Bar('b1'); var b2 = new Bar('b2'); b1.speak(); b2.speak();
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();
与第一段代码相比,第二段代码简洁了许多,我们只是把对象关联起来,并不需要那些既复杂又令人困惑的模仿类的行为(构造函数、原型以及new)。
下面让我们看看两段代码对应的思维模型。
类风格的代码的思维模型强调实体及实体间的关系:

从图中可以看出这是一张十分复杂的关系网。此外,如果你跟着图中的箭头走就会发现,JavaScript机制有很强的内部连贯性。
举例来说,JavaScript中的函数之所以可以访问call(..)、apply(..)和bind(..),就是因为函数本身是对象,而函数对象同样有[[Prototype]]并且关联到Function.prototype对象,因此所有函数对象都可以通过委托调用这些默认方法。下面我们展示一张简化版的图,它只展示了必要的对象和关系:

仍然很复杂,是吧?虚线表示的是Bar.prototype继承Foo.prototype之后丢失的.constructor属性引用,它们还没有被修复。
现在让我们看看对象关联风格代码的思维模型:

可以看出,对象关联风格的代码显然更加简洁,因为这种代码只关注一件事:对象之间的关联关系。
其他的“类”技巧都是非常复杂并且令人疑惑的。去掉它们之后,事情会变得简单许多。
3.类与对象
看看“类”和“行为委托”在真实场景中的应用:创建UI控件(按钮、下拉列表,等等)。
“类”风格实现:
// 父类 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) { // 调用“Super”构造函数 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) { // "Super"调用,丑陋的显式伪多态 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); });
对象关联风格委托实现:
var Widget = { init: function(width, height) { this.width = width; this.height = height; 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) { 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); });
使用对象关联风格来编写代码时不需要把Widget和Button当作父类和子类。相反,Widget只是一个对象,包含一组通用的函数,任何类型的控件都可以委托,Button同样只是一个对象。(当然,它会通过委托关联到Widget!)
在委托设计模式中,除了建议使用不相同并且更具描述性的方法名之外,还要通过对象关联避免丑陋的显式伪多态调用,代之以简单的相对委托调用this.init(..)和this.insert(..)。
使用类构造函数的话,你需要(并不是硬性要求,但是强烈建议)在同一个步骤中实现构造和初始化。然而,在许多情况下把这两步分开更灵活。
对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。
4.更好的语法
在ES6中我们可以在任意对象的字面形式中使用简洁方法声明(concise method declaration),所以对象关联风格的对象可以这样声明(和class的语法糖一样):
// 使用更好的对象字面形式语法和简洁方法 var AuthController = { errors: [], checkAuth() {// 妈妈再也不用担心代码里有function了! // ... }, server(url, data) { // ... } }; // 现在把AuthController关联到LoginController Object.setPrototypeOf(AuthController, LoginController);
对象的字面形式仍然需要使用“,”来分隔元素,而class语法不需要。这个区别对于整体的设计来说无关紧要。
简洁方法有一个非常小但是非常重要的缺点。
var Foo = { bar() {}, baz: function baz() {} }; // 去掉语法糖之后的代码如下所示 var Foo = { bar: function() {}, baz: function baz() {} };
由于函数对象本身没有名称标识符,所以bar()的缩写形式(function()..)实际上会变成一个匿名函数表达式并赋值给bar属性。由于匿名函数没有name标识符,这会导致:
①调试栈更难追踪;
②自我引用(递归、事件(解除)绑定,等等)更难;
③代码(稍微)更难理解。
简洁方法没有第1和第3个缺点。
去掉语法糖的版本使用的是匿名函数表达式,通常来说并不会在追踪栈中添加name,但是简洁方法很特殊,会给对应的函数对象设置一个内部的name属性,这样理论上可以用在追踪栈中。(但是追踪的具体实现是不同的,因此无法保证可以使用。)
很不幸,简洁方法无法避免第二个缺点,它们不具备可以自我引用的词法标识符。使用简洁方法时一定要小心这一点。如果你需要自我引用的话,那最好使用传统的具名函数表达式来定义对应的函数,不要使用简洁方法。
5.内省
自省就是检查实例的类型。类实例的自省主要目的是通过创建方式来判断对象的结构和功能。
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.isPrototyoeOf(b1);// true
显然这是一种非常糟糕的方法。举例来说,(使用类时)你最直观的想法可能是使用Bar instanceof Foo(因为很容易把“实例”理解成“继承”),但是在JavaScript中这是行不通的,你必须使用Bar.prototype instanceof Foo。
对象关联风格的代码,其内省更加简洁。
var Foo = {/* .. */}; var Bar = Object.create(Foo); Bar... var b1 = Object.create(Bar);
使用对象关联时,所有的对象都是通过[[Prototype]]委托相互关联,下面是内省的方法:
Foo.isPrototypeOf(b1);// true Bar.isPrototypeOf(b1);// true Object.getPrototypeOf(b1) === Bar;// true
我们并不需要使用间接的形式,比如Foo.prototype,这种方法显然更加简洁并且清晰。再说一次,我们认为JavaScript中对象关联比类风格的代码更加简洁(而且功能相同)。
6.小结
在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是唯一(合适)的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模式:行为委托。
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript的[[Prototype]]机制本质上就是行为委托机制。也就是说,我们可以选择在JavaScript中努力实现类机制,也可以拥抱更自然的[[Prototype]]委托机制。
当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。
对象关联(对象之前互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把它们抽象成类。对象关联可以用基于[[Prototype]]的行为委托非常自然地实现。

浙公网安备 33010602011771号