基于原型继承,动态对象扩展,闭包,JavaScript已经成为当今世界上最灵活和富有表现力的编程语言之一。

这里有一个很重要的概念需要特别指出:在JavaScript中,包括所有的函数,数组,键值对和数据结构都是对象。 举个简单的例子:

var testFunc = function testFunc() {

};
testFunc.customP = "James";
console.log(testFunc.customP);

上边的代码中,testFunc可以添加customP这个属性,说明testFunc本身就是一个对象。在JavaScript中,函数名是一个指向函数对象的指针,我们看下边的代码:

var testFunc = function testFunc() {
console.log("Hello world");
};
var anotherTestFunc = testFunc;
testFunc = null;
anotherTestFunc();

即使把testFunc置为空,上边的程序仍然打印了Hello world。通过上边的例子,我们演示了函数作为对象的证据,然而JavaScript中的基本数据类型,在特定的情况下也会体现出对象的特性:

'[email protected]'.split('@')[1]; // => example.com

当我们可以使用属性获取符.来操作基本类型的时候,它就表现的很像对象,但我们不能给它赋值。原因是:基本类型会被临时包装成object,之后会立刻抛弃这个包装,表面上看像是赋值成功了,但下次是无法访问之前的赋值的。

接下来,我们会探讨JavaScript中Object的一些问题,这和其他面向对象的语言有很大不同,我们会解释为什么JavaScripts不是面向对象类型的语言,而是原型语言。

类型继承是否应该被淘汰

Design Patterns: Elements of Reusable Object Oriented Software这本书中有两个关于面向对象设计程序的原则:

  • Program to an interface, not an implementation 面向接口编程
  • Favor object composition over class inheritance 优先使用组合,而非继承

在某种意义上,上边的第二个原则同样遵循了第一个原则,因为继承把父类暴露给了子类,这样的话,子类就被设计成了一个实现,而不是接口了。因此我们得出一个结论,类型继承破坏了封装原则,并且把子类同它的祖先紧密联系在一起。

举一个更生动的例子,我们可以把类型继承比作是家具,我们用很多设计好的零部件来拼装一个家具,如果这些零部件都符合设计,那么我们有很高的机会组装成功,如果有一些不符合要求,那么就会组装失败。

在程序设计中,组合就像乐高积木,大部分的零件被设计成能够和其他零件拼接到一起,这样,我们就能够很灵活的进行组装了。

如何按照组件化的思想来设计程序,不是本篇文章的内容,现在我们来看看反对继承的理由是什么:

  • 高耦合 继承在面向对象的设计中的耦合性最高,子类与其祖先类紧密相连
  • 层次划分不灵活 在真实的开发中,往往不会出现单层的继承,如果使用了多层次的继承,那么很可能有很多继承过来的属性是不需要的,这样就造成了代码的过度重复
  • 多继承难以理解 有时候很有必要会继承多个父类,对于这种情况的处理跟单一继承是不一样的,会更复杂,需要处理冲突和不一致的情况,并且代码也变得难以阅读和理解
  • 脆弱的架构 高耦合的程序,很难对某个类进行代码重构,这就体现了架构的脆弱性
  • 大猩猩/香蕉问题 父类中的某些属性可能不是我们需要的,子类可以重写父类的属性,但不能选择继承那些属性,就像,我只需要香蕉,继承却给了我一个拿着香蕉的大猩猩和整片丛林

JavaScript的继承和其他面向对象语言的继承有很大不同,只有我们了解了继承的缺点,才能更好的使用好这些特性。

Prototypes

prototype是JavaScript中很重要的一个概念,他可以说是对其他对象的一个模仿。又很像是一个类,你可以用他来构建很多实例对象。但他的本质就是一个对象。 我们可以通过prototype做两件事情:

  • 访问一个共享的原型对象,也叫代理原型
  • 克隆一个原型

代理原型

我们先把原型编程这一概念弄明白,在大多数的面向对象的语言中,对象就跟模具一样,我们根据模具来制造对象,在JavaScript中,不是这样的,通过prototype 给object赋值一个原型或对象,然后在新产生的对象身上做修改,也就是说新对象获取了原型的数据。这就是原型编程思想。

在JavaScript中,对象内部都有一个原型对象,当一个对象查询某个属性或方法的时候,JavaScript引擎首先会搜索对象本身是否答案,如果没有,就会去它的原型对象中继续搜索,如果没有,再去它的原型的原型中去找,这就形成了一个原型链。直到Object的prototype为止。

我们看一段代码:

if (!Object.create) {
Object.create = function (o) {
if (arguments.length > 1) {
throw new Error('Object.create implementation' + ' only accepts the first parameter.');
}
function F() {}
F.prototype = o;
return new F();
};
}

Object.create可用于创建一个对象,它的函数原型中接受两个参数,第一个参数是原型对象,表示新建的对象的原型,必填,第二个参数是属性数组,表示新建对象的属性。它在ES5中被引入,因此上边的代码是考虑到兼容问题的。

通过上边的代码可以看出来,其内部创建了一个F构造器,然后把原型参数通过F.prototype = o进行赋值,最后使用构造器生成一个对象。因此我们得出下边几个结论:

  • 使用F.prototype = o这样的方法给对象的原型赋值

  • new F()是怎么的过程?

      var a = {};
    a.__proto__ = F.prototype;
    F.call(a);

有一个很容易让人迷惑的地方,我们先看代码:

var a = new Object();
console.log(a.prototype); // => undefined
console.log(a.__proto__); // => {}

在上边我们不是解释过了吗?一个对象内部默认会指向一个原型,但是为什么上边第二行代码打印的结果是undefined呢?

这就引出了__proto__的概念,我觉得这篇文章写的不错。上边的代码输出为undefined,但是__proto__却有值,说明每个对象内部真正创建的原型是__proto__,而prototype只起到了辅助的作用,完全可以把他当做一个普通的属性来看待。当使用Object.create方法创建对象的时候,参数中的原型会赋值给新对象的__proto__而不是而prototype。

再来看段代码:

var switchProto = {
isOn: function isOn() {
return this.state;
},
toggle: function toggle() {
this.state = !this.state;
return this;
},
meta: {
name: "James"
},
state: false
},
switch1 = Object.create(switchProto),
switch2 = Object.create(switchProto); var state1 = switch1.toggle().isOn();
console.log(state1); var state2 = switch2.isOn();
console.log(state2); console.log(switchProto.isOn()); /*当改变一个对象或者数组中的属性时,会有影响,这说明了这两个赋值的方式不是copy。*/
switch1.meta.name = "Bond";
console.log(switch2.meta.name); switch2.meta = { name: "zhangsan"};
console.log(switch1.meta.name);

上边代码中的switch1和switch2都指向了同一个原型,当执行完var state1 = switch1.toggle().isOn();后,state1的结果为true,而state2为false,这也正好验证了上边解释的JavaScript寻找属性或方法的原理。查找会使用原型链,赋值则不一样,如果没有该属性,直接在对象内部创建该属性,这时候跟原型没关系。

如果修改原型中的对象或数组时,需要特别注意,会对原型产生副作用,但是对对象或数组直接赋值不会产生影响。因此在原型中使用对象或数组时,要十分小心。

原型克隆

原型编程也是有缺点的,如果两个对象共享了同一原型,那么更改原型内容的话会影响到其他的对象,我们看一个例子:

var testPrototype = {
name: "James",
obj: {
objName: "objName"
}
};
var b = Object.create(testPrototype);
var c = Object.create(testPrototype);
b.obj.objName = "Test"; console.log(c.obj.objName); // => Test

上边的代码演示了共享数据造成的问题,只有理解了如何触发这些问题,才能更好的避免错误的发生。我们解决上边出现的问题的思路就是采用值拷贝,复制一份数据。

. extend() 方法在jQuery和Underscore中都存在,它的作用就是实现原型的拷贝。我们看看Underscore内部的实现方法:

_.extend = function(obj) {
each(slice.call(arguments, 1), function(source) { for (var prop in source) {
obj[prop] = source[prop];
}
});
return obj;
};

each函数会取出对象中的每一个属性,然后赋值给source,最终把所有的属性赋值给了obj。我们就不做其他演示了,这种通过遍历属性,然后赋值的方法原理是直接属性赋值,因此我们说这种方式没有使用原型继承。

这种deepCopy的思想在不同语言中还是比较重要的一个思想,在JavaScript中,我们应该使用代理原型来共享那些公共的属性,使用原型拷贝来操纵独享的数据,这是一条基本的编程原则。

The Flyweight Pattern(享元模式)

假如有一组属性和方法是被很多实例对象共享的,把他们设计成可复用的模式就是享元模式,相对于给每一个实例对象大量相同的属性和方法,享元模式大大提高了内存性能。

而JavaScript的原型非常完美的契合享元模式。假如我们要开发一款游戏,游戏中的每一个敌人都有一些共有的属性,比如名字,位置,血量值,还有一些共有的方法,比如攻击,防御等等。如果我们每创建出一个敌人对象都要把这些属性进行赋值,无疑会造成大量的性能问题。我们看看下边这段程序:

var enemyPrototype = {
name: "James",
position: {
x: 0,
y: 0
},
setPosition: function setPosition(x, y) {
this.position = {
x: x,
y: y
};
return this;
},
health: 20,
bite: function bite() { },
evade: function evade() { }
},
spawnEnemy = function () {
return Object.create(enemyPrototype);
}; var james1 = spawnEnemy();
var james2 = spawnEnemy(); james1.health = 5;
console.log(james2.__proto__);
console.log(james2.health);
console.log(james1.__proto__.health); james1.setPosition(10, 10);
console.log(james1.position);
console.log(james1.__proto__.position);
console.log(james2.position);

james1和james2这两个敌人共享了一个enemyPrototype,这也算是一个默认配置。修改了一个的属性,不会影响其他对象的属性。

值得注意的一点是,在上边我们也提到了修改原型中的Object或数组一定要小心,那么在这个例子中,我们通过setPosition这个函数解决了这个问题,核心就是这个函数中的this关键字。

Object Creation(创建对象)

关于JavaScript中对象的创建,我在这里谈两点:一种是使用构造器,另一种是使用字面量。

我们之前也提到过,使用构造器初始化对象是很面向对象的编程思想,这在JavaScript中并不推荐,原因是它不能很好的利用原型这一利器。我们看个例子:

function Car(color, direction, mph) {
var isParkingBrakeOn = false;
this.color = color || "black";
this.direction = direction || 0;
this.mph = mph || 0; this.gas = function gas(amount) {
amount = amount || 10;
this.mph += amount;
return this;
}; this.brake = function brake(amount) {
amount = amount || 10;
this.mph = (this.mph - amount) < 0 ? 0 : this.mph - amount;
return this;
}; this.toggleParkingBrake = function toggleParkingBrake() {
isParkingBrakeOn = !isParkingBrakeOn;
return this;
}; this.isParked = function isParked() {
return isParkingBrakeOn;
}
} var car = new Car();
console.log(car.color); // => black
console.log(car.gas(30).mph); // => 30
console.log(car.brake(20).mph); // => 10
console.log(car.toggleParkingBrake().isParked()); // => true

仔细观察上边的代码,可以总结出下边几点:

  • 在设计构造器函数的时候,函数名第一个字母要大写,创建对象时要使用new关键字
  • 函数内部对外暴露的属性,方法使用this关键字
  • 函数内部可以添加私有变量

最重要的是理解new Object()这一过程的原理,再次强调一下:

var a = {};
a.__proto__ = F.prototype;
F.call(a);

另一种创建方式是使用字面量:

var myCar =  {
isParkingBrakeOn: false,
color: "black",
direction: 0,
mph: 0, gas: function gas(amount) {
amount = amount || 10;
this.mph += amount;
return this;
}, brake: function brake(amount) {
amount = amount || 10;
this.mph = (this.mph - amount) < 0 ? 0 : this.mph - amount;
return this;
}, toggleParkingBrake: function toggleParkingBrake() {
this.isParkingBrakeOn = !this.isParkingBrakeOn;
return this;
}, isParked: function isParked() {
return this.isParkingBrakeOn;
}
} console.log(myCar.color); // => black
console.log(myCar.gas(30).mph); // => 30
console.log(myCar.brake(20).mph); // => 10
console.log(myCar.toggleParkingBrake().isParked()); // => true

代码稍微做了改变,实现的效果一模一样。有一个缺点是,不能使用私有变量,如果我要生成多个Car对象,需要反复的写上边的代码。那么我们应该如何批量的生产对象呢?答案就在下一个小结。

Factories (工厂方法)

我们本篇讨论的主要内容就是Object,上边我们已经提到了使用字面量的方式创建对象有一个最大的缺点就是无法使用私有变量,我们可以使用工厂方法完美解决这个问题。

工厂方法本质上就是一个函数,函数的返回值就是我们想要创建的对象。这个函数就像工厂一个样能够批量生产出规格一样的的产品。

var car = function car(color, direction, mph) {
var isParkingBrakeOn = false;
return {
color: "black",
direction: 0,
mph: 0, gas: function gas(amount) {
amount = amount || 10;
this.mph += amount;
return this;
}, brake: function brake(amount) {
amount = amount || 10;
this.mph = (this.mph - amount) < 0 ? 0 : this.mph - amount;
return this;
}, toggleParkingBrake: function toggleParkingBrake() {
this.isParkingBrakeOn = !this.isParkingBrakeOn;
return this;
}, isParked: function isParked() {
return this.isParkingBrakeOn;
}
} } var myCar = car(); console.log(myCar.color); // => black
console.log(myCar.gas(30).mph); // => 30
console.log(myCar.brake(20).mph); // => 10
console.log(myCar.toggleParkingBrake().isParked()); // => true

我们把之前的代码稍作修改,就成了一个工厂方法,每当调用car()就会产生一个对象,这就是工厂方法,他相比于构造器的优势就在于不需要使用new关键字。

到目前为止,我们已经可以使用3中方式创建对象了:

  • 构造器
  • 字面量
  • 工厂方法

还有一个好玩的事情就是在工厂方法中使用原型,一定要记住的一点是,新建的对象会继承原型中的所有属性和方法。

var carPrototype = {
gas: function gas(amount) {
amount = amount || 10; this.mph += amount; return this;
},
brake: function brake(amount) {
amount = amount || 10;
this.mph = ((this.mph - amount) < 0)? 0
: this.mph - amount; return this;
},
color: 'pink',
direction: 0,
mph: 0
},
car = function car(options) {
return extend(Object.create(carPrototype), options);
},
myCar = car({
color: 'red'
}); console.log(myCar.color);

上边这种方式最大的优点就是在创建对象时,可以为新建对象自由扩展属性和方法,这主要得益于extend函数的作用。

JavaScript是一门动态语言,我们可以使用下边的方法给carPrototype动态的扩展属性和方法:

extend(carPrototype, {
name: 'Porsche',
color: 'black',
mph: 220
});

Stamps

Stamps是一个JavaScript库,他模仿了其他面向对象语言中的类。通常我们定义了一个类,类里边有属性,方法,初始化方法,有时候某个属性还可能是另一个类。我们看看Stamps的示例代码:

const MyStamp = stampit()       // create new empty stamp
.props({ // add properties to your future objects
myProp: 'default value'
})
.methods({ // add methods to your future objects
getMyProp() {
return this.myProp;
}
})
.init(function ({value}) { // add initializers to your future objects
this.myProp = value || this.myProp;
})
.compose(AnotherStamp); // add other stamp behaviours to your objects console.log(typeof MyStamp); // 'function'
console.log(MyStamp()); // { myProp: 'default value' } console.log(typeof MyStamp().getMyProp); // 'function'
console.log(MyStamp().getMyProp()); // default value console.log(MyStamp({value: 'new value'})); // { myProp: 'new value' }
console.log(MyStamp({value: 'new value'}).getMyProp()); // new value

MyStamp的风格跟面向对象的类十分相似,谨记一点,MyStamp是一个函数,

// Some privileged methods with some private data.
const Availability = stampit().init(function() {
var isOpen = false; // private this.open = function open() {
isOpen = true;
return this;
};
this.close = function close() {
isOpen = false;
return this;
};
this.isOpen = function isOpenMethod() {
return isOpen;
}
});

上边的代码创建了一个Availability对象,在设计上,我们给这个对象提供了开和关这两个方法,并且提供了一个获取当前状态的函数isOpen,var isOpen是一个私有变量,用于保存状态信息。

// Here's a stamp with public methods, and some state:
const Membership = stampit({
methods: {
add(member) {
this.members[member.name] = member;
return this;
},
getMember(name) {
return this.members[name];
}
},
properties: {
members: {}
}
});

这段代码中,我们设计了一个会员管理类,提供了两个公共的方法add getMember和一个公共的属性members 。这些东西在外部都是可以访问的。

// Let's set some defaults:
const Defaults = stampit({
init({name, specials}) {
this.name = name || this.name;
this.specials = specials || this.specials;
},
properties: {
name: 'The Saloon',
specials: 'Whisky, Gin, Tequila'
}
});

这段代码为了演示给对像一个默认值,init({name, specials})这行代码我不太理解,{name, specials}这是什么意思?不会报错?

// Classical inheritance has nothing on this.
// No parent/child coupling. No deep inheritance hierarchies.
// Just good, clean code reusability.
const Bar = stampit(Defaults, Availability, Membership); // Create an object instance
const myBar = Bar({name: 'Moe\'s'}); // Silly, but proves that everything is as it should be.
myBar.add({name: 'Homer'}).open().getMember('Homer');

重要的事情说三遍、重要的事情说三遍、重要的事情说三遍,使用stampit最大的优点就是

04-27 03:11