07.JavaScript基础系列:类

226 阅读16分钟

JavaScript基础系列

在 JavaScript 中,类的实现是基于原型继承机制的。如果两个实例都从同一个原型对象上继承了属性,我们说它们是同一个类的实例。

如果两个对象继承自同一个原型,往往意味着(但不是绝对)它们是由同一个构造函数创建并初始化的。

1.类和原型

在 JavaScript 中,类的所有实例对象都从同一个原型对象上继承属性,原型对象是类的核心。

function inherit(p) {
    if (p === null) throw TypeError();
    if (Object.create) return Object.create(p);     // 存在,直接使用
    
    var t = typeof p;
    if (t !== 'object' || t !== 'function') throw TypeError();

    function f() {};        // 定义一个空构造函数
    f.prototype = p;        // 将其原型属性设置为 p
    return new f();         // 使用 f() 创建 p 的继承对象
}

定义一个原型对象,可通过 inherit() 函数创建一个继承自它的对象,这样就定义了一个 JavaScript 类。

还可以通过一个工厂函数用以创建并初始化类的实例。

// 工厂方法返回一个新的对象
function range(from, to) {
    // 使用 inherit 创建对象,继承自下面定义的原型对象
    // 原型对象作为函数的一个属性存储,并定于所有对象所共享的方法
    var r = inherit(range.methods);
	
    // 这两个属性是不可继承的,每个对象都拥有唯一的属性
    r.from = from;
    r.to = to
	
    // 返回新创建的对象
    return r;
}

// 原型对象定义方法,这些方法为每个对象所继承
range.methods = {
    includes: function(x) {
        return this.form <= x <= this.to;
    },
    toString: function() {
        return '(' + this.from + '...' + this.to + ')';
    }	
}

var r = range(1, 3);
r.includes(2);      // true
r.toString();       // "(1...3)"

2.类和构造函数

通过工厂方法定义类并不常用,主要原因是没有定义构造函数。构造函数是用来初始化新创建的对象,使用关键字 new 调用构造函数会完成下面两个操作:

  • 会自动创建一个新对象
  • 构造函数的 prototype 属性会被用做新对象的原型,即同一个构造函数创建的对象都继承自一个相同的对象,它们都是同一个类的成员
// 构造函数定义类,这里并没有创建并返回一个对象,仅仅是初始化
function Range(from, to) {
    this.from = from;
    this.to = to;
}

// 原型对象不含有 constructor 属性
Range.prototype = {
    includes: function(x) {
        return this.form <= x <= this.to;
    },
    toString: function() {
        return '(' + this.from + '...' + this.to + ')';
    }	
}
var r = new Range(1, 3);
r.includes(2);      // true
r.toString();       // "(1...3)"

构造函数与工厂函数的区别

  • 构造函数的首字母要大写;工厂函数首字母小写
  • 构造函数是通过 new 调用的;工厂函数不必使用 new
  • 构造函数不必返回新创建的对象,会自动创建对象;工厂函数需要返回新创建的对象
  • 构造函数的原型属性只能挂载到 prototype 上,普通函数不需要

构造函数和类标识

原型对象是类的唯一标识,当且仅当两个对象继承自同一个原型对象时,它们才属于同一个类的实例。

初始化对象状态的构造函数不能作为类的标识,两个构造函数的 prototype 属性可能指向同一个原型对象,那么这两个构造函数创建的实例属于同一个类的。

instanceof 方法是检测对象是否继承自类的原型,不会检测对象是否通过类的构造函数创建的。

constructor 属性

任何 JavaScript 函数都可以用做构造函数,并且调用构造函数是需要用到一个 prototype 属性。

每个 JavaScript 都自动拥有一个 prototype 属性,这个属性是一个对象,这个对象包含唯一一个不可枚举属性 constructor,constructor 属性的值是一个函数对象。

var F = function() {}       // 函数对象
var p = F.prototype         // 这是 F 相关联的原型对象
var c = p.constructor;      // 这是与原型相关联的函数

// 对于任意函数 F.prototype.constructor === F
c === F                     // true 

可以看到构造函数的原型中存在预先定义好的 constructor 属性,对象继承的 constructor 均指代它们的构造函数。由于构造函数是类的 "公共标识",因此这个 constructor 属性为对象提供了类。

var o = new F()
// constructor 属性指代这个类
o.constructor === F         // true

通过使用自定义一个新对象重新预定义 prototype 对象,这个新定义的原型对象不含有 constructor 属性,需要显示的设置 constructor 属性。

Range.prototype = {
    // 显示设置构造函数反向引用
    constructor: Range,
    includes: function(x) {
        return this.form <= x <= this.to;
    },
    toString: function() {
        return '(' + this.from + '...' + this.to + ')';
    }	
}

使用预定义的原型对象,预定义的原型对象包含 constructor 属性。

Range.prototype.includes = function(x) {
    return this.form <= x <= this.to;
},

3.JavaScript 类的继承

JavaScript 中的函数是以值的形式出现,方法和字段之间并没有太大的区别,如果属性值是函数,那么这个属性就定义一个方法,否则就是一个普通的属性或字段。

JavaScript 的三种不同的对象

  • 构造函数对象

构造函数对象为 JavaScript 的类定义了名字,任何添加到这个构造函数对象的属性都是类字段和类方法

  • 原型对象

原型对象的属性被类的所有实例所继承,如果原型对象的属性值是函数,这个函数就作为类的实例的方法调用

  • 实例对象

类的每个实例都是一个独立的对象,直接给这个类实例定义的属性是不为所有实例对象所共享的

在 JavaScript 中定义类的步骤可以缩减为三步算法:

  • 定义一个构造函数,并设置初始化新对象的实例属性
  • 给构造函数的 prototype 对象定义实例的方法
  • 给构造函数定义类字段和类属性
// constructor 设置实例的属性的函数
// methods 实例的方法,复制到原型中
// statics 类属性,复制到构造函数中
function defineClass(constructor, methods, statics) {
    if (methods) extend(constructor.prototype, methods)
    if (statics) extend(constructor, statics)
    return constructor;
}

4.JavaScript 类的扩充

JavaScript 中基于原型的继承机制是动态的:对象从其原型继承属性,如果创建对象之后,原型的属性发生变化,也会影响到继承这个原型的所有实例对象。这意味着我们可以通过给原型对象添加新方法来扩充 JavaScript 类。

JavaScript 内置类的原型对象也是一样如此开放,可以给他们追加方法。

if (!Function.prototype.bind) {
    Function.prototype.bind = function(o) {
        // bind() 方法的代码
    }
}

可以给 Object.prototype 添加方法,从而使所有的对象都可以调用这个方法,但不建议这样做。原因是在 ECMAScript 5 之前,无法将这些新增方法设置为不可枚举的,这些属性可以被 for/in 循环遍历到。但可以使用 Object.defineProperty() 方法安全的扩充 Object.prototype。

5.类和类型

三种检测任意对象的类的方法:

  • instanceof 运算符

instanceof 左操作数是待检测其类的对象,右操作数是定义类的构造函数。如果 o 继承自 c.prototype,则表达式 o instanceof c 值为 true,这里的继承可以不是直接继承。

构造函数是类的公共标识,原型是唯一标识。instanceof 只能检测对象的继承关系,而不能检测创建对象的构造函数。

instanceof 无法通过对象获得类名,只能检测对象是否属于指定的类名,在多个执行上下文的场景中它无法正常工作(比如在浏览器窗口的多个框架子页面中)。

每个窗口和框架子页面都具有单独的执行上下文,每个上下文都包含独有的全局变量和一组构造函数,在两个不同框架页面中创建的两个数组继承自两个相同但相互独立的原型对象,其中一个框架页面中的数组不是另一个框架页面的 Array() 构造函数的实例,instanceof 运算结果是 false。

  • constructor 属性

构造函数是类的公共标识,可以使用 constructor 属性来识别对象是否属于某个类。

(12).constructor === Number     // true
'str'.constructor === String    // true

使用 constructor 属性检测对象属于某个类的方法不足之处和 instanceof 一样,在多个执行上下文的场景中是无法正常工作的(比如在多个浏览器窗口的多个框架子页面中)。

JavaScript 中并非所有的对象都包含 constructor 属性,在每个新创建的函数原型上默认会有 constructor 属性。但我们常常会忽略原型上的 constructor 属性。

  • 构造函数的名字

使用 instanceof 运算符和 constructor 属性检测对象所属的类有一个主要的问题,在多个执行上下文中存在构造函数的多个副本的时候,这两种方法的检测结果会出错。

一种解决方案可以使用构造函数的名字而不是构造函数本身作为标识符。这种使用构造函数名字来识别对象的类的做法和使用 constructor 属性一样有一个问题,并不是所有的对象都具有 constructor 属性。

鸭式辩型

上面三种方法多少都会有些问题,解决办法就是规避掉这些问题:不要关注 "对象的类是什么",而是关注 "对象能做什么",这种思考问题的方法成为 "鸭式辩型"(像鸭子一样走路,游泳并且嘎嘎叫的鸟就是鸭子)。

举个例子,当我们讨论的是类数组对象,在很多场景下,我们并不知道一个对象是否真的是 Array 的实例,当然我们可以通过判断是否包含非负的 length 属性来得知是否是 Array 的实例等等。

6.JavaScript 中的面向对象技术

1.标准转换方法

  • toString()

toString() 作用是返回一个可以表示这个对象的字符串,在希望使用字符串的地方用到对象的话,JavaScript 会自动调用这个方法。如果没实现这个方法,会默认从 Object.prototype 中继承 toString() 方法,这个方法的运算结果是 "[object Object]",这个字符串用处不大。

toString() 方法应该返回一个可读的字符串,这样最终用户才能将这个输出值利用起来。

  • valueOf()

valueOf() 作用是将对象转换为原始值,比如,当数学运算符(除了 "+" 运算符)和关系运算符作用于数字文本表示的对象时,会自动调用 valueOf() 方法。

大多数对象没有合适的原始值来表示它们,也没有定义这个方法。

  • toJSON()

toJSON() 方法是由 JSON.stringify() 自动调用的。对于一个对象有 toJSON() 方法,JSON.stringify() 并不会对传入的对象做序列化操作,而会调用 toJSON()。

JSON 格式用于序列良好的数据结构,而且能够处理 JavaScript 原始值、数组和纯对象。

当对一个对象执行序列化操作时,它会忽略对象的原型和构造函数,返回一个字符串,如果将这些字符串传入 JSON.parse() 会得到一个纯对象,不包含继承来的方法。

valueOf() 和 toString() 区别在这里:点击查看

  • 比较方法

JavaScript 的相等运算符比较对象时,比较的是引用而不是值,也就是说,给定两个对象引用,如果要看它们是否指向同一个对象,不是检查这两个对象具有相同的属性名和相同的属性值,而是直接比较这两个单独的对象是否相等。

为了能让自定义类的实例具有比较的功能,可以定义一个名叫 equals() 实例方法,这个方法只能接收一个实参,如果这个实参和调用此方法的对象相等的话则返回 true,这里说的 "相等" 的含义是根据类的上下文来决定的。

将对象用于 JavaScript 的关系运算符(< 和 <=),JavaScript 会首先调用对象的 valueOf() 方法,如果这个返回返回一个原始值,则直接比较原始值。对于大多数类没有 valueOf() 方法,为了按照显式定义的规则来比较这些类型的对象,可以定义一个名为 compareTo() 方法。

  • 方法借用

JavaScript 中的方法没有什么特别的,无非是一些简单的函数,赋值给了对象的属性,可以通过对象来调用它。

以经典的面向对象语言的视角来看 JavaScript 的话,把一个类的方法用到其他的类中的做法称为 "多重继承"。然而 JavaScript 并不是经典的面向对象语言,更倾向于将这种方法重用更正式称为 "方法借用"。

  • 私有状态

类似 Java 的编程语言允许声明类的 "私有" 实例字段,这些私有实例字段只能被类的实例方法访问,且在类的外部是不可见的。

JavaScript 可以通过将变量闭包在一个构造函数内来模拟实现私有实例字段,调用构造函数会创建一个实例。

7.子类

在面向对象编程中,类B可以继承自另外一个类A,我们将A称为父类,将B称为子类。B的实例从A继承了所有的实例方法,类B可以定义自己的实例方法。

当B中的重载方法(B中定义与A中同名方法)调用A中的重载方法,这种做法称为"方法链"。子类的构造函数有时会调用父类的构造函数,这种做法称为"构造函数链"。

在 JavaScript 中创建子类的关键之处在于,采用合适的方法对原型对象进行初始化。如果类B继承自类A,B.protoype 必须是 A.protoype 的后嗣。B 的实例继承自 B.prototype,同样也继承自 A.prototype。

  • 定义子类

JavaScript 的对象可以从类的原型中继承属性(通常继承的是方法),如果 O 是类 B 的实例,B 是 A 子类,那么 O 也一定会从 A 中继承了属性。为了确保 B 的原型对象继承自 A 的原型对象,可以这样实现:

B.prototype = inherit(A.prototype);     // 子类派生自父类
B.prototype.construtor = B;             // 重载继承来的 construtor 属性

这两行代码是在 JavaScript 中创建子类的关键。如果不这样做,原型对象仅仅是一个普通对象,它只继承自 Object.prototype。

function inherit(p) {
    if (p === null) throw TypeError();
    if (Object.create) return Object.create(p);     // 存在,直接使用

    var t = typeof p;
    if (t !== 'object' || t !== 'function') throw TypeError();
	
    function f() {};        // 定义一个空构造函数
    f.prototype = p;        // 将其原型属性设置为 p
    return new f();         // 使用 f() 创建 p 的继承对象
}
	
function extend(o, p) {
    for (prop in p) {       // 遍历p中的所有属性
        o[prop] = p[prop];  // 将属性添加到 o 中
    }
    return o;
}

// superclass 父类的构造函数
// constructor 新的子类的构造函数
// method: 实例方法: 复制到原型中
// statics: 	类属性: 复制到构造函数中
function defineSubClass(superclass, constructor, methods, statics) {
    // 建立子类的原型对象
    constructor.prototype = inherit(superclass.prototype);
    constructor.prototype.constructor = constructor;
	
    // 像对常规类一样复制方法和类属性
    if (methods) extend(constructor.prototype, methods);
    if (statics) extend(constructor, statics);
    // 返回这个类	
    return constructor;
}

不使用 defineSubClass() 函数手动实现子类代码

function inherit(p) {
    if (p === null) throw TypeError();
    if (Object.create) return Object.create(p); // 存在,直接使用

    var t = typeof p;
    if (t !== 'object' || t !== 'function') throw TypeError();
	
    function f() {};        // 定义一个空构造函数
    f.prototype = p;        // 将其原型属性设置为 p
    return new f();         // 使用 f() 创建 p 的继承对象
}
	
function extend(o, p) {
    for (prop in p) {       // 遍历p中的所有属性
        o[prop] = p[prop];  // 将属性添加到 o 中
    }
    return o;
}

function Person(name, age) {
    this.name = name;
    this.age = age || 0;	
}

Person.prototype.say = function() {
    console.log('hello world');
    return this;
}

Person.prototype.toString = function (num) {
    return this.name + ';' + this.age;
}


function Teacher(interests) {
    this. interests = interests;
    // 作为普通函数调用父类的构造函数
    Person.apply(this, arguments)
}

// 创建一个原型对象,这个原型对象继承 Person 的原型
Teacher.prototype = inherit(Person.prototype)

// 给原型添加属性
extend(Teacher.prototype, {
    // 设置合适的 constructor 属性
    constructor: Teacher,
    updateInterests: function(newInterests) {
        this.interests = newInterests;
        return this;
    }
})

需要注意的是,Teacher 不是将 Person 中的方法列表静态的借用过来,而是动态地从 Person 继承方法。如果给 Person 添加新方法,Teacher 的所有实例会立即拥有这个方法(假设 Teacher 没有定义同名的方法)

  • 组合 VS 子类

面向对象编程中一条广为人知的设计原则:组合优于继承。可以利用组合的原理定义一个新的实现,它 "包装" 了另外一个对象。

function extend(o, p) {
    for (prop in p) {       // 遍历p中的所有属性    
        o[prop] = p[prop];  // 将属性添加到 o 中
    }
    return o;
}

function Person(name, age) {
    this.name = name;
    this.age = age || 0;	
}

Person.prototype.toString = function (num) {
    return this.name + ';' + this.age;
}


function Teacher(person, interests) {
    // 组合形式
    this.person = person;
    this.interests = interests;
}
	
extend(Teacher.prototype, {
    toString: function() {				
        return this.person.toString() + ';' + this.interests;
    },
    updateInterests: function(newInterests) {
        this.interests = newInterests;
        return this;
    }
})

var p1 = new Person('tom', 20)
var t1 = new Teacher(p1, 'book')

8. ECMAScript 5 中的类

ECMAScript 3 在给对象添加属性后,通过 for/in 循环中。

function Person(name) {
    this.name = name;
}

Person.prototype.toString = function() {
    return this.name;
}

var p1 = new Person('tom');

p1.age = 20;

for (var i in p1 ) {
    console.log(i);     // name  age  toString
}

Object.keys(p1);        // ["name", "age"]

ECMAScript 5 给属性特性增加了方法支持(getter、setter、可枚举性、可写性和可配置性),增加了对象可扩展的限制。

function Person(name) {
    this.name = name;
}

Person.prototype.toString = function() {
    return this.name;
}

var p1 = new Person('tom');

Object.defineProperty(p1, 'age', {
    value: 20,
    writeable: false,
    enumable: false
})

for (var i in p1 ) {
    console.log(i);     // name  toString
}

ECMAScript 5 还可以设置属性为只读的,当我们希望类的实例都是不可变的,这个特性非常有帮助。通过使用 Object.defineProperties() 为类创建原型对象,并将(原型对象的)实例方法设置为不可枚举的,就像内置类的方法一样。

// 创建一个不可变的类,它的属性和方法都是只读的。

function Person(name) {
    var props = {
        name: {
            value: name,
            enumerable: true,
            writeable: true,
            configurable: false
        }
    }
	
    if (this instanceof Person) {
        // 使用构造函数调用
        Object.defineProperties(this, props);
    } else {
        // 使用工厂方法调用
        return Object.create(Person.prototype, props);  
    }
}

Object.defineProperties(Person.prototype, {
    toString: {
        value: function() { return "this.name: " + this.name }
    }
})

Object.defineProperty() 和 Object.defineProperties() 创建新属性时,默认的属性特性的值都是 false,修改已经存在的属性时,默认的属性特性依然保持不变。

ECMAScript 5 可以通过 Object.preventExtensions() 将对象设置为不可扩展的;

ECMAScript 5 可以通过 Object.seal() 阻止用户给对象添加新属性,同时将属性设置为不可配置的。