对象和类那些事

115 阅读10分钟

1. 对象

对象是包括 JavaScript 在内的所有面向对象的编程语言的一个重要组成部分。尤其在 JavaScript 中,对象更是扮演了一个极其重要的角色。JavaScript 号称,事实上也是,一切皆对象。因此,要想学好JavaScript,了解对象是非常必要的。

1.1 创建对象

了解对象的起点是了解他如何被创建出来的。基于 JavaScript 语言,创建对象的方式有以下三种:工厂模式,构造函数,原型函数。

1. 工厂模式

工厂模式创建 JavaScript 对象基于一个工厂函数。该函数规定了基于该函数创建的对象的模板,比如说对象有哪些属性,哪些方法等等。并且返回由该工厂函数创建的对象。

代码示例:

function createPreson(name, age, job) {
    let o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function() {
        console.log(this.name);
    }
    
    return o;
 }

上述示例的函数,类似一个按照模板制造对象的工厂,提供一组相关参数返回一个被制造好的对象。如果用工厂生产产品来作类比,那么一组参数就是原材料,对象就是产品。产品之间相互是独立的,意味着利用工厂函数制造出来的对象是互相没有关联的。这样子会有什么问题呢,就是新对象的类型无法确定

2. 构造函数

构造函数利用 js 中的 new 关键字来创建对象。我们知道,使用 new 关键字调用构造函数会做五件事情:

  • 在内存中创建一个新对象
  • 在新对象的 ._proto_ 属性赋值为构造函数的 prototype 属性
  • 构造函数内部的 this 指针指向新创建的对象
  • 执行构造函数内部的代码
  • 如果构造函数返回非空对象,则返回该对象;否则,返回刚刚创建的新对象。

代码示例:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function() {
        console.log(this.name);
    }
}

从上述示例中,我们可以发现,相较于工厂函数而言,构造函数没有直接创建对象,而是把属性和方法直接赋值给了 this,并且它没有 return。在写法上,更加的清新。

此外,关于构造函数,这里有一个约定俗成的规则:构造函数的首字母都是要大写的,非构造函数则以小写字母开头

关于构造函数,最大的优点就是可以标识新创建的对象的类型。JavaScript 提供了 instanceof 操作符,用来检验对象是根据哪个构造函数创建的。

和工厂函数一样,构造函数也存在它自己的问题。对于构造函数新构造的对象而言,在 this 上的所有方法和属性都是独立的。换句话说,新建一个对象,就会有相应的属性和方法被新建出来。然而,在实际的应用当中,一些属性和方法是这些对象所共有的,不需要再重新新建一份,只需要找到他存在什么地方,然后大家一起共用就可以了。

3. 原型模式

在了解原型模式之前,需要了解一下原型是什么?

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。这个对象就是通过调用构造函数创建的对象的原型。

从原型的定义中不难发现,原型模式解决了构造函数存在的问题。就拿上述提到的示例而言,sayName这个属性,在所有的对象创建中是一模一样的内容。因此,对于它而言使用构造函数复制多份就显得没有必要,而且会占内存空间。于是,我们把它放到原型上。具体实现代码如下:

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

Person.prototype.sayName = function() {
    console.log(this.name);
}

综上所述,最好的对象创建方案是将构造函数和原型模式结合起来。将一些会变的参数放到构造函数中,不变的属性和方法通过原型的方式添加。

1.2 对象的属性相关

JavaScript 对象的属性分为两种:数据属性和访问器属性。

  1. 数据属性:数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入这个位置。对于数据属性而言,有 4 个特性描述它们的行为:

    • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改他的特性,以及是否可以把它改为访问器属性。默认值为 true
    • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认值为 true
    • [[Writable]]:表示属性值是否可以被修改。默认值为true
    • [[Value]]:表示属性实际的值,默认为 undefined
  2. 访问器属性:访问器属性不包含数据值。相反,他们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。同样有 4 个特性描述他们的行为:

    • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改他的特性,以及是否可以把它改为数值属性。默认值为 true
    • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认值为 true
    • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined
    • [[Set]]:设置函数,在写入属性时调用。默认值为 undefined

我们可以通过直接在对象中添加 . 操作符来定义属性;也可以使用Object.defineProperties来定义对象的属性。两者之间的区别是后者可以通过参数设置属性的特性。

代码示例:

let book = {};
Object.defineProperties(book, {
    year_: {
        value: 2017
    },
    
    edition: {
        value: 1
    }
})

2. 类

在面向对象编程的语言中,类是很重要的一个概念。类之所以为类,是因为其三大特性:封装、继承和多态。

2.1 实例化

关于类的实例化问题,前面对象创建的内容已经描述过,这里就不再赘述了。这里需要提到的是,前文的对象创建的模版就是我们提到的类,根据该模板创建的对象就是该类的实例化。类的实例化有两种方法:

  1. 构造函数
  2. 工厂函数

2.2 继承

2.2.1 原理

JS 中的继承通常被叫做原型继承。原型编程范型遵循以下基本规则:

  1. 所有的数据都是对象
  2. 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
  3. 对象会记住他的原型
  4. 如果对象无法响应这个请求,则会将该请求委托给它的原型

原型继承的基本思想就是通过原型继承多个引用类型的属性和方法。在原型继承中,有三个概念是至关重要的:构造函数、原型和实例。其关系为:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型

如果原型是另一个类型的实例,那么这个原型本身有一个内部指针指向另一个实例,同时另一个实例也有一个指向另一个构造函数的实例。在这种情况下,原型和实例之间构成了一条原型链

由于我们的原型链不能无限的往前面指,原型链总会有一个头,也就是说 js 中所有的对象应该都是以某一个对象为原型制作出来的。那么,原型链的所谓顶端是什么样的呢?

图片.png

如图所示,所有的 js 对象的原型链都指向 Object.prototype 这个原型对象,也就是说,所有的 JS 对象都是由 Object.prototype 这个原型对象生成的。按照常理来说,Object.prototype 应该是 Object 对象的原型对象,Function.prototype 应该是 Function 对象的原型对象。它们分别指向对应的构造函数。这幅图的意思是 Function 是由 Object 构造出来的。也就是说 Function instanceof Object 的结果应该为 true。我们来检验一下:

console.log(Function instanceof Object)   //true
console.log(Object instanceof Function)   //true

实际的运行结果和我们的猜想是应证的,但是奇怪的是反过来也成立。这是什么原因呢?

图片.png

从上图中,我们看到,这几者之间的关系比较复杂。但原型链的指向遵循以下两个准则:

  1. 除了 Object 其他所有内置函数的原型对象的内置属性 ._proto_ 都指向 Object.prototype, Object 构造函数的内置属性 ._proto_ 为 null。(即所有函数的原型对象都是由 Object.prototype 构造出来的)
  2. 所有的内置函数(包括 Function,其实应该是所有的构造函数)的内置属性 ._proto_ 都指向 Function.prototype 对象(即所有内置函数都是由 Function 构造出来的)

这样我们就不难理解了,Function 也是一个对象,所有他的 ._prpto_ 属性指向了 Object.prototype;Object 也是一个函数,所以它的 ._proto_ 属性指向 Function.prototype.

2.2.2 实现

1. 构造函数继承

最简单的一种继承方式:

function SuberType() {
    this.colors = ["red", "blue", "green"];
}

function SubType() {
// 继承 SuperType
    SuperType.call(this);
}

这种继承方式使用 .call 方法,将 this 绑定到实例的对象上,完成对于父类的继承。这种方式的缺点是:子类不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。

2. 组合继承

组合继承综合了原型链和构造函数,将两种方式的有点集中起来。基本思路是:使用原型链继承原型上的属性和方法,而通过构造函数继承实例属性。

function SuberType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
    console.log(this.name);
}

function SubType(name, age) {
// 继承 SuperType 的属性
    SuperType.call(this, name);
    
    this.age = age;
}

// 继承方法
SubType.prototype = new SuperType();

SubType.prototype.sayAge = function() {
    console.log(this.age);
}

3. 原型式继承

原型继承的基本原理是创建一个临时的构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时构造函数的实例。本质上,是对传入的对象进行了一次浅复制:

function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}

原型式继承的适用情况:你有一个对象,想在他的基础上再创建一个新对象。你需要把这个对象先传给 object(), 然后再对返回的对象进行适当修改。

在 ECMAScript 5 中,增加了 Object.create() 方法,将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象的原型对象,以及给新对象定义额外的属性对象。在只有一个参数是,Object.create() 与这里的 object() 方法效果相同。

原型式继承非常适合不需要独立创建构造寒素,但仍然需要在对象间共享信息的场合

4. 寄生式继承

寄生式继承是一种与原型式继承比较接近的一种继承方式,其基本思路为:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本的代码如下:

function createAnother(original) {
    let clone = object(original);
    clone.sayHi = function() {
        console.log("hi");
    }
    
    return clone;
}

寄生式继承同样适用主要关注对象,二不在乎类型和构造函数的场景。

5. 组合寄生式继承

废话不多说,直接上代码:

function inheritPrototype(subType, superType) {
    let prototype = object(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

function SuperType(name) {
    this.name = name;
    this.color = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
    console.log(this.name);
}

function SubType(name, age) {
    SuperType.call(this, name);   // 第二次调用 SuperType,绑定 this
    this.age = age;
}

inheritPrototype(subType, superType);

SubType.prototype.sayAge = function() {
    console.log(this.age);
}

下图描述了这段代码发生的事情:

图片.png

组合寄生式继承通过盗用构造函数继承属性,但使用混合式原型链集成方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。

在笔者看来,组合寄生式继承是最可以模拟类继承的一种继承方式。