简介
JavaScript中“类”和传统OOP类的不同
在其它面向对象的编程语言中,如C++、Java等,对象的使用往往是从定义一个类开始:定义类的数据成员和函数成员。在需要使用对象的时候,通过实例化类得到类的实例(对象)。但是,在JS中,情况有所不同——JS中没有类的概念。JS不具备传统的面向对象(OOP)的语言所支持的类和接口等基本结构(这一点可以阅读JavaScript高级程序设计第五版第五章开头部分)。
因而,在JS使用“类”(更合理的叫法应该是对象),更像是在已有的JS基础上去实现传统OOP语言的类和接口特性。因而,我们将会看到不同的实现策略。这也是在一开始让我产生困惑的地方,JS的类怎么可以这样定义,又可以这样定义,在别的OOP语言中定义类不是相对固定的吗?在这里,我们要记住的是,在JS使用“类”是在已有语言功能的基础上,去设计符合传统OOP特性的解决方案,因此,根据场景不同,我们可以采用不同的方案,这也是为什么没有所谓“固定方案”的原因。
(小孩子才做选择,我全要!)
传统OOP定义类的方式
以cpp为例,当我们要定义一个类,我们可以这样写:
class Person{
public:
string ID;
getID(){
return this->ID;
}
private:
string name;
int age;
};
定义类的主要目的是将对象进行封装和抽象,提供可访问的接口给外部,而外部则不需要对对象的内部有过多的了解。
同样,在JS中我们也同样希望达到这样的目的,我们又该如何在已有的基础上设计符合我们的“类”方案呢?
JS - 理解对象
JS中定定义一个对象有两种方式:
第一种方式通过创建Object实例,并向其中添加需要的数据和函数。
var Person = new Object();
Person.name = "oliver";
Person.sayName = function(){
console.log(this.name);
};
第二种方式:采用{}定义对象:
var Person = {
name: "oliver",
sayName: function(){
console.log(this.name);
}
};
属性类型
在具体介绍如何设计“类”方案之前,先简单说明JS中的数据属性。数据属性包含一个数据值的位置,可以对其进行读写。JS中定义了四个描述其行为的特性:
[[configurable]]
: 表示是否可以通过delete
删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。[[enumerable]]
: 表示是否能通过for-in循环访问属性。[[writable]]
: 表示能够修改属性的值。[[value]]
: 包含这个属性的数据值。
如之前的Person的name属性,其指定的值是"oliver",即它的value特性将被设置为"oliver",对这个值的任何修改都将反映到这个位置。
要修改属性的默认特性,必须使用Object.defineProperty()方法,这个方法接收三个参数,属性所在的对象,属性的名字和一个描述符对象。描述符对象的属性必须是configurable、enumerable、writable和value。设置其中一个或多个值,可以修改对应的特性值。
var Person = new Object();
Object.defineProperty(Person, "name", {
writable: true,
value: "oliver"
});
当定义多个属性的时候,这样显然会导致很多代码工作,可以使用Object.defineProperties()方法来定义多个属性。
var Person = new Object();
Object.defineProperties(Person,{
name:{
writable: true,
value: "oliver"
},
age:{
writable: true,
value: 18
}
});
创建对象
虽然Object构造函数或者对象字面量都可以用来创建单个对象,但这种方式的缺点也很明显:使用一个接口创建很多对象,会产生大量的重复代码。
因此需要设定合理的方案来解决这一问题。
工厂模式
工厂模式将对象创建的细节进行封装,并提供接口供对象创建的使用。
function createPerson(name, age){
var o = new Object();
o.name = "oliver";
o.age = 18;
o.sayName = function(){
console.log(this.name);
};
return o;
}
var person1 = createPerson("Kate", 18);
var person2 = createPerson("Tom", 18);
这种方式的缺点是,创建的对象并没有归属到设想的某个类,比如希望这些对象都是Person类,然而现在它们全是Object。
构造函数模式
JS中的构造函数可以用来创建特定类型的对象。想Object和Array这样的原生构造函数会在运行时自动出现在执行环境中。可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。可以使用构造函数模式将前面的例子重写如下:
function Person(name, age){
this.name = name;
this.age = age;
this.sayName = function(){
console.log(this.name);
};
}
var person1 = new Person("Kate", 18);
var person2 = new Person("Tom", 18);
构造函数模式和工厂模式相比有几个明显的区别:
- 没有显示的创建对象
- 直接将属性和方法赋给了this对象
- 没有return语句
要创建Person的实例,必须使用new操作符。这种方式调用构造函数实际上会经历一下4个步骤:
- 创建一个新对象
- 将构造函数的作用于赋给新对象(因此this就指向了这个新对象)
- 执行构造函数的这段代码(为这个新对象添加属性)
- 返回新对象
这两个对象都有constructor属性,该属性指向Person。
创建自定义的构造函数意味着将它的实例标识为一种特定的类型。这正是其优于工厂模式的地方。
console.log(person1 instanceof object); // true
console.log(person1 instanceof Person); // true
注意: 如果不使用new操作符,那么Person函数和普通函数没有区别。如果执行如下语句:
var person3 = Person("Nichole", 18);
那么name和age属性都被添加到当前的global环境中(浏览器则是window)。
构造函数模式虽然比工厂模型有所改进,但是这种模式依然是不完美的。方法成员都会在每个实例上重新创建一次。
因为在JS中函数也是一种对象,继承自Function。上述代码等价于
function Person(name, age){
this.name = name;
this.age = age;
// 与声明函数在逻辑上是等价的
this.sayName = new Function("console.log(this.name)");
}
从这个角度来看,每个实例的sayName函数都是不同的Function实例。
console.log(person1.sayName == person2.sayName); // false
为了完成同样的任务而创建两个不同的Function实例的确没有必要。可以把函数的定义转到构造函数之外,来解决问题:
function Person(name, age){
this.name = name;
this.age = age;
this.sayName = sayName;
}
function sayName(){
console.log(this.name);
}
var person1 = new Person("Kate", 18);
var person2 = new Person("Tom", 18);
虽然解决了每个Person实例中的sayName函数不同Function实例的问题,但是将sayName函数的定义转移到外部导致对象的封装性被破坏。如果对象有很多的函数,那么这些函数全都暴露在外部,就丝毫没有封装性可言。
好在这一问题可以通过原型模式进行解决。
原型模式
JS中创建的每一个函数都一个prototype(原型)属性,这个属性是一个指针,指向一个对象,这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。如果按照字面意思来理解,那么prototype就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法
(成也原型败原型,后面再说)。不必在构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中,如下面例子:
function Person(){
}
Person.prototype.name = "oliver";
Person.prototype.age = 18;
Proson.prototype.sayName = function(){
console.log(this.name);
}
var person1 = new Person();
person1.sayName(); // oliver
var person2 = new Person();
person2.sayName(); // oliver
console.log(person1.sayName == person2.sayName); // true
关于原型的一些用法细节不是这里讨论的重点,具体可以阅读JavaScript高级程序设计 第五版 6.2.3节 原型模式
。
这里要说的一点是,虽然我们可以利用共享原型的方法来解决sayName函数是不同Function实例问题,但原型的共享同样也导致了其他数据属性的共享
。
person1.sayName(); // oliver
person2.sayName(); // oliver
person1.name = "Kate";
person1.sayName(); // Kate
person2.sayName(); // Kate
我们期望的对象实例用于独自的数据成员,共享函数成员,但是原型模式的这种共享却只实现了我们一半的愿望。相信聪明的读者已经想到了,我们可以用构造函数模式去让每个实例拥有独立的数据成员,而用原型模式去共享函数成员。于是,有了下面的组合使用模型。
组合使用构造函数模式和原型模式
组合模式集上面两种模式的优点,是一种广泛使用的创建自定义类型的方法。我们用组合模式重写上面的例子:
function Person(name, age){
this.name = name;
this.age = age;
}
Person.prototype = {
constructor: Person,
sayName: function(){
console.log(tihs.name);
}
}
var person1 = new Person1("Kate", 18);
var person2 = new Person2("Tom", 18);
console.log(person1.sayName == person2.sayName); // true
console.log(person1.name == person2.name); // false
这种模式可以说是一种定义引用类型的默认模式。
总结
本文讨论了定义对象的几种方式,虽然常用的是组合使用构造函数模式和原型模式
,但是了解工厂模式,构造函数模式和原型模式对于理解JS中对象的定义有着很好帮助。
工厂模式简化了对象的创建代码工程,但是却不能将实例划归到具体的对象,这些实例都是Object。
构造函数模式虽然能将实例划归到具体的对象类型,如person1和person2都是Person对象。但是构造模式所定义的每一个成员函数都是不同的,这和对象的设计思想不符合。
原型模式则能够将成员共享,但这种共享却将数据成员也一同共享了。
混合使用构造函数模式和原型模式,利用两者的优势,解决了上面提到的问题,是一种广泛运用的定义引用类型的方案。
2018年12月02日17:12:20