Javascript 物件导向设计
在javascript中,几乎一切都是物件,在物件导向设计的模式下,应该会常看到这样的状况:
var dipsy = { name: "dipsy", color: "green", sayHello: function(){ console.log("Hello!");} }; var po = { name: "po", color: "red", sayHello: function(){ console.log("Hello!");} }; var lala = { name: "lala", color: "yellow", sayHello: function(){ console.log("Hello!");} };
这样的方式很不经济,这样的设计也没有重用性可言,这时候你应该会想到物件导向语言中常有的class。由这个class可以产出一堆长得差不多的「徒子徒孙」,也就是实例。
但是javascript中(还)没有Class,怎幺办呢? 这时我们就要自己动手写一个了…
javascript类别实作
var Person = function(name, age) { this.name = name; this.age = age; }; var p1 = new Person('Kevin',18);
刚接触js的前端工程师或许没见过”var p1 = new Person(‘Kevin’,18);”这样的物件宣告方式, 他其实就相当于以下这四行程式码:
var p1 = {}; Person.call(p1, 'Kevin', 18); // 以p1身分执行Person建构函数 p1.__proto__ = Person.prototype; // 先别管,后面会提到 p1.constructer = Person; // 这个物件的建构者是Person
一般在建立物件时,最常见的方法是这样:
var obj = {}; console.log(obj.constructer); // Object(){}
从上面这两行程式码可以得知,当我们在建立物件时,其实是由一个存在于global的Object函数来产生他的。 了解到这点以后,你会发现你可以这样来建立物件:
var obj = new Object();
实作private attributes
在物件导向的语言中,有public和private属性的分别,而在javascript中,我们可以利用「闭包」来製作一个外界无法直接读取的变数,这样的特性就如同私有变数了。 现在我们就来看看要如何实作他:
var Person = function(name, age) { var name = name; // private (这句可以省略) var age = age; // private (这句可以省略) this.getName = function() { return name }; // public this.getAge = function() { return age }; // public }; var p1 = new Person('Kevin',18); console.log(p1.name); // undefined console.log(p1.age); // undefined console.log(p1.getName()); // 'Keivn' console.log(p1.getAge()); // 18
为什幺会有这样的情况? 原来是因为javascript在进行程式的「预编译」时,会先将静态定义的函数给建立出来,这时函数的「视野」(scope)是基于「词法作用域」的原则来定义。也就是说,他的地盘是在这个函数实际存在的地方,而非被呼叫的地方。 我们再看看下面这个错误的例子,或许你就会比较了解:
var Person = function(name, age) { var name = name; // private var age = age; // private this.getName = getName; // public this.getAge = getAge; // public }; var getName = function() { return name }; var getAge = function() { return age }; var p1 = new Person('Kevin',18); console.log(p1.name); // undefined console.log(p1.age); // undefined console.log(p1.getName()); // undefined console.log(p1.getAge()); // undefined
因为function在参考变数时,只会一层一层往外找, 所以上面这段程式码中,getName及getAge是无法往Person这个建构函数中找age、name这两个变数的,因为以下三个情况都不成立:
- getName及getAge所在的scope找不到age、name
- getName及getAge所在的scope的外层中找不到age、name(在这个例子中他们已经在最外层了)
- 找不到age、name这两个全域变数
类别的继承(以prototype实作)
javascript是个很活的语言,在实作物件导向的「继承」机制时,大致可以分为两种作法,这一节讲的是「以protoype来实作继承模式」
什幺是prototype?
prototype是函数物件特有的属性,当利用函数物件来建立一个物件(实例)时(var obj = new F()),实际上是做了以下的事情:
- 新增一个空物件 ( var obj={} )
- 将空物件的__proto__指向建构式的prototype ( obj.__proto__=F.prototype )
- 在新物件的scope中执行建构式 ( F.apply(obj,arguments) )
在第二个步骤中,建构式的prototype这个物件以reference的方式asign给实例的「__proto__」属性(注意,是双底线喔) 之后,__proto__中的所有属性、方法,就如同这个实例原生拥有的一样了,举例来说:
function Person(name, age) { this.name = name; this.age = age; } Person.prototype.nation = "Taiwan"; var p1 = new Person("Kevin", "18"); console.log(p1); // Person {name: "Kevin", age: "18", nation: "Taiwan"}
从上面的code中我们可以看到,虽然我们没有为p1指定nation,但是因为p1的建构函数的prototype中有这个属性,所以p1可以藉由__proto__来参考到他的值。
Note: __proto__并不是正规的物件属性,只是一个指标,帮助我们了解原形链的运作原理, 在撰写javascript程式的时候我们并不应该直接使用他。
prototype chain
延续前面的程式码…如果我们又为p1增加一个属性”nation”的话会发生什幺事呢?
p1.nation = "USA"; console.log(p1); // Person {name: "Kevin", age: "18", nation: "USA", nation: "Taiwan"} console.log(p1.nation); // "USA"
这时你会发现p1同时拥有两个nation的属性,一个是来自类别(建构函数)的prototype,一个是自身拥有的属性。 而在呼叫这个属性时会先找原生的,如果没有就会往prototype找,还没有的话就会再找这个prototype物件的类别的prototype找….直到最上层为止,这个概念就是「prototype chain」。 下面这个多层继承的範例应该能让你更加了解prototype chain的原理:
// 哺乳纲 function Mammals() { this.blood = "warm"; } // 灵长目 function Primate() { this.tail = true; this.skin = "hairy"; } Primate.prototype = new Mammals(); // 人科 function Homo() { this.skin = "smooth"; } Homo.prototype = new Primate(); var human = new Homo(); human.name = "Kevin"; console.log(human.name); // "Kevin", from self. console.log(human.skin); // "smooth", from Homo. console.log(human.tail); // "true", from Primate. console.log(human.blood); // "warm", from Mammals.
prototype设计模式的漏洞
相信以上的範例应该能让你对prototype实作的继承模式有一定的认知,但是这样实作的继承模式会有如下的风险:
function Human() {} Human.prototype.blood = "red"; Human.prototype.body = ["foot","hand"]; var john = new Human(); var kevin = new Human(); john.blood = "purple"; //john因为不明原因突变,血变成紫色的 john.body.push("wing"); //john因为不明原因突变,长出翅膀来了 alert(kevin.blood); // "red" alert(john.blood); // "purple" alert(kevin.body.toString()); // "foot, hand, wing" alert(kevin.body.toString()); // "foot, hand, wing"
从上面的例子可以看到,john因为不明原因而突变了。但是在john突变之后,kevin的血虽然没有变色,但是却莫名其妙长出了翅膀。很明显的,我们不小心改动到了Human的prototype。 原来在我们为john的blood指定颜色时,javascript会为john这个物件增加一个属于自己的”blood”属性,这种情况就跟为物件增加属性的方式一样。于是在后来的呼叫时,会先找到john自己的blood属性。 但要john的body属性执行push函式时,会发生在john中找不到body的状况,于是就往上找到了Human.prototype的body属性,并由他来执行push函式,此时改动到的便是Human.prototype.body了,也就连带的影响到了kevin。
类别的继承(借用建构式)
call是函数物件特有的方法,他的用途是在指定的作用域中执行这个函数。 有些人对apply或许有印象,他们两个基本上是一样的东西,只是传递变数的方式不同,这边我们不多做赘述。 我们直接来看看要如何用它来实作javascript的继承模式:
// 哺乳纲 function Mammals() { this.blood = "warm"; } // 灵长目 function Primate() { Mammals.call(this); // 记得放前面,不然会盖掉重複的属性 this.tail = true; this.skin = "hairy"; } Primate.prototype = new Mammals(); // 人科 function Homo() { Primate.call(this); // 记得放前面,不然会盖掉重複的属性 this.skin = "smooth"; } var human = new Homo(); human.name = "Kevin"; alert(human.name); // "Kevin", from self alert(human.skin); // "smooth", from Homo alert(human.tail); // "true", from Primate alert(human.blood); // "warm", from Mammals
借用建构式的缺点
以借用建构式的方式来实作继承,会发生一个问题,就是父类别的prototype没有被继承给子类别。 这时我们可以用以下的方法来补足:
- function Child() {
- Parent.apply(this, arguments);
} Child.prototype = new Parent();
这样的作法乍看之下很像是多此一举,但和单纯的prototype继承比起来,这种方式在自身以及prototype中保留了来自父类别建构式的属性,当自身的属性被删除时,prototype中的同名属性也会”亮起”
实践多继承
上面提到了两种继承的实作模式,而第二种以call实作的方法可以很轻鬆的达到多继承的设计,我们来看看以下的例子:
// 章鱼 function Octopus() { this.legs = 8; } // 小猫 function Pussy() { this.speak = function () { console.log( "meow~" ); }; } // 八爪猫 function Octopussy(name) { Octopus.call(this); Pussy.call(this); this.name = name; }
实践Mixin机制
在很多情况下,多重继承的複杂性是被人诟病的(有兴趣可以看看Ruby发明者写的”松本行弘的程式世界”,里面有提到这部份) 也因为这样,多种物件导向语言都不支援多继承,而是改以interface或mixin的概念来实现扩充性。 这边我们要来讲讲mixin。其实mixin就跟jQuery的extend概念一样:
var a = {height: 30, width: 20}, b = {long: 10}; $.extend(a,b); console.log(a);//{height: 30, width: 20, long: 10}
接下来我们就来看一下要怎幺实现mixin设计:
function mixin(a,b) { for (key in b) { a[key]=b[key]; } return a; }
类别的静态方法与属性
在撰写物件导向的语言时,常常会用到static的机制。 在javascript物件导向设计中的class本身就一开始就以function的形式存在,其实就是static了。 接下来我们要在这个class中增加属于class本身的方法和属性,即为static method、static attribute。
function Human(name,sex) { this.name = name; this.sex = sex||"?"; } Human.findByName = function (name) { this.people[name]; }; Human.people = {}; Human.new = function(name, sex){ var human = new Human(name,sex); this.people[name]=human; }
实现多型
在物件导向的继承关係中,多型(polymorphism)是很常见的设计。 多型可以让继承自同一父类别的类别拥有相同的函数,但是可以依不同的子类别去重新定义这个函数,例如说:
哺乳类.getFoot(); // Error:”我没有脚” 猩猩.getFoot(); // “我有两只脚” 狗狗.getFoot(); // “我有四只脚”
猩猩和狗狗都是继承自哺乳类,但是呼叫同名的”getFoot”函数时却有不同的实作,我们来看看要怎幺实作他:
// 哺乳纲 function Mammals() { // constructor } Mammals.prototype.getFoot = function(){ throw new Error ("我没有脚"); } function Chimp() { // constructor } Chimp.prototype = new Mammals(); Chimp.prototype.getFoot = function(){ console.log("我有两只脚"); } function Dog() { // constructor } Dog.prototype = new Mammals(); Dog.prototype.getFoot = function(){ console.log("我有四只脚"); }