本文介绍 JavaScript 基于对象的语言特性,解析如何通过构造函数和原型(prototype)模拟类的功能。详细阐述 new 关键字的执行步骤及原型链接机制,并分析早期简单继承实现的缺陷,为后续深入探讨经典继承方案奠定基础。
面向对象与基于对象
绝大多数开发人员都具备 C++、C# 或 Java 等面向对象语言的开发背景。在这些传统语言中,类(Class)和实例(Instance)是两个核心概念:类用于定义一类事物的公共行为与方法,而实例则是类的具体实现。此外,封装、继承和多态构成了面向对象编程的三大支柱。
然而在 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 等。