《JavaScript 模式》读书笔记(6)— 代码复用模式3
- 2020 年 4 月 30 日
- 筆記
我们之前聊了聊基本的继承的概念,也聊了很多在JavaScript中模拟类的方法。这篇文章,我们主要来学习一下现代继承的一些方法。
九、原型继承
下面我们开始讨论一种称之为原型继承(prototype inheritance)的“现代”无类继承模式。在本模式中并不涉及类,这里的对象都是继承自其他对象。以这种方式考虑:有一个想要复用的对象,并且想创建的第二个对象需要从第一个对象中获取其功能。
下面的代码展示了该如何开始着手实现这种模式:
// 要继承的对象 var parent = { name:"Papa" }; // 新对象 var child = object(parent); // 测试 alert(child.name) //"Papa"
在前面的代码片段中,存在一个以对象字面量(object literal)创建的名为parent的现有对象,并且要创建另外一个与parent具有相同属性和方法的名为child的对象。child对象是由一个名为object()的函数所创建。JavaScript中并不存在该函数(不要与构造函数object()弄混淆),为此,让我们看看该如何定义该函数。
与类似继承模式的圣杯版本相似,首先,可以使用空的临时构造函数F()。然后,将F()的原型属性设置为父对象。最后,返回一个临时构造函数的新实例:
function object(o) { function F() {} F.prototype = o; return new F(); }
下图展示了在使用原型继承模式时的原型链。图中的child最初是一个空对象,他没有自身的属性,但是同时他又通过受益于__proto__链接而具有其父对象的全部功能。
讨论
在原型继承模式中,并不需要使用字面量符合(literal notation)来创建父对象(尽管这可能是一种比较常见的方式)。如下代码所示,可以使用构造函数创建父对象,请注意,如果这样做的话,“自身”属性和构造函数的原型的属性都将被继承:
// 父构造函数 function Person() { // an "own" property this.name = "Adam" } // 添加到原型的属性 Person.prototype.getName = function () { return this.name; }; // 创建一个新的Person类对象 var papa = new Person(); // 继承 var kid = object(papa); // 测试自身的属性 // 和继承的原型属性 console.log(kid.getName());
在本模式的另外一个变化中,可以选择仅继承现有构造函数的原型对象。请记住,对象继承自对象,而不论父对象是如何创建的。下面使用了前面的例子演示该变化,仅需稍加修改代码即可:
// 父构造函数 function Person() { // an "own" property this.name = "Adam" } // 添加到原型的属性 Person.prototype.getName = function () { return this.name; }; // 继承 var kid = object(Person.prototype); console.log(typeof kid.getName); console.log(typeof kid.name);
增加到ECMAScript5中
在ECMAScript5中,原型继承模式已经正式成为该语言的一部分。这种模式是通过方法Object.create()来实现的。也就是说,不需要推出与object()类似的函数,它已经内嵌在语言中:
var child = Object.create(parent);
Object.create()接受一个额外的参数,即一个对象。这个额外对象的属性将会被添加到新对象中,以此作为新对象自身的属性,然后Object.create()返回该新对象。这提供了很大的方便,使您可以仅采用一个方法调用即可实现继承并在此基础上构建子对象。比如:
var child = Object.create(parent,{ age : { value : 2} }); child.hasOwnProperty("age");
可能还会发现一些JavaScript库中已经实现了原型继承模式。例如,在YUI3中是Y.Object()方法。
十、通过复制属性实现继承
让我们看另一种继承模式,即通过复制属性实现继承。在这种模式中,对象将从另一个对象中获取功能,其方法是仅需将其复制即可。下面是一个示例函数extend()实现复制继承的例子:
function extend(parent, child){ var i; child = child || {}; for(i in parent) { if(parent.hasOwnProperty(i)) { child[i] = parent[i] } } return child }
上面点的代码是一个简单的实现,它仅遍历父对象的成员并将其复制出来。在本示例实现中,child对象是可选的。如果不传递需要扩展的已有对象,那么他会创建并返回一个全新的对象。
var dad = {name : "Adam"}; var kid = extend(dad); console.log(kid.name)
上面给出的是一种所谓浅复制的对象。另一方面,深度复制意味着属性检查,如果即将复制的属性是一个对象或者一个数组,这样的话,它将会递归遍历该属性并且还会将属性中的元素复制出来。在使用前复制(由于JavaScript中的对象是通过引用而传递的)的时候,如果改变了子对象的属性,并且该属性恰好是一个对象,那么这种操作表示也正在修改父对象。其实,这也是更可取的方法,但是当处理其他对象和数组时,这种前复制也可能导致意外发生。考虑下列情况:
var dad = { counts:[1,2,3], reads:{paper:true} } var kid = extend(dad); kid.counts.push(4); console.log(dad.counts.toString()); console.log(dad.reads === kid.reads)
现在让我们修改extend()函数以实现深度复制。所有需要做的事情就是检查某个属性的类型是否为对象,如果是这样的话,需要递归复制出该对象的属性。另外,还需要检查该对象是否为一个真实对象或者一个数组,我们可以使用第三章中讨论的方法检查其数组性质。因此,深度复制版本的extend()函数看起来是这样的:
function extendDeep(parent,child) { var i, toStr = Object.prototype.toString, astr = "[object Array]"; child = child || {}; for(i in parent) { if(parent.hasOwnProperty(i)) { if(typeof parent[i] === 'object'){ child[i] = (toStr.call(parent[i]) === astr) ? [] : {}; extendDeep(parent[i],child[i]) } else { child[i] = parent[i] } } } return child; }
现在开始测试这种新的实现方式,由于它能够为我们创建对象的真实副本,因此子对象的修改并不会影响其父对象。
var kid = extendDeep(dad); kid.counts.push(4); console.log(kid.counts.toString()); console.log(dad.counts.toString()); console.log(dad.reads === kid.reads); kid.reads.paper = false; kid.reads.web = true; console.log(dad.reads.paper)
这种属性复制模式比较简单且得到了广泛的应用。值得注意的是,本模式中根本没有涉及到任何原型,本模式仅与对象以及它们自身的属性相关。
混入
可以针对这种通过属性复制实现继承的思想作进一步的扩展,现在让我们思考一种“mix-in”混入模式。mix-in模式并不是复制一个完整的对象,而是从多个对象中复制出任意的成员并将这些成员组合成一个新的对象。
mix-in实现比较简单,只需遍历每个参数,并且复制出传递给该函数的每个对象中的每个属性。
function mix() { var arg,prop,child = {}; for(arg = 0;arg < arguments.length; arg += 1) { for(prop in arguments[arg]) { if(arguments[arg].hasOwnProperty(prop)) { child[prop] = arguments[arg][prop]; } } } return child; }
现在,您有一个通用的mix-in函数,可以向他传递任意数量的对象,其结果将获得一个具有所有源对象属性的新对象。下面是一个使用示例:
var cake = mix( {eggs:2,large:true}, {butter:2,salted:true}, {flour:'3 cups'}, {sugar:'sure!'} ) console.dir(cake)
注意:如果已经学习过那些正式包含mix-in概念的语言,并且习惯于mix-in的概念,那么可能希望修改一个或多个父对象时可以影响其子对象,但是在本节给定的实现中并不是这样的。子啊这里我们仅简单循环、复制自身的属性,以及断开与父对象之间的链接。
十一、借用方法
有时候,可能恰好仅需要现有对象其中的一个或两个方法。在想要重用这些方法的同时,但是又不希望与源对象形成父-子继承关系。也就是说,指向使用所需要的方法,而不希望继承那些永远都不会用到的其他方法。在这种情况下,可以通过使用借用方法模式来实现,而这时受益于call()和apply()函数方法。您已经在本书中见到过这种模式,比如,甚至于在本章中extendDeep()函数的实现内部都见到过。
如您所知,JavaScript中的函数也是对象,并且它们自身也附带着一些有趣的方法,比如apply()和call()方法。这两者之间的唯一区别在于其中一个可以接受传递给将被调用方法的参数数组,而另一个仅逐个接受参数。可以使用这些方法以借用现有对象的功能。
//call()例子 notmyobj.doStuff.call(myobj,param1,p2,p3); // apply()例子 notmyobj.doStuff.apply(myobj,[param1,p2,p3]);
在以上代码中,存在一个名为MyObj的对象,并且还知道其他名为notmyobj的对象中有一个名为doStuff()的有用方法。您无需经历继承所带来的麻烦,也无需继承myobj对象永远都不会用到的一些方法,可以仅临时性的借用方法doStuff()即可。
可以传递对象、任意参数以及借用方法,并将它们绑定到您的对象中以作为this本身的成员。从根本上说,您的对象将在一小段时间内伪装成其他对象,从而借用其所需的方法。这就像得到了继承的好处,但是却无需支付遗产税(这里指其他您不需要的属性或方法)。
例子:借用数组方法
本模式的一个常见实现方法是借用数组方法。
数组具有一些有用的放啊,而形如arguments的类似数组的对象并不具有这些方法。因此,arguments可以借用数组的方法,比如slice()方法:
function f() { var args = [].slice.call(arguments,1,3); return args; } console.log(f(1,2,3,4,5,6));
在这个例子中,创建一个空数组的原因只是为了使用数组的方法。此外,能够实现同样功能但是语句稍微长一点的方式是直接从Array的原型中借用方法,即使用Array.prototype.slice.call()方法。这种方式需要输入更长一点的字符,但是却可以节省创建一个空数组的工作。
借用和绑定
考虑到借用方法不是通过调用call()/apply()就是通过简单的赋值,在借用方法的内部,this所指向的对象是基于调用表达式而确定的。但是有时候,最好能够“锁定”this的值,或者将其绑定到特定对象并预先确定该对象。
让我们看下面这个例子,其中存在一个名为one的对象,且具有say()方法:
var one = { name:"object", say: function(greet) { return greet + ', ' + this.name; } }; // 测试 console.log(one.say('hi')); //结果为“hi,object”
现在,另一个对象two中并没有say()方法,但是可以从对象one中借用该方法,如下所示:
var two = { name:"another object" }; console.log(one.say.apply(two,["hello"]));
在上述例子中,借用的say()方法内部的this指向了two对象,因而this.name的值为“another object”。但是在什么样的场景中,应该将函数指针赋值给一个全局变量,或者将该函数作为回调函数来传递?在客户端编程中有许多事件和回调函数,因此确实发生了很多这样混淆的事情。
// 给变量赋值 // this将指向全局变量 var say = one.say; console.log(say('hoho')); // 作为回调函数传递 var yetanother = { name:"Yet another object", method:function(callback) { return callback("Hola"); } }; console.log(yetanother.method(one.say));
在以上两种情况下,say()方法内部的this指向了全局对象,并且整个代码段都无法按照预期正常运行。为了修复(也就是说,绑定)对象与方法之间的关系,我们可以使用如下的一个简单函数:
function bind(o,m) { return function () { return m.apply(o,[].slice.call(arguments)) } }
这个bind()函数接受了一个对象o和一个方法m,并且将两者绑定起来,然后返回另一个函数。其中,返回的函数可以通过闭包来访问o和m。因此,即时在bind()返回后,内部函数热盎然可以访问o和m,并且总是指向原始对象和方法。下面,让我们使用bind()创建一个新的函数:
var twosay = bind(two,one.say); console.log(twosay('yo'))
正如您上面所看到的,即时twosay()以全局函数方式而创建,但是say()方法内部的this并没有指向全局对象,实际上它指向了传递给bind()的对象two。无论您如何调用twosay(),该方法永远是绑定到对象two上。
奢侈的拥有绑定所需要付出的代价就是额外的必报的开销。
Function.prototype.bind()
ECMAScript5中将bind()方法添加到了Function.prototype中,使得bind()就像apply()和call()一样简单易用。因此,可以执行如下表达式:
var newFunc = obj.someFunc.bind(myobj,1,2,3);
上述表达式的含义是将someFunc()和myobj绑定在一起,并且预填充someFunc()期望的前三个参数。这也是第四章中所讨论的部分函数应用的一个例子。
当程序在ES5之前的环境运行时,让我们看看应该如何实现Function.prototype.bind():
if(typeof Function.prototype.bind === 'undefined') { Function.prototype.bind = function(thisArg) { var fn = this, slice = Array.prototype.slice, args = slice.call(arguments,1); return function () { return fn.apply(thisArg,args.concat(slice.call(arguments))); } } }
这个实现看起来可能有点熟悉,它使用了部分应用并拼接了参数列表,即那些传递给bind()的参数(除了第一个以外),以及那些传递给由bind()所返回的新函数的参数,其中该新函数将在以后被调用。下面是一个使用示例:
var twosay2 = one.say.bind(two); console.log(twosay2("Bonjour"));
在前面的例子中,除了提供了将被绑定的对象以外,并没有向bind()传递任何参数。在下面的例子,让我们传递一个参数以实现部分应用:
var twosay3 = one.say.bind(two,"Enchante"); console.log(twosay3())
小结
当在JavaScript中涉及到继承时,有很多可供选择的方法。这些方法对于学习和理解多种不同的模式大有裨益,因为它们有助于提高您对语言的掌握程度。在本章中,您了解了几种类式继承模式以及集中现代继承模式,从而可以解决继承相关的问题。
然而,在开发过程中经常面临的继承可能并不是一个问题。其中一部分的原因在于,事实上使用的JavaScript库可能以这样或那样的方式解决了该问题,而另一个方面的原因在于很少需要在JavaScript中建立长而且复杂的继承链。在静态强类型的语言中,继承可能是唯一复用代码的方法。在JavaScript中,经常有更简洁且优美的方法,其中包括借用方法、绑定、复制属性以及从多个对象中混入属性等多种方法。
最后,请记住,代码重用才是最终目的,而继承只是实现这一目标的方法之一。
到这里,这一篇就结束了,后面,我们开始学习设计模式!