类
在JavaScript中,类的实现是基于原型继承机制的。如果两个实例都从同一个原型对象上继承了属性,我们说它们是同一个类的实例。
构造函数
从某种意义上讲,定义构造函数即是定义类,所以构造函数名首字母要大写,而普通的函数都是首字母小写。
// 构造函数,首字母大写
// 注意,这里并没有创建并返回一个对象,仅仅是初始化
function Range(from , to) {
// 添加2个属性,这2个属性是不可继承的,每个对象都拥有唯一的属性
this.from = from;
this.to = to;
}
// 构造函数的原型对象
Range.prototype = {
includes: function(x) { return this.from <= x && x <= this.to; },
foreach: function(f) {
for(var x = Math.ceil(this.from); x <= this.to; x++)
f(x);
},
toString: function() { return "(" + this.from + "..." + this.to + ")"; }
};
// 直接调用构造函数
// 报错"TypeError: a is undefined",因为Range()没有返回值(即返回undefined)
var a = Range(1, 3);
// 通过new构造Range对象并给this赋值
var r = new Range(1, 3);
console.log(r.includes(2)); // true,2在范围内
r.foreach(console.log); // 输出1 2 3
console.log(r); // 输出(1...3)
类的标识
原型对象是类的唯一标识,如果两个构造函数的prototype属性指向同一个原型对象,那么这两个构造函数创建的实例是属于同一类的。
构造函数通常用做类名,当使用instanceof运算符来检测对象是否属于某个类时会用到构造函数。
r instanceof Range
实际上instanceof运算符检查r是否继承自Range.prototype。
constructor属性
任何JavaScript函数都可以用做构造函数,并且每个JavaScript函数(ECMAScript5中的Function.bind()方法返回的函数除外)都拥有一个prototype属性。这个属性的值是一个对象,这个对象包含唯一一个不可枚举属性constructor,constructor属性的值是一个函数。
var F = function() {};
console.log(typeof F); // function
console.log(F.prototype.constructor === F) // true,对于任意函数F.prototype.constructor == F
var o = new F();
console.log(typeof o); // object
console.log(o.constructor === F); // true
但是在上面的例子Range()中,Range重新定义了prototype,所以创建对象的constructor属性将不再是Range(),而是直接使用Object.prototype.construtor,即Object()。
为了解决这个问题,可以在定义prototype时,显式指定constructor属性的值,如下:
Range.prototype = {
constructor: Range, // 显式指定构造函数
...
};
属性、方法特性
在JavaScript中,属性和方法可以分为以下几种:
类别 | 含义 |
---|---|
实例属性 | 它们是基于实例的属性或变量,用以保存独立对象的状态。 |
实例方法 | 它们是类的所有实例所共享的方法,由每个独立的实例调用。实例方法中使用this存取实例属性。 |
类属性 | 这些属性是属于类的,而不是属于类的某个实例的。 |
类方法 | 这些方法是属于类的,而不是属于类的某个实例的。 |
function Complex(real, imaginary) {
if(isNaN(real) || isNaN(imaginary))
throw new TypeError();
// 定义2个 “实例属性”
this.r = real;
this.i = imaginary;
}
// 定义2个 “实例方法”
Complex.prototype.add = function(that) {
return new Complex(this.r + that.r, this.i + that.i);
};
Complex.prototype.toString = function() {
return "{" + this.r + "," + this.i + "}";
};
// 定义2个 “类属性”
Complex.ZERO = new Complex(0, 0);
Complex.ONE = new Complex(1, 0);
// 定义1个 “类方法”
Complex.equals = function(that) {
return that != null &&
that.constructor === Complex && // 判断相同类型
this.r === that.r && this.i === that.i;
};
var c = new Complex(2, 3); // 使用构造函数创建新的对象
var d = new Complex(c.i, c.r); // 使用c的 “实例属性”
c.add(d).toString(); // "{5,5}",使用 “实例方法”
类的扩充
JavaScript中基于原型的继承机制是动态的:对象从原型继承属性,如果创建对象之后,原型的属性发生改变,会影响到继承这个原型的所有实例对象。
我们可以通过给原型对象添加新方法来扩充JavaScript类,如下所示:
String.prototype.trim = String.prototype.trim || function() {
if(!this) return this; // 空字符串不做处理
return this.replace(/^\s+|\s+$/g, "");
};
对象类型的判断
使用instanceof运算符
instanceof运算符的右操作数是构造函数,但计算过程实际上是检测了对象的继承关系,而不是检测创建对象的构造函数。
还可以使用isPrototypeOf()方法来判断原型链上是否存在某个特定的原型对象。
Range.prototype.isPrototypeOf(r);
这种方法的缺点是:
在两个不同框架页面创建的两个数组继承自两个相同但相互独立的原型对象,其中一个框架页面中的数组不是另一个框架页面的Array()构造函数的实例,instanceof运算符结果是false。
使用constructor属性
构造函数是类的公共标识,所以最直接的方法是使用constructor属性,如下:
function typeAndValue(x) {
if(x == null) return ""; // null和undefined没有构造函数
switch(x.constructor) {
// 原始类型
case Number: return "Number: " + x;
case String: return "String: " + x;
// 内置类型
case Date: return "Date: " + x;
case RegExp: return "RegExp: " + x;
// 自定义类型
case Complex: return "Complex: " + x;
}
}
这种方式的缺点同使用instanceof一样。
使用构造函数名称
一个函数里的Array()构造函数和另一个窗口中的Array()构造函数是不相等的,但是它们的名字是一样的。所以可以通过构造函数名来判断对象类型。
function type(o) {
var t, c, n; // type, class, name
// 处理null值的特殊情形
if(o === null) return "null"
// 处理NaN,NaN和它自身不相等
if(o !== o) return "nan";
// 原始类型处理:Number, String, Boolean
if((t = typeof o) !== "object") return t;
// 内置类型处理:Date, RegExp
if((c = classof(o)) !== "Object") return c;
// 自定义类型处理
if(o.constructor && typeof o.constructor === "function" &&
(n = o.constructor.getName()))
return n;
return "Object";
}
function classof(o) {
return Object.prototype.toString.call(o).slice(8, -1);
}
Function.prototype.getName = function() {
if("name" in this) return this.name;
return this.name = this.toString().match(/function\s*([^(]*)\(/))[1]);
};
此种方式的缺点是:
如果函数是匿名函数,则getName()返回空字符串,无法进行类型判断。
鸭式辩型
上面提到的各种技术都有些问题,规避掉这些问题的办法是:不要关注”对象的类是什么”,而是关注”对象能做什么”。
下面给出一个判断对象是否实现了参数列出的方法:
function quacks(o /*, ... */) {
for(var i=1; i < arguments.length; i++) {
var arg = arguments[i];
switch(typeof arg) {
case "string":
if(typeof o[arg] !== "function") return false;
continue;
case "function":
arg = arg.prototype; // 进入下一个case
case "object":
for(var m in arg) {
if(typeof arg[m] !== "function") continue; // 跳过不是方法的属性
if(typeof o[m] !== "function") return false;
}
}
}
return true;
}
这个函数有2点局限性:
- 只是通过函数名来判断函数是否存在,而没有关注细节信息(函数参数、参数类型等)。
- 不能应用于内置类型,因为内置类型的方法是不可枚举的。
JavaScript中的面向对象技术
枚举类型的实现
function enumeration(namesToValues) {
var enumeration = function() { throw "Can‘t Instantiate Enumeration"; }
var proto = enumeration.prototype = {
constructor: enumeration,
toString: function() { return this.name; },
valueOf: function() { return this.value; },
toJSON: function() { return this.name; }
};
enumeration.values = [];
for(name in namesToValues) {
// e使用enumeration的原型对象,即与enumberation是同一类型
var e = inherit(proto);
e.name = name;
e.value = namesToValues[name];
// 定义枚举值
enumeration[name] = e;
enumeration.values.push(e);
}
enumeration.foreach = function(f, c) {
for(var i=0; i < this.values.length; i++)
f.call(c, this.values[i]);
};
return enumeration;
}
// 使用4个值创建枚举对象
var Coin = enumeration({Penny: 1, Nickel: 5, Dime: 10, Quarter: 25});
var c = Coin.Dime;
c instanceof Coin; // true
c.constructor == Coin; // true
Coin.Quarter + 3*Coin.Nickel; // 调用valueOf()函数
对象的值比较
JavaScript的相等运算符比较对象时,比较的是引用而不是值。
我们可以自定义值比较函数,可分为2步实现:
- 类型比较
- 属性值比较
// 判断2个集合是否相等
Set.prototype.equals = function(that) {
// 一些次要情况的快捷处理
if(this === that) return true;
// 判断参数是否是集合类型
if(! (that instanceof Set)) return false;
// 判断2个集合大小是否相等
if(this.size() != that.size()) return false;
// 逐个判断每个元素的值
try {
this.foreach(function(v) {
if(!that.contains(v))
// 通过抛异常来终止foreach循环
throw false; });
return true;
} catch(x) {
if(x === false) return false;
throw x; // 重新抛出异常
}
};
方法借用(borrowing)
一个函数可以赋值给2个属性,然后作为2个方法来调用它。把一个类的方法用到其他的类中的做法称为”方法借用”。
私有变量
在经典的面向对象编程中,允许声明类的”私有”实例字段,这些私有实例字段只能被类的实例方法访问,在类的外部是不可见的。
在JavaScript中可以通过将变量(或参数)闭包在一个构造函数内来模拟实现私有实例字段。
// 将Range类的端点进行简单封装
function Range(from, to) {
this.from = function() { return from; }
this.to = function() { return to; }
}
但需要注意的是,这种封装技术占用更多的内存,并且运行速度更慢。
重载构造函数
我们可以通过重载构造函数来执行不同的初始化方法,注意:重载后,原始的构造函数不再可用。
下面给出重载Set()构造函数的代码:
function Set() {
this.values = {};
this.n = 0;
if(arguments.length == 1 && isArrayLike(arguments[0]))
this.add.apply(this, arguments[0]);
else if(arguments.length > 0)
this.add.apply(this, arguments);
}
子类(subclass & superclass)
JavaScript的对象可以从类的原型对象中继承属性。如果O是类B的实例,B是A的子类,那么O也一定从A中继承了属性。
定义子类
// 定义Set的子类,它的成员不能是null和undefined
function NonNullSet() {
// 直接调用父类的构造函数
Set.apply(this, arguments);
}
// 将NonNullSet设置为Set的子类
NonNullSet.prototype = inherit(Set.prototype)
// 设置constructor属性
NonNullSet.prototype.constructor = NonNullSet;
// 重写add()方法,不接收null和undefined
NonNullSet.prototype.add = function() {
for(var i=0; i < arguments.length; i++)
if(arguments[i] == null)
throw new Error("Can‘t add null or undefined to a NonNullSet.‘");
// 调用父类的add()方法
return Set.prototype.add.apply(this, arguments);
};
对象组合
面向对象编程中有一条设计原则:组合优于继承。
下面使用组合代替继承:
function NonNullSet(set) {
// 存储集合属性
this.set = set;
}
NonNullSet.prototype.add = function() {
for(var i=0; i < arguments.length; i++)
if(arguments[i] == null)
throw new Error("Can‘t add null or undefined to a NonNullSet.‘");
// 使用存储的集合对象
return this.set.add.apply(this.set, arguments);
};
抽象类
JavaScript中也可以模拟实现抽象类,如下:
// 定义一个抽象方法
function abstractmethod() { throw new Error("abstract method"); }
// 定义一个抽象类
function AbstractSet() { throw new Error("Can‘t instantiate abstract classes"); }
AbstractSet.prototype.contains = abstractmethod;
// 定义一个非抽象子类(重定义了contains()方法)
// SingletonSet是只读的,只包含一个成员
var SingletonSet = AbstractSet.extend(
function SingletonSet(member) { this.member = member; },
{
contains: function(x) { return x === this.member; },
size: function() { return 1; }
}
);
ECMAScipt5中的类
ECMAScipt5给属性特性增加了方法支持(getter、setter、可枚举性、可写性和可配置性),而且增加了对象可扩展性的限制。
定义不可变的类
// Range的属性都是只读的
function Range(from, to) {
var props = {
// writable, configurable属性值都为false
from: { value: from, enumerable: true, writable: false, configurable: false },
to: { value: to, enumerable: true, writable: false, configurable: false }
};
if(this instanceof Range) // 如果作为构造函数来调用
Object.defineProperties(this, props);
else // 否则,作为工厂方法来调用
return Object.create(Range.prototype, props);
}
封装对象状态变量
getter和setter方法可以更健壮地将状态变量封装起来,并且这2个方法是无法删除的。
function Range(from, to) {
function getFrom() { return from; }
function getTo() { return to; }
// 设置getter
Object.defineProperties(this, {
from: { get: getFrom, enumerable: true, configurable: false },
to: { get: getTo, enumerable: true, configurable: false }
});
}
防止类的扩展
Object.preventExtensions()可以将对象设置为不可扩展的,即不可添加新属性。
Object.seal()不只将对象设置为不可扩展,同时还将属性设置为不可配置。
模块
一般来讲,模块是一个独立的JavaScript文件。模块文件可以包含一个类定义、一组相关的类、一个实用函数库的代码。
使用对象作为命名空间
在模块创建过程中避免污染全局变量的一种方法是使用一个对象作为命名空间。
var collections; // 声明一级命名空间"collections"
if(!collections)
collections = {};
collections.sets = {}; // 声明二级命名空间"sets"
当使用模块文件时,可将模块内的命名空间直接导入到全局命名空间中,如:
var Set = collections.sets;
// 可以直接使用Set来使用collections.sets
// ...
使用函数作为命名空间
在一个函数中定义的变量和函数都属于函数的局部成员,在函数的外部是不可见的。实际上,可以将这个函数作用域用做模块的私有命名空间。
var collections;
if(!collections)
collections = {};
collections.sets = {};
// 使用函数做命名空间
// 立即执行立即得到定义
(function namespace() {
collections.sets.AbstractSet = AbstractSet;
collections.sets.NonNullSet = NonNullSet;
// ...
}());