Javascript 物件导向设计

优质
小牛编辑
125浏览
2023-12-01

在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这两个变数的,因为以下三个情况都不成立:

  1. getName及getAge所在的scope找不到age、name
  2. getName及getAge所在的scope的外层中找不到age、name(在这个例子中他们已经在最外层了)
  3. 找不到age、name这两个全域变数

类别的继承(以prototype实作)

javascript是个很活的语言,在实作物件导向的「继承」机制时,大致可以分为两种作法,这一节讲的是「以protoype来实作继承模式」

什幺是prototype?

prototype是函数物件特有的属性,当利用函数物件来建立一个物件(实例)时(var obj = new F()),实际上是做了以下的事情:

  1. 新增一个空物件 ( var obj={} )
  2. 将空物件的__proto__指向建构式的prototype ( obj.__proto__=F.prototype )
  3. 在新物件的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("我有四只脚");
}