JavaScript类和模块

在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;
    // ...
    
}());
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,440评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,814评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,427评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,710评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,625评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,014评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,511评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,162评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,311评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,262评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,278评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,989评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,583评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,664评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,904评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,274评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,856评论 2 339

推荐阅读更多精彩内容

  • 博客内容:什么是面向对象为什么要面向对象面向对象编程的特性和原则理解对象属性创建对象继承 什么是面向对象 面向对象...
    _Dot912阅读 1,394评论 3 12
  • 本章内容 理解对象属性 理解并创建对象 理解继承 面向对象语言有一个标志,那就是它们都有类的概念,而通过类可以创建...
    闷油瓶小张阅读 836评论 0 1
  • 文/兰风 图/暗紫 流云暗涌蓝心波动 黄昏一夕之间投下的影 希望中缠绕着的绝望理不清 不愿说出的殇 选择...
    兰风蕙露阅读 290评论 4 4
  • 不断离开,然后越来越不懂得爱和被爱的感觉,转眼到了被催逼的年纪,却十分惰怠地故意放慢了脚步,一彧如此真切地被困在新...
    FLRY阅读 254评论 0 0
  • 1. 块级元素和行内元素有什么区别? 块级元素总是独占一行,表现为另起一行开始, 而且其后的元素也必须另起一行显示...
    庄海鑫阅读 112评论 0 1