作者: zyl910
一、缘由
由于在ES6之前,JavaScript中没有定义类(class)语法。导致大家用各种五花八门的办法来定义类,代码风格不统一。而且对于模拟面向对象的三大支柱“封装”、“继承”、“多态”,更是有许多专门的深度研究,实现办法更加复杂,不利于JavaScript新手使用。
于是我将这些优秀方法提炼总结,化繁为简。目标是——就算是JavaScript新手,只要有一点的面向对象编程经验(即有Java、C#等面向对象编程经验),也能按照本文的办法,轻松的在JavaScript中定义类,完整的使用封装、继承、多态等特性来组织代码。
其次,该方案还有这些优点——
- 兼容所有的浏览器。目前ES6尚未普及,很多浏览器尚不支持。而本方法因其采用了简单的语法(应该是在ES3的范围内),故兼容目前所有的浏览器。实测在 IE5~11、Edge、Chrome、Firefox等浏览器中均测试通过。
- 兼容命名空间方案(JavaScript 实现命名空间(namespace)的最佳方案 ),便于管理大型代码。
- 使用起来与其他面向对象编程语言非常相似。仅是在定义时的写法稍有区别。
- 支持用JSDuck生成文档。且JSDuck能完美的识别本文所介绍的面向对象特性,生成有用的文档。便于理解,提高可维护性。
- 兼容ES6。因ES6的class关键字实际上只是一个语法糖,它内部仍是靠prototype(原型)机制。
- 有利于代码风格统一。
二、定义类的基本写法
2.1 使用构造函数法来定义类
(在ES6以前)JavaScript推荐使用构造函数法来定义类。具体来说,就是写一个构造函数(constructor),然后用new 关键字对该类来构造对象实例。
例如现在需要定义一个 PersonInfo(个人信息)类,它有name(姓名)、gender(性别)字段。便这样定义类——
function PersonInfo() {
// 自身的实例字段.
this.name = ""; // 姓名.
this.gender = 0; // 性别. 0未知, 1男, 2女.
}
随后便可用 new 关键字来创建对象,可以使用“对象.属性”的语法来访问那些在构造函数中定义的实例字段了。例如——
var p = new PersonInfo();
p.name = "Zhang San"; // 张三.
alert(p.name);
2.2 编写方法
定义类之后,便可为它编写方法了。具体办法是在构造函数的原型中增加函数。例如我们给 PersonInfo 类增加一个 getHello 方法 取得欢迎文本。
function PersonInfo() {
this.name = ""; // 姓名.
this.gender = 0; // 性别. 0未知, 1男, 2女.
}
PersonInfo.prototype.getHello = function() {
var rt = "Hello " + this.name;
return rt;
};
随后便可以使用“对象.方法(参数...)”的语法来调用方法了。
var p = new PersonInfo();
p.name = "Zhang San"; // 张三.
alert(p.getHello());
2.3 增加addlog函数简化测试
目前是用alert弹出对话框来显示处理结果的。该办法对于以后测试不利,会导致需要连续点多次确定等麻烦。
故还是写一个addlog(追加日志)的函数比较好,在textarea中显示测试结果。
网页中增加textarea控件——
输出:<br/>
<textarea id="txtresult" rows="12" style="width:95%" readonly ></textarea>
然后我们的测试函数可改成这样——
function doTest() {
var txtresult = document.getElementById("txtresult");
txtresult.value = "";
// do
var p = new PersonInfo();
p.name = "Zhang San"; // 张三.
addlog(p.getHello());
}
2.4 小结
以上便是普通JavaScript教程中所讲的定义类的办法。该办法能组织实例字段,能编写方法,能都满足很多简单的需求了。
但该办法的缺点也很明显——
- 所有内容都定义在全局变量空间。可能会造成全局名称污染,不利于大型项目。
- 封装性差。所有成员都暴露在对象实例里或是原型(prototype)里,所有人都能访问,一不小心可能会弄乱数据。
- 不支持继承。JavaScript没有继承语法,无法直接定义子类。
- 不支持多态。由于无法实现继承,导致它不支持多态。
下列章节将解决这些问题。
三、基本写法的改进
3.1 使用命名空间来避免全局名称污染
为了避免全局名称污染,可使用命名空间(namespace)机制。
虽然JavaScript没有命名空间的语法,但可以通过一些办法模拟。详见 JavaScript 实现命名空间(namespace)的最佳方案 [] 。
其机制很简单,就是定义一个 Object 变量作为命名空间。然后便可将 类的构造函数 绑定到该命名空间中,随后便可按原来的办法给类再绑定方法。
例如将PersonInfo类放到jsnamespace命名空间中,可这样做——
var jsnamespace = window.jsnamespace || {};
jsnamespace.PersonInfo = function() {
this.name = ""; // 姓名.
this.gender = 0; // 性别. 0未知, 1男, 2女.
}
jsnamespace.PersonInfo.prototype.getHello = function() {
var rt = "Hello " + this.name;
return rt;
};
随后便可使用该类了,注意需要写全命名空间。
var p = new jsnamespace.PersonInfo();
p.name = "Zhang San"; // 张三.
addlog(p.getHello());
3.2 改进构造函数
3.2.1 构造函数参数
一般来说,可以通过构造函数参数的办法,来简化对象的创建、赋值。
jsnamespace.PersonInfo = function(name, gender) {
this.name = name; // 姓名.
this.gender = gender; // 性别. 0未知, 1男, 2女.
}
可这样使用——
var p = new jsnamespace.PersonInfo("Zhang San", 1); // 张三, 男.
addlog(p.getHello());
该做法有2点不足——
- 随着以后对该类的改进,可能会增加更多的实例字段,那可能会导致参数列表的频繁改动。
- 非常依赖参数顺序,万一传参时某个参数的顺序写错,便会引发数据问题。
3.2.2 拷贝构造函数
为了解决上述的2点不足,且为了方便对象复制。故推荐使用“拷贝构造函数”这种构造函数写法。
具体做法是,构造函数只用一个 Object 型的参数。
jsnamespace.PersonInfo = function(cfg) {
cfg = cfg || {}; // 当没传cfg参数时,将它当作空对象。
this.name = cfg["name"] || ""; // 姓名.
this.gender = cfg["gender"] || 0; // 性别. 0未知, 1男, 2女.
}
可这样使用——
var p = new jsnamespace.PersonInfo({"name": "Zhang San"}); // 张三, 男.
addlog(p.getHello());
注意上述例子中没传 gender 参数。因构造函数中的 this.gender = cfg["gender"] || 0
语句,故 gender 属性会赋值为默认值0。
另外,拷贝构造函数更适合于在继承的场合下使用,详见后面的章节。
3.3 使用JSDuck文档注释来改进代码的可读性
对于大型代码来说,即使写了注释,阅读代码也是非常费神的。
这时可编写文档注释,然后用工具将其生成为参考文档。有组织的文档,比代码更易读。且有了文档注释后,代码也更易读懂了。
且文档注释的一些标记能进一步加强代码的可读性。例如(ES6之前的)JavaScript没有class关键字,用构造函数法定义类与普通函数差异不大,分辨、搜索起来有一些麻烦。而文档注释一般提供了@class
关键字来表示类。
对于JavaScript来说,个人觉得最好用的文档注释工具是JSDuck。
将上面的代码加上JSDuck风格的文档注释,则变成在这样——
/** @class
* JavaScript的命名空间.
* @abstract
*/
var jsnamespace = window.jsnamespace || {};
/** @class
* 个人信息. 构造函数法的类.
*/
jsnamespace.PersonInfo = function(cfg) {
cfg = cfg || {};
/** @property {String} 姓名. */
this.name = cfg["name"] || "";
/** @property {Number} 性别. 0未知, 1男, 2女. */
this.gender = cfg["gender"] || 0;
};
/**
* 取得欢迎字符串.
*
* @return {String} 返回欢迎字符串.
*/
jsnamespace.PersonInfo.prototype.getHello = function() {
var rt = "Hello " + this.name;
return rt;
};
JSDuck文档注释标记说明——
@class
: 表示这是一个类。@abstract
: 该类(或方法)是抽象的。由于JSDuck没有命名空间的关键字,于是习惯上用 @class @abstract 组合表示命名空间。@property
: 属性。其格式为“@property {类型} 说明”。@cfg
: 构造函数cfg中的参数。其格式为“@cfg {类型} 说明”。一般情况下,cfg参数与公开的属性(@property
)相同,这时只用@property
就行了,个人觉得不用写@cfg
了。@return
: 返回值。其格式为“@return {类型} 说明”。
若想知道JSDuck的文档注释的写法的更多说明,可参考其官网wiki ( https://github.com/senchalabs/jsduck/wiki ),或是查看网上教程 (详见“参考文献”)。
对于其生成的文档,详见“8.2 用JSDuck生成文档”。
3.4 枚举
之前对于性别,是直接用数值代码来表示。数值代码的可读性差,且不易维护,很多编程语言有“定义枚举”语法来解决该问题。
虽然JavaScript没有“定义枚举”语法,但是可以通过一些办法来模拟。例如可以定义一个 Object变量,其中的字段就是各种枚举值。因(ES6之前的)JavaScript没有常量关键字(const),为了区分只读的枚举值与普通字段,建议使用大写字母来命名枚举值。
并且JSDuck有定义枚举的标注—— @enum
。
现在便可在 jsnamespace命名空间中 定义一个名为 GenderCode 的枚举了——
/** @enum
* 性别代码. 枚举类.
*/
jsnamespace.GenderCode = {
/** 未知 */
"UNKNOWN": 0,
/** 男 */
"MALE": 1,
/** 女 */
"FEMALE": 2
};
随后我们可以改进构造函数,使用枚举值。
jsnamespace.PersonInfo = function(cfg) {
cfg = cfg || {};
/** @property {String} 姓名. */
this.name = cfg["name"] || "";
/** @property {jsnamespace.GenderCode} 性别. */
this.gender = cfg["gender"] || jsnamespace.GenderCode.UNKNOWN;
};
使用了枚举值之后,代码可读性、可维护性增加了很多。且JSDuck文档能将 gender 的类型作为链接,方便查看。
随后在使用时,也应该坚持用枚举值——
var p = new jsnamespace.PersonInfo({"name": "Zhang San", "gender": jsnamespace.GenderCode.MALE}); // 张三, 男.
addlog(p.getHello());
3.4.1 应用:将称谓文本加到欢迎字符串中
有了性别代码枚举后,便可考虑将称谓文本加到欢迎字符串中,使欢迎文本更有意义。
具体办法是可以写一个getAppellation方法计算称谓,然后在getHello中调用该方法拼接欢迎文本。
/**
* 取得称谓.
*
* @return {String} 返回称谓字符串.
*/
jsnamespace.PersonInfo.prototype.getAppellation = function() {
var rt = "";
if (jsnamespace.GenderCode.MALE == this.gender) {
rt = "Mr.";
} else if (jsnamespace.GenderCode.FEMALE == this.gender) {
rt = "Ms.";
}
return rt;
};
/**
* 取得欢迎字符串.
*
* @return {String} 返回欢迎字符串.
*/
jsnamespace.PersonInfo.prototype.getHello = function() {
var rt = "Hello " + this.getAppellation() + " " + this.name;
return rt;
};
随后改进一下测试代码——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.PersonInfo({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE}); // 李四.
addlog(p1.getHello());
addlog(p2.getHello());
便可看到——
Hello Mr. Zhang San
Hello Ms. Li Si
四、封装
封装(encapsulation):将程序按照一定的逻辑分成多个互相协作的部分,并对外界提供稳定的部分(暴露稳定部分),而将改变部分隐藏起来,外界只能通过暴露的部分向这个对象发送操作请求从而享受对象提供的服务,而不必管对象内部是如何运行的。
封装性体现在2个方面——
- 对外隐藏实现细节,只能用专门约定的界面方法去操作。
- 对内能实现变量的共享,用于实现一些复杂逻辑。
4.1 私有静态变量
在 JavaScript 中,可以使用立即执行函数(Immediately-Invoked Function Expression, IIFE)来隐藏私有变量。该办法也很适合用在对象的封装性上。
例如若想将之前 getHello 中的 "Hello"放到一个内部的私有变量中(欢迎单词 m_WordHello),可以这样写——
/** @class
* JavaScript的命名空间.
* @abstract
*/
var jsnamespace = window.jsnamespace || {};
/** @class
* 个人信息. 构造函数法的类.
*/
jsnamespace.PersonInfo = function(cfg) {
cfg = cfg || {};
/** @property {String} 姓名. */
this.name = cfg["name"] || "";
/** @property {Number} 性别. 0未知, 1男, 2女. */
this.gender = cfg["gender"] || 0;
};
(function(){
/**
* 欢迎单词.
* @static @private
*/
var m_WordHello = "Hello";
/**
* 取得称谓.
*
* @return {String} 返回称谓字符串.
*/
jsnamespace.PersonInfo.prototype.getAppellation = function() {
var rt = "";
if (jsnamespace.GenderCode.MALE == this.gender) {
rt = "Mr.";
} else if (jsnamespace.GenderCode.FEMALE == this.gender) {
rt = "Ms.";
}
return rt;
};
/**
* 取得欢迎字符串.
*
* @return {String} 返回欢迎字符串.
*/
jsnamespace.PersonInfo.prototype.getHello = function() {
var rt = m_WordHello + " " + this.getAppellation() + " " + this.name;
return rt;
};
})();
即将私有变量与prototype方法绑定代码都放到立即执行函数中了。该写法的优点有——
- 实现了封装性。私有变量(如m_WordHello)只能在这个立即执行函数的内部使用,不会暴露到外部。
- 实现了变量共享。使getHello方法能访问到私有静态变量m_WordHello。
- 同一个类的方法定义都写在一个大括号中、使用同一层缩进,可提高代码的可读性。且有利于编辑器的代码折叠功能。
按照面向对象编程的定义,m_WordHello实际上是一个静态私有变量。故在它的文档注释中加上“@static @private”标记。
对于私有成员命名,建议使用“m_”前缀。这样能与公开成员区分开,提高代码的可读性。
JSDuck文档注释标记说明——
@static
: 静态。@private
: 私有。
对于JSDuck生成的文档,注意它默认是不显示私有级别的。可点击“Show”,在下拉菜单中勾选“Private”,便可显示私有成员。
4.2 私有静态函数
有些时候我们重构代码时,会将一些责任移到私有静态函数,使主要逻辑更短更易读。另外还可将各方法之间的重复代码移到私有静态函数中,避免重复。
例如可重构 getAppellation ,将计算称谓文本的责任,移到一个 m_getAppellationText 函数中。
(function(){
/**
* 取得称谓文本.
*
* @param {jsnamespace.GenderCode} gender 性别.
* @return {String} 返回称谓字符串.
* @static @private
*/
var m_getAppellationText = function(gender) {
var rt = "";
if (jsnamespace.GenderCode.MALE == gender) {
rt = "Mr.";
} else if (jsnamespace.GenderCode.FEMALE == gender) {
rt = "Ms.";
}
return rt;
};
/**
* 取得称谓.
*
* @return {String} 返回称谓字符串.
*/
jsnamespace.PersonInfo.prototype.getAppellation = function() {
var rt = m_getAppellationText(this.gender);
return rt;
};
})();
JSDuck文档注释标记说明——
@param
: 参数说明。其格式为“@param {类型} 参数名 说明”。
将代码改成这样后,原先的测试代码依然能正常工作。
注意m_getAppellationText是将一个函数表达式赋值给它,而没有使用函数声明。这样做有3个好处——
- 可读性高。若是立即执行函数里再套一个函数声明,有可能看不太明白代码运行顺序的脉络,可能不少JavaScript新手会觉得很晕。但像这样写成“函数表达式赋值给变量”,可简单的看成代码顺序运行,只是做了变量绑定、方法绑定操作,很容易理解。
- 有了函数变量后,便于以后做一些用到函数变量的工作。例如可考虑将m_getAppellationText函数变量传给其他地方。
4.3 公开静态成员
静态成员是属于整个类的而不是某个对象实例的。故有些时候,是需要将静态成员公开给外部使用的。
对于大多数的面向对象编程语言,可使用“类.成员”的语法,来使用静态成员。故我们也应该兼容该语法。
对于JavaScript来说,类的构造函数也是一个 Function,Function也是一种Object,并且Object可随时在它上面增加字段或函数。即,在构造函数上增加字段或函数,就是给类绑定公开的静态属性、静态方法。
4.3.1 公开静态方法
例如对于上面的m_WordHello,可提供一套get/set方法(getWordHello、setWordHello),使外部能够读写该值。
(function(){
/**
* 欢迎单词.
* @static @private
*/
var m_WordHello = "Hello";
// -- static method --
/** 取得欢迎单词.
*
* @return {String} 返回欢迎单词.
* @static
*/
jsnamespace.PersonInfo.getWordHello = function() {
return m_WordHello;
};
/** 设置欢迎单词.
*
* @param {String} v 欢迎单词.
* @static
*/
jsnamespace.PersonInfo.setWordHello = function(v) {
m_WordHello = v;
};
})();
随后改进一下测试代码,将欢迎单词换为Welcome——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.PersonInfo({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE}); // 李四.
addlog(p1.getHello());
addlog(p2.getHello());
jsnamespace.PersonInfo.setWordHello("Welcome");
addlog(p1.getHello());
addlog(p2.getHello());
便可看到——
Hello Mr. Zhang San
Hello Ms. Li Si
Welcome Mr. Zhang San
Welcome Ms. Li Si
4.3.2 公开静态属性
虽然可通过“给构造函数这个对象增加字段”的办法来模拟静态成员属性,但是在一般情况并不推荐这样做。因为JavaScript中没有对属性进行读写控制的语法,故一般情况下建议参考上一节的办法,做一对 get/set 方法。
除非是无需读写控制的字段,才可考虑“直接给构造函数增加字段”的办法。
4.4 私有实例成员
JavaScript中无法实现实例字段、对象方法(绑定到prototype的函数)的private封装。
有一种变通策略,就是给这些私有实例字段、对象方法加上“m_”前缀,提醒它们是私有的,外部不要访问。
由于这些实例字段、对象方法在业务上不应访问,但语法上能够访问(且很多时候,子类需要访问它们,后面的章节会详述)。故我建议给它们的JSDuck文档注释中加上 @protected 标记。这样还有助于在JSDuck生成的文档中用“Show”筛选可见性。
五、继承
继承(inherit)也称为派生(extend),在UML里称为泛化(generalization)。继承关系中,被继承的称为父类(或基类),从父类继承而得的被称为子类(或派生类)。继承是保持对象差异性的同时共享对象相似性的复用。能够被继承的类总是含有并只含有它所抽象的那一类事务的共同特点。继承提供了实现复用,只要从一个类继承,我们就拥有了这个类的所有行为。语义上的“继承”表示“是一种(is-a)”的关系。
5.1 转发构造函数,继承实例字段
在JavaScript中,可以使用call或apply方法实现“用指定对象来调用某个方法”的办法。call、apply对构造函数也是有效的,故可以用他们来实现构造函数转发功能,即在子类的构造函数中去调父类的构造函数,使其构造好父类的实例字段。
例如需要新建一个Employee(雇员信息)类,它继承自PersonInfo(个人信息)类,它多了个 email 参数。便可这样定义该类(的构造函数)——
jsnamespace.Employee = function(cfg) {
cfg = cfg || {};
jsnamespace.PersonInfo.call(this, (PersonInfo));
// 自身的实例字段.
/** @property {String} 电子邮箱. */
this.email = cfg["email"] || "";
};
对上面代码的解释——
- 对 cfg 变量进行规范化。
- 使用call调用父类的构造函数(PersonInfo),这样它便会给this对象 增加父类的实例字段(name、gender)。
- 父类(PersonInfo)构造函数调用完成后,便可添加自己(Employee)的实例变量(email)了。
这里便可看出“拷贝构造函数”写法的优点——
- 只有一个 cfg 参数,故可以很简单的通过 call 调父类。就算使用多层继承,也一样简单,各个类只转发它父类的构造函数就行。
- 能很方便的将 cfg 参数传递给父类,使父类也能用到参数来初始化变量。
测试代码——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"}); // 李四.
表明现在已成功的继承了实例字段。
5.2 绑定原型链,继承方法
刚才仅是继承了实例字段,还缺方法的继承。这时得使用JavaScript的原型链机制。
5.2.1 定义extend函数
由于JavaScript原型链机制不太容易理解,这里直接给出了封装好的函数,重点讲解怎么使用。若对原理感兴趣,可看“参考文献”中的文章。
/** 继承. 即设置好 Child 的原型为 Parent的原型实例,并设置 uber 属性.
*
* @param {Function} Child 子类(构造函数).
* @param {Function} Parent 父类(构造函数).
* @static
*/
jsnamespace.extend = function(Child, Parent) {
var F = function(){};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
Child.uber = Parent.prototype;
};
因为我们已经使用了命名空间机制,故可将该函数放到jsnamespace命名空间中。
5.2.2 使用extend
有了extend函数后,便可以用它来给子类继承方法了。
例如让Employee继承父类PersonInfo的方法,便只写这一行语句就行了——
jsnamespace.extend(jsnamespace.Employee, jsnamespace.PersonInfo);
测试代码——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"}); // 李四.
addlog(p1.getHello());
addlog(p2.getHello());
便可看到——
Hello Mr. Zhang San
Hello Ms. Li Si
5.3 改进子类的JSDuck文档注释,申明继承关系
做好刚才的2步后(构造函数转发、使用extend),虽然JavaScript中已经能完整的使用继承功能了。但对于JSDuck文档注释来说,还需要手工加上@extends标记,使JSDuck了解它们的继承关系。
语法很简单,“@extends 父类的类名(构造函数名)”,放在类(@class)的文档注释就行。
代码如下——
/** @class
* 雇员信息. 构造函数法的类.
*
* @extends jsnamespace.PersonInfo
*/
jsnamespace.Employee = function(cfg) {
cfg = cfg || {};
jsnamespace.PersonInfo.call(this, cfg);
// 自身的实例字段.
/** @property {String} 电子邮箱. */
this.email = cfg["email"] || "";
};
jsnamespace.extend(jsnamespace.Employee, jsnamespace.PersonInfo);
5.4 多层继承
现在来做一个综合练习吧,测试一下多层继承。具体来说,即新增一个 Staff(职员信息)类,让它继承自 Employee(雇员信息)类,形成“Staff->Employee->PersonInfo”的继承关系。
Staff(职员信息)类还多了一个duty(职务称号)属性。
代码如下——
/** @class
* 职员信息. 构造函数法的类.
*
* @extends jsnamespace.Employee
*/
jsnamespace.Staff = function(cfg) {
cfg = cfg || {};
jsnamespace.Employee.call(this, cfg);
// 自身的实例字段.
/** @property {String} 职务称号. */
this.duty = cfg["duty"] || "";
};
jsnamespace.extend(jsnamespace.Staff, jsnamespace.Employee);
测试代码——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"}); // 李四.
var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"}); // 王五.
addlog(p1.getHello());
addlog(p2.getHello());
addlog(p3.getHello());
便可看到——
Hello Mr. Zhang San
Hello Ms. Li Si
Hello Mr. Wang Wu
5.5 instanceof
JavaScript有个instanceof运算符,可用来判断对象的类型。本文的介绍的继承方案,是支持的instanceof运算符。包括在使用多层继承时。
测试代码——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"}); // 李四.
var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"}); // 王五.
addlog(p1.getHello());
addlog(p2.getHello());
addlog(p3.getHello());
// instanceof.
addlog("// instanceof");
addlog("p1 instanceof jsnamespace.PersonInfo: " + (p1 instanceof jsnamespace.PersonInfo) );
addlog("p1 instanceof jsnamespace.Employee: " + (p1 instanceof jsnamespace.Employee) );
addlog("p1 instanceof jsnamespace.Staff: " + (p1 instanceof jsnamespace.Staff) );
addlog("p2 instanceof jsnamespace.PersonInfo: " + (p2 instanceof jsnamespace.PersonInfo) );
addlog("p2 instanceof jsnamespace.Employee: " + (p2 instanceof jsnamespace.Employee) );
addlog("p2 instanceof jsnamespace.Staff: " + (p2 instanceof jsnamespace.Staff) );
addlog("p3 instanceof jsnamespace.PersonInfo: " + (p3 instanceof jsnamespace.PersonInfo) );
addlog("p3 instanceof jsnamespace.Employee: " + (p3 instanceof jsnamespace.Employee) );
addlog("p3 instanceof jsnamespace.Staff: " + (p3 instanceof jsnamespace.Staff) );
便可看到——
Hello Mr. Zhang San
Hello Ms. Li Si
Hello Mr. Wang Wu
// instanceof
p1 instanceof jsnamespace.PersonInfo: true
p1 instanceof jsnamespace.Employee: false
p1 instanceof jsnamespace.Staff: false
p2 instanceof jsnamespace.PersonInfo: true
p2 instanceof jsnamespace.Employee: true
p2 instanceof jsnamespace.Staff: false
p3 instanceof jsnamespace.PersonInfo: true
p3 instanceof jsnamespace.Employee: true
p3 instanceof jsnamespace.Staff: true
5.5.1 在浏览器中查看继承树
在浏览器中按F12打开开发者工具,在JavaScript代码中下断点,便可在旁边的变量面板中查看对象变量的详情。例如可看到对象变量的继承树(其实JavaScript的标准术语叫“原型链”)。
可看到——
- 观察
[prototype]
,可看到 p3 的类型是jsnamespace.Staff,其继承关系为“jsnamespace.Staff -> jsnamespace.Employee -> jsnamespace.PersonInfo -> object”。 - p3成功的继承了其所有父类的实例字段。
六、多态
多态(polymorphism)是“允许用户将父对象设置成为一个或更多的它的子对象相等的技术,赋值后,基类对象就可以根据当前赋值给它的派生类对象的特性以不同的方式运作”(Charlie Calvert)。多态扩大了对象的适应性,改变了对象单一继承的关系。多态是行为的抽象,它使得同名方法可以有不同的响应方式,我们可以通过名字调用某一方法而无需知道哪种实现将被执行,甚至无需知道执行这个实现的对象类型。
6.1 覆写(override)
多态性中的最重要的,是覆写(override)机制,它允许子类修改父类。即在父类中定义方法,然后子类覆写同名方法。这样在调用该名字的方法时,不同的对象运行的是各自子类的逻辑。
先前 PersonInfo、Employee、Staff 的 getHello 方法,均是只返回 name、gender 这2个属性的值的。但这个不太符合实际需要,因为Employee、Staff 其实增加了属性。
例如现在想让Employee的getHello方法还返回该类新增email字段的值。这时便可使用覆写机制了,代码如下——
(function(){
jsnamespace.Employee.prototype.getHello = function() {
var rt = jsnamespace.PersonInfo.prototype.getHello.call(this);
rt = rt + " (" + this.email + ")";
return rt;
};
})();
注:
- 很多情况下子类覆写方法时,并不需要彻底重写,而是可以先调父类的同名方法后再进行个性化处理。在JavaScript中,可以使用 call、apply 来调用父类的方法,其格式一般为“类名.prototype.方法名.call(this, 参数)”。
- 覆写时没有额外特殊操作,简单的给子类编写方法就行。因为JavaScript是根据原型链来逐层查找方法的。若子类定义了方法便用子类的,否则去找父类的,找不到时报错。
- 根据之前的经验(四、封装),我们将代码写在了一个立即执行函数中。
测试代码——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"}); // 李四.
addlog(p1.getHello());
addlog(p2.getHello());
便可看到——
Hello Mr. Zhang San
Hello Ms. Li Si (lisi@mail.com)
6.2 覆写时的文档注释
子类覆写方法时,一般情况是不用重新写一遍文档注释的。使用 @inheritdoc
命令,可让该方法继承其父类的文档注释。
(function(){
/** @inheritdoc */
jsnamespace.Employee.prototype.getHello = function() {
var rt = jsnamespace.PersonInfo.prototype.getHello.call(this);
rt = rt + " (" + this.email + ")";
return rt;
};
})();
虽然JSDuck提供了 @override
关键字,但是因为JSDuck会自动识别覆写关系,故可省略。
JSDuck所生成的文档中有这些信息——
- 类的说明的右上角,会显示继承关系。
- “Defined By”会显示该成员是哪个类定义的。
- “Show”下拉菜单中有“Inherited”复选框,可用来控制是否显示继承的成员。
- 左下角可切换左侧树的显示模式——“By Package”(按包,即按命名空间)、“By Inheritance”(按继承关系)。
6.3 多层继承时的覆写
多层继承时,也可按同样的办法来覆写。
例如给Staff的getHello返回信息中加上duty的内容。
(function(){
/** @inheritdoc */
jsnamespace.Staff.prototype.getHello = function() {
var rt = jsnamespace.Employee.prototype.getHello.call(this);
rt = rt + " [" + this.duty + "]";
return rt;
};
})();
测试代码——
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"}); // 李四.
var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"}); // 王五.
addlog(p1.getHello());
addlog(p2.getHello());
addlog(p3.getHello());
便可看到——
Hello Mr. Zhang San
Hello Ms. Li Si (lisi@mail.com)
Hello Mr. Wang Wu (wangwu@mail.com) [主任]
6.4 接口多态的讨论
接口(interface)多态也是一种常见的多态机制。但是JavaScript语法不支持接口,虽然可以用原型链去模拟,但会使程序变得过于复杂,恐怕会降低可读性、可维护性。且JSDuck也不支持接口,不利于维护开发文档。
建议采取以下策略来避免接口——
- 简单情况下其实并不需要使用多接口,例如只有一种可变逻辑时。可以在父类定义好方法,然后子类覆写这些方法就行,即模板方法模式(Template Method Pattern)。
- 有多种可变逻辑时,可使用 桥接模式(Bridge Pattern),一种可变逻辑就是一种父类(即C++术语中的“虚基类”,其等价于接口),分别由不同的子类来实现逻辑。
- 若逻辑很复杂,且桥接模式不合适时,可使用JavaScript传统的回调函数机制来解决问题。正好JavaScript里的函数机制很强大。
七、完整范例
7.1 JavaScript源码
7.1.1 jsnamespace.js
/*! @file jsnamespace.js
* 演示JavaScript中如何模拟命名空间,并支持 构造函数法、闭包法 来构造类. 还演示了 jsduck 文档注释.
*
* @author zhouyuelin
* @version v1.0
*/
/** @class
* JavaScript的命名空间.
* @abstract
*/
var jsnamespace = window.jsnamespace || {};
/** 继承. 即设置好 Child 的原型为 Parent的原型实例,并设置 uber 属性.
*
* @param {Function} Child 子类(构造函数).
* @param {Function} Parent 父类(构造函数).
* @static
*/
jsnamespace.extend = function(Child, Parent) {
var F = function(){};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
Child.uber = Parent.prototype;
};
// == enum ==
/** @enum
* 性别代码. 枚举类.
*/
jsnamespace.GenderCode = {
/** 未知 */
"UNKNOWN": 0,
/** 男 */
"MALE": 1,
/** 女 */
"FEMALE": 2
};
// == PersonInfo class ==
/** @class
* 个人信息. 构造函数法的类.
*/
jsnamespace.PersonInfo = function(cfg) {
cfg = cfg || {};
/** @property {String} 姓名. */
this.name = cfg["name"] || "";
/** @property {jsnamespace.GenderCode} 性别. */
this.gender = cfg["gender"] || jsnamespace.GenderCode.UNKNOWN;
};
(function(){
/**
* 欢迎单词.
* @static @private
*/
var m_WordHello = "Hello";
/**
* 取得称谓文本.
*
* @param {jsnamespace.GenderCode} gender 性别.
* @return {String} 返回称谓字符串.
* @static @private
*/
var m_getAppellationText = function(gender) {
var rt = "";
if (jsnamespace.GenderCode.MALE == gender) {
rt = "Mr.";
} else if (jsnamespace.GenderCode.FEMALE == gender) {
rt = "Ms.";
}
return rt;
};
/**
* 取得称谓.
*
* @return {String} 返回称谓字符串.
*/
jsnamespace.PersonInfo.prototype.getAppellation = function() {
var rt = m_getAppellationText(this.gender);
return rt;
};
/**
* 取得欢迎字符串.
*
* @return {String} 返回欢迎字符串.
*/
jsnamespace.PersonInfo.prototype.getHello = function() {
var rt = m_WordHello + " " + this.getAppellation() + " " + this.name;
return rt;
};
// -- static method --
/** 取得欢迎单词.
*
* @return {String} 返回欢迎单词.
* @static
*/
jsnamespace.PersonInfo.getWordHello = function() {
return m_WordHello;
};
/** 设置欢迎单词.
*
* @param {String} v 欢迎单词.
* @static
*/
jsnamespace.PersonInfo.setWordHello = function(v) {
m_WordHello = v;
};
})();
// == Employee class ==
/** @class
* 雇员信息. 构造函数法的类.
*
* @extends jsnamespace.PersonInfo
*/
jsnamespace.Employee = function(cfg) {
cfg = cfg || {};
jsnamespace.PersonInfo.call(this, cfg);
// 自身的实例字段.
/** @property {String} 电子邮箱. */
this.email = cfg["email"] || "";
};
jsnamespace.extend(jsnamespace.Employee, jsnamespace.PersonInfo);
(function(){
/** @inheritdoc */
jsnamespace.Employee.prototype.getHello = function() {
var rt = jsnamespace.PersonInfo.prototype.getHello.call(this);
rt = rt + " (" + this.email + ")";
return rt;
};
})();
// == Staff class ==
/** @class
* 职员信息. 构造函数法的类.
*
* @extends jsnamespace.Employee
*/
jsnamespace.Staff = function(cfg) {
cfg = cfg || {};
jsnamespace.Employee.call(this, cfg);
// 自身的实例字段.
/** @property {String} 职务称号. */
this.duty = cfg["duty"] || "";
};
jsnamespace.extend(jsnamespace.Staff, jsnamespace.Employee);
(function(){
/** @inheritdoc */
jsnamespace.Staff.prototype.getHello = function() {
var rt = jsnamespace.Employee.prototype.getHello.call(this);
rt = rt + " [" + this.duty + "]";
return rt;
};
})();
// == PersonInfoUtil class ==
/** @class
* 个人信息工具. 闭包法的类.
*/
jsnamespace.PersonInfoUtil = function () {
/**
* 前缀.
*
* @static @private
*/
var _prefix = "[show] ";
return {
/** 显示信息.
*
* @param {jsnamespace.PersonInfo} p 个人信息.
* @static
*/
show: function(p) {
var s = _prefix;
if (!!p) {
s += p.getHello();
}
alert(s);
},
/** 版本号. @readonly */
version: 0x100
};
}();
7.1.2 jsnamespace_test.htm
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>测试JavaScript 命名空间</title>
</head>
<body>
<script type="text/javascript" src="jsnamespace.js"></script>
<script type="text/javascript" src="jsnamespace_sub.js"></script>
<script type="text/javascript">
/** 追加日志.
*
* @param {String} str 日志字符串.
*/
function addlog(str) {
var txtresult = document.getElementById("txtresult");
txtresult.value = txtresult.value + str + "\r\n";
}
/** 测试. */
function doTest() {
var txtresult = document.getElementById("txtresult");
txtresult.value = "";
// do
//alert(jsnamespace);
var p1 = new jsnamespace.PersonInfo();
p1.name = "Zhang San"; // 张三.
p1.gender = jsnamespace.GenderCode.MALE;
var p2 = new jsnamespace.Employee({"name": "Li Si", "gender": jsnamespace.GenderCode.FEMALE, "email": "lisi@mail.com"}); // 李四.
var p3 = new jsnamespace.Staff({"name": "Wang Wu", "gender": jsnamespace.GenderCode.MALE, "email": "wangwu@mail.com", "duty": "主任"}); // 王五.
addlog(p1.getHello());
addlog(p2.getHello());
addlog(p3.getHello());
// setWordHello.
addlog("// setWordHello");
jsnamespace.PersonInfo.setWordHello("Welcome");
addlog(p1.getHello());
addlog(p2.getHello());
addlog(p3.getHello());
// instanceof.
addlog("// instanceof");
addlog("p1 instanceof jsnamespace.PersonInfo: " + (p1 instanceof jsnamespace.PersonInfo) );
addlog("p1 instanceof jsnamespace.Employee: " + (p1 instanceof jsnamespace.Employee) );
addlog("p1 instanceof jsnamespace.Staff: " + (p1 instanceof jsnamespace.Staff) );
addlog("p2 instanceof jsnamespace.PersonInfo: " + (p2 instanceof jsnamespace.PersonInfo) );
addlog("p2 instanceof jsnamespace.Employee: " + (p2 instanceof jsnamespace.Employee) );
addlog("p2 instanceof jsnamespace.Staff: " + (p2 instanceof jsnamespace.Staff) );
addlog("p3 instanceof jsnamespace.PersonInfo: " + (p3 instanceof jsnamespace.PersonInfo) );
addlog("p3 instanceof jsnamespace.Employee: " + (p3 instanceof jsnamespace.Employee) );
addlog("p3 instanceof jsnamespace.Staff: " + (p3 instanceof jsnamespace.Staff) );
// PersonInfoUtil.
//jsnamespace.PersonInfoUtil.show(p1);
//jsnamespace.PersonInfoUtil.show(p2);
//jsnamespace.PersonInfoUtil.show(p3);
}
</script>
<h1>测试JavaScript 命名空间</h1>
<input type="button" value="测试" OnClick="doTest();" title="doTest" />
<br/>
输出:<br/>
<textarea id="txtresult" rows="12" style="width:95%" readonly ></textarea>
</body>
</html>
7.2 用JSDuck生成文档
可用 jsduck命令来生成文档。对于本文的范例代码,可使用目录中的“jsduck_make.bat”来生成文档,随后可通过 “doc”子目录中的“index.html”查看文档。
以下截图,就是JSDuck根据上面的范例代码所生成文档。可发现它完美的识别了代码中的类(class),正确的生成了属性、方法等的文档,还能清晰的查看继承树、方法覆盖(override)。
八、总结
简单来说,本文所介绍的编写类的写法,是分为3段来写的——
- 构造函数。这是JavaScript传统的定义类的写法。
- 使用extend来构造继承关系(原型链)。仅对需要用到继承的类,才这样做。
- 在立即执行函数中编写方法及私有成员,保证封装性。
源码地址:
https://github.com/zyl910/test_jsduck
参考文献
- 阮一峰《Javascript定义类(class)的三种方法》. http://www.ruanyifeng.com/blog/2012/07/three_ways_to_define_a_javascript_class.html
- 阮一峰《Javascript面向对象编程(二):构造函数的继承》. http://www.ruanyifeng.com/blog/2010/05/object-oriented_javascript_inheritance.html
- keepfool《玩转JavaScript OOP[4]——实现继承的12种套路》. http://www.cnblogs.com/keepfool/p/5592256.html
- MDN《Object.prototype》. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/prototype
- 姜俊杰《JavaScript instanceof 运算符深入剖析》. https://www.ibm.com/developerworks/cn/web/1306_jiangjj_jsinstanceof/
- JSDuck官网: https://github.com/senchalabs/jsduck
- zyl910《Javascript自动化文档工具JSDuck在Windows下的使用心得》. http://www.cnblogs.com/zyl910/p/test_jsduck_on_windows.html
- zyl910《JavaScript 实现命名空间(namespace)的最佳方案——兼容主流的定义类(class)的方法,兼容所有浏览器,支持用JSDuck生成文档》. http://www.cnblogs.com/zyl910/p/js_namespace_bestpractice.html
(完)