类和构造函数


上篇教程展示了在 JavaScript 中定义类的一种方法,但是不常用,因为没有定义构造函数,构造函数是用来初始化新创建的对象的。前面已经提到,需要使用 new 关键字来调用构造函数,构造函数的一个重要特征是构造函数的 prototype 属性被用作新对象的原型,这意味着通过同一个构造函数创建的所有对象都继承自同一个对象。下面我们使用构造函数替代上例的工厂函数定义类:

// range2.js 使用构造函数来实现一个能表示值的范围的类

// 这是一个构造函数,用于初始化新创建的「范围对象」
function Range(from, to) {
  // 存储新的「范围对象」的起始位置和结束位置
  // 这两个属性是不可继承的,每个对象都拥有唯一属性
  this.from = from;
  this.to = to;
}

// 所有的「范围对象」都继承自这个对象
// 注意,属性的名字必须是「prototype」
Range.prototype = {
  // 如果 x 在范围内,返回 true,否则返回 false
  // 这个方法可以比较数字范围,也可以比较字符串和日期范围
  includes: function (x) {
    return this.from <= x && x <= this.to;
  },

  // 对于范围内的每个整数都调用一次f
  // 这个方法只用作数字范围
  foreach: function (f) {
    for (var x = Math.ceil(this.from); x <= this.to; x++) {
      f(x);
    }
  },

  // 返回表示这个范围的字符串
  toString: function () {
    return "(" + this.from + "..." + this.to + ")";
  }
};

我们将构造函数命名为 Range,这里遵循了一个常见的编程约定:定义构造函数即是定义类,类名首字母要大写。并以此与普通函数进行区分。在调用构造函数之前就已经创建了新对象,所以可以通过 this 关键字获取这个新对象并对其进行初始化。构造函数也不必返回新创建的对象,其背后的执行逻辑是:构造函数先自动创建对象,然后将构造函数作为这个新对象的方法调用一次,最后返回这个新对象。

下面对上述代码进行调用,得到的结果和之前工厂函数实现的一样:

唯一的不同就是调用方式的区别:构造函数只能通过 new 关键字进行调用。

构造函数和类的标识

原型对象是类的唯一标识,当且仅当两个对象继承自同一个原型对象时,它们才是同一个类的实例。而初始化类的状态的构造函数则不能作为类的标识,只要两个构造函数的 prototype 属性指向同一个原型对象,那么这两个构造函数创建的实例就属于同一个类。

我们在使用 instanceof 运算符来检测对象是否属于某个类时也会用到构造函数:

r instanceof Range

但实际上 instanceof 运算符并不会检查 r 是否由 Range() 构造函数初始化而来,而是检查 r 是否继承自 Range.prototype,构造函数只是类的「外在表现」。

constructor 属性

任何 JavaScript 函数都可以用作构造函数,而调用构造函数是需要用到 prototype 属性的,因此,每个 JavaScript 函数(Function.bind()方法返回函数除外)都自动拥有一个 prototype 属性,这个属性的值是一个对象,这个对象包含唯一一个不可枚举的属性 constructorconstructor 属性的值是一个函数对象:

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

我们在上面定义 Range() 构造函数时,自己实现的 Range.prototype 原型对象并不会包含 constructor 属性,因此对应的 Range 实例也不会包含这个属性。要解决这一问题,需要显式给原型添加一个构造函数:

Range.prototype = {
  constructor: Range,

  // 如果 x 在范围内,返回 true,否则返回 false
  // 这个方法可以比较数字范围,也可以比较字符串和日期范围
  includes: function (x) {
    return this.from <= x && x <= this.to;
  },

  // 对于范围内的每个整数都调用一次f
  // 这个方法只用作数字范围
  foreach: function (f) {
    for (var x = Math.ceil(this.from); x <= this.to; x++) {
      f(x);
    }
  },

  // 返回表示这个范围的字符串
  toString: function () {
    return "(" + this.from + "..." + this.to + ")";
  }
};

或者,另一种常见的解决办法是使用预定义的原型对象,而不是自己重新定义,预定义的原型对象已经包含了 construcotr 属性,然后依次给原型对象添加方法:

// range3.js 使用构造函数来实现一个能表示值的范围的类

// 这是一个构造函数,用于初始化新创建的「范围对象」
function Range(from, to) {
  // 存储新的「范围对象」的起始位置和结束位置
  // 这两个属性是不可继承的,每个对象都拥有唯一属性
  this.from = from;
  this.to = to;
}

// 扩展预定义的 Range.prototype 原型对象,而不是重写
Range.prototype.includes = function (x) {
  return this.from <= x && x <= this.to;
};

Range.prototype.foreach = function (f) {
  for (var x = Math.ceil(this.from); x <= this.to; x++) {
    f(x);
  }
};

Range.prototype.toString = function () {
  return "(" + this.from + "..." + this.to + ")";
};

下图展示了构造函数与原型对象之间的关系,包括原型对象到构造函数的反向引用以及构造函数创建的实例:


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 类和原型

>> 下一篇: 在 JavaScript 中实现 Java 式的类继承