JavaScript高级语法笔记(二):对象与类

149 阅读25分钟

对象与类

对象的属性

属性的概念:
  • 每个创建的对象实例都有自己的属性和方法,这些属性在创建时都带有一些特征值,这些特征值仅供内部使用,在JavaScript中不能直接访问他们,按规定这些特征值需要放在两对方括号中,例如[[Enumerable]]。
  • ECMAScript中有两类属性,分别是数据属性与选择器属性。
数据属性
  • 数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有4个特性描述它们的行为。

    • [[Configurable]]:表示属性是否可以通过delete删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是true。
    • [[Enumerable]]:表示属性是否可以通过for-in循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是true。
    • [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是true。
    • [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为undefined。
  • 使用Object.defineProperty()方法修改属性的默认特性:

    • 这个方法接收3个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包 含:configurable、enumerable、writable和value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。

      例如:

      let person = {};
            Object.defineProperty(person, "name", {
              writable: false,
           
            value: "Nicholas"
       });
       console.log(person.name); // "Nicholas"
       person.name = "Greg";
       console.log(person.name); // "Nicholas"
      

      这个例子创建了一个名为name的属性并给它赋予了一个只读的 值"Nicholas"。这个属性的值就不能再修改了,在非严格模式下尝 试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性的值会抛出错误。

    • 此外,一个属性被定义为不可配置之后,就不能再变回可配置的了。再次调用Object.defineProperty()并修改任何非writable属性会导致错误。虽然可以对同一个属性多次调用Object.defineProperty(), 但在把configurable设置为false之后就会受限制了。

    • 在调用Object.defineProperty()时,configurable、enumerable和 writable的值如果不指定,则都默认为false。多数情况下,可能 都不需要Object.defineProperty()提供的这些强大的设置。

访问器属性
  • 访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这 函数必须决定对数据做出什么修改。访问器属性有4个特性描述它们的行为:

    • [[Configurable]]:表示属性是否可以通过delete删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是true。
    • [[Enumerable]]:表示属性是否可以通过for-in循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是true。
    • [[Get]]:获取函数,在读取属性时调用。默认值为undefined。
    • [[Set]]:设置函数,在写入属性时调用。默认值为undefined。
  • 访问器属性是不能直接定义的,必须使用Object.defineProperty()。

    例如:

    // 定义一个对象,包含伪私有成员year_和公共成员edition 
    let book = {
      year_: 2017,
      edition: 1,
    };
    Object.defineProperty(book, "year", {
      get() {
        return this.year_;
      },
      set(newValue) {
        if (newValue > 2017) {
          this.year_ = newValue;
          this.edition += newValue - 2017;
        }
      },
    });
    book.year = 2018;
    ​
    console.log(book.edition); // 2
    

    在这个例子中,对象book有两个默认属性:year_ 和edition。year_ 中的下划线常用来表示该属性并不希望在对象方法的外部被访问。 另一个属性year被定义为一个访问器属性,其中获取函数简单地返回year_ 的值,而设置函数会做一些计算以决定正确的edition。

  • 只定义获取函数意味着属性是只读的,尝试修改属性会被忽略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性是不能读取的,非严格模式下读取会返回undefined,严格模式下会抛出错误。

其他对象方法
  • 同时定义多个属性:Object.defineProperties()

    let book = {};
    Object.defineProperties(book, {
      year_: {
        value: 2017,
      },
      edition: {
        value: 1,
      },
    ​
      year: {
        get() {
          return this.year_;
        },
        set(newValue) {
          if (newValue > 2017) {
            this.year_ = newValue;
            this.edition += newValue - 2017;
          }
        },
      },
    });
    
  • 获取对象的属性描述符:

    • getOwnPropertyDescriptor ()
    • getOwnPropertyDescriptors()

创建对象

工厂模式

工厂模式是抽象创建特定对象的过程。下面的例子展示了一种按照特定接口创建对象的方式:

function createPerson(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;
}
​
let person1 = createPerson("Nicholas", 29, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");

这里,函数createPerson()接收3个参数,根据这几个参数构建了一个包含Person信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含3个属性和1个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什 么类型)。

构造函数模式

ECMAScript中的构造函数是用于创建特定类型对象的。像Object和Array这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

比如,前面的例子使用构造函数模式可以这样写:

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function () {
    console.log(this.name);
  };
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Nicholas
person2.sayName(); // Greg

Person()内部的代码跟createPerson()基本是一样的,只是有如下区别:

  • 没有显式地创建对象。
  • 属性和方法直接赋值给了this。
  • 没有return。

另外,要注意函数名Person的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。

  • 使用new操作符调用构造函数会执行如下操作:

    • 在内存中创建一个新对象。
    • 这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype属性。
    • 构造函数内部的this被赋值为这个新对象(即this指向新对象)。
    • 执行构造函数内部的代码(给新对象添加属性)。
    • 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
  • 构造函数也是函数:

    • 构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个函数定义为构造函数的特殊语法。 任何函数只要使用new操作符调用就是构造函数,而不使用new操作符调用的函数就是普通函数。
  • 构造函数的问题:

    • 构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。
原型模式

每个函数都会创建一个prototype属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型。

  • 理解原型

    • 创建一个函数时,会按照特定的规则为这个函数创建一个prototype属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。
    • 在自定义构造函数时,原型对象默认只会获得constructor属性, 其他的所有方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。Firefox、Safari和Chrome会在每个对象上暴露 _ proto _属性,通过这个属性可以访问对象的原型。
    • 关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。
/**
* 构造函数可以是函数表达式
* 也可以是函数声明,因此以下两种形式都可以:
* function Person() {}
* let Person = function() {}
*/function Person() {}
/**
 * 声明之后,构造函数就有了一个 * 与之关联的原型对象:
 */
console.log(typeof Person.prototype);
console.log(Person.prototype);
// {
//   constructor: f Person(),
//   __proto__: Object
// }
​
​
/**
* 如前所述,构造函数有一个prototype属性 
* 引用其原型对象,而这个原型对象也有一个 
* constructor属性,引用这个构造函数
* 换句话说,两者循环引用:
*/
console.log(Person.prototype.constructor === Person); // true/**
 * 正常的原型链都会终止于Object的原型对象 
 * Object原型的原型是null
 */
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Person.prototype.__proto__.constructor === Object); // true
console.log(Person.prototype.__proto__.__proto__ === null); // trueconsole.log(Person.prototype.__proto__);
// {
//   constructor: f Object(),
//   toString: ...
//   hasOwnProperty: ...
//   isPrototypeOf: ...
//   ...  
// }let person1 = new Person(),
  person2 = new Person();
/**
 * 构造函数、原型对象和实例 
 * 是3个完全不同的对象: 
 */
console.log(person1 !== Person); // true
console.log(person1 !== Person.prototype); // true
console.log(Person.prototype !== Person); // true/**
 * 实例通过__proto__链接到原型对象,
 * 它实际上指向隐藏特性[[Prototype]] 
 * 构造函数通过prototype属性链接到原型对象 
 * 实例与构造函数没有直接联系,与原型对象有直接联系 
 * */
console.log(person1.__proto__ === Person.prototype); // true
conosle.log(person1.__proto__.constructor === Person); // true/**
 * 同一个构造函数创建的两个实例 
 * 共享同一个原型对象:
 */
console.log(person1.__proto__ === person2.__proto__); // true/**
 * instanceof检查实例的原型链中 
 * 是否包含指定构造函数的原型: 
 * */
console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
console.log(Person.prototype instanceof Object); // true

对于前面例子中的Person构造函数和Person.prototype,可以通过下图看出各个对象之间的关系。

image-20220330204529226.png

  • 原型层级

    • 在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。

    • hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自Object的,会在属性存在于调用它的对象实例上时返回true,如下面的例子所示:

      function Person() {}
      Person.prototype.name = "Nicholas";
      Person.prototype.age = 29;
      Person.prototype.job = "Software Engineer";
      Person.prototype.sayName = function () {
        console.log(this.name);
      };
      let person1 = new Person();
      let person2 = new Person();
      console.log(person1.hasOwnProperty("name")); // false
      ​
      person1.name = "Greg";
      console.log(person1.name); // "Greg",来自实例 
      console.log(person1.hasOwnProperty("name")); // true
      console.log(person2.name); // "Nicholas",来自原型
      console.log(person2.hasOwnProperty("name")); // false
      delete person1.name;
      console.log(person1.name); // "Nicholas",来自原型
      console.log(person1.hasOwnProperty("name")); // false
      
    • 原型和in操作符

      • 在单独使用in操作符时,会在可以通过对象访问指定属性时返回true, 无论该属性是在实例上还是在原型上。

      • 如果要确定某个属性是否存在于原型上,则可以像下面这样同时使用hasOwnProperty()和in操作符:

        function hasPrototypeProperty(object, name) {
          return !object.hasOwnProperty(name) && name in object;
        }
        

        只要通过对象可以访问,in操作符就返回true, 而hasOwnProperty()只有属性存在于实例上时才返回true。因此,只要in操作符返回true且hasOwnProperty()返回false,就说明该属性是一个原型属性。

      • 在for-in循环中使用in操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。

      • 要获得对象上所有可枚举的实例属性,可以使用Object.keys()方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。比如:

        function Person() {}
        Person.prototype.name = "Nicholas";
        Person.prototype.age = 29;
        Person.prototype.job = "Software Engineer";
        Person.prototype.sayName = function () {
          console.log(this.name);
        };
        ​
        let keys = Object.keys(Person.prototype);
        console.log(keys); // "name,age,job,sayName"
        let p1 = new Person();
        p1.name = "Rob";
        p1.age = 31;
        let p1keys = Object.keys(p1);
        console.log(p1keys); // "[name,age]"
        
      • 如果想列出所有实例属性,无论是否可以枚举,都可以使用Object.getOwnPropertyNames()。

    • 属性枚举顺序

      • for-in循环和Object.keys()的枚举顺序是不确定的,取决于JavaScript引擎, 可能因浏览器而异。
      • Object.getOwnPropertyNames()、Object.getOwnPropertySymbols() 和Object.assign()的枚举顺序是确定性的。先以升序枚举数值键, 然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。
  • 对象迭代

    • ECMAScript 2017新增了两个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法Object.values()和Object.entries()接收一个对象,返回它们内容的数组。Object.values()返回对象值的数组,Object.entries()返回键/值对的数组。

      const o = {
        foo: "bar",
        baz: 1,
        qux: {},
      };
      console.log(Object.values(o));
      // ["bar", 1, {}]
      console.log(Object.entries(o));
      // [["foo", "bar"], ["baz", 1], ["qux", {}]]
      

      注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制,且符号属性会被忽略。

    • 因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。

    • 原型模式的问题:

      原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。 我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。真正的问题来自包含引用值的属性。来看下面的例子:

      function Person() {}
      Person.prototype = {
        onstructor: Person,
        name: "Nicholas",
        age: 29,
        job: "Software Engineer",
        friends: ["Shelby", "Court"],
        sayName() {
          console.log(this.name);
        },
      };
      let person1 = new Person();
      let person2 = new Person();
      person1.friends.push("Van");
      console.log(person1.friends); // "Shelby,Court,Van"
      console.log(person2.friends); // "Shelby,Court,Van"
      console.log(person1.friends === person2.friends); // true
      

      这里,Person.prototype有一个名为friends的属性,它包含一个字符串数组。然后这里创建了两个Person的实例。person1.friends通 过push方法向数组中添加了一个字符串。由于这个friends属性存在于Person.prototype而非person1上,新加的这个字符串也会在 (指向同一个数组的)person2.friends上反映出来。如果这是有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。

继承

原型链
  • 概念:

    • 每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。
    • 原型链扩展了前面描述的原型搜索机制。我们知道,在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型。
  • 默认原型

    • 默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。任何函数的默认原型都是一个Object的实例,这意味着这个实例有一个内部指针指向Object.prototype。这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。
  • 原型与继承的关系

    • Instanceof操作符:如果一个实例的原型链中出现过相应的构造 函数,则instanceof返回true。
    • isPrototypeOf()方法:原型链中的每个原型都可以调用这个方法,只要原型链中包含这个原型,这个方法就返回true。
  • 关于方法

    • 子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。
    • 以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。
  • 原型链的问题

    • 主要问题出现在原型中包含引用值的时候。原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。
    • 第二个问题是,子类型在实例化时不能给父类型的构造函数传参。
盗用构造函数
  • 概念

    • 为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函 数”(constructor stealing)的技术在开发社区流行起来(这种技术有时 也称作“对象伪装”或“经典继承”)。基本思路很简单:在子类构造函数中调用父类构造函数。
    • 可以使用apply()和call()方法以新创建的对象为上下文执行构造函数:

      function SuperType() {
        this.colors = ["red", "blue", "green"];
      }
      function SubType() {
        // 继承SuperType
        SuperType.call(this);
      }
      let instance1 = new SubType();
      instance1.colors.push("black");
      console.log(instance1.colors); // "red,blue,green,black"
      let instance2 = new SubType();
      console.log(instance2.colors); // "red,blue,green"
      
  • 传递参数

    • 相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。

      function SuperType(name){
        this.name = name;
      }
      function SubType() {
      // 继承SuperType并传参 SuperType.call(this, "Nicholas");
      // 实例属性
        this.age = 29;
      }
      let instance = new SubType();
      console.log(instance.name); // "Nicholas";
      console.log(instance.age);  // 29
      
  • 盗用构造函数的问题

    • 盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
组合继承
  • 概念

    • 组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将 两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和 方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原 型上以实现重用,又可以让每个实例都有自己的属性。

      function SubType(name, age) {
        // 继承属性 SuperType.call(this, name);
        this.age = age;
      }
      // 继承方法
      SubType.prototype = new SuperType();
      SubType.prototype.sayAge = function () {
        console.log(this.age);
      };
      let instance1 = new SubType("Nicholas", 29);
      instance1.colors.push("black");
      console.log(instance1.colors); // "red,blue,green,black"
      instance1.sayName(); // "Nicholas";
      instance1.sayAge(); // 29
      let instance2 = new SubType("Greg", 27);
      console.log(instance2.colors); // "red,blue,green"
      instance2.sayName(); // "Greg";
      instance2.sayAge(); // 27
      
  • 组合继承弥补了原型链和盗用构造函数的不足,是JavaScript中使用最多的继承模式。而且组合继承也保留了instanceof操作符和 isPrototypeOf()方法识别合成对象的能力。

原型式继承
  • 概念

    • 2006年,Douglas Crockford写了一篇文章:《JavaScript中的原型式继 承》(“Prototypal Inheritance in JavaScript”)。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。

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

      这个object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质 上,object()是对传入的对象执行了一次浅复制。

  • Object.create()方法

    • ECMAScript 5通过增加Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。
    • 在只有一个参数时,Object.create()与这里的object()方法效果相同。
    • Object.create()的第二个参数与Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。
  • 原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

寄生式继承
  • 寄生式继承背后的思路 类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。
  • 通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。
寄生组合继承
  • 组合继承的效率问题

    • 最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。
  • 寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。

class关键字
  • ECMAScript 6新引入的class关键字具有正式定义类的能力。类(class)是ECMAScript中新的基础性语法糖结构。虽然ECMAScript 6类表面上看起来可以支 持正式的面向对象编程,但实际它背后使用的仍然是原型和构造函数的概念。
类定义
  • 与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用class关键字加大括号:

    // 类声明
    class Person {}
    // 类表达式
    const Animal = class {};
    
  • 函数声明可以提升,但类定义不能。

  • 函数受函数作用域限制,而类受块作用域限制。

  • 类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。

类构造函数
  • constructor关键字

    • constructor关键字用于在类定义块内部创建类的构造函数。方法名constructor会告诉解释器在使用new操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将 构造函数定义为空函数。
  • 类构造函数与构造函数的主要区别是,调用类构造函数必须使 用new操作符。而普通构造函数如果不使用new调用,那么就会以 局的this(通常是window)作为内部对象。调用类构造函数时如果忘了使用new则会抛出错误。
实例、原型和类成员
  • 实例成员

    • 每次通过new调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加“自有”属性。
    • 每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。
  • 原型方法和访问器

    • 为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。
    • 类定义也支持获取和设置访问器。语法与行为跟普通对象一样。
  • 静态类方法

    • 可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。与原型成员类似,静态成员每个类上只能有一个。

    • 静态类方法非常适合作为实例工厂:

      class Person {
        constructor(age) {
          this.age_ = age;
        }
        sayAge() {
          console.log(this.age_);
      }
      static create() {
      // 使用随机年龄创建并返回一个Person实例
      return new Person(Math.floor(Math.random()*100));
      } }
      console.log(Person.create()); // Person { age_: ... }
      
  • 非函数原型和类成员

    • 虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加。
    • 类定义中之所以没有显式支持添加数据成员,是因为在 共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过this引用的数据。
  • 迭代器与生成器方法

    • 因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

      class Person {
        constructor() {
          this.nicknames = ['Jack', 'Jake', 'J-Dog'];
        }
        *[Symbol.iterator]() {
          yield *this.nicknames.entries();
      } }
      let p = new Person();
      for (let [idx, nickname] of p) {
        console.log(nickname);
      }
      // Jack
      // Jake
      // J-Dog
      

      也可以只返回迭代器实例:

      class Person {
        constructor() {
          this.nicknames = ['Jack', 'Jake', 'J-Dog'];
        }
        [Symbol.iterator]() {
          return this.nicknames.entries();
      } }
      let p = new Person();
      for (let [idx, nickname] of p) {
        console.log(nickname);
      }
      // Jack
      // Jake
      // J-Dog
      
继承
  • 继承基础

    • ES6类支持单继承。使用extends关键字,就可以继承任何拥 有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)。
    • extends关键字也可以在类表达式中使用,因此let Bar = class extends Foo {}是有效的语法。
  • 构造函数、HomeObject和super()

    • 派生类的方法可以通过super关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用super可以调用父类构造函数。

    • 在静态方法中可以通过super调用继承的类上定义的静态方法。

    • ES6给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的 对象。这个指针是自动赋值的,而且只能在JavaScript引擎内部 访问。super始终会定义为[[HomeObject]]的原型。

    • 在使用super时要注意几个问题:

      • super只能在派生类构造函数和静态方法中使用。
      • 不能单独引用super关键字,要么用它调用构造函数,要么用它引用静态方法。
      • 调用super()会调用父类构造函数,并将返回的实例赋值给this。
      • super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
      • 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的参数。
      • 在类构造函数中,不能在调用super()之前引用this。
      • 如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象。
  • 抽象基类

    • 有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。new.target保存通过new关键字调用的类或函数。通过在实例化时检测new.target是不是抽象基类,可以阻止对抽象基类的实例化:

      // 抽象基类 class Vehicle {
        constructor() {
          console.log(new.target);
          if (new.target === Vehicle) {
            throw new Error('Vehicle cannot be directly instantiated');
          }
      } }
      // 派生类
      class Bus extends Vehicle {}
      new Bus();       // class Bus {}
      new Vehicle();   // class Vehicle {}
      // Error: Vehicle cannot be directly instantiated
      
    • 另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在 了,所以可以通过this关键字来检查相应的方法:

      // 抽象基类
      class Vehicle {
        constructor() {
          if (new.target === Vehicle) {
            throw new Error("Vehicle cannot be directly instantiated");
          }
          if (!this.foo) {
            throw new Error("Inheriting class must define foo()");
          }
          console.log("success!");
        }
      }
      // 派生类
      class Bus extends Vehicle {
        foo() {}
      }
      // 派生类
      class Van extends Vehicle {}
      new Bus(); // success!
      new Van(); // Error: Inheriting class must define foo()
      
  • 继承内置类型

    • ES6类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:

      class SuperArray extends Array {}
      let a1 = new SuperArray(1, 2, 3, 4, 5);
      let a2 = a1.filter(x => !!(x%2))
      console.log(a1);  // [1, 2, 3, 4, 5]
      console.log(a2);  // [1, 3, 5]
      console.log(a1 instanceof SuperArray);  // true
      console.log(a2 instanceof SuperArray);  // true
      
  • 类混入

    • 很多JavaScript框架(特别是React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中, 然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。