九.JavaScript类和模块

229 阅读16分钟

在JavaScript中也可以定义对象的类,让每个对象都共享某些属性。类的成员或实例都包含一些属性,用以存放或定义它们的状态,其中有些属性定义了它们的行为(通常称为方法)。这些行为通常是由类定义的,而且为所有实例所共享。

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

1.类和原型

在JavaScript中,类的所有实例对象都从同一个原型对象上继承属性。因此,原型对象是类的核心。 下面实例是一个用以创建并初始化类的“工厂”函数

//range.js:实现一个能表示值的范围的类
//这个工厂方法返回一个新的"范围对象"
function range(from, to) {
  //使用Object.create()函数来创建一个新对象,其中第一个参数是这个新对象的原型。
  //原型对象作为range函数的一个属性存储,并定义所有"范围对象"所共享的方法(行为)
  var r = Object.create(range.methods); 
  //存储新的"范围对象"的起始位置和结束位置(状态)
  //这两个属性是不可继承的,每个对象都拥有唯一的属性
  r.from = from;
  r.to = to; 
  return r; //返回这个新创建的对象
}
//原型对象定义方法,这些方法为每个范围对象所继承
range.methods = {
  //如果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 + ")";
  }
}; //这里是使用"范围对象"的一些例子

var r = range(1, 3); //创建一个范围对象
r.includes(2); //=>true:2在这个范围内
r.foreach(console.log); //输出1 2 3
console.log(r); //输出(1...3)

2.类和构造函数

  • 构造函数是用来初始化新创建的对象的。
  • 使用new调用构造函数会自动创建一个新对象,因此构造函数本身只需初始化这个新对象的状态即可。
  • 调用构造函数的一个重要特征是,构造函数的prototye属性被用做新对象的原型。这意味着通过同一个构造函数创建的所有对象都继承自一个相同的对象,因此它们都是同一个类的成员。
//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 + ")";
  }
}; //这里是使用"范围对象"的一些例子
var r = new Range(1, 3); //创建一个范围对象
r.includes(2); //=>true:2在这个范围内
r.foreach(console.log); //输出1 2 3
console.log(r); //输出(1...3)

注意Range()构造函数是通过new关键字调用的(在示例代码的末尾),而range()工厂函数则不必使用new。

在调用构造函数之前就已经创建了新对象,通过this关键字可以获取这个新对象。Range()构造函数只不过是初始化this而已。构造函数甚至不必返回这个新创建的对象,构造函数会自动创建对象,然后将构造函数作为这个对象的方法来调用一次,最后返回这个新对象。

对Range()构造函数的调用会自动使用Range.prototype作为新Range对象的原型。

2.1 构造函数和类的标识

  • 原型对象是类的唯一标识:当且仅当两个对象继承自同一个原型对象时,它们才是属于同一个类的实例。
  • 而初始化对象的状态的构造函数则不能作为类的标识,两个构造函数的prototype属性可能指向同一个原型对象。那么这两个构造函数创建的实例是属于同一个类的。
  • 构造函数不像原型那样基础,但构造函数是类的“外在表现”。很明显的,构造函数的名字通常用做类名。
r instanceof Range//如果r继承自Range.prototype,则返回true

instanceof运算符并不会检查r是否是由Range()构造函数初始化而来,而会检查r是否继承自Range.prototype。

2.2 constructor属性

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

image.png

可以看到构造函数的原型中存在预先定义好的constructor属性,这意味着对象通常继承的constructor均指代它们自身的构造函数

image.png

由于构造函数是类的“公共标识”,因此这个constructor属性为对象提供了类。

image.png

构造函数预定义的原型对象包含constructor属性

3. 构造函数对象,原型对象,实例对象

  • 构造函数(对象)为JavaScript的类定义了名字。任何添加到这个构造函数对象中的属性都是类字段和类方法(如果属性值是函数的话就是类方法)。
  • 原型对象的属性被类的所有实例所继承,如果原型对象的属性值是函数的话,这个函数就作为类的实例的方法来调用。
  • 类的每个实例都是一个独立的对象,直接给这个实例对象定义的属性是不会为所有实例对象所共享的。定义在实例对象上的非函数属性,实际上是实例的字段。

在JavaScript中定义类的步骤可以缩减为一个分三步的算法。第一步,先定义一个构造函数,并设置初始化新对象的实例属性。第二步,给构造函数的prototype对象定义实例的方法。第三步,给构造函数定义类字段和类属性。

将这三个步骤封装进一个简单的defineClass()函数中

//一个用以定义简单类的函数
function defineClass(
  constructor, //用以设置实例的属性的函数
  methods, //实例的方法,复制至原型中
  statics
) {
  //类属性,复制至构造函数中
  if (methods) extend(constructor.prototype, methods);
  if (statics) extend(constructor, statics);
  return constructor;
}
//这是Range类的另一个实现
var SimpleRange = defineClass(
  function(f, t) {
    this.f = f;
    this.t = t;
  },
  {
    includes: function(x) {
      return this.f <= x && x <= this.t;
    },
    toString: function() {
      return this.f + "..." + this.t;
    }
  },
  {
    upto: function(t) {
      return new SimpleRange(0, t);
    }
  }
);
function extend(o) {
  for (var i = 1; i < arguments.length; i++) {
    var source = arguments[i];
    for (var prop in source) o[prop] = source[prop];
  }
  return o;
}

image.png

“手动”来实现:Complex.js:表示复数的类

function Complex(real, imaginary) {
  if (isNaN(real) || isNaN(imaginary))
  {
    //确保两个实参都是数字
    throw new TypeError(); //如果不都是数字则抛出错误
  }
  this.r = real; //复数的实部
  this.i = imaginary; //复数的虚部
} /*
*类的实例方法定义为原型对象的函数值属性
*这里定义的方法可以被所有实例继承,并为它们提供共享的行为
*需要注意的是,JavaScript的实例方法必须使用关键字this
*来存取实例的字段
*/
//当前复数对象加上另外一个复数,并返回一个新的计算和值后的复数对象
Complex.prototype.add = function(that) {
  return new Complex(this.r + that.r, this.i + that.i);
}; //当前复数乘以另外一个复数,并返回一个新的计算乘积之后的复数对象
Complex.prototype.mul = function(that) {
  return new Complex(
    this.r * that.r - this.i * that.i,
    this.r * that.i + this.i * that.r
  );
}; //计算复数的模,复数的模定义为原点(0,0)到复平面的距离
Complex.prototype.mag = function() {
  return Math.sqrt(this.r * this.r + this.i * this.i);
}; //复数的求负运算
Complex.prototype.neg = function() {
  return new Complex(-this.r, -this.i);
}; //将复数对象转换为一个字符串
Complex.prototype.toString = function() {
  return "{" + this.r + "," + this.i + "}";
};
//检测当前复数对象是否和另外一个复数值相等
Complex.prototype.equals = function(that) {
  return (
    that != null && //必须有定义且不能是null
    that.constructor === Complex && //并且必须是Complex的实例
    this.r === that.r &&
    this.i === that.i
  ); //并且必须包含相同的值
};
/*
 *类字段(比如常量)和类方法直接定义为构造函数的属性
 *需要注意的是,类的方法通常不使用关键字this,
 *它们只对其参数进行操作
 */
//这里预定义了一些对复数运算有帮助的类字段
//它们的命名全都是大写,用以表明它们是常量
//(在ECMAScript 5中,还能设置这些类字段的属性为只读)
Complex.ZERO = new Complex(0, 0);
Complex.ONE = new Complex(1, 0);
Complex.I = new Complex(0, 1);
//这个类方法将由实例对象的toString方法返回的字符串格式解析为一个Complex对象
//或者抛出一个类型错误异常
Complex.parse = function(s) {
  try {
    //假设解析成功
    var m = Complex._format.exec(s); //利用正则表达式进行匹配
    return new Complex(parseFloat(m[1]), parseFloat(m[2]));
  } catch (x) {
    //如果解析失败则抛出异常
    throw new TypeError("Can't parse'" + s + "'as a complex number.");
  }
}; //定义类的"私有"字段,这个字段在Complex.parse()中用到了
//下划线前缀表明它是类内部使用的,而不属于类的公有API的部分
Complex._format = /^\{([^,]+),([^}]+)\}$/;

image.png

4 类的扩充

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

//返回当前复数的共轭复数
Complex.prototype.conj = function() {
  return new Complex(this.r, -this.i);
};

JavaScript内置类的原型对象也是一样如此“开放”,也就是说可以给数字、字符串、数组、函数等数据类型添加方法。

//多次调用这个函数f,传入一个迭代数
//比如,要输出"hello"三次:
//var n=3;
//n.times(function(n){console.log(n+"hello");});
Number.prototype.times = function(f, context) {
  var n = Number(this);
  for (var i = 0; i < n; i++) f.call(context, i);
}; //如果不存在ES5的String.trim()方法的话,就定义它
//这个方法用以去除字符串开头和结尾的空格
String.prototype.trim =
  String.prototype.trim ||
  function() {
    if (!this) return this; //空字符串不做处理
    return this.replace(/^\s+|\s+$/g, ""); //使用正则表达式进行空格替换
  }; //返回函数的名字,如果它有(非标准的)name属性,则直接使用name属性
//否则,将函数转换为字符串然后从中提取名字
//如果是没有名字的函数,则返回一个空字符串
Function.prototype.getName = function() {
  return this.name || this.toString().match(/function\s*([^()*]\(/)[1];
};

5.类和类型

三种用以检测任意对象的类的技术:instanceof运算符,constructor属性,以及构造函数的名字。

5.1 instanceof运算符

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

尽管instanceof运算符的右操作数是构造函数,但计算过程实际上是检测了对象的继承关系,而不是检测创建对象的构造函数。

不使用构造函数作为中介的方法:isPrototypeOf()方法

range.methods.isPrototypeOf(r);//range.method是否是对象r的原型对象

instanceof运算符和isPrototypeOf()方法的缺点是,我们无法通过对象来获得类名,只能检测对象是否属于指定的类名。在客户端JavaScript中还有一个比较严重的不足,就是在多窗口和多框架子页面的Web应用中兼容性不佳。

5.2 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; //处理自定义类型
  }
}

image.png

需要注意的是,在代码中关键字case后的表达式都是函数,如果改用typeof运算符或获取到对象的类名称属性的话,它们应当改为字符串。

5.3 构造函数的名称

对于那些没有name属性的JavaScript实现来说,可以将函数转换为字符串,然后从中提取出函数名

可以判断值的类型的type()函数:

/**
*以字符串形式返回o的类型:
*-如果o是null,返回"null";如果o是NaN,返回"nan"
*-如果typeof所返回的值不是"object",则返回这个值
*(注意,有一些JavaScript的实现将正则表达式识别为函数)
*-如果o的类不是"Object",则返回这个值
*-如果o包含构造函数并且这个构造函数具有名称,则返回这个名称
*-否则,一律返回"Object"
**/
function type(o) {
  var t, c, n; //type,class,name
  //处理null值的特殊情形
  if (o === null) return "null"; //另外一种特殊情形:NaN和它自身不相等
  if (o !== o) return "nan"; //如果typeof的值不是"object",则使用这个值
  //这可以识别出原始值的类型和函数
  if ((t = typeof o) !== "object") return t; 
  //返回对象的类名,除非值为"Object"
  //这种方式可以识别出大多数的内置对象
  if ((c = classof(o)) !== "Object") return c; 
  //如果对象构造函数的名字存在的话,则返回它
  if (
    o.constructor &&
    typeof o.constructor === "function" &&
    (n = o.constructor.getName())
  )
    return n; //其他的类型都无法判别,一律返回"Object"
  return "Object";
}
//返回对象的类
function classof(o) {
  return Object.prototype.toString.call(o).slice(8, -1);
} //返回函数的名字(可能是空字符串),不是函数的话返回null
Function.prototype.getName = function() {
  if ("name" in this) return this.name;
  return (this.name = this.toString().match(/function\s*([^(]*)\(/)[1]);
};

image.png 这种使用构造函数名字来识别对象的类的做法和使用constructor属性一样有一个问题:并不是所有的对象都具有constructor属性。此外,并不是所有的函数都有名字。

//这个构造函数没有名字
var Complex=function(x,y){this.r=x;this.i=y;};
//这个构造函数有名字
var Range=function Range(f,t){this.from=f;this.to=t;};

5.4 鸭式辨型

不要关注“对象的类是什么”,而是关注“对象能做什么”,称为“鸭式辩型”。这句话可以理解为“如果一个对象可以像鸭子一样走路、游泳并且嘎嘎叫,就认为这个对象是鸭子,哪怕它并不是从鸭子类的原型对象继承而来的”。

按照鸭式辩型的理念定义了quacks()函数。quacks()用以检查一个对象(第一个实参)是否实现了剩下的参数所表示的方法。

//如果o实现了除第一个参数之外的参数所表示的方法,则返回true
function quacks(o /*,...*/) {
  for (var i = 1; i < arguments.length; i++) {
    //遍历o之后的所有参数
    var arg = arguments[i];
    switch (
      typeof arg //如果参数是:
    ) {
      case "string": //string:直接用名字做检查
        if (typeof o[arg] !== "function") return false;
        continue;
      case "function": //function:检查函数的原型对象上的方法
        //如果实参是函数,则使用它的原型
        arg = arg.prototype; //进入下一个case
      case "object": //object:检查匹配的方法
        for (var m in arg) {
          //遍历对象的每个属性
          if (typeof arg[m] !== "function") continue; //跳过不是方法的属性
          if (typeof o[m] !== "function") return false;
        }
    }
  }
  //如果程序能执行到这里,说明o实现了所有的方法
  return true;
}

quacks()函数只是通过特定的名称来检测对象是否含有一个或多个值为函数的属性。我们无法得知这些已经存在的属性的细节信息,比如,函数是干什么用的?它们需要多少参数?参数类型是什么?这是鸭式辩型的本质所在

6. JavaScript面向对象技术

6.1 一个例子:集合类

集合(set)是一种数据结构,用以表示非重复值的无序集合。集合的基础方法包括添加值、检测值是否在集合中

function Set() {
  //这是一个构造函数
  this.values = {}; //集合数据保存在对象的属性里
  this.n = 0; //集合中值的个数
  this.add.apply(this, arguments); //把所有参数都添加进这个集合
}
//将每个参数都添加至集合中
Set.prototype.add = function() {
  for (var i = 0; i < arguments.length; i++) {
    //遍历每个参数
    var val = arguments[i]; //待添加到集合中的值
    var str = Set._v2s(val); //把它转换为字符串
    if (!this.values.hasOwnProperty(str)) {
      //如果不在集合中
      this.values[str] = val; //将字符串和值对应起来
      this.n++; //集合中值的计数加一
    }
  }
  return this; //支持链式方法调用
}; //从集合删除元素,这些元素由参数指定
Set.prototype.remove = function() {
  for (var i = 0; i < arguments.length; i++) {
    //遍历每个参数
    var str = Set._v2s(arguments[i]); //将字符串和值对应起来
    if (this.values.hasOwnProperty(str)) {
      //如果它在集合中
      delete this.values[str]; //删除它
      this.n--; //集合中值的计数减一
    }
  }
  return this; //支持链式方法调用
}; //如果集合包含这个值,则返回true;否则,返回false
Set.prototype.contains = function(value) {
  return this.values.hasOwnProperty(Set._v2s(value));
}; //返回集合的大小
Set.prototype.size = function() {
  return this.n;
}; //遍历集合中的所有元素,在指定的上下文中调用f
Set.prototype.foreach = function(f, context) {
  for (var s in this.values) //遍历集合中的所有字符串
    if (this.values.hasOwnProperty(s))
      //忽略继承的属性
      f.call(context, this.values[s]); //调用f,传入value
}; //这是一个内部函数,用以将任意JavaScript值和唯一的字符串对应起
Set._v2s = function(val) {
  switch (val) {
    case undefined:
      return "u"; //特殊的原始值
    case null:
      return "n"; //值只有一个字母
    case true:
      return "t"; //代码
    case false:
      return "f";
    default:
      switch (typeof val) {
        case "number":
          return "#" + val; //数字都带有#前缀
        case "string":
          return '"' + val; //字符串都带有"前缀
        default:
          return "@" + objectId(val); //Objs and funcs get@
      }
  }

  //对任意对象来说,都会返回一个字符串
  //针对不同的对象,这个函数会返回不同的字符串
  //对于同一个对象的多次调用,总是返回相同的字符串
  //为了做到这一点,它给o创建了一个属性,在ES5中,这个属性是不可枚举且是只读的
  function objectId(o) {
    var prop = "|**objectid**|"; //私有属性,用以存放id
    if (!o.hasOwnProperty(prop))
      //如果对象没有id
      o[prop] = Set._v2s.next++; //将下一个值赋给它
    return o[prop]; //返回这个id
  }
};

Set._v2s.next = 100; //设置初始id的值
var set = new Set();
set.add(undefined, null, 1, "str");
console.log(set);

image.png

6.2 一个例子:枚举类型

枚举类型(enumerated type)是值的有限集合,如果值定义为这个类型则该值是可列出(或“可枚举”)的。

JavaScript中的枚举类型

//这个函数创建一个新的枚举类型,实参对象表示类的每个实例的名字和值
//返回值是一个构造函数,它标识这个新类
//注意,这个构造函数也会抛出异常:不能使用它来创建该类型的新实例
//返回的构造函数包含名/值对的映射表
//包括由值组成的数组,以及一个foreach()迭代器函数
function enumeration(namesToValues) {
  //这个虚拟的构造函数是返回值
  var enumeration = function() {
    throw "Can't Instantiate Enumerations";
  }; //枚举值继承自这个对象
  var proto = (enumeration.prototype = {
    constructor: enumeration, //标识类型
    toString: function() {
      return this.name;
    }, //返回名字
    valueOf: function() {
      return this.value;
    }, //返回值
    toJSON: function() {
      return this.name;
    } //转换为JSON
  });
  enumeration.values = []; //用以存放枚举对象的数组
  //现在创建新类型的实例
  for (name in namesToValues) {
    //遍历每个值
    var e = Object.create(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;
}
enumeration({ a: 1, b: 2 });
console.log(enumeration({ a: 1, b: 2 }));

image.png

使用枚举类型来表示一副扑克牌:

function enumeration(namesToValues) {
  //这个虚拟的构造函数是返回值
  var enumeration = function() {
    throw "Can't Instantiate Enumerations";
  }; //枚举值继承自这个对象
  var proto = (enumeration.prototype = {
    constructor: enumeration, //标识类型
    toString: function() {
      return this.name;
    }, //返回名字
    valueOf: function() {
      return this.value;
    }, //返回值
    toJSON: function() {
      return this.name;
    } //转换为JSON
  });
  enumeration.values = []; //用以存放枚举对象的数组
  //现在创建新类型的实例
  for (name in namesToValues) {
    //遍历每个值
    var e = Object.create(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;
}
//定义一个表示"玩牌"的类

function Card(suit, rank) {
  this.suit = suit; //每张牌都有花色
  this.rank = rank; //以及点数
}
//使用枚举类型定义花色和点数
Card.Suit = enumeration({ Clubs: 1, Diamonds: 2, Hearts: 3, Spades: 4 });
Card.Rank = enumeration({
  Two: 2,
  Three: 3,
  Four: 4,
  Five: 5,
  Six: 6,
  Seven: 7,
  Eight: 8,
  Nine: 9,
  Ten: 10,
  Jack: 11,
  Queen: 12,
  King: 13,
  Ace: 14
}); //定义用以描述牌面的文本
Card.prototype.toString = function() {
  return this.rank.toString() + "of" + this.suit.toString();
}; //比较扑克牌中两张牌的大小
Card.prototype.compareTo = function(that) {
  if (this.rank < that.rank) return -1;
  if (this.rank > that.rank) return 1;
  return 0;
}; //以扑克牌的玩法规则对牌进行排序的函数
Card.orderByRank = function(a, b) {
  return a.compareTo(b);
}; //以桥牌的玩法规则对扑牌进行排序的函数
Card.orderBySuit = function(a, b) {
  if (a.suit < b.suit) return -1;
  if (a.suit > b.suit) return 1;
  if (a.rank < b.rank) return -1;
  if (a.rank > b.rank) return 1;
  return 0;
}; //定义用以表示一副标准扑克牌的类
function Deck() {
  var cards = (this.cards = []); //一副牌就是由牌组成的数组
  Card.Suit.foreach(function(s) {
    //初始化这个数组
    Card.Rank.foreach(function(r) {
      cards.push(new Card(s, r));
    });
  });
}
//洗牌的方法:重新洗牌并返回洗好的牌
Deck.prototype.shuffle = function() {
  //遍历数组中的每个元素,随机找出牌面最小的元素,并与之(当前遍历的元素)交换
  var deck = this.cards,
    len = deck.length;
  for (var i = len - 1; i > 0; i--) {
    var r = Math.floor(Math.random() * (i + 1));
    var temp; //随机数

    temp = deck[i];
    deck[i] = deck[r];
    deck[r] = temp; //交换
  }
  return this;
}; //发牌的方法:返回牌的数组
Deck.prototype.deal = function(n) {
  if (this.cards.length < n) throw "Out of cards";
  return this.cards.splice(this.cards.length - n, n);
}; //创建一副新扑克牌,洗牌并发牌
var deck = new Deck().shuffle();
console.log(deck);
var hand = deck.deal(13).sort(Card.orderBySuit);
console.log(hand);

image.png

6.3 四个转换 类的实例对象类型的方法

  • toString()。这个方法的作用是返回一个可以表示这个对象的字符串。在希望使用字符串的地方用到对象的话(比如将对象用做属性名或使用“+”运算符来进行字符串连接运算),JavaScript会自动调用这个方法。如果没有实现这个方法,类会默认从Object.prototype中继承toString()方法,这个方法的运算结果是"[object Object]"
  • toLocaleString()和toString()极为类似:toLocaleStri ng()是以本地敏感性(locale-sensitive)的方式来将对象转换为字符串。
  • valueOf(),它用来将对象转换为原始值。比如,当数学运算符(除了“+”运算符)和关系运算符作用于数字文本表示的对象时,会自动调用valueOf()方法。大多数对象都没有合适的原始值来表示它们,也没有定义这个方法。
  • toJSON(),这个方法是由JSON.stringify()自动调用的。JSON格式用于序列化良好的数据结构,而且可以处理JavaScript原始值、数组和纯对象。它和类无关,当对一个对象执行序列化操作时,它会忽略对象的原型和构造函数。有些些类则必须自定义toJSON()方法来定制个性化的序列化格式。
//将这些方法添加至Set类的原型对象中
extend(Set.prototype, {
  //将集合转换为字符串
  toString: function() {
    var s = "{",
      i = 0;
    this.foreach(function(v) {
      s += (i++ > 0 ? "," : "") + v;
    });
    return s + "}";
  }, //类似toString,但是对于所有的值都将调用toLocaleString()
  toLocaleString: function() {
    var s = "{",
      i = 0;
    this.foreach(function(v) {
      if (i++ > 0) s += ",";
      if (v == null) s += v;
      //null和undefined
      else s += v.toLocaleString(); //其他情况
    });
    return s + "}";
  }, //将集合转换为值数组
  toArray: function() {
    var a = [];
    this.foreach(function(v) {
      a.push(v);
    });
    return a;
  }
}); //对于要从JSON转换为字符串的集合都被当做数组来对待
Set.prototype.toJSON = Set.prototype.toArray;

6.4 类的实例对象的比较方法

JavaScript的相等运算符比较对象时,比较的是引用而不是值。也就是说,给定两个对象引用,要看它们是否指向同一个对象

给Set类定义equals()相等比较方法

Set.prototype.equals = function(that) {
  //一些次要情况的快捷处理
  if (this === that) return true; 
  //如果that对象不是一个集合,它和this不相等
  //我们用到了instanceof,使得这个方法可以用于Set的任何子类
  //如果希望采用鸭式辩型的方法,可以降低检查的严格程度
  //或者可以通过this.constructor==that.constructor来加强检查的严格程度
  //注意,null和undefined两个值是无法用于instanceof运算的
  if (!(that instanceof Set)) return false; //如果两个集合的大小不一样,则它们不相等
  if (this.size() != that.size()) return false; //现在检查两个集合中的元素是否完全一样
  //如果两个集合不相等,则通过抛出异常来终止foreach循环
  try {
    this.foreach(function(v) {
      if (!that.contains(v)) throw false;
    });
    return true; //所有的元素都匹配:两个集合相等
  } catch (x) {
    if (x === false) return false; //如果集合中有元素在另外一个集合中不存在
    throw x; //重新抛出异常
  }
};

如果将对象用于JavaScript的关系比较运算符,比如“<”和“<=”,JavaScript会首先调用对象的valueOf()方法,如果这个方法返回一个原始值,则直接比较原始值。

compareTo()方法应当只能接收一个参数,这个方法将这个参数和调用它的对象进行比较。如果this对象小于参数对象,compareTo()应当返回比0小的值。如果this对象大于参数对象,应当返回比0大的值。如果两个对象相等,应当返回0。

image.png

给类定义了compareTo()方法,这样就可以对类的实例组成的数组进行排序了。Array.sort()方法可以接收一个可选的参数,这个参数是一个函数,用来比较两个值的大小,这个函数返回值的约定和compareTo()方法保持一致。

ranges.sort(function(a,b){return a.compareTo(b);});

6.5 类方法的借用

多个类中的方法可以共用一个单独的函数。我们倾向于将这种方法重用称为“方法借用”(borrowing)

6.6 类的私有状态

经常需要将对象的某个状态封装或隐藏在对象内,只有通过对象的方法才能访问这些状态,对外只暴露一些重要的状态变量可以直接读写。

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

这就需要在构造函数内部定义一个函数(因此这个函数可以访问构造函数内部的参数和变量),并将这个函数赋值给新创建对象的属性。

获取端点的方式从之前直接从属性读取变成了通过from()和to()方法来读取:

function Range(from, to) {
  //不要将端点保存为对象的属性,相反
  //定义存取器函数来返回端点的值
  //这些值都保存在闭包中
  this.from = function() {
    return from;
  };
  this.to = function() {
    return to;
  };
}
//原型上的方法无法直接操作端点
//它们必须调用存取器方法
Range.prototype = {
  constructor: Range,
  includes: function(x) {
    return this.from() <= x && x <= this.to();
  },
  foreach: function(f) {
    for (var x = Math.ceil(this.from()), max = this.to(); x <= max; x++) f(x);
  },
  toString: function() {
    return "(" + this.from() + "..." + this.to() + ")";
  }
};

使用闭包来封装类的状态的类一定会比不使用封装的状态变量的等价类运行速度更慢,并占用更多内存。

6.7 构造函数的重载和工厂方法

有时候,我们希望对象的初始化有多种方式。通过重载(overload)这个构造函数让它根据传入参数的不同来执行不同的初始化方法。

function Set() {
  this.values = {}; //用这个对象的属性来保存这个集合
  this.n = 0; //集合中值的个数
  //如果传入一个类数组的对象,将这个元素添加至集合中
  //否则,将所有的参数都添加至集合中
  if (arguments.length == 1 && Array.isArray(arguments[0]))
    this.add.apply(this, arguments[0]);
  else if (arguments.length > 0) this.add.apply(this, arguments);
}

这段代码所定义的Set()构造函数可以显式将一组元素作为参数列表传入,也可以传入元素组成的数组。

可以写一个工厂方法——一个类的方法用以返回类的一个实例。下面这个工厂方法用来通过数组初始化Set对象:

Set.fromArray = function(a) {
  s = new Set(); //创建一个空集合
  s.add.apply(s, a); //将数组a的成员作为参数传入add()方法
  return s; //返回这个新集合
};

可以给工厂方法定义任意的名字,不同名字的工厂方法用以执行不同的初始化。

可以定义多个构造函数继承自一个原型对象的,如果这样做的话,由这些构造函数的任意一个所创建的对象都属于同一类型。

//Set类的一个辅助构造函数
function SetFromArray(a) {
  //通过以函数的形式调用Set()来初始化这个新对象
  //将a的元素作为参数传入[8]
  Set.apply(this, a);
}
//设置原型,以便SetFromArray能创建Set的实例
SetFromArray.prototype = Set.prototype;
var s = new SetFromArray([1, 2, 3]);
s instanceof Set; //=>true

7 子类

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

  • 如果B的方法重载了A中的方法,B中的重载方法可能会调用A中的重载方法,这种做法称为“方法链”(method chaining)。
  • 同样,子类的构造函数B()有时需要调用父类的构造函数A(),这种做法称为“构造函数链”
  • 子类还可以有子类,当涉及类的层次结构时,往往需要定义抽象类(abstract class)。抽象类中定义的方法没有实现。抽象类中的抽象方法是在抽象类的具体子类中实现的。

7.1 定义子类

首先要确保B的原型对象继承自A的原型对象。

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

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

//用一个简单的函数defineSubclass创建简单的子类
function defineSubclass(
  superclass, //父类的构造函数
  constructor, //新的子类的构造函数
  methods, //实例方法:复制至原型中
  statics
) {
  //类属性:复制至构造函数中
  //建立子类的原型对象
  constructor.prototype = Object.create(superclass.prototype);
  constructor.prototype.constructor = constructor; //像对常规类一样复制方法和类属性
  if (methods) extend(constructor.prototype, methods);
  if (statics) extend(constructor, statics); //返回这个类
  return constructor;
}
//也可以通过父类构造函数的方法来做到这一点
Function.prototype.extend = function(constructor, methods, statics) {
  return defineSubclass(this, constructor, methods, statics);
};
function extend(o) {
  for (var i = 1; i < arguments.length; i++) {
    for (var prop in arguments[i]) {
      o[prop] = arguments[i][prop];
    }
  }
  return o;
}

不使用defineSubclass()函数如何“手动”实现子类SingletonSet。

//构造函数
function SingletonSet(member) {
  this.member = member; //记住集合中这个唯一的成员
}
//创建一个原型对象,这个原型对象继承自Set的原型
SingletonSet.prototype = Object.create(Set.prototype); //给原型添加属性
//如果有同名的属性就覆盖Set.prototype中的同名属性
extend(SingletonSet.prototype, {
  //设置合适的constructor属性
  constructor: SingletonSet, //这个集合是只读的:调用add()和remove()都会报错
  add: function() {
    throw "read-only set";
  },
  remove: function() {
    throw "read-only set";
  },
  emove: function() {
    throw "read-only set";
  }, //SingletonSet的实例中永远只有一个元素
  size: function() {
    return 1;
  }, //这个方法只调用一次,传入这个集合的唯一成员
  foreach: function(f, context) {
    f.call(context, this.member);
  }, //contains()方法非常简单:只须检查传入的值是否匹配这个集合唯一的成员即可
  contains: function(x) {
    return x === this.member;
  }
});

需要注意的是,SingletonSet不是将Set中的方法列表静态地借用过来,而是动态地从Set类继承方法。如果给Set.prototype添加新的方法,Set和SingletonSet的所有实例就会立即拥有这个方法

7.2 构造函数和方法链

定义子类时,我们往往希望对父类的行为进行修改或扩充,而不是完全替换掉它们。为了做到这一点,构造函数和子类的方法需要调用或链接到父类构造函数和父类方法。

/*
 *NonNullSet是Set的子类,它的成员不能是null和undefined
 */
function NonNullSet() {
  //仅链接到父类
  //作为普通函数调用父类的构造函数来初始化通过该构造函数调用创建的对象
  Set.apply(this, arguments);
}
//将NonNullSet设置为Set的子类
NonNullSet.prototype = Object.create(Set.prototype);
NonNullSet.prototype.constructor = NonNullSet;
//为了将null和undefined排除在外,只须重写add()方法
NonNullSet.prototype.add = function() {
  //检查参数是不是null或undefined
  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);
};

“过滤后的集合”,这个集合中的成员必须首先传入一个过滤函数再执行添加操作。为此,定义一个类工厂函数,传入一个过滤函数,返回一个新的Set子类。

//定义一个只能保存字符串的"集合"类
var StringSet = filteredSetSubclass(Set, function(x) {
  return typeof x === "string";
}); //这个集合类的成员不能是null、undefined或函数
var MySet = filteredSetSubclass(NonNullSet, function(x) {
  return typeof x !== "function";
});
/*
*这个函数返回具体Set类的子类
*并重写该类的add()方法用以对添加的元素做特殊的过滤
*/
function filteredSetSubclass(superclass, filter) {
  var constructor = function() {
    //子类构造函数
    superclass.apply(this, arguments); //调用父类构造函数
  };
  var proto = (constructor.prototype = Object.create(superclass.prototype));
  proto.constructor = constructor;
  proto.add = function() {
    //在添加任何成员之前首先使用过滤器将所有参数进行过滤
    for (var i = 0; i < arguments.length; i++) {
      var v = arguments[i];
      if (!filter(v)) throw "value" + v + "rejected by filter";
    }
    //调用父类的add()方法
    superclass.prototype.add.apply(this, arguments);
  };

  return constructor;
}

上边例子用一个函数将创建子类的代码包装起来,这样就可以在构造函数和方法链中使用父类的参数,而不是通过写死某个父类的名字来使用它的参数。也就是说如果想修改父类,只须修改一处代码即可,而不必对每个用到父类类名的地方都做修改。

7.3 组合

面向对象编程中一条广为人知的设计原则:“组合优于继承”

使用组合代替继承的集合的实现:

/*
*实现一个FilteredSet,它包装某个指定的"集合"对象,
*并对传入add()方法的值应用了某种指定的过滤器
*"范围"类中其他所有的核心方法延续到包装后的实例中
*/
var FilteredSet = Set.extend(
  function FilteredSet(set, filter) {
    //构造函数
    this.set = set;
    this.filter = filter;
  },

  {
    //实例方法
    add: function() {
      //如果已有过滤器,直接使用它
      if (this.filter) {
        for (var i = 0; i < arguments.length; i++) {
          var v = arguments[i];
          if (!this.filter(v))
            throw new Error("FilteredSet:value" + v + "rejected by filter");
        }
      }
      //调用set中的add()方法
      this.set.add.apply(this.set, arguments);

      return this;
    }, //剩下的方法都保持不变
    remove: function() {
      this.set.remove.apply(this.set, arguments);
      return this;
    },
    contains: function(v) {
      return this.set.contains(v);
    },
    size: function() {
      return this.set.size();
    },
    foreach: function(f, c) {
      this.set.foreach(f, c);
    }
  }
);

var s=new FilteredSet(new Set(),function(x){return x!==null;});
var t=new FilteredSet(s,{function(x){return!(x instanceof Set);}};

7.4 类的层次结构和抽象类

定义了一个层次结构的抽象的集合类。

  • AbstractSet只定义了一个抽象方法:contains()。任何类只要“声称”自己是一个表示范围的类,就必须至少定义这个ontains()方法。
  • 然后,定义AbstractSet的子类AbstractEnumerableSet。这个类增加了抽象的size()和foreach()方法,而且定义了一些有用的非抽象方法(toString()、toArray()、equals()等),AbstractEnumerableSet并没有定义add()和remove()方法,它只代表只读集合。SingletonSet可以实现为非抽象子类。
  • 最后,定义了AbstractEnumerableSet的子类AbstractWritableSet。定义了抽象方法add()和remove(),并实现了诸如union()和intersection()等非具体方法,这两个方法调用了add()和remove()。AbstractWritableSet是Set和FilteredSet类相应的父类。

抽象类和非抽象Set类的层次结构:

//这个函数可以用做任何抽象方法,非常方便
function abstractmethod() {
  throw new Error("abstract method");
}
/*
 *AbstractSet类定义了一个抽象方法:contains()*/
function AbstractSet() {
  throw new Error("Can't instantiate abstract classes");
}
AbstractSet.prototype.contains = abstractmethod;
/*
 *NotSet是AbstractSet的一个非抽象子类
 *所有不在其他集合中的成员都在这个集合中
 *因为它是在其他集合是不可写的条件下定义的
 *同时由于它的成员是无限个,因此它是不可枚举的
 *我们只能用它来检测元素成员的归属情况
 *注意,我们使用了Function.prototype.extend()方法来定义这个子类
 */

var NotSet = AbstractSet.extend(
  function NotSet(set) {
    this.set = set;
  },

  {
    contains: function(x) {
      return !this.set.contains(x);
    },
    toString: function(x) {
      return "~" + this.set.toString();
    },
    equals: function(that) {
      return that instanceof NotSet && this.set.equals(that.set);
    }
  }
); /*
 *AbstractEnumerableSet是AbstractSet的一个抽象子类
 *它定义了抽象方法size()和foreach()
 *然后实现了非抽象方法isEmpty()、toArray()、to[Locale]String()和equals()方法
 *子类实现了contains()、size()和foreach(),这三个方法可以很轻易地调用这5个非抽象方法
 */
var AbstractEnumerableSet = AbstractSet.extend(
  function() {
    throw new Error("Can't instantiate abstract classes");
  },
  {
    size: abstractmethod,
    foreach: abstractmethod,
    isEmpty: function() {
      return this.size() == 0;
    },
    toString: function() {
      var s = "{", i = 0;
      this.foreach(function(v) {
        if (i++ > 0) s += ",";
        s += v;
      });
      return s + "}";
    },
    toLocaleString: function() {
      var s = "{", i = 0;
      this.foreach(function(v) {
        if (i++ > 0) s += ",";
        if (v == null) s += v;
        //null和undefined
        else s += v.toLocaleString(); //其他的情况
      });
      return s + "}";
    },
    toArray: function() {
      var a = [];
      this.foreach(function(v) {
        a.push(v);
      });
      return a;
    },
    equals: function(that) {
      if (!(that instanceof AbstractEnumerableSet)) return false; 
      //如果它们的大小不同,则它们不相等
      if (this.size() != that.size()) return false;
      //检查每一个元素是否也在that中
      try {
        this.foreach(function(v) {
          if (!that.contains(v)) throw false;
        });
        return true; //所有的元素都匹配:集合相等
      } catch (x) {
        if (x === false) return false; //集合不相等
        throw x; //发生了其他的异常:重新抛出异常
      }
    }
  }
); /*
*SingletonSet是AbstractEnumerableSet的非抽象子类
*singleton集合是只读的,它只包含一个成员
*/
var SingletonSet = AbstractEnumerableSet.extend(
  function SingletonSet(member) {
    this.member = member;
  },
  {
    contains: function(x) {
      return x === this.member;
    },
    size: function() {
      return 1;
    },
    foreach: function(f, ctx) {
      f.call(ctx, this.member);
    }
  }
); /*
*AbstractWritableSet是AbstractEnumerableSet的抽象子类
*它定义了抽象方法add()和remove()
*然后实现了非抽象方法union()、intersection()和difference()
*/
var AbstractWritableSet = AbstractEnumerableSet.extend(
  function() {
    throw new Error("Can't instantiate abstract classes");
  },
  {
    add: abstractmethod,
    remove: abstractmethod,
    union: function(that) {
      var self = this;
      that.foreach(function(v) {
        self.add(v);
      });
      return this;
    },
    intersection: function(that) {
      var self = this;
      this.foreach(function(v) {
        if (!that.contains(v)) self.remove(v);
      });
      return this;
    },
    difference: function(that) {
      var self = this;
      that.foreach(function(v) {
        self.remove(v);
      });
      return this;
    }
  }
); /*
*ArraySet是AbstractWritableSet的非抽象子类
*它以数组的形式表示集合中的元素
*对于它的contains()方法使用了数组的线性查找
*因为contains()方法的算法复杂度是O(n)而不是O(1)
*它非常适用于相对小型的集合,注意,这里的实现用到了ES5的数组方法indexOf()和forEach()
*/
var ArraySet = AbstractWritableSet.extend(
  function ArraySet() {
    this.values = [];
    this.add.apply(this, arguments);
  },
  {
    contains: function(v) {
      return this.values.indexOf(v) != -1;
    },
    size: function() {
      return this.values.length;
    },
    foreach: function(f, c) {
      this.values.forEach(f, c);
    },
    add: function() {
      for (var i = 0; i < arguments.length; i++) {
        var arg = arguments[i];
        if (!this.contains(arg)) this.values.push(arg);
      }
      return this;
    },
    remove: function() {
      for (var i = 0; i < arguments.length; i++) {
        var p = this.values.indexOf(arguments[i]);
        if (p == -1) continue;
        this.values.splice(p, 1);
      }
      return this;
    }
  }
);

8 ES5中的类

8.1 类属性不可枚举

ECMAScript 5可以通过设置属性为“不可枚举”(nonenumerable)来让属性不会被for/in遍历到。

//将代码包装在一个匿名函数中,这样定义的变量就在这个函数作用域内
(function() {
  //定义一个不可枚举的属性objectId,它可以被所有对象继承
  //当读取这个属性时调用getter函数
  //它没有定义setter,因此它是只读的
  //它是不可配置的,因此它是不能删除的
  Object.defineProperty(Object.prototype, "objectId", {
    get: idGetter, //取值器
    enumerable: false, //不可枚举的
    configurable: false //不可删除的
  }); //当读取objectId的时候直接调用这个getter函数
  function idGetter() {
    //getter函数返回该id
    if (!(idprop in this)) {
      //如果对象中不存在id
      if (!Object.isExtensible(this))
        //并且可以增加属性
        throw Error("Can't define id for nonextensible objects");

      Object.defineProperty(this, idprop, {
        //给它一个值
        value: nextid++, //就是这个值
        writable: false, //只读的
        enumerable: false, //不可枚举的
        configurable: false //不可删除的
      });
    }

    return this[idprop]; //返回已有的或新的值
  } //idGetter()用到了这些变量,这些都属于私有变量
  var idprop = "|**objectId**|"; //假设这个属性没有用到
  var nextid = 1; //给它设置初始值
})(); //立即执行这个包装函数

image.png

8.2 类属性只读,不可变的类

使用Object.defineProperties()和Object.create()来定义不可变的和不可枚举的属性。

//这个方法可以使用new调用,也可以省略new,它可以用做构造函数也可以用做工厂函数
function Range(from, to) {
  //这些是对from和to只读属性的描述符
  var props = {
    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, //创建并返回这个新Range对象,
      props
    ); //属性由props指定
}
//如果用同样的方法给Range.prototype对象添加属性
//那么我们需要给这些属性设置它们的特性
//因为我们无法识别出它们的可枚举性、可写性或可配置性,这些属性特性默认都是false
Object.defineProperties(Range.prototype, {
  includes: {
    value: function(x) {
      return this.from <= x && x <= this.to;
    }
  },
  foreach: {
    value: function(f) {
      for (var x = Math.ceil(this.from); x <= this.to; x++) f(x);
    }
  },
  toString: {
    value: function() {
      return "(" + this.from + "..." + this.to + ")";
    }
  }
});
  1. Object.defineProperty()和Object.defineProperties()可以用来创建新属性,也可以修改已有属性的特性。
  2. 当用它们创建新属性时,默认的属性特性的值都是false。但当用它们修改已经存在的属性时,默认的属性特性依然保持不变。

属性描述符工具函数:

//将o的指定名字(或所有)的属性设置为不可写的和不可配置的
function freezeProps(o) {
  var props =
    arguments.length == 1 //如果只有一个参数
      ? Object.getOwnPropertyNames(o) //使用所有的属性
      : Array.prototype.splice.call(arguments, 1); //否则传入了指定名字的属性

  props.forEach(function(n) {
    //将它们都设置为只读的和不可变的
    //忽略不可配置的属性
    if (!Object.getOwnPropertyDescriptor(o, n).configurable) return;

    Object.defineProperty(o, n, { writable: false, configurable: false });
  });

  return o; //所以我们可以继续使用它
}
//将o的指定名字(或所有)的属性设置为不可枚举的和可配置的
function hideProps(o) {
  var props =
    arguments.length == 1 //如果只有一个参数
      ? Object.getOwnPropertyNames(o) //使用所有的属性
      : Array.prototype.splice.call(arguments, 1); //否则传入了指定名字的属性

  props.forEach(function(n) {
    //将它们设置为不可枚举的
    //忽略不可配置的属性
    if (!Object.getOwnPropertyDescriptor(o, n).configurable) return;

    Object.defineProperty(o, n, { enumerable: false });
  });

  return o;
}

一个简单的不可变的Range类就用到了刚才定义的工具函数:

function Range(from, to) {
  //不可变的类Range的构造函数
  this.from = from;
  this.to = to;
  freezeProps(this); //将属性设置为不可变的
}
Range.prototype = hideProps({
  //使用不可枚举的属性来定义原型
  constructor: Range,
  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 + ")";
  }
});

8.3 封装类的属性状态

可以通过定义属性getter和setter方法将状态变量更健壮地封装起来,这两个方法是无法删除的

//这个版本的Range类是可变的,但将端点变量进行了良好的封装
//但端点的大小顺序还是固定的:from<=to
function Range(from, to) {
  //如果from大于to
  if (from > to) throw new Error("Range:from must be<=to"); 
  //定义存取器方法以维持不变
  function getFrom() {
    return from;
  }
  function getTo() {
    return to;
  }
  function setFrom(f) {
    //设置from的值时,不允许from大于to
    if (f <= to) from = f;
    else throw new Error("Range:from must be<=to");
  }
  function setTo(t) {
    //设置to的值时,不允许to小于from
    if (t >= from) to = t;
    else throw new Error("Range:to must be>=from");
  }

  //将使用取值器的属性设置为可枚举的、不可配置的
  Object.defineProperties(this, {
    from: { get: getFrom, set: setFrom, enumerable: true, configurable: false },

    to: { get: getTo, set: setTo, enumerable: true, configurable: false }
  });
}
//和前面的例子相比,原型对象没有做任何修改
//实例方法可以像读取普通的属性一样读取from和to
Range.prototype = hideProps({
  constructor: Range,
  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 + ")";
  }
});

8.4 将类设置为不可扩展

  • Object.preventExtensions()可以将对象设置为不可扩展的,也就是说不能给对象添加任何新属性。
  • Object.seal()则更加强大,它除了能阻止用户给对象添加新属性,还能将当前已有的属性设置为不可配置的,这样就不能删除这些属性了(但不可配置的属性可以是可写的,也可以转换为只读属性)。

通过这样一句简单的代码来阻止对Object.prorotype的扩展:

Object.seal(Object.prototype);

JavaScript的另外一个动态特性是“对象的方法可以随时替换”:

var original_sort_method = Array.prototype.sort;
Array.prototype.sort = function() {
  var start = new Date();
  original_sort_method.apply(this, arguments);
  var end = new Date();
  console.log("Array sort took" + (end - start) + "milliseconds.");
};

如果对象o继承了只读属性p,那么给o.p的赋值操作将会失败,就不会给o创建新属性。

8.5 子类和ES5

使用Object.create()创建原型对象,这个原型对象继承自父类的原型,同时给新创建的对象定义属性。

function StringSet() {
  this.set = Object.create(null); //创建一个不包含原型的对象
  this.n = 0;
  this.add.apply(this, arguments);
}
//注意,使用Object.create()可以继承父类的原型
//而且可以定义单独调用的方法,因为我们没有指定属性的可写性、可枚举性和可配置性
//因此这些属性特性的默认值都是false
//只读方法让这个类难于子类化(被继承)
StringSet.prototype = Object.create(AbstractWritableSet.prototype, {
  constructor: { value: StringSet },
  contains: {
    value: function(x) {
      return x in this.set;
    }
  },
  size: {
    value: function(x) {
      return this.n;
    }
  },
  foreach: {
    value: function(f, c) {
      Object.keys(this.set).forEach(f, c);
    }
  },
  add: {
    value: function() {
      for (var i = 0; i < arguments.length; i++) {
        if (!(arguments[i] in this.set)) {
          this.set[arguments[i]] = true;

          this.n++;
        }
      }

      return this;
    }
  },
  remove: {
    value: function() {
      for (var i = 0; i < arguments.length; i++) {
        if (arguments[i] in this.set) {
          delete this.set[arguments[i]];
          this.n--;
        }
      }
      return this;
    }
  }
});

使用Object.create()创建对象时传入了参数null,这个创建的对象没有任何继承任何成员。

8.6 属性描述符

给Object.prototype添加了properties()方法(这个方法是不可枚举的)。这个方法的返回值是一个对象,用以表示属性的列表,并定义了有用的方法用来输出属性和属性特性(对于调试非常有用),用来获得属性描述符(当复制属性同时复制属性特性时非常有用)以及用来设置属性的特性

/*
*给Object.prototype定义properties()方法,
*这个方法返回一个表示调用它的对象上的属性名列表的对象
*(如果不带参数调用它,就表示该对象的所有属性)
*返回的对象定义了4个有用的方法:toString()、descriptors()、hide()和show()
*/
(function namespace() {
  //将所有逻辑闭包在一个私有函数作用域中
  //这个函数成为所有对象的方法
  function properties() {
    var names; //属性名组成的数组
    if (arguments.length == 0)
      //所有的自有属性
      names = Object.getOwnPropertyNames(this);
    else if (arguments.length == 1 && Array.isArray(arguments[0]))
      names = arguments[0];
    //名字组成的数组
    //参数列表本身就是名字
    else names = Array.prototype.splice.call(arguments, 0);
    //返回一个新的Properties对象,用以表示属性名字

    return new Properties(this, names);
  }

  //将它设置为Object.prototpye的新的不可枚举的属性
  //这是从私有函数作用域导出的唯一一个值
  Object.defineProperty(Object.prototype, "properties", {
    value: properties,
    enumerable: false,
    writable: true,
    configurable: true
  }); 
  //这个构造函数是由上面的properties()函数所调用的
  //Properties类表示一个对象的属性集合
  function Properties(o, names) {
    this.o = o; //属性所属的对象
    this.names = names; //属性的名字
  }
  //将代表这些属性的对象设置为不可枚举的

  Properties.prototype.hide = function() {
    var o = this.o,hidden = { enumerable: false };
    this.names.forEach(function(n) {
      if (o.hasOwnProperty(n)) Object.defineProperty(o, n, hidden);
    });
    return this;
  }; //将这些属性设置为只读的和不可配置的
  Properties.prototype.freeze = function() {
    var o = this.o, frozen = { writable: false, configurable: false };
    this.names.forEach(function(n) {
      if (o.hasOwnProperty(n)) Object.defineProperty(o, n, frozen);
    });
    return this;
  }; //返回一个对象,这个对象是名字到属性描述符的映射表
  //使用它来复制属性,连同属性特性一起复制
  //Object.defineProperties(dest,src.properties().descriptors());
  Properties.prototype.descriptors = function() {
    var o = this.o,
      desc = {};
    this.names.forEach(function(n) {
      if (!o.hasOwnProperty(n)) return;

      desc[n] = Object.getOwnPropertyDescriptor(o, n);
    });
    return desc;
  }; //返回一个格式化良好的属性列表
  //列表中包含名字、值和属性特性,使用"permanent"表示不可配置
  //使用"readonly"表示不可写,使用"hidden"表示不可枚举
  //普通的可枚举、可写和可配置属性不包含特性列表
  Properties.prototype.toString = function() {
    var o = this.o; //在下面嵌套的函数中使用
    var lines = this.names.map(nameToString);
    return "{\n" + lines.join(",\n") + "\n}";

    function nameToString(n) {
      var s = "", desc = Object.getOwnPropertyDescriptor(o, n);

      if (!desc) return "nonexistent" + n + ":undefined";
      if (!desc.configurable) s += "permanent";
      if ((desc.get && !desc.set) || !desc.writable) s += "readonly";
      if (!desc.enumerable) s += "hidden";
      if (desc.get || desc.set) s += "accessor" + n;
      else
        s +=
          n +
          ":" +
          (typeof desc.value === "function" ? "function" : desc.value);

      return s;
    }
  }; //最后,将原型对象中的实例方法设置为不可枚举的
  //这里用到了刚定义的方法
  Properties.prototype.properties().hide();
})(); //立即执行这个匿名函数

9 模块

一般来讲,模块是一个独立的JavaScript文件。模块文件可以包含一个类定义、一组相关的类、一个实用函数库或者是一些待执行的代码。只要以模块的形式编写代码,任何JavaScript代码段就可以当做一个模块

不同的模块必须避免修改全局执行上下文,因此后续模块应当在它们所期望运行的原始(或接近原始)上下文中执行

9.1 将对象用作模块命名空间

在模块创建过程中避免污染全局变量的一种方法是使用一个对象作为命名空间。它将函数和值作为命名空间对象属性存储起来(可以通过全局变量引用),而不是定义全局函数和变量。

有时模块作者会使用更深层嵌套的命名空间。如果sets模块是另外一组更大的模块集合的话,它的命名空间可能会是collections.sets

按照约定,模块的文件名应当和命名空间匹配。

9.2 将函数作为模块私有命名空间

模块的实现往往需要一些额外的辅助函数和方法,这些函数和方法并不需要在模块外部可见。可以将函数作用域用做模块的私有命名空间(有时称为“模块函数”

//声明全局变量Set,使用一个函数的返回值给它赋值
//函数结束时紧跟的一对圆括号说明这个函数定义后立即执行
//它的返回值将赋值给Set,而不是将这个函数赋值给Set
//注意它是一个函数表达式,不是一条语句,因此函数"invocation"并没有创建全局变量
var Set = (function invocation() {
  function Set() {
    //这个构造函数是局部变量
    this.values = {}; //这个对象的属性用来保存这个集合
    this.n = 0; //集合中值的个数
    this.add.apply(this, arguments); //将所有的参数都添加至集合中
  }
  //给Set.prototype定义实例方法
  //这里省略了详细代码
  Set.prototype.contains = function(value) {
    //注意我们调用了v2s(),而不是调用带有笨重的前缀的set._v2s()
    return this.values.hasOwnProperty(v2s(value));
  };
  Set.prototype.size = function() {
    return this.n;
  };
  Set.prototype.add = function() {
    /*...*/
  };
  Set.prototype.remove = function() {
    /*...*/
  };
  Set.prototype.foreach = function(f, context) {
    /*...*/
  }; //这里是上面的方法用到的一些辅助函数和变量
  //它们不属于模块的共有API,但它们都隐藏在这个函数作用域内
  //因此我们不必将它们定义为Set的属性或使用下划线作为其前缀
  function v2s(val) {
    /*...*/
  }
  function objectId(o) {
    /*...*/
  }
  var nextId = 1; //这个模块的共有API是Set()构造函数
  //我们需要把这个函数从私有命名空间中导出来
  //以便在外部也可以使用它,在这种情况下,我们通过返回这个构造函数来导出它
  //它变成第一行代码所指的表达式的值
  return Set;
})(); //定义函数后立即执行

注意,这里使用了立即执行的匿名函数。模块函数返回构造函数,这个构造函数随后赋值给一个全局变量。将值返回已经清楚地表明API已经导出在函数作用域之外。

一旦将模块代码封装进一个函数,就需要一些方法导出其公用API,以便在模块函数的外部调用它们。

如果模块API包含多个单元,则它可以返回命名空间对象。