类 class

摘自《深入理解ES6》

尽管一些JS开发者强烈认为这门语言不需要类,但为处理类而创建的代码库如此之多,导致ES6最终引入了类。

ES5 中的仿类结构

JSES5及更早版本中都不存在类。与类最接近的是:创建一个构造器,然后将方法指派到该构造器的原型上。这种方式通常被称为创建一个自定义类型。

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

  PersonType.prototype.sayName = function () {
    console.log(this.name);
  };
  let person = new PersonType("Nicholas");
  person.sayName(); // 输出 "Nicholas"
  console.log(person instanceof PersonType); // true
  console.log(person instanceof Object); // true

此代码中的PersonType是一个构造器函数,并创建了单个属性namesayName()方法被指派到原型上,因此在PersonType对象的所有实例上都共享了此方法。接下来,使用new运算符创建了PersonType 的一个新实例 person,此对象会被认为是一个通过原型继承了PersonTypeObject的实例。

类的声明

类在 ES6 中最简单的形式就是类声明,它看起来很像其他语言中的类。


这个PersonClass类声明的行为非常类似上个例子中的 PersonType。类声明允许你在其中使用特殊的constructor方法名称直接定义一个构造器,而不需要先定义一个函数再把它当作构造器使用。

由于类的方法使用了简写语法,于是就不再需要使用 function 关键字。constructor 之外的方法名称则没有特别的含义,因此可以随你高兴自由添加方法。

PersonClass声明实际上创建了一个拥有 constructor 方法及其行为的函数,这也是typeof PersonClass会得到 "function"结果的原因。

类与自定义类型之间的区别

尽管类与自定义类型之间有相似性,但仍然要记住一些重要的区别:

  • 类声明不会被提升,这与函数定义不同。类声明的行为与 let相似,因此在程序的执行到达声明处之前,类会存在于暂时性死区内。
  • 类声明中的所有代码会自动运行在严格模式下,并且也无法退出严格模式。
  • 类的所有方法都是不可枚举的,这是对于自定义类型的显著变化,后者必须用Object.defineProperty() 才能将方法改变为不可枚举。
  • 类的所有方法内部都没有 [[Construct]],因此使用new来调用它们会抛出错误。
  • 调用类构造器时不使用new,会抛出错误。
  • 试图在类的方法内部重写类名,会抛出错误。
  // 直接等价于 PersonClass
  let PersonType2 = (function () {
    "use strict";
    const PersonType2 = function (name) {
      // 确认函数被调用时使用了 new
      if (typeof new.target === "undefined") {
        throw new Error("Constructor must be called with new.");
      }
      this.name = name;
    }
    Object.defineProperty(PersonType2.prototype, "sayName", {
      value: function () {
        // 确认函数被调用时没有使用 new
        if (typeof new.target !== "undefined") {
          throw new Error("Method cannot be called with new.");
        }
        console.log(this.name);
      },
      enumerable: false,
      writable: true,
      configurable: true
    });
    return PersonType2;
  }());

首先要注意这里有两个PersonType2 声明:一个在外部作用域的let声明,一个在IIFE内部的const声明。这就是为何类的方法不能对类名进行重写、而类外部的代码则被允许。构造器函数检查了new.target ,以保证被调用时使用了 new,否则就抛出错误。接下来,sayName() 方法被定义为不可枚举,并且此方法也检查了new.target,它则要保证在被调用时没有使用 new 。最后一步是将构造器函数返回出去。

不变的类名

只有在类的内部,类名才被视为是使用const 声明的。这意味着你可以在外部重写类名,但不能在类的方法内部这么做。例如:

class Foo {
  constructor() {
     Foo = "bar"; // 执行时抛出错误
  }
}
// 但在类声明之后没问题
Foo = "baz";

在此代码中,类构造器内部的 Foo与在类外部的 Foo 是不同的绑定。内部的Foo就像是用 const定义的,不能被重写,当构造器尝试使用任何值重写Foo时,都会抛出错误。但由于外部的Foo就像是用 let声明的,你可以随时重写类名。

作为一级公民的类

ES6延续了传统,让类同样成为一级公民。这就使得类可以被多种方式所使用。例如,它能作为参数传入函数:

  function createObject(classDef) {
    return new classDef();
  }

  let obj = createObject(class {
    sayHi() {
      console.log("Hi!");
    }
  });
  obj.sayHi(); // "Hi!"

访问器属性

自有属性需要在类构造器中创建,而类还允许你在原型上定义访问器属性。为了创建一个getter,要使用 get 关键字,并要与后方标识符之间留出空格;创建setter用相同方式,只是要换用set 关键字。例如:

  class CustomHTMLElement {
    constructor(element) {
      this.element = element;
    }

    get html() {
      return this.element.innerHTML;
    }

    set html(value) {
      this.element.innerHTML = value;
    }
  }

  var descriptor = Object.getOwnPropertyDescriptor(CustomHTMLElement.prototype, "html");
  console.log("get" in descriptor); // true
  console.log("set" in descriptor); // true
  console.log(descriptor.enumerable); // false

此代码中的CustomHTMLElement类用于包装一个已存在的 DOM 元素。它的属性html拥有gettersetter,委托了元素自身的innerHTML 方法。该访问器属性被创建在CustomHTMLElement.prototype上,并且像其他类属性那样被创建为不可枚举属性。非类的等价表示如下:

  // 直接等价于上个范例
  let CustomHTMLElement = (function () {
    "use strict";
    const CustomHTMLElement = function (element) {
      // 确认函数被调用时使用了 new
      if (typeof new.target === "undefined") {
        throw new Error("Constructor must be called with new.");
      }
      this.element = element;
    }
    Object.defineProperty(CustomHTMLElement.prototype, "html", {
      enumerable: false,
      configurable: true,
      get: function () {
        return this.element.innerHTML;
      },
      set: function (value) {
        this.element.innerHTML = value;
      }
    });
    return CustomHTMLElement;
  }());

正如之前的例子,此例说明了使用类语法能够少写大量的代码。仅仅为html访问器属性定义的代码量,就几乎相当于等价的类声明的全部代码量了。

需计算的成员名

对象字面量与类之间的相似点还不仅前面那些。类方法与类访问器属性也都能使用需计算的名称。语法相同于对象字面量中的需计算名称:无须使用标识符,而是用方括号来包裹一个表达式。例如:

  let methodName = "sayName";

  class PersonClass {
    constructor(name) {
      this.name = name;
    }

    [methodName]() {
      console.log(this.name);
    }
  }

  let me = new PersonClass("Nicholas");
  me.sayName(); // "Nicholas"

此版本的PersonClass使用了一个变量来命名类定义内的方法。字符串"sayName" 被赋值给了 methodName 变量,而 methodName变量则被用于声明方法。 sayName() 方法在此后能被直接访问。

生成器方法

 class MyClass {
    * createIterator() {
      yield 1;
      yield 2;
      yield 3;
    }
  }

  let instance = new MyClass();
  let iterator = instance.createIterator();

此代码创建了一个拥有 createIterator() 生成器的MyClass类。该方法返回了一个迭代器,它的值在生成器内部用硬编码提供。当你使用一个对象来表示值的集合、并要求能简单迭代这些值,那么生成器方法就非常有用。数组、 SetMap都拥有多个生成器方法,负责让开发者用多种方式来操作它们的项。

静态成员

直接在构造器上添加额外方法来模拟静态成员,这在ES5及更早版本中是另一个通用的模式。例如:

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

  // 静态方法
  PersonType.create = function (name) {
    return new PersonType(name);
  };
  // 实例方法
  PersonType.prototype.sayName = function () {
    console.log(this.name);
  };
  var person = PersonType.create("Nicholas");

在其他编程语言中,工厂方法 PersonType.create() 会被认定为一个静态方法,它的数据不依赖 PersonType的任何实例。

ES6的类简化了静态成员的创建,只要在方法与访问器属性的名称前添加正式的static标注。作为一个例子,此处有个与上例等价的类:

  class PersonClass {
    // 等价于 PersonType 构造器
    constructor(name) {
      this.name = name;
    }

    // 等价于 PersonType.prototype.sayName
    sayName() {
      console.log(this.name);
    }

    // 等价于 PersonType.create
    static create(name) {
      return new PersonClass(name);
    }
  }

  let person = PersonClass.create("Nicholas");

PersonClass的定义拥有名为create() 的单个静态方法,此语法与sayName()基本相同,只多了一个 static关键字。你能在类中的任何方法与访问器属性上使用 static 关键字,唯一限制是不能将它用于 constructor方法的定义。

使用派生类进行继承

ES6之前,实现自定义类型的继承是个繁琐的过程。严格的继承要求有多个步骤。例如,研究以下范例:

 function Rectangle(length, width) {
    this.length = length;
    this.width = width;
  }

  Rectangle.prototype.getArea = function () {
    return this.length * this.width;
  };

  function Square(length) {
    Rectangle.call(this, length, length);
  }

  Square.prototype = Object.create(Rectangle.prototype, {
    constructor: {
      value: Square,
      enumerable: true,
      writable: true,
      configurable: true
    }
  });
  var square = new Square(3);
  console.log(square.getArea()); // 9
  console.log(square instanceof Square); // true
  console.log(square instanceof Rectangle); // true

Square继承了Rectangle ,为此它必须使用Rectangle.prototype所创建的一个新对象来重写 Square.prototype,并且还要调用Rectangle.call()方法。

类让继承工作变得更轻易,使用熟悉的 extends 关键字来指定当前类所需要继承的函数,即可。生成的类的原型会被自动调整,而你还能调用 super()方法来访问基类的构造器。此处是与上个例子等价的 ES6代码:

  class Rectangle {
    constructor(length, width) {
      this.length = length;
      this.width = width;
    }

    getArea() {
      return this.length * this.width;
    }
  }

  class Square extends Rectangle {
    constructor(length) {
      // 与 Rectangle.call(this, length, length) 相同
      super(length, length);
    }
  }

  var square = new Square(3);
  console.log(square.getArea()); // 9
  console.log(square instanceof Square); // true
  console.log(square instanceof Rectangle); // true

此次 Square 类使用了extends关键字继承了 RectangleSquare构造器使用了super()配合指定参数调用了Rectangle 的构造器。注意与 ES5版本的代码不同,Rectangle 标识符仅在类定义时被使用了(在 extends之后)。

屏蔽类方法

派生类中的方法总是会屏蔽基类的同名方法。例如,你可以将 getArea() 方法添加到Square类,以便重定义它的功能:

class Square extends Rectangle {
  constructor(length) {
    super(length, length);
  }
  // 重写并屏蔽 Rectangle.prototype.getArea()
  getArea() {
    return this.length * this.length;
  }
}

由于getArea()已经被定义为Square的一部分,Rectangle.prototype.getArea()方法就不能在Square的任何实例上被调用。当然,你总是可以使用 super.getArea()方法来调用基类中的同名方法,就像这样:

class Square extends Rectangle {
  constructor(length) {
    super(length, length);
  }
  // 重写、屏蔽并调用了 Rectangle.prototype.getArea()
  getArea() {
    return super.getArea();
  }
}

用这种方式使用superthis 值会被自动设置为正确的值,因此你就能进行简单的调用。

继承静态成员

如果基类包含静态成员,那么这些静态成员在派生类中也是可用的。继承的工作方式类似于其他语言,但对于JS而言则是新概念。此处有个范例:

  class Rectangle {
    constructor(length, width) {
      this.length = length;
      this.width = width;
    }

    getArea() {
      return this.length * this.width;
    }

    static create(length, width) {
      return new Rectangle(length, width);
    }
  }

  class Square extends Rectangle {
    constructor(length) {
    // 与 Rectangle.call(this, length, length) 相同
      super(length, length);
    }
  }

  var rect = Square.create(3, 4);
  console.log(rect instanceof Rectangle); // true
  console.log(rect.getArea()); // 12
  console.log(rect instanceof Square); // false

在此代码中,一个新的静态方法 create() 被添加到Rectangle 类中。通过继承,该方法会以 Square.create() 的形式存在,并且其行为方式与 Rectangle.create()一样。

从表达式中派生类

ES6中派生类的最强大能力,或许就是能够从表达式中派生类。只要一个表达式能够返回一个具有 [[Construct]] 属性以及原型的函数,你就可以对其使用 extends 。例如:

 function Rectangle(length, width) {
    this.length = length;
    this.width = width;
  }

  Rectangle.prototype.getArea = function () {
    return this.length * this.width;
  };

  class Square extends Rectangle {
    constructor(length) {
      super(length, length);
    }
  }

  var x = new Square(3);
  console.log(x.getArea()); // 9
  console.log(x instanceof Rectangle); // true

Rectangle 被定义为ES5 风格的构造器,而 Square则是一个类。由于 Rectangle具有[[Construct]] 以及原型, Square 类就能直接继承它。

继承内置对象

几乎从JS数组出现那天开始,开发者就想通过继承机制来创建他们自己的特殊数组类型。在ES5及早期版本中,这是不可能做到的。试图使用传统继承并不能产生功能正确的代码,例如:

console.log() 在此代码尾部的输出说明了:对数组使用传统形式的 JS继承,产生了预期外的行为。MyArray实例上的 length属性以及数值属性,其行为与内置数组并不一致,因为这些功能并未被涵盖在 Array.apply() 或数组原型中。

  // 内置数组的行为
  var colors = [];
  colors[0] = "red";
  console.log(colors.length); // 1
  colors.length = 0;
  console.log(colors[0]); // undefined
  // 在 ES5 中尝试继承数组
  function MyArray() {
    Array.apply(this, arguments);
  }

  MyArray.prototype = Object.create(Array.prototype, {
    constructor: {
      value: MyArray,
      writable: true,
      configurable: true,
      enumerable: true
    }
  });
  var colors = new MyArray();
  colors[0] = "red";
  console.log(colors.length); // 0
  colors.length = 0;
  console.log(colors[0]); // "red"

ES6中的类,其设计目的之一就是允许从内置对象上进行继承。为了达成这个目的,类的继承模型与 ES5 或更早版本的传统继承模型有轻微差异:

  • ES5 的传统继承中, this的值会先被派生类(例如 MyArray)创建,随后基类构造器(例如Array.apply() 方法)才被调用。这意味着 this一开始就是MyArray的实例,之后才使用了 Array 的附加属性对其进行了装饰。

  • ES6基于类的继承中,this的值会先被基类( Array)创建,随后才被派生类的构造器(MyArray)所修改。结果是 this初始就拥有作为基类的内置对象的所有功能,并能正确接收与之关联的所有功能。以下范例实际展示了基于类的特殊数组:

 class MyArray extends Array {
  // 空代码块
  }
  var colors = new MyArray();
  colors[0] = "red";
  console.log(colors.length); // 1
  colors.length = 0;
  console.log(colors[0]); // undefined

MyArray直接继承了Array,因此工作方式与正规数组一致。与数值索引属性的互动更新了length 属性,而操纵 length 属性也能更新索引属性。这意味着你既能适当地继承Array来创建你自己的派生数组类,也同样能继承其他的内置对象。伴随着这些附加功能,ES6与派生类型有效解决了从内置类型进行派生这最后的特殊情况,不过这种情况仍然值得继续探索。

你可能感兴趣的:(类 class)