多应用+插件架构,代码干净,二开方便,首家独创一键云编译技术,文档视频完善,免费商用码云13.8K 广告
## 2.6 行为委托 ### 2.6.1 面向委托的设计 **1. 类理论** 假设我们需要在软件中建模一些类似的任务(“XYZ”、“ABC”等)。 如果使用类,那设计方法可能是这样的:定义一个通用父(基)类,可以将其命名为Task,在Task 类中定义所有任务都有的行为。接着定义子类XYZ 和ABC,它们都继承自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 { // ... } ~~~ **2. 委托理论** 首先你会定义一个名为Task 的对象(既不是类也不是函数),它会包含所有任务都可以使用(委托)的具体行为。接着,对于每个任务(“XYZ”、“ABC”)你都会定义一个对象来存储对应的数据和行为。你会把特定的任务对象都关联到Task 功能对象上,让它们在需要的时候可以进行委托。 基本上你可以想象成,执行任务“XYZ”需要两个兄弟对象(XYZ 和Task)协作完成。但是我们并不需要把这些行为放在一起,通过类的复制,我们可以把它们分别放在各自独立的对象中,需要时可以允许XYZ 对象委托给Task。 ~~~ Task = { setID: function(ID) { this.id = ID; }, outputID: function() { console.log( this.id ); } }; // 让XYZ 委托Task 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 ... = ... ~~~ 在这段代码中,Task 和XYZ 并不是类( 或者函数), 它们是对象。XYZ 通过`Object.create(..)` 创建,它的[[Prototype]] 委托了Task 对象。相比于面向类(或者说面向对象),这种编码风格应称为“对象关联”(OLOO,objects linked to other objects)。我们真正关心的只是XYZ 对象(和ABC 对象)委托了Task 对象。 对象关联风格的代码还有一些不同之处。 * 1)在上面的代码中,id 和label 数据成员都是直接存储在XYZ 上(而不是Task)。通常来说,在[[Prototype]] 委托中最好把状态保存在委托者(XYZ、ABC)而不是委托目标(Task)上。 * 2) 在类设计模式中,我们故意让父类(Task)和子类(XYZ)中都有outputTask 方法,这样就可以利用重写(多态)的优势。在委托行为中则恰好相反:我们会尽量避免在`[[Prototype]] `链的不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法来消除引用歧义。  这个设计模式要求尽量少使用容易被重写的通用方法名,提倡使用更有描述性的方法名,尤其是要写清相应对象行为的类型。这样做实际上可以创建出更容易理解和维护的代码,因为方法名(不仅在定义的位置,而是贯穿整个代码)更加清晰(自文档)。 * 3) `this.setID(ID)`;XYZ 中的方法首先会寻找XYZ 自身是否有setID(..),但是XYZ 中并没有这个方法名,因此会通过[[Prototype]] 委托关联到Task 继续寻找,这时就可以找到setID(..) 方法。此外,由于调用位置触发了this 的隐式绑定规则,因此虽然setID(..) 方法在Task 中,运行时this 仍然会绑定到XYZ,这正是我们想要的。后面的this.outputID(),原理相同。 **委托行为**意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。 #### **互相委托(禁止)** 你无法在两个或两个以上互相(双向)委托的对象之间创建循环委托。如果你把B 关联到A 然后试着把A 关联到B,就会出错。 之所以要禁止互相委托,是因为引擎的开发者们发现在设置时检查(并禁止!)一次无限循环引用要更加高效,否则每次从对象中查找属性时都需要进行检查。 #### **调试** 通常来说,JavaScript 规范并不会控制浏览器中开发者工具对于特定值或者结构的表示方式,浏览器和引擎可以自己选择合适的方式来进行解析,因此浏览器和工具的解析结果并不一定相同。 这段传统的“类构造函数”JavaScript 代码在Chrome 开发者工具的控制台中结果如下所示: ~~~ function Foo() {} var a1 = new Foo(); a1; // Foo {} ~~~ 分析原理: ~~~ var Foo = {}; var a1 = Object.create( Foo ); a1; // Object {} Object.defineProperty( Foo, "constructor", { enumerable: false, value: function Gotcha(){} }); a1; // Gotcha {} ~~~ 本例中Chrome 的控制台确实使用了`.constructor.name`。Chrome 内部跟踪(只用于调试输出)“构造函数名称”的方法是Chrome自身的一种扩展行为,并不包含在JavaScript 的规范中。 如果你并不是使用“构造函数”来生成对象,比如使用对象关联风格来编写代码,那Chrome 就无法跟踪对象内部的“构造函数名称”,这样的对象输出是`Object {}`,意思是“Object() 构造出的对象”。 当然,这并不是对象关联风格代码的缺点。当你使用对象关联风格来编写代码并使用行为委托设计模式时,并不需要关注是谁“构造了”对象(就是使用new 调用的那个函数)。只有使用类风格来编写代码时Chrome 内部的“构造函数名称”跟踪才有意义,使用对象关联时这个功能不起任何作用。 **3. 比较思维模型** 下面是典型的(“原型”)面向对象风格: ~~~ 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(); ~~~ 子类Bar 继承了父类Foo,然后生成了b1 和b2 两个实例。b1 委托了Bar.prototype,后者委托了Foo.prototype。 使用对象关联风格来编写功能完全相同的代码: ~~~ Foo = { init: function(who) { this.me = who; }, identify: function() { return "I am " + this.me; } }; 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(); ~~~ #### **两段代码对应的思维模型** 首先,类风格代码的思维模型强调实体以及实体间的关系: ![](https://box.kancloud.cn/97f969512821c9b8b0a15060224ae9ad_737x623.png) 从图中可以看出这是一张十分复杂的关系网。此外,如果你跟着图中的箭头走就会发现,JavaScript 机制有很强的内部连贯性。 举例来说,JavaScript 中的函数之所以可以访问`call(..)、apply(..) 和bind(..)`,就是因为函数本身是对象。而函数对象同样有`[[Prototype]]` 属性并且关联到`Function.prototype` 对象,因此所有函数对象都可以通过委托调用这些默认方法。 好,下面我们来看一张简化版的图,它更“清晰”一些——只展示了必要的对象和关系: ![](https://box.kancloud.cn/184e60022780de37ba8c79b9e57bb138_733x674.png) 虚线表示的是Bar.prototype 继承Foo.prototype 之后丢失的.constructor属性引用,它们还没有被修复。即使移除这些虚线,这个思维模型在你处理对象关联时仍然非常复杂。 现在我们看看对象关联风格代码的思维模型: ![](https://box.kancloud.cn/214bf54db0e9166f4d68f02fab4215e8_731x668.png) 这种代码只关注一件事:**对象之间的关联关系**。 ### 2.6.2 类与对象 真实场景应用“类”与“行为委托” **1. 控件“类”** 下面这段代码展示的是如何在不使用任何“类”辅助库或者语法的情况下,使用纯 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) { // 调用“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 ); } ); ~~~ 在面向对象设计模式中我们需要先在父类中定义基础的render(..),然后在子类中重写它。子类并不会替换基础的render(..),只是添加一些按钮特有的行为。可以看到代码中出现了丑陋的显式伪多态,即通过`Widget.call` 和`Widget.prototype.render.call` 从“子类”方法中引用“父类”中的基础方法。 #### ES6的class语法糖 ~~~ class Widget { constructor(width,height) { this.width = width || 50; this.height = height || 50; 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) { super( width, height ); this.label = label || "Default"; this.$elem = $( "<button>" ).text( this.label ); } render($where) { super( $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 ); } ); ~~~ 尽管语法上得到了改进,但实际上这里并没有真正的类,class 仍然是通过`[[Prototype]]`机制实现的。无论你使用的是传统的原型语法还是ES6 中的新语法糖,你仍然需要用“类”的概念来对问题(UI 控件)进行建模。 **2. 委托控件对象** ~~~ 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) { // 委托调用 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 同样只是一个对象。 从设计模式的角度来说, 我们并没有像类一样在两个对象中都定义相同的方法名render(..),相反,我们定义了两个更具描述性的方法名(insert(..) 和build(..))。同理,初始化方法分别叫作init(..) 和setup(..)。 之前的一次调用(`var btn1 = new Button(..)`)现在变成了两次(`var btn1 = Object.create(Button) 和btn1.setup(..)`)使用类构造函数的话,你需要(并不是硬性要求,但是强烈建议)在同一个步骤中实现构造和初始化。然而,在许多情况下把这两步分开(就像对象关联代码一样)更灵活。 举例来说,假如你在程序启动时创建了一个实例池,然后一直等到实例被取出并使用时才执行特定的初始化过程。这个过程中两个函数调用是挨着的,但是完全可以根据需要让它们出现在不同的位置。对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。 ### 2.6.3 更简洁的设计 假定场景:我们有两个控制器对象,一个用来操作网页中的登录表单,另一个用来与服务器进行验证(通信)。 我们需要一个辅助函数来创建Ajax 通信。我们使用的是jQuery,它不仅可以处理Ajax 并且会返回一个类Promise 的结果,因此我们可以使用`.then(..) `来监听响应。 在传统的类设计模式中,我们会把基础的函数定义在名为Controller 的类中,然后派生两个子类LoginController 和AuthController,它们都继承自Controller 并且重写了一些基础行为: ~~~ // 父类 function Controller() { this.errors = []; } Controller.prototype.showDialog(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 (user.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(); auth.checkAuth( // 除了继承,我们还需要合成 new LoginController() ); ~~~ 所有控制器共享的基础行为是`success(..)、failure(..) 和showDialog(..)`。子类`LoginController` 和`AuthController` 通过重写failure(..) 和success(..) 来扩展默认基础类行为。此外,注意`AuthController` 需要一个`LoginController` 的实例来和登录表单进行交互,因此这个实例变成了一个数据属性。 #### 反类 ~~~ 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 (user.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(); ~~~ 借助对象关联,你可以简单地向委托链上添加一个或多个对象,而且同样不需要实例化: ~~~ var controller1 = Object.create( AuthController ); var controller2 = Object.create( AuthController ); ~~~ 在行为委托模式中,AuthController 和LoginController 只是对象,它们之间是兄弟关系,并不是父类和子类的关系。代码中AuthController 委托了LoginController,反向委托也完全没问题。 这种模式的重点在于只需要两个实体(LoginController 和AuthController),而之前的模式需要三个。 ### 2.6.4 更好的语法 ES6 的class 语法可以简洁地定义类方法,这个特性让class 乍看起来更有吸引力(但应避免使用) ~~~ class Foo { methodName() { /* .. */ } } ~~~ 在ES6 中可以在任意对象的字面形式中使用简洁方法声明(concise methoddeclaration),所以对象关联风格的对象可以这样声明(和class 的语法糖一样): ~~~ var LoginController = { errors: [], getUser() { // ... }, getPassword() { // ... } // ... }; ~~~ 唯一的区别是对象的字面形式仍然需要使用“,”来分隔元素,而class 语法不需要。 此外,在ES6 中,你可以使用对象的字面形式来改写之前繁琐的属性赋值语法( 比如AuthController 的定义), 然后用`Object.setPrototypeOf(..) `来修改它的`[[Prototype]]`: ~~~ // 使用更好的对象字面形式语法和简洁方法 var AuthController = { errors: [], checkAuth() { // ... }, server(url,data) { // ... } // ... }; // 现在把AuthController 关联到LoginController Object.setPrototypeOf( AuthController, LoginController ); ~~~ #### 反词法 简洁方法有一个非常小但是非常重要的缺点。思考下面的代码: ~~~ var Foo = { bar() { /*..*/ }, baz: function baz() { /*..*/ } }; ~~~ 去掉语法糖之后的代码如下所示: ~~~ var Foo = { bar: function() { /*..*/ }, baz: function baz() { /*..*/ } }; ~~~ 由于函数对象本身没有名称标识符, 所以bar() 的缩写形式(`function()..`)实际上会变成一个匿名函数表达式并赋值给bar 属性。相比之下,具名函数表达式(`function baz()..`)会额外给`.baz` 属性附加一个词法名称标识符baz。 匿名函数没有name 标识符,这会导致: * 调试栈更难追踪; * 自我引用(递归、事件(解除)绑定,等等)更难; * 代码(稍微)更难理解。 去掉语法糖的版本使用的是**匿名函数表达式**,通常来说并不会在追踪栈中添加name,但是简洁方法很特殊,会给对应的函数对象设置一个内部的name 属性,这样理论上可以用在追踪栈中。(但是追踪的具体实现是不同的,因此无法保证可以使用。) 简洁方法无法避免第2 个缺点,它们不具备可以自我引用的词法标识符。思考下面的代码: ~~~ 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(){..}`),不要使用简洁方法。 ### 2.6.5 内省 **自省**就是检查实例的类型。类实例的自省主要目的是通过创建方式来判断对象的结构和功能。 ~~~ function Foo() { // ... } Foo.prototype.something = function(){ // ... } var a1 = new Foo(); // 之后 if (a1 instanceof Foo) { a1.something(); } ~~~ 因为Foo.prototype( 不是Foo !) 在a1 的[[Prototype]] 链上, 所以instanceof 操作(会令人困惑地)告诉我们a1 是Foo“类”的一个实例。知道了这点后,我们就可以认为a1 有Foo“类”描述的功能。 当然,Foo 类并不存在, 只有一个普通的函数Foo, 它引用了a1 委托的对象(Foo.prototype)。从语法角度来说,instanceof 似乎是检查a1 和Foo 的关系,但是实际上它想说的是**a1 和Foo.prototype(引用的对象)是互相关联的。** 之前介绍的抽象的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 ~~~ 还有一种常见但是可能更加脆弱的内省模式,叫“**鸭子类型**”。 举例来说: ~~~ if (a1.something) { a1.something(); } ~~~ 我们并没有检查a1 和委托something() 函数的对象之间的关系,而是假设如果a1 通过了测试a1.something 的话,那a1 就一定能调用.something()(无论这个方法存在于a1 自身还是委托到其他对象)。这个假设的风险其实并不算很高。 ES6 的Promise 就是典型的“鸭子类型”,出于各种各样的原因,我们需要判断一个对象引用是否是Promise,但是判断的方法是检查对象是否有then() 方法。换句话说,如果对象有then() 方法,ES6 的Promise 就会认为这个对象是“可持续”(thenable)的,因此会期望它具有Promise 的所有标准行为。(如果有一个不是Promise 但是具有then() 方法的对象,那你千万不要把它用在ES6 的Promise 机制中,否则会出错。) 对象关联风格代码,其内省更加简洁。 之前的Foo/Bar/b1 对象关联例子(只包含关键代码): ~~~ var Foo = { /* .. */ }; var Bar = Object.create( Foo ); Bar... var b1 = Object.create( Bar ); ~~~ 使用对象关联时,所有的对象都是通过[[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 ~~~