本文深入解析JavaScript基于原型的继承机制,详细阐述prototype、constructor与this关键字的工作原理。通过构造函数模拟类、原型链链接及继承实现中的常见问题,结合经典库实现方案,帮助开发者掌握JS面向对象编程的核心概念与最佳实践。
面向对象与基于对象
绝大多数开发人员都具备面向对象语言(如C++、C#、Java)的开发经验。在传统面向对象范式中,类和实例是两个核心概念:类定义了一类事物的公共行为与方法,而实例则是类的具体实现。此外,封装、继承和多态被视为面向对象编程的三大支柱。
然而在JavaScript中,上述特性似乎并不直接存在。JavaScript并非纯粹的面向对象语言,而是一种基于对象的语言。其显著特征在于万物皆对象,包括字符串、数组、日期、数字,甚至函数也是对象。例如:
// 定义一个函数 - add
function add(a, b) {
add.invokeTimes++;
return a + b;
}
// 因为函数本身也是对象,这里为函数add定义一个属性,用来记录此函数被调用的次数
add.invokeTimes = 0;
add(1 + 1);
add(2 + 3);
console.log(add.invokeTimes); // 2
模拟JavaScript中类和继承
在面向对象语言中,我们使用类来创建自定义对象。既然JavaScript中所有事物都是对象,那么该如何创建自定义对象呢?
这便引入了原型(prototype)的概念。我们可以将prototype视为一种模板,新创建的自定义对象都是该模板的一个拷贝(实际上并非拷贝,而是内部存在一个不可见的链接,给人以拷贝的错觉)。
以下示例展示了如何通过prototype创建自定义对象:
// 构造函数
function Person(name, sex) {
this.name = name;
this.sex = sex;
}
// 定义Person的原型,原型中的属性可以被自定义对象引用
Person.prototype = {
getName: function() {
return this.name;
},
getSex: function() {
return this.sex;
}
}
在此例中,函数Person被称为构造函数,即用于创建自定义对象的函数。由此可见,JavaScript通过构造函数结合原型的方式,模拟实现了类的功能。创建自定义对象(即实例化类)的代码如下:
var zhang = new Person("ZhangSan", "man");
console.log(zhang.getName()); // "ZhangSan"
var chun = new Person("ChunHua", "woman");
console.log(chun.getName()); // "ChunHua"
当执行代码var zhang = new Person("ZhangSan", "man")时,内部实际上执行了以下步骤:
- 创建一个空白对象(new Object())。
- 将Person.prototype中的属性(键值对)拷贝到该空对象中(尽管前文提到内部实现是隐藏链接,但此处表现为属性复制的效果)。
- 将该对象通过this关键字传递给构造函数并执行构造函数。
- 将该对象赋值给变量zhang。
为了证明prototype模板并非被拷贝到实例化对象中,而是采用链接方式,请参考以下代码:
function Person(name, sex) {
this.name = name;
this.sex = sex;
}
Person.prototype.age = 20;
var zhang = new Person("ZhangSan", "man");
console.log(zhang.age); // 20
// 覆盖prototype中的age属性
zhang.age = 19;
console.log(zhang.age); // 19
delete zhang.age;
// 在删除实例属性age后,此属性值又从prototype中获取
console.log(zhang.age); // 20
这种在JavaScript内部实现的隐藏prototype链接,是JavaScript运行的基础,也是模拟实现继承的关键所在。
如何在JavaScript中实现简单的继承?以下示例将创建一个雇员类Employee,它从Person继承了原型prototype中的所有属性。
function Employee(name, sex, employeeID) {
this.name = name;
this.sex = sex;
this.employeeID = employeeID;
}
// 将Employee的原型指向Person的一个实例
// 因为Person的实例可以调用Person原型中的方法, 所以Employee的实例也可以调用Person原型中的所有属性。
Employee.prototype = new Person();
Employee.prototype.getEmployeeID = function() {
return this.employeeID;
};
var zhang = new Employee("ZhangSan", "man", "1234");
console.log(zhang.getName()); // "ZhangSan
上述关于继承的实现较为粗糙,且存在诸多问题:
- 在创建Employee构造函数和原型(后文简称类)时,便对Person进行了实例化,这种做法并不恰当。
- Employee的构造函数无法调用父类Person的构造函数,导致在Employee构造函数中对name和sex属性进行重复赋值。
- Employee中的函数会覆盖Person中的同名函数,缺乏重载机制(此问题与前一条性质相同)。
- 创建JavaScript类的语法过于零散,不如C#/Java中的语法优雅。
- 实现中存在constructor属性指向错误的问题,这将在第二篇文章中讨论。
我们将在第三章完善这个例子。
JavaScript继承的实现
鉴于JavaScript本身缺乏完整的类和继承实现,且手工实现方式存在诸多缺陷,网上已涌现出多种解决方案:
- Douglas Crockford - Prototypal Inheritance in JavaScript
- Douglas Crockford - Classical Inheritance in JavaScript
- John Resig - Simple JavaScript Inheritance
- Dean Edwards - A Base Class for JavaScript Inheritance
- Prototype
- Mootools
- Extjs
本系列文章将逐一深入分析这些实现,最终旨在深入理解JavaScript中类和继承的实现方式。
下一章我们将介绍类实现中的相关知识,包括this、constructor、prototype等。
本章将重点介绍JavaScript中几个关键属性(this、constructor、prototype),这些属性对于理解JavaScript类和继承的实现至关重要。
this
this表示当前对象。若在全局作用域使用this,则指代当前页面对象window;若在函数中使用this,其指代对象取决于运行时该函数被调用的上下文。我们还可以使用apply和call两个全局方法来改变函数中this的具体指向。
首先看一个在全局作用域使用this的示例:
<script type="text/javascript">
console.log(this === window); // true
console.log(window.alert === this.alert); // true
console.log(this.parseInt("021", 10)); // 10
</script>
函数中的this是在运行时决定的,而非函数定义时,如下所示:
// 定义一个全局函数
function foo() {
console.log(this.fruit);
}
// 定义一个全局变量,等价于window.fruit = "apple";
var fruit = "apple";
// 此时函数foo中this指向window对象
// 这种调用方式和window.foo();是完全等价的
foo(); // "apple"
// 自定义一个对象,并将此对象的属性foo指向全局函数foo
var pack = {
fruit: "orange",
foo: foo
};
// 此时函数foo中this指向window.pack对象
pack.foo(); // "orange"
全局函数apply和call可用于改变函数中this的指向,示例如下:
// 定义一个全局函数
function foo() {
console.log(this.fruit);
}
// 定义一个全局变量
var fruit = "apple";
// 自定义一个对象
var pack = {
fruit: "orange"
};
// 等价于window.foo();
foo.apply(window); // "apple"
// 此时foo中的this === pack
foo.apply(pack); // "orange"
注:apply和call两个函数的作用相同,唯一区别在于参数定义不同。
由于在JavaScript中函数也是对象,因此可以看到如下有趣的示例:
// 定义一个全局函数
function foo() {
if (this === window) {
console.log("this is window.");
}
}
// 函数foo也是对象,所以可以定义foo的属性boo为一个函数
foo.boo = function() {
if (this === foo) {
console.log("this is foo.");
} else if (this === window) {
console.log("this is window.");
}
};
// 等价于window.foo();
foo(); // this is window.
// 可以看到函数中this的指向调用函数的对象
foo.boo(); // this is foo.
// 使用apply改变函数中this的指向
foo.boo.apply(window); // this is window.
prototype
我们在第一章中已使用prototype模拟类和继承的实现。prototype本质上仍是一个JavaScript对象,且每个函数都有一个默认的prototype属性。若该函数用于创建自定义对象的场景,则称该函数为构造函数。例如以下简单场景:
// 构造函数
function Person(name) {
this.name = name;
}
// 定义Person的原型,原型中的属性可以被自定义对象引用
Person.prototype = {
getName: function() {
return this.name;
}
}
var zhang = new Person("ZhangSan");
console.log(zhang.getName()); // "ZhangSan"
作为类比,我们考虑JavaScript中的数据类型——字符串(String)、数字(Number)、数组(Array)、对象(Object)、日期(Date)等。我们有理由相信,在JavaScript内部,这些类型均作为构造函数来实现,例如:
// 定义数组的构造函数,作为JavaScript的一种预定义类型
function Array() {
// ...
}
// 初始化数组的实例
var arr1 = new Array(1, 56, 34, 12);
// 但是,我们更倾向于如下的语法定义:
var arr2 = [1, 56, 34, 12];
同时,对数组操作的许多方法(如concat、join、push)也应在prototype属性中定义。
实际上,JavaScript所有固有数据类型都具有只读的prototype属性(这是合理的:若修改这些类型的prototype属性,预定义的方法将消失),但我们可以向其中添加自己的扩展方法。
// 向JavaScript固有类型Array扩展一个获取最小值的方法
Array.prototype.min = function() {
var min = this[0];
for (var i = 1; i < this.length; i++) {
if (this[i] < min) {
min = this[i];
}
}
return min;
};
// 在任意Array的实例上调用min方法
console.log([1, 56, 34, 12].min()); // 1
注意:这里有一个陷阱,向Array的原型中添加扩展方法后,当使用for-in循环数组时,该扩展方法也会被循环出来。
以下代码说明了这一点(假设已向Array的原型中扩展了min方法):
var arr = [1, 56, 34, 12];
var total = 0;
for (var i in arr) {
total += parseInt(arr[i], 10);
}
console.log(total); // NaN
解决方法也很简单:
var arr = [1, 56, 34, 12];
var total = 0;
for (var i in arr) {
if (arr.hasOwnProperty(i)) {
total += parseInt(arr[i], 10);
}
}
console.log(total); // 103
constructor
constructor始终指向创建当前对象的构造函数。例如以下示例:
// 等价于 var foo = new Array(1, 56, 34, 12);
var arr = [1, 56, 34, 12];
console.log(arr.constructor === Array); // true
// 等价于 var foo = new Function();
var Foo = function() { };
console.log(Foo.constructor === Function); // true
// 由构造函数实例化一个obj对象
var obj = new Foo();
console.log(obj.constructor === Foo); // true
// 将上面两段代码合起来,就得到下面的结论
console.log(obj.constructor.constructor === Function); // true
但当constructor遇到prototype时,有趣的事情便发生了。我们知道每个函数都有一个默认的属性prototype,而该prototype的constructor默认指向该函数。如下例所示:
function Person(name) {
this.name = name;
};
Person.prototype.getName = function() {
return this.name;
};
var p = new Person("ZhangSan");
console.log(p.constructor === Person); // true
console.log(Person.prototype.constructor === Person); // true
// 将上两行代码合并就得到如下结果
console.log(p.constructor.prototype.constructor === Person); // true
然而,当我们重新定义函数的prototype时(注意:此处是覆盖而非修改),constructor的行为便显得奇怪,如下示例:
function Person(name) {
this.name = name;
};
Person.prototype = {
getName: function() {
return this.name;
}
};
var p = new Person("ZhangSan");
console.log(p.constructor === Person); // false
console.log(Person.prototype.constructor === Person); // false
console.log(p.constructor.prototype.constructor === Person); // false
这是为什么呢?
原因在于,覆盖Person.prototype时,等价于执行以下代码操作:
Person.prototype = new Object({
getName: function() {
return this.name;
}
});
而constructor始终指向创建自身的构造函数,因此此时Person.prototype.constructor === Object,即:
function Person(name) {
this.name = name;
};
Person.prototype = {
getName: function() {
return this.name;
}
};
var p = new Person("ZhangSan");
console.log(p.constructor === Object); // true
console.log(Person.prototype.constructor === Object); // true
console.log(p.constructor.prototype.constructor === Object); // true
如何修正这种问题?方法也很简单,重新覆盖Person.prototype.constructor即可:
function Person(name) {
this.name = name;
};
Person.prototype = new Object({
getName: function() {
return this.name;
}
});
Person.prototype.constructor = Person;
var p = new Person("ZhangSan");
console.log(p.constructor === Person); // true
console.log(Person.prototype.constructor === Person); // true
console.log(p.constructor.prototype.constructor === Person); // true
下一章我们将对第一章提到的Person-Employee类和继承的实现进行完善。
posted @
2009-07-08 16:30三生石上 阅读(512) |
评论 (1) | 编辑面向对象与基于对象
绝大多数开发人员都具备面向对象语言(如C++、C#、Java)的开发经验。在传统面向对象范式中,类和实例是两个核心概念:类定义了一类事物的公共行为与方法,而实例则是类的具体实现。此外,封装、继承和多态被视为面向对象编程的三大支柱。
然而在JavaScript中,上述特性似乎并不直接存在。JavaScript并非纯粹的面向对象语言,而是一种基于对象的语言。其显著特征在于万物皆对象,包括字符串、数组、日期、数字,甚至函数也是对象。例如:
// 定义一个函数 - add
function add(a, b) {
add.invokeTimes++;
return a + b;
}
// 因为函数本身也是对象,这里为函数add定义一个属性,用来记录此函数被调用的次数
add.invokeTimes = 0;
add(1 + 1);
add(2 + 3);
console.log(add.invokeTimes); // 2
模拟JavaScript中类和继承
在面向对象语言中,我们使用类来创建自定义对象。既然JavaScript中所有事物都是对象,那么该如何创建自定义对象呢?
这便引入了原型(prototype)的概念。我们可以将prototype视为一种模板,新创建的自定义对象都是该模板的一个拷贝(实际上并非拷贝,而是内部存在一个不可见的链接,给人以拷贝的错觉)。
以下示例展示了如何通过prototype创建自定义对象:
// 构造函数
function Person(name, sex) {
this.name = name;
this.sex = sex;
}
// 定义Person的原型,原型中的属性可以被自定义对象引用
Person.prototype = {
getName: function() {
return this.name;
},
getSex: function() {
return this.sex;
}
}
在此例中,函数Person被称为构造函数,即用于创建自定义对象的函数。由此可见,JavaScript通过构造函数结合原型的方式,模拟实现了类的功能。创建自定义对象(即实例化类)的代码如下:
var zhang = new Person("ZhangSan", "man");
console.log(zhang.getName()); // "ZhangSan"
var chun = new Person("ChunHua", "woman");
console.log(chun.getName()); // "ChunHua"
当执行代码var zhang = new Person("ZhangSan", "man")时,内部实际上执行了以下步骤:
- 创建一个空白对象(new Object())。
- 将Person.prototype中的属性(键值对)拷贝到该空对象中(尽管前文提到内部实现是隐藏链接,但此处表现为属性复制的效果)。
- 将该对象通过this关键字传递给构造函数并执行构造函数。
- 将该对象赋值给变量zhang。
为了证明prototype模板并非被拷贝到实例化对象中,而是采用链接方式,请参考以下代码:
function Person(name, sex) {
this.name = name;
this.sex = sex;
}
Person.prototype.age = 20;
var zhang = new Person("ZhangSan", "man");
console.log(zhang.age); // 20
// 覆盖prototype中的age属性
zhang.age = 19;
console.log(zhang.age); // 19
delete zhang.age;
// 在删除实例属性age后,此属性值又从prototype中获取
console.log(zhang.age); // 20
这种在JavaScript内部实现的隐藏prototype链接,是JavaScript运行的基础,也是模拟实现继承的关键所在。
如何在JavaScript中实现简单的继承?以下示例将创建一个雇员类Employee,它从Person继承了原型prototype中的所有属性。
function Employee(name, sex, employeeID) {
this.name = name;
this.sex = sex;
this.employeeID = employeeID;
}
// 将Employee的原型指向Person的一个实例
// 因为Person的实例可以调用Person原型中的方法, 所以Employee的实例也可以调用Person原型中的所有属性。
Employee.prototype = new Person();
Employee.prototype.getEmployeeID = function() {
return this.employeeID;
};
var zhang = new Employee("ZhangSan", "man", "1234");
console.log(zhang.getName()); // "ZhangSan
上述关于继承的实现较为粗糙,且存在诸多问题:
- 在创建Employee构造函数和原型(后文简称类)时,便对Person进行了实例化,这种做法并不恰当。
- Employee的构造函数无法调用父类Person的构造函数,导致在Employee构造函数中对name和sex属性进行重复赋值。
- Employee中的函数会覆盖Person中的同名函数,缺乏重载机制(此问题与前一条性质相同)。
- 创建JavaScript类的语法过于零散,不如C#/Java中的语法优雅。
- 实现中存在constructor属性指向错误的问题,这将在第二篇文章中讨论。
我们将在第三章完善这个例子。