深入理解JavaScript中的面向对象(三)

Heero.Luo发表于9年前,已被查看2193次

在JavaScript中,虽然借助原型链就可以实现继承,但这里面还是有很多细节问题的要处理的。分析并解决这些问题后,就可以把创建类的过程写成一个通用函数了。

constructor属性

JavaScript中的对象都有一个constructor的属性指向其构造函数。例如:

function A() { }
var a = new A();
a.constructor; // A

确切地说,constructor属性是位于构造函数的prototype上。下面的代码可以证实这一规则:

function A() { }

var a = new A();
console.log(a.constructor); // A

delete A.prototype.constructor; // 删除原型上的constructor属性
console.log(a.constructor); // Object

由于删除了A.prototype下的constructor属性,所以访问a.constructor的时候,在原型链中的查找就得查到Object.prototype上去,而Object.prototype.constructor自然就是Object。

现在看一下简单的原型链继承方案带来的问题:

function A() { }
function B() { }
B.prototype = new A();

var a = new A();
a.constructor; // A
var b = new B();
b.constructor; // A

可见,b的constructor应为B,但却成了A。原因是:b.constructor即B.prototype.constructor,而此时B.prototype是一个A对象,A对象的constructor即A.prototype.constructor,而A.prototype.constructor正是A。

幸好constructor是一个可写的属性,所以只要重新设定这个值,问题就解决了:

function A() { }
function B() { }
B.prototype = new A();
B.prototype.constructor = B; // important

var a = new A();
a.constructor; // A
var b = new B();
b.constructor; // B

instanceof操作符

从字面意思来看,instanceof用于判断某个对象是否某个类的实例,但准确地说,它是用于检测某个对象的原型链中是否包含某个构造函数的prototype。举个例子:

var arr = new Array();
arr instanceof Array; // true
arr instanceof Object; // true

由于Array.prototype和Object.prototype都在arr的原型链中,所以上面的测试结果均为true。另外还要注意,instanceof的检测只跟原型链有关,跟constructor属性没有任何关系。所以,基于原型链的继承不会影响到instanceof的检测。

带参数的构造函数

前面通过原型链实现继承的例子中,构造函数都是不带参数的,一旦有参数,这个问题就复杂很多了。先看看下面的例子:

function A(data) {
	this.name = data.name;
}
A.prototype.sayName = function() {
	console.log(this.name);
};
function B() { }
B.prototype = new A();
B.prototype.constructor = B;

var b = new B();

这段代码运行的时候会产生异常:

Cannot read property 'name' of undefined

出现异常的代码就是A构造函数内的那一行。出现异常的原因是:要访问data.name,就得保证data不为null或undefined,但是执行B.prototype=new A()时,却没有传参数进去,此时data为undefined,访问data.name就会出现异常。

仅解决这个问题并不难,只要在new A()时传入一个不为null且不为undefined的参数就行:

function A(data) {
	this.name = data.name;
}
A.prototype.sayName = function() {
	console.log(this.name);
};
function B() { }
B.prototype = new A({ });
B.prototype.constructor = B;

var b = new B();
b.sayName(); // undefined

然而,实际情况远没有这么简单。

其一,A的参数可能不止一个,其内部逻辑也可能更为复杂,随便传参数进去很有可能导致异常。要彻底解决这个问题,可以借助一个空函数:

function A(data) {
	this.name = data.name;
}
A.prototype.sayName = function() {
	console.log(this.name);
};
function B() { }

function Empty() { }
Empty.prototype = A.prototype; // important
B.prototype = new Empty();
B.prototype.constructor = B;

var b = new B();
b.sayName(); // undefined

Empty即为该空函数,它的prototype被更改为A.prototype,即Empty与A共享同一个prototype。因此,在忽略构造函数内部逻辑的前提下,把B.prototype设成Empty的实例跟设成A的实例效果是一样的。但因为Empty内部没有逻辑,所以new Empty()肯定不会产生异常。

此外,ES5中的Object.create也可以解决这个问题:

function A(data) {
	this.name = data.name;
}
A.prototype.sayName = function() {
	console.log(this.name);
};

function B() { }
B.prototype = Object.create(A.prototype); // important
B.prototype.constructor = B;

var b = new B();
b.sayName(); // undefined

其二,很多时候我们需要把子类构造函数的参数传给父类构造函数。比如说达到这样的效果:

var b = new B({ name: 'b1' });
b.name; // 'b1'

这就需要在子类构造函数中调用父类构造函数:

function A(data) {
	this.name = data.name;
}
function B() {
	A.apply(this, arguments); //important
}

function Temp() { }
Temp.prototype = A.prototype;
B.prototype = new Temp();
B.prototype.constructor = B;

var b = new B({ name: 'b1' });
console.log(b.name);

通过A.apply(this, arguments)就可以确保操作的对象为当前对象(this),且把所有参数(arguments)传到A。

createClass函数

总算是完全解决了这些细节问题,为了不在每次创建类的时候都要写这么一大堆代码,我们把这个过程写成一个函数:

function createClass(constructor, methods, Parent) {
	var $Class = function() {
		// 有父类的时候,需要调用父类构造函数
		if (Parent) {
			Parent.apply(this, arguments);
		}
		constructor.apply(this, arguments);
	};

	if (Parent) {
		// 处理原型链
		var $Parent = function() { };
		$Parent.prototype = Parent.prototype;
		$Class.prototype = new $Parent();
		// 重设constructor
		$Class.prototype.constructor = $Class;
	}

	if (methods) {
		// 复制方法到原型
		for (var m in methods) {
			if ( methods.hasOwnProperty(m) ) {
				$Class.prototype[m] = methods[m];	
			}
		}
	}

	return $Class;
}

在这个函数的基础上,把计算周长的问题解决掉:

// 形状类
var Shape = createClass(function() {
	this.setName('形状');
}, {
	getName: function() { return this._name; },
	setName: function(name) { this._name = name; },
	perimeter: function() { }
});

// 矩形类
var Rectangle = createClass(function() {
	this.setLength(0);
	this.setWidth(0);
	this.setName('矩形');
}, {
	setLength: function(length) {
		if (length < 0) {
			throw new Error('...');
		}
		this.__length = length;
	},
	getLength: function() { return this.__length; },
	setWidth: function(width) {
		if (width < 0) {
			throw new Error('...');
		}
		this.__width = width;
	},
	getWidth: function() { return this.__width; },
	perimeter: function() {
		return (this.__length + this.__width) * 2;
	}
}, Shape);

// 正方形类
var Square = createClass(function() {
	this.setLength(0);
	this.setName('正方形');
}, {
	setLength: function(length) {
		if (length < 0) {
			throw new Error('...');
		}
		this.__length = length;
	},
	getLength: function() { return this.__length; },
	perimeter: function() {
		return this.__length * 4;
	}
}, Shape);

// 圆形
var Circle = createClass(function() {
	this.setRadius(0);
	this.setName('圆形');
}, {
	setRadius: function(radius) {
		if (radius < 0) {
			throw new Error('...');
		}
		this.__radius = radius;
	},
	getRadius: function() { return this.__radius; },
	perimeter: function() {
		return 2 * Math.PI * this.__radius;
	}
}, Shape);


function computePerimeter(shape) {
	console.log( shape.getName() + '的周长是' + shape.perimeter() );
}

var rectangle = new Rectangle();
rectangle.setWidth(10);
rectangle.setLength(20);
computePerimeter(rectangle);

var square = new Square();
square.setLength(10);
computePerimeter(square);

var circle = new Circle();
circle.setRadius(10);
computePerimeter(circle);

最后

最后总结一下在JavaScript中模拟面向对象的要点:

  • new的是构造函数,而不是类;
  • 属性写在构造函数内,方法写到原型链上;
  • 继承可以通过原型链实现;
  • 封装难以实现,可通过代码规范来约束。

此外,鉴于构造函数是函数,普通函数也是函数,建议通过不同的命名规则区分它们:给构造函数命名时使用Pascal命名法,给普通函数命名时使用驼峰命名法。

评论 (1条)

发表评论

(必填)

(选填,不公开)

(选填,不公开)

(必填)