Skip to content

1. 类和原型

  • 在 JavaScript 中,类使用基于原型的继承。
  • 如果两个对象从同一个原型继承属性,就称这两个对象是同一个类的实例。
  • 类意味着一组对象从同一个原型对象继承属性,因此,原型对象是类的核心。
  • 通常通过一个函数来创建和初始化新对象,如:
javascript
// 工厂函数用于创建对象
function range(from, to) {
  let r = Object.create(range.methods);

  r.from = from;
  r.to = to;

  return r;
}

// 定义原型对象,由所有的range类实例继承
range.methods = {
  includes(x) {
    return this.from <= x && x <= this.to;
  },

  *[Symbol.iterator]() {
    for (let x = Math.ceil(this.from); x <= this.to; x++) yield x;
  },

  toString() {
    return "(" + this.from + "..." + this.to + ")";
  }
}

let r = range(1, 3);
r.includes(2) // true
[...r] // [1, 2, 3]

注意

  • range() 是一个工厂函数,用于创建新的 range 对象。
  • 使用 range.methods 保存这个类的原型对象,不是习惯写法。
  • range() 为每个对象定义了 fromto 属性,这两个属性非共享,非继承。
  • 使用 Symbol.iterator 定义了一个迭代器,方法名前有 *,表明是一个生成器函数。
  • 使用 this 是所有类方法的特征。

2. 类和构造函数

  • 构造函数是一种专门用于创建对象的函数。
  • 构造函数需要使用 new 关键字调用,会自动创建对象,因此构造函数本身只需要初始化新对象的状态。
  • 构造函数的 prototype 属性将用作新对象的原型,因此使用同一个构造函数创建的所有对象都继承自同一个对象,是同一个类的成员。
  • 只有函数对象才有 prototype 属性,但箭头函数没有。
javascript
function Range(from, to) {
  this.from = from;
  this.to = to;
}

// 原型对象必须给 prototype 属性
Range.prototype = {
  includes(x) {
    return this.from <= x && x <= this.to;
  },

  *[Symbol.iterator]() {
    for (let x = Math.ceil(this.from); x <= this.to; x++) yield x;
  },

  toString() {
    return "(" + this.from + "..." + this.to + ")";
  }
}

let r = range(1, 3);
r.includes(2) // true
[...r] // [1, 2, 3]

注意

  • 构造函数按约定函数名首字母大写。
  • 构造函数中不需要显示返回新创建的对象,它调用会自动创建新对象,并将构造函数作为新对象的方法来调用,然后返回新对象。
  • 构造函数调用时会自动把 prototype 属性作为新对象的原型。
  • 构造函数及原型中的方法不能使用箭头函数定义。

额外知识点:

  • 构造函数内部可以通过 new.target 表达式判断该函数是否作为构造函数被调用了,如果该表达式有值,就是作为构造函数被调用,否则就是普通函数调用,没有使用 new 关键字。
  • 如果想让构造函数可以不使用 new 关键字创建对象,可以这样编码:
javascript
function Range(from, to) {
  if (!new.target) return new Range(from, to);
  // 初始化代码
  this.from = from;
  this.to = to;
}

2.1 构造函数、类标识和 instanceof

  • 构造函数不是类的基本标识,因为不同的构造函数的 prototype 可能指向同一个对象,都能创建同一个类的实例。
  • 原型对象才是类的基本标识,构造函数是类的外在表现。
  • instanceof 操作符左侧操作数应是要检测的对象,右侧操作数应该是某个类的构造函数。
  • instanceof 会照原型链向上查找,只要右侧构造函数的 prototype 存在于原型链中,就返回 true
  • instanceof 并非检查左侧对象是否是右侧构造函数创建的,而是检查是否继承了右侧构造函数的 prototype 对象。
javascript
function Strange() {}
Strange.prototype = Range.prototype;
new Strange() instanceof Range // true

2.2 constructor 属性

  • 普通函数自动拥有一个 prototype 属性,值是一个对象,因此可以不用给该属性显式赋值一个对象。
  • 普通函数自动拥有的 prototype 对象有一个不可枚举的 constructor 属性,值就是函数对象本身。
javascript
let F = function() {}
F.prototype.constructor === F // true
  • 由构造函数创建的对象也会继承 constructor 属性,返回该对象的类。
javascript
const f = new F();
f.constructor === F // true
  • 如果利用新对象覆盖 prototype 属性,需要显式地添加 constructor 对象。
javascript
Range.prototype = {
  // 显式设置constructor
  constructor: Range,

  /** 以下是其它方法 */
}

3. 使用 class 关键字的类

  • 新增 class 关键字并未改变 JavaScript 类基于原型的本质。
  • 使用 class 定义类更方便,可以把它看成更基础类定义机制的语法糖。
javascript
class Range {
  constructor(from, to) {
    // 这些属性为对象独有,非共享
    this.from = from;
    this.to = to;
  }

  includes(x) {
    return this.from <= x && x <= this.to;
  }

  *[Symbol.iterator]() {
    for (let x = Math.ceil(this.from); x <= this.to; x++) yield x;
  }

  toString() {
    return "(" + this.from + "..." + this.to + ")";
  }
}

let r = range(1, 3);
r.includes(2) // true
[...r] // [1, 2, 3]

说明

  • 类以 class 关键字声明,后跟类名和花括号中的类体。
  • 类体中方法以简写形式声明,方法之间没有逗号,与对象中声明有点区别。
  • constructor 用于定义类的构造函数,实际上会定义一个与类名相同的新变量,并将该函数赋值给新变量。
  • 如果类不需要初始化,可以不写构造函数,解释器会自动创建一个空构造函数。
  • 使用 extends 关键字可以继承另一个类。
javascript
class Span extends Range {
  constructor(start, length) {
    if (length > 0) {
      super(start, start + length);
    } else {
      super(start + length, start);
  }
}
  • 与函数声明类似,类也可以通过语句或表达式声明。类的表达式声明也可以包括可选的类名,该类名只能在类体内部访问。类的表达式声明并不常用:
javascript
let Square = class { constructor(x) { this.area = x * x } };
new Square(3).area // 9

注意

  • 即使没有出现 "use strict" 指令,class 声明体中所有代码默认处于严格模式。
  • 与函数声明不同,类声明不会提升。

3.1 静态方法

  • class 体中,把 static 关键字放在方法声明前面可以定义静态方法。
  • 静态方法是作为构造函数而非原型对象的属性定义的。
javascript
class Range {
  ...

  static parse(s) {
    let matches = s.match(/^\((\d+)\.\.\.(\d+)\)$/);
    if(!matches) {
      throw new TypeError(`Cannot parse Range from "${s}".`)
    }
    return new Range(parseInt(matches[1]), parseInt(matches[2]));
  }
}

let r = Range.parse('(1...10)') // 返回新Range对象
r.parse('(1...10)') // TypeError

3.2 获取方法,设置方法及其它形式方法

  • class 体内,可以像在对象字面量中一样定义获取方法和设置方法,唯一区别就是类体内方法后不加逗号。
  • 一般来说,对象字面量支持的所有简写方法都可以在类体中使用,包括生成器方法和名为表达式的方法。

3.3 公有、私有和静态字段

  • 在较新的 ECMAScript 版本(至少是 ES6 之后)中可以使用字段声明语法,字段可以不用写在构造函数中:
javascript
// 新字段声明语法
class Buffer {
  size = 0;
  capacity = 4096;
  // 虽然左操作数中this省略了,但在右操作数中使用时不能省略
  buffer = new Unit8Array(this.capacity);
}

// 等价于
class Buffer {
  constructor() {
    this.size = 0;
    this.capacity = 4096;
    this.buffer = new Unit8Array(this.capacity);
  }
}
  • 声明时可以不初始化,值为 undefined
  • 在字段名前加上 #,则该字段只能在类体内部使用,对类外部的任何代码都不可见,不可访问:
javascript
class Buffer {
  size = 0;
  capacity = 4096;
  // 私有字段
  #buffer = new Unit8Array(this.capacity);
}
  • 私有字段必须先使用字段声明语法才能使用。
  • 在字段前加上 static 关键字,这些字段会被创建为构造函数的属性,而非实例属性。在以前,静态字段只能在类体外,定义类之后定义:
javascript
class Buffer {
  size = 0;
  // 静态字段,只能通过 Buffer.capacity 访问
  static capacity = 4096;
  #buffer = new Unit8Array(this.capacity);
}

3.4 示例

javascript
class Complex {

  static ZERO = new Complex(0, 0);
  
  // 使用私有字段保存实数和虚数部分
  #real = 0;
  #imaginary = 0;

  constructor(real, imaginary) {
    this.#real = real;
    this.#imaginary = imaginary;
  }

  set real(v) {
    this.#real = v;
  }

  set imaginary(v) {
    this.#imaginary = v;
  }

  get real() {
    return this.#real;
  }

  get imaginary() {
    return this.#imaginary;
  }

  plus(that) {
    return new Complex(this.#real + that.real, this.#imaginary + that.imaginary);
  }

  times(that) {
    return new Complex(this.#real * that.real - this.#imaginary * that.imaginary,
                      this.#real * that.imaginary + this.#imaginary * that.real);
  }

  static sum(c, d) {
    return c.plus(d);
  }

  static product(c, d) {
    return c.times(d);
  }

  // 每个类都应该有个toString()
  toString() {
    return `${this.#real}, ${this.#imaginary}`;
  }

  // 测试两个实例是否相等
  equals(that) {
    return that instanceof Complex && this.#real === that.real && 
      this.#imaginary === that.imaginary;
  }
}

let c = new Complex(2, 3);
let d = new Complex(c.imaginary, c.real);
c.plus(d).toString() // {5, 5}
Complex.product(c, d).toString() // {0, 13}
Complex.ZERO.toString() // {0, 0}

4. 为已有类添加方法

  • JavaScript 基于原型的继承机制是动态的。
  • 只要给原型对象添加方法,就可以增强 JavaScript 类。
  • 不建议给内置类型的原型对象增加方法,可能导致兼容问题 。
javascript
Complex.prototype.conj = function() {
  return new Complex(this.#real, -this.#imaginary);
}

5. 子类

5.1 子类与原型

class 关键字之前,想要实现继承,需要让子类的原型对象继承父类的原型对象:

javascript
// 子类的构造函数
function Span(start, span) {
  if (span >= 0) {
    this.from = start;
    this.to = start + span;
  } else {
    this.to = start;
    this.from = start + span;
  }
}

// 继承父类原型
Span.prototype = Object.create(Range.prototype);

// 定义constructor
Span.prototype.constructor = = Span;

// 覆盖父类的toString()
Span.prototype.toString = function() {
  return `(${this.from}...${this.to - this.from})`
}

5.2 通过 extends 和 super 创建子类

在 ES6 之后,可以通过 extends 关键字继承父类,通过 super 调用父类构造函数和方法:

javascript
class TypedMap extends Map {
  constructor(keyType, valueType, entries) {
    // 如果制定了键值对,则检查类型
    if (entries) {
      for (let [k, v] of entries) {
        if (typeof k !== keyType || typeof v !== valueType) {
          throw new TypeError(`Wrong type for entry [${k}, ${v}]`)
        }
      }

      // 初始化父类
      super(entries);

      // 初始化子类
      this.keyType = keyType;
      this.valueType = valueType;
    }

    // 重写set方法
    set(key, value) {
      if (this.keyType && typeof key !== this.keyType) {
        throw new TypeError(`${key} is not of type ${this.keyType}`)
      }
      if (this.valueType && typeof value !== this.valueType) {
        throw new TypeError(`${value} is not of type ${this.valueType}`)
      }
      // 如果类型正确,调用父类方法
      return super.set(key, value);
    }
  }
}

注意

  • 如果使用 extends 关键字定义类,则类的构造函数中必须显式通过 super() 调用父类构造函数。
  • 如果没有在子类中定义构造函数,解释器会自动创建。同时,构造函数会取得传入的值并传给 super()
  • 在通过 super() 调用父类构造函数之前,不能在构造函数中使用 this 关键字。
  • 在构造函数中,new.target 引用的是被调用的构造函数,当子类构造函数被调用并通过 super 调用父类构造函数时,父类构造函数中的 new.target 指向的是子类的构造函数。

5.3 委托而不是继承

  • 当一个类与另一个类有相同的行为,可以通过子类继承该行为,也可以在类中创建该类的实例,委托该实例对象去执行某些行为,这时候不需要创建子类,只需要组合其他类。
  • 一个准则:能组合就不继承。

5.4 类层次与抽象类

  • 抽象父类可以定义部分实现供所有子类继承和共享,子类需要实现父类定义的抽象方法。
  • JavaScript 没有正式定义抽象方法或抽象类的语法。
javascript
// 抽象类
class AbstractSet {
  // 抛出错误,强制子类必须定义该方法
  has(x) {
    throw new Error("Abstract method")
  }
} 

// AbstractSet的抽象子类
class AbstractEnumerableSet extends AbstractSet {
  // 抽象获取方法
  get size() { throw new Error("Abstract method") }
  // 抽象迭代器
  *[Symbol.iterator]() { throw new Error("Abstract method") }

  isEmpty() { return this.size === 0 }
  toString() { return `${Array.from(this).join(',')}` }
  equals(set) {
    // 如果另一个集合不是AbstractEnumerableSet,那肯定不相等
    if(!(set instanceof AbstractEnumerableSet)) return false;

    // 如果大小不一样,肯定不相等
    if(this.size !== set.size) return false;

    // 循环检查元素
    for(let element of this) {
      if(!set.has(element)) return false;
    }

    return true;
  }
}

// AbstractEnumerableSet 的抽象子类
class AbstractWritableSet extends AbstractEnumerableSet {
  insert(x) { throw new Error("Abstract method") }
  remove(x) { throw new Error("Abstract method") }

  add(set) {
    for(let element of set) {
      this.insert(element)
    }
  }

  subtract(set) {
    for(let element of set) {
      this.remove(element)
    }
  }

  interset(set) {
    for(let element of this) {
      if(!set.has(element)) {
        this.remove(element)
      }
    }
  }
}