• JavaScript中的Class


    JavaScript中的类

    一、基本语法

    基本语法:

    class MyClass {
      prop = value; // 属性
    
      constructor(...) { // 构造器
        // ...
      }
    
      method(...) {} // method
    
      get something(...) {} // getter 方法
      set something(...) {} // setter 方法
    
      [Symbol.iterator]() {} // 有计算名称(computed name)的方法(此处为 symbol)
      // ...
    }
    

    new MyClass的调用流程:

    • 一个新对象被创建
    • constructor 使用给定的参数运行

    类的方法之间没有逗号。不要与对象字面量相混淆。

    关系解释:

    class User {
      constructor(name) { this.name = name; }
      sayHi() { alert(this.name); }
    }
    
    // JavaScript 中,class 是一个函数
    alert(typeof User); // function
    
    // ...或者,更确切地说,是 constructor 方法
    alert(User === User.prototype.constructor); // true
    
    // 方法在 User.prototype 中,例如:
    alert(User.prototype.sayHi); // alert(this.name);
    
    // 在原型中实际上有两个方法
    alert(Object.getOwnPropertyNames(User.prototype)); // constructor, sayHi
    

    类不仅仅是语法糖,它与函数之间存在着重大差异:

    1. 通过 class 创建的函数具有特殊的内部属性标记 [[IsClassConstructor]]: true。编程语言会在许多地方检查该属性。例如,与普通函数不同,必须使用 new 来调用它:
    class User {
      constructor() {}
    }
    
    alert(typeof User); // function
    User(); // Error: Class constructor User cannot be invoked without 'new'
    

    此外,大多数 JavaScript 引擎中的类构造器的字符串表示形式都以 “class…” 开头:

    class User {
      constructor() {}
    }
    
    alert(User); // class User { ... }
    
    1. 类方法不可枚举。 类定义将 "prototype" 中的所有方法的 enumerable 标志设置为 false。这很好,因为如果我们对一个对象调用 for..in 方法,我们通常不希望 class 方法出现。

    2. 类总是使用 use strict。 在类构造中的所有代码都将自动进入严格模式。

    类表达式

    就像函数一样,类可以在另外一个表达式中被定义,被传递,被返回,被赋值等。

    这是一个类表达式的例子:

    let User = class {
      sayHi() {
        alert("Hello");
      }
    };
    

    甚至可以动态地“按需”创建类,就像这样:

    function makeClass(phrase) {
      // 声明一个类并返回它
      return class {
        sayHi() {
          alert(phrase);
        }
      };
    }
    // 创建一个新的类
    let User = makeClass("Hello");
    new User().sayHi(); // Hello
    

    Getters/setters

    这样的类声明可以通过在 User.prototype 中创建 getters 和 setters 来实现:

    class User {
      constructor(name) {
        // 调用 setter
        this.name = name;
      }
      get name() {
        return this._name;
      }
      set name(value) {
        if (value.length < 4) {
          alert("Name is too short.");
          return;
        }
        this._name = value;
      }
    }
    

    计算属性名称 […]

    类似于对象字面量:

    class User {
      ['say' + 'Hi']() {
        alert("Hello");
      }
    }
    new User().sayHi();
    

    Class 字段

    “类字段”是一种允许添加任何属性的语法。

    类字段重要的不同之处在于,它们会在每个独立对象中被设好,而不是设在 User.prototype:

    class User {
      name = "John";
    }
    
    let user = new User();
    alert(user.name); // John
    alert(User.prototype.name); // undefined
    

    也可以在赋值时使用更复杂的表达式和函数调用:

    class User {
      name = prompt("Name, please?", "John");
    }
    
    let user = new User();
    alert(user.name); // John
    

    使用类字段制作绑定方法

    如果一个对象方法被传递到某处,或者在另一个上下文中被调用,则 this 将不再是对其对象的引用。

    例如,此代码将显示 undefined:

    class Button {
      constructor(value) {
        this.value = value;
      }
    
      click() {
        alert(this.value);
      }
    }
    
    let button = new Button("hello");
    
    setTimeout(button.click, 1000); // undefined
    

    这个问题被称为“丢失 this”。有两种可以修复它的方式:

    • 传递一个包装函数,例如 setTimeout(() => button.click(), 1000)。
    • 将方法绑定到对象,例如在 constructor 中。

    类字段提供了另一种非常优雅的语法:

    class Button {
      constructor(value) {
        this.value = value;
      }
      click = () => {
        alert(this.value);
      }
    }
    
    let button = new Button("hello");
    setTimeout(button.click, 1000); // hello
    

    类字段 click = () => {...} 是基于每一个对象被创建的,在这里对于每一个 Button 对象都有一个独立的方法,在内部都有一个指向此对象的 this。我们可以把 button.click 传递到任何地方,而且 this 的值总是正确的。

    在浏览器环境中,它对于进行事件监听尤为有用。

    二、继承

    继承是一个类扩展另一个类,在现有功能之上创建新功能。

    “extends” 关键字使用了很好的旧的原型机制进行工作。

    在 extends 后允许任意表达式:类语法不仅允许指定一个类,在 extends 后可以指定任意表达式。

    例如,一个生成父类的函数调用:

    function f(phrase) {
      return class {
        sayHi() { alert(phrase); }
      };
    }
    
    class User extends f("Hello") {}
    
    new User().sayHi(); // Hello
    

    对于高级编程模式,例如当我们根据许多条件使用函数生成类,并继承它们时来说可能很有用。

    重写方法

    Class 为此提供了 "super" 关键字。

    • 执行 super.method(...) 来调用一个父类方法。
    • 执行 super(...) 来调用一个父类 constructor(只能在我们的 constructor 中)。

    例如,让我们的 rabbit 在停下来的时候自动 hide:

    class Animal {
      constructor(name) {
        this.speed = 0;
        this.name = name;
      }
      run(speed) {
        this.speed = speed;
        alert(`${this.name} runs with speed ${this.speed}.`);
      }
      stop() {
        this.speed = 0;
        alert(`${this.name} stands still.`);
      }
    }
    
    class Rabbit extends Animal {
      hide() {
        alert(`${this.name} hides!`);
      }
      stop() {
        super.stop(); // 调用父类的 stop
        this.hide(); // 然后 hide
      }
    }
    let rabbit = new Rabbit("White Rabbit");
    rabbit.run(5); // White Rabbit 以速度 5 奔跑
    rabbit.stop(); // White Rabbit 停止了。White rabbit hide 了!
    

    箭头函数没有 super,箭头函数没有this。如果被访问,它会从外部函数获取。

    重写 constructor

    class Rabbit extends Animal {
      // 为没有自己的 constructor 的扩展类生成的
      constructor(...args) {
        super(...args);
      }
    }
    

    它调用了父类的 constructor,并传递了所有的参数。如果我们没有写自己的 constructor,就会出现这种情况。

    继承类的 constructor 必须调用 super(...),并且 (!) 一定要在使用 this 之前调用。

    在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。派生构造器具有特殊的内部属性 [[ConstructorKind]]:"derived"。这是一个特殊的内部标签。该标签会影响它的 new 行为:

    • 当通过 new 执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给 this。
    • 但是当继承的 constructor 执行时,它不会执行此操作。它期望父类的 constructor 来完成这项工作

    因此,派生的 constructor 必须调用 super 才能执行其父类(base)的 constructor,否则 this 指向的那个对象将不会被创建。并且我们会收到一个报错。

    重写类字段: 一个棘手的注意要点

    高阶要点

    当我们访问在父类构造器中的一个被重写的字段时,这里会有一个诡异的行为,这与绝大多数其他编程语言都很不一样。请思考此示例:

    class Animal {
      name = 'animal';
    
      constructor() {
        alert(this.name); // (*)
      }
    }
    
    class Rabbit extends Animal {
      name = 'rabbit';
    }
    
    new Animal(); // animal
    new Rabbit(); // animal
    

    这里,Rabbit 继承自 Animal,并且用它自己的值重写了 name 字段。

    因为 Rabbit 中没有自己的构造器,所以 Animal 的构造器被调用了。

    有趣的是在这两种情况下:new Animal() 和 new Rabbit(),在 (*) 行的 alert 都打印了 animal。

    换句话说, 父类构造器总是会使用它自己字段的值,而不是被重写的那一个。

    古怪的是什么呢?

    如果这还不清楚,那么让我们用方法来进行比较。

    这里是相同的代码,但是我们调用 this.showName() 方法而不是 this.name 字段:

    class Animal {
      showName() {  // 而不是 this.name = 'animal'
        alert('animal');
      }
    
      constructor() {
        this.showName(); // 而不是 alert(this.name);
      }
    }
    
    class Rabbit extends Animal {
      showName() {
        alert('rabbit');
      }
    }
    
    new Animal(); // animal
    new Rabbit(); // rabbit
    

    请注意:这时的输出是不同的。

    这才是我们本来所期待的结果。当父类构造器在派生的类中被调用时,它会使用被重写的方法。

    ……但对于类字段并非如此。正如前文所述,父类构造器总是使用父类的字段。

    这里为什么会有这样的区别呢?

    实际上,原因在于字段初始化的顺序。类字段是这样初始化的:

    对于基类(还未继承任何东西的那种),在构造函数调用前初始化。
    对于派生类,在 super() 后立刻初始化。
    在我们的例子中,Rabbit 是派生类,里面没有 constructor()。正如先前所说,这相当于一个里面只有 super(...args) 的空构造器。

    所以,new Rabbit() 调用了 super(),因此它执行了父类构造器,并且(根据派生类规则)只有在此之后,它的类字段才被初始化。在父类构造器被执行的时候,Rabbit 还没有自己的类字段,这就是为什么 Animal 类字段被使用了。

    这种字段与方法之间微妙的区别只特定于 JavaScript。

    幸运的是,这种行为仅在一个被重写的字段被父类构造器使用时才会显现出来。接下来它会发生的东西可能就比较难理解了,所以我们要在这里对此行为进行解释。

    如果出问题了,我们可以通过使用方法或者 getter/setter 替代类字段,来修复这个问题。

    深入:内部探究和 [[HomeObject]]

    让我们问问自己,以技术的角度它是如何工作的?当一个对象方法执行时,它会将当前对象作为 this。随后如果我们调用 super.method(),那么引擎需要从当前对象的原型中获取 method。但这是怎么做到的?

    这个任务看起来是挺容易的,但其实并不简单。引擎知道当前对象的 this,所以它可以获取父 method 作为 this.proto.method。不幸的是,这个“天真”的解决方法是行不通的。

    让我们演示一下这个问题。简单起见,我们使用普通对象而不使用类。

    如果你不想知道更多的细节知识,你可以跳过此部分,并转到下面的 [[HomeObject]] 小节。这没关系的。但如果你感兴趣,想学习更深入的知识,那就继续阅读吧。

    在下面的例子中,rabbit.proto = animal。现在让我们尝试一下:在 rabbit.eat() 我们将会使用 this.proto 调用 animal.eat():

    let animal = {
      name: "Animal",
      eat() {
        alert(`${this.name} eats.`);
      }
    };
    
    let rabbit = {
      __proto__: animal,
      name: "Rabbit",
      eat() {
        // 这就是 super.eat() 可以大概工作的方式
        this.__proto__.eat.call(this); // (*)
      }
    };
    
    rabbit.eat(); // Rabbit eats.
    

    在 (*) 这一行,我们从原型(animal)中获取 eat,并在当前对象的上下文中调用它。请注意,.call(this) 在这里非常重要,因为简单的调用 this.proto.eat() 将在原型的上下文中执行 eat,而非当前对象。

    在上面的代码中,它确实按照了期望运行:我们获得了正确的 alert。

    现在,让我们在原型链上再添加一个对象。我们将看到这件事是如何被打破的:

    let animal = {
      name: "Animal",
      eat() {
        alert(`${this.name} eats.`);
      }
    };
    
    let rabbit = {
      __proto__: animal,
      eat() {
        // ...bounce around rabbit-style and call parent (animal) method
        this.__proto__.eat.call(this); // (*)
      }
    };
    
    let longEar = {
      __proto__: rabbit,
      eat() {
        // ...do something with long ears and call parent (rabbit) method
        this.__proto__.eat.call(this); // (**)
      }
    };
    
    longEar.eat(); // Error: Maximum call stack size exceeded
    

    代码无法再运行了!我们可以看到,在试图调用 longEar.eat() 时抛出了错误。

    原因可能不那么明显,但是如果我们跟踪 longEar.eat() 调用,就可以发现原因。在 (*) 和 (**) 这两行中,this 的值都是当前对象(longEar)。这是至关重要的一点:所有的对象方法都将当前对象作为 this,而非原型或其他什么东西。

    因此,在 (*) 和 (**) 这两行中,this.proto 的值是完全相同的:都是 rabbit。它们俩都调用的是 rabbit.eat,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。

    这张图介绍了发生的情况:

    1. 在 longEar.eat() 中,(**) 这一行调用 rabbit.eat 并为其提供 this=longEar。
    // 在 longEar.eat() 中我们有 this = longEar
    this.__proto__.eat.call(this) // (**)
    // 变成了
    longEar.__proto__.eat.call(this)
    // 也就是
    rabbit.eat.call(this);
    
    1. 之后在 rabbit.eat 的 (*) 行中,我们希望将函数调用在原型链上向更高层传递,但是 this=longEar,所以 this.proto.eat 又是 rabbit.eat!
    // 在 rabbit.eat() 中我们依然有 this = longEar
    this.__proto__.eat.call(this) // (*)
    // 变成了
    longEar.__proto__.eat.call(this)
    // 或(再一次)
    rabbit.eat.call(this);
    
    1. ……所以 rabbit.eat 在不停地循环调用自己,因此它无法进一步地提升。

    这个问题没法仅仅通过使用 this 来解决。

    [[HomeObject]]

    为了提供解决方法,JavaScript 为函数添加了一个特殊的内部属性:[[HomeObject]]。

    当一个函数被定义为类或者对象方法时,它的 [[HomeObject]] 属性就成为了该对象。

    然后 super 使用它来解析(resolve)父原型及其方法。

    让我们看看它是怎么工作的,首先,对于普通对象:

    let animal = {
      name: "Animal",
      eat() {         // animal.eat.[[HomeObject]] == animal
        alert(`${this.name} eats.`);
      }
    };
    
    let rabbit = {
      __proto__: animal,
      name: "Rabbit",
      eat() {         // rabbit.eat.[[HomeObject]] == rabbit
        super.eat();
      }
    };
    
    let longEar = {
      __proto__: rabbit,
      name: "Long Ear",
      eat() {         // longEar.eat.[[HomeObject]] == longEar
        super.eat();
      }
    };
    
    // 正确执行
    longEar.eat();  // Long Ear eats.
    

    它基于 [[HomeObject]] 运行机制按照预期执行。一个方法,例如 longEar.eat,知道其 [[HomeObject]] 并且从其原型中获取父方法。并没有使用 this。

    方法并不是“自由”的

    正如我们之前所知道的,函数通常都是“自由”的,并没有绑定到 JavaScript 中的对象。正因如此,它们可以在对象之间复制,并用另外一个 this 调用它。

    [[HomeObject]] 的存在违反了这个原则,因为方法记住了它们的对象。[[HomeObject]] 不能被更改,所以这个绑定是永久的。

    在 JavaScript 语言中 [[HomeObject]] 仅被用于 super。所以,如果一个方法不使用 super,那么我们仍然可以视它为自由的并且可在对象之间复制。但是用了 super 再这样做可能就会出错。

    下面是复制后错误的 super 结果的示例:

    let animal = {
      sayHi() {
        alert(`I'm an animal`);
      }
    };
    
    // rabbit 继承自 animal
    let rabbit = {
      __proto__: animal,
      sayHi() {
        super.sayHi();
      }
    };
    
    let plant = {
      sayHi() {
        alert("I'm a plant");
      }
    };
    
    // tree 继承自 plant
    let tree = {
      __proto__: plant,
      sayHi: rabbit.sayHi // (*)
    };
    
    tree.sayHi();  // I'm an animal (?!?)
    

    调用 tree.sayHi() 显示 “I’m an animal”。这绝对是错误的。

    原因很简单:

    • 在 (*) 行,tree.sayHi 方法是从 rabbit 复制而来。也许我们只是想避免重复代码?
    • 它的 [[HomeObject]] 是 rabbit,因为它是在 rabbit 中创建的。没有办法修改 [[HomeObject]]。
    • tree.sayHi() 内具有 super.sayHi()。它从 rabbit 中上溯,然后从 animal 中获取方法。

    这是发生的情况示意图:

    方法,不是函数属性

    [[HomeObject]] 是为类和普通对象中的方法定义的。但是对于对象而言,方法必须确切指定为 method(),而不是 "method: function()"。

    这个差别对我们来说可能不重要,但是对 JavaScript 来说却非常重要。

    在下面的例子中,使用非方法(non-method)语法进行了比较。未设置 [[HomeObject]] 属性,并且继承无效:

    let animal = {
      eat: function() { // 这里是故意这样写的,而不是 eat() {...
        // ...
      }
    };
    
    let rabbit = {
      __proto__: animal,
      eat: function() {
        super.eat();
      }
    };
    
    rabbit.eat();  // 错误调用 super(因为这里没有 [[HomeObject]])
    

    总结:方法在内部的 [[HomeObject]] 属性中记住了它们的类/对象。这就是 super 如何解析父方法的。
    因此,将一个带有 super 的方法从一个对象复制到另一个对象是不安全的。

    三、静态属性、静态方法

    语法:

    class MyClass {
      static property = ...;
    
      static method() {
        ...
      }
    }
    

    等于:

    MyClass.property = ...
    MyClass.method = ...
    

    静态的(static),可以把一个方法赋值给类的函数本身,而不是赋给它的 "prototype"。

    class User {
      static staticMethod() {
        alert(this === User);
      }
    }
    User.staticMethod(); // true
    

    实际上跟直接将其作为属性赋值的作用相同:

    class User { }
    
    User.staticMethod = function() {
      alert(this === User);
    };
    
    User.staticMethod(); // true
    

    通常,静态方法用于实现属于该类但不属于该类任何特定对象的函数。

    例如,我们有对象 Article,并且需要一个方法来比较它们。一个自然的解决方案就是添加 Article.compare 方法,像下面这样:

    class Article {
      constructor(title, date) {
        this.title = title;
        this.date = date;
      }
    
      static compare(articleA, articleB) {
        return articleA.date - articleB.date;
      }
    }
    
    // 用法
    let articles = [
      new Article("HTML", new Date(2019, 1, 1)),
      new Article("CSS", new Date(2019, 0, 1)),
      new Article("JavaScript", new Date(2019, 11, 1))
    ];
    
    articles.sort(Article.compare);
    alert( articles[0].title ); // CSS
    

    静态的属性也是可能的,它们看起来就像常规的类属性,但前面加有 static:

    另一个例子是所谓的“工厂factory”方法。想象一下,我们需要通过几种方法来创建一个文章:

    • 通过用给定的参数来创建(title,date 等)。可以通过 constructor 来实现
    • 使用今天的日期来创建一个空的文章。可以创建类的一个静态方法来实现。
    • ……其它方法。
    class Article {
      constructor(title, date) {
        this.title = title;
        this.date = date;
      }
    
      static createTodays() {
        // 记住 this = Article
        return new this("Today's digest", new Date());
      }
    }
    let article = Article.createTodays();
    alert( article.title ); // Today's digest
    

    静态方法也被用于与数据库相关的公共类,可以用于搜索/保存/删除数据库中的条目, 就像这样:

    // 假定 Article 是一个用来管理文章的特殊类
    // 静态方法用于移除文章:
    Article.remove({id: 12345});
    

    静态属性

    静态的属性也是可能的,它们看起来就像常规的类属性,但前面加有 static,等同于直接给类赋值。

    继承静态属性和方法

    class Animal {
      static planet = "Earth";
      constructor(name, speed) {
        this.speed = speed;
        this.name = name;
      }
      run(speed = 0) {
        this.speed += speed;
        alert(`${this.name} runs with speed ${this.speed}.`);
      }
      static compare(animalA, animalB) {
        return animalA.speed - animalB.speed;
      }
    }
    
    // 继承于 Animal
    class Rabbit extends Animal {
      hide() {
        alert(`${this.name} hides!`);
      }
    }
    let rabbits = [
      new Rabbit("White Rabbit", 10),
      new Rabbit("Black Rabbit", 5)
    ];
    rabbits.sort(Rabbit.compare);
    rabbits[0].run(); // Black Rabbit runs with speed 5.
    alert(Rabbit.planet); // Earth
    

    现在我们调用 Rabbit.compare 时,继承的 Animal.compare 将会被调用。

    它是如何工作的?再次,使用原型。你可能已经猜到了,extends 让 Rabbit 的 [[Prototype]] 指向了 Animal。

    所以,Rabbit extends Animal 创建了两个 [[Prototype]] 引用:

    1. Rabbit 函数原型继承自 Animal 函数。
    2. Rabbit.prototype 原型继承自 Animal.prototype。

    继承对常规方法和静态方法都有效。

    这里,让我们通过代码来检验一下:

    class Animal {}
    class Rabbit extends Animal {}
    
    // 对于静态的
    alert(Rabbit.__proto__ === Animal); // true
    
    // 对于常规方法
    alert(Rabbit.prototype.__proto__ === Animal.prototype); // true
    

    一个有意思的问题:类扩展自对象?

    正如我们所知道的,所有的对象通常都继承自 Object.prototype,并且可以访问“通用”对象方法,例如 hasOwnProperty 等。例如:

    class Rabbit {
      constructor(name) {
        this.name = name;
      }
    }
    let rabbit = new Rabbit("Rab");
    // hasOwnProperty 方法来自于 Object.prototype
    alert( rabbit.hasOwnProperty('name') ); // true
    

    但是,如果我们像这样 "class Rabbit extends Object" 把它明确地写出来,那么结果会与简单的 "class Rabbit" 有所不同么?

    不同之处在哪里?

    下面是此类的示例代码(它无法正常运行 — 为什么?修复它?):

    class Rabbit extends Object {
      constructor(name) {
        this.name = name;
      }
    }
    let rabbit = new Rabbit("Rab");
    alert( rabbit.hasOwnProperty('name') ); // Error
    

    首先,让我们看看为什么之前的代码无法运行。

    如果我们尝试运行它,就会发现原因其实很明显。派生类的 constructor 必须调用 super()。否则 "this" 不会被定义。

    下面是修复后的代码:

    class Rabbit extends Object {
      constructor(name) {
        super(); // 需要在继承时调用父类的 constructor
        this.name = name;
      }
    }
    let rabbit = new Rabbit("Rab");
    alert( rabbit.hasOwnProperty('name') ); // true
    

    但这还不是全部原因。

    即便修复了它,"class Rabbit extends Object" 和 class Rabbit 之间仍存在着重要差异。

    我们知道,“extends” 语法会设置两个原型:

    1. 在构造函数的 "prototype" 之间设置原型(为了获取实例方法)。
    2. 在构造函数之间会设置原型(为了获取静态方法)。

    在我们的例子里,对于 class Rabbit extends Object,它意味着:

    class Rabbit extends Object {}
    
    alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
    alert( Rabbit.__proto__ === Object ); // (2) true
    

    所以,现在 Rabbit 可以通过 Rabbit 访问 Object 的静态方法,像这样:

    class Rabbit extends Object {}
    
    // 通常我们调用 Object.getOwnPropertyNames
    alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // a,b
    

    但是如果我们没有 extends Object,那么 Rabbit.proto 将不会被设置为 Object。

    下面是示例:

    class Rabbit {}
    
    alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
    alert( Rabbit.__proto__ === Object ); // (2) false (!)
    alert( Rabbit.__proto__ === Function.prototype ); // true,所有函数都是默认如此
    
    // error,Rabbit 中没有这样的函数
    alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // Error
    

    所以,在这种情况下,Rabbit 没有提供对 Object 的静态方法的访问。

    顺便说一下,Function.prototype 有一些“通用”函数方法,例如 call 和 bind 等。在上述的两种情况下它们都是可用的,因为对于内建的 Object 构造函数而言,Object.proto === Function.prototype。

    我们用一张图来解释:

    所以,简而言之,这里有两点区别:

    class Rabbit class Rabbit extends Object
    needs to call super() in constructor
    Rabbit.proto === Function.prototype Rabbit.proto === Object

    四、私有的、受保护的属性和方法

    内部接口和外部接口

    私有的:只能从类的内部访问。这些用于内部接口。

    受保护的 “waterAmount”

    做一个简单的咖啡机类:

    class CoffeeMachine {
      waterAmount = 0; // 内部的水量
    
      constructor(power) {
        this.power = power;
        alert( `Created a coffee-machine, power: ${power}` );
      }
    }
    // 创建咖啡机
    let coffeeMachine = new CoffeeMachine(100);
    // 加水
    coffeeMachine.waterAmount = 200;
    

    受保护的属性通常以下划线 _ 作为前缀。一个众所周知的约定.

    class CoffeeMachine {
      _waterAmount = 0;
      set waterAmount(value) {
        if (value < 0) throw new Error("Negative water");
        this._waterAmount = value;
      }
      get waterAmount() {
        return this._waterAmount;
      }
      constructor(power) {
        this._power = power;
      }
    }
    
    // 创建咖啡机
    let coffeeMachine = new CoffeeMachine(100);
    // 加水
    coffeeMachine.waterAmount = -10; // Error: Negative water
    

    只读的 “power”

    对于 power 属性,让我们将它设为只读。有时候一个属性必须只能被在创建时进行设置,之后不再被修改。

    class CoffeeMachine {
      // ...
      constructor(power) {
        this._power = power;
      }
      get power() {
        return this._power;
      }
    }
    // 创建咖啡机
    let coffeeMachine = new CoffeeMachine(100);
    alert(`Power is: ${coffeeMachine.power}W`); // 功率是:100W
    coffeeMachine.power = 25; // Error(没有 setter)
    

    这里我们使用了 getter/setter 语法。但大多数时候首选 get.../set... 函数,像这样:

    class CoffeeMachine {
      _waterAmount = 0;
    
      setWaterAmount(value) {
        if (value < 0) throw new Error("Negative water");
        this._waterAmount = value;
      }
      getWaterAmount() {
        return this._waterAmount;
      }
    }
    new CoffeeMachine().setWaterAmount(100);
    

    私有的 “#waterLimit”

    这儿有一个马上就会被加到规范中的已完成的 Javascript 提案,它为私有属性和方法提供语言级支持。

    私有属性和方法应该以 # 开头。它们只在类的内部可被访问。

    例如,这儿有一个私有属性 #waterLimit 和检查水量的私有方法 #checkWater:

    class CoffeeMachine {
      #waterLimit = 200;
    
      #checkWater(value) {
        if (value < 0) throw new Error("Negative water");
        if (value > this.#waterLimit) throw new Error("Too much water");
      }
    
    }
    
    let coffeeMachine = new CoffeeMachine();
    
    // 不能从类的外部访问类的私有属性和方法
    coffeeMachine.#checkWater(); // Error
    coffeeMachine.#waterLimit = 1000; // Error
    

    在语言级别,# 是该字段为私有的特殊标志。我们无法从外部或从继承的类中访问它。

    私有字段与公共字段不会发生冲突。我们可以同时拥有私有的 #waterAmount 和公共的 waterAmount 字段。

    例如,让我们使 waterAmount 成为 #waterAmount 的一个访问器:

    class CoffeeMachine {
    
      #waterAmount = 0;
    
      get waterAmount() {
        return this.#waterAmount;
      }
    
      set waterAmount(value) {
        if (value < 0) throw new Error("Negative water");
        this.#waterAmount = value;
      }
    }
    
    let machine = new CoffeeMachine();
    
    machine.waterAmount = 100;
    alert(machine.#waterAmount); // Error
    

    与受保护的字段不同,私有字段由语言本身强制执行。这是好事儿。

    但是如果我们继承自 CoffeeMachine,那么我们将无法直接访问 #waterAmount。我们需要依靠 waterAmount getter/setter。在许多情况下,这种限制太严重了。如果我们扩展 CoffeeMachine,则可能有正当理由访问其内部。这就是为什么大多数时候都会使用受保护字段,即使它们不受语言语法的支持。

    私有字段不能通过 this[name] 访问私有字段很特别。正如我们所知道的,通常我们可以使用 this[name] 访问字段。对于私有字段来说,这是不可能的:this['#name'] 不起作用。这是确保私有性的语法限制。

    五、扩展内建类

    内建的类,例如 Array,Map 等也都是可以扩展的(extendable)。

    例如,这里有一个继承自原生 Array 的类 PowerArray:

    // 给 PowerArray 新增了一个方法(可以增加更多)
    class PowerArray extends Array {
      isEmpty() {
        return this.length === 0;
      }
    }
    
    let arr = new PowerArray(1, 2, 5, 10, 50);
    alert(arr.isEmpty()); // false
    
    let filteredArr = arr.filter(item => item >= 10);
    alert(filteredArr); // 10, 50
    alert(filteredArr.isEmpty()); // false
    

    请注意一个非常有趣的事儿。内建的方法例如 filter,map 等 — 返回的正是子类 PowerArray 的新对象。它们内部使用了对象的 constructor 属性来实现这一功能。

    在上面的例子中,arr.constructor === PowerArray

    当 arr.filter() 被调用时,它的内部使用的是 arr.constructor 来创建新的结果数组,而不是使用原生的 Array。这真的很酷,因为我们可以在结果数组上继续使用 PowerArray 的方法。

    甚至,我们可以定制这种行为。

    我们可以给这个类添加一个特殊的静态 getter Symbol.species。如果存在,则应返回 JavaScript 在内部用来在 map 和 filter 等方法中创建新实体的 constructor。

    如果我们希望像 map 或 filter 这样的内建方法返回常规数组,我们可以在 Symbol.species 中返回 Array,就像这样:

    class PowerArray extends Array {
      isEmpty() {
        return this.length === 0;
      }
    
      // 内建方法将使用这个作为 constructor
      static get [Symbol.species]() {
        return Array;
      }
    }
    
    let arr = new PowerArray(1, 2, 5, 10, 50);
    alert(arr.isEmpty()); // false
    
    // filter 使用 arr.constructor[Symbol.species] 作为 constructor 创建新数组
    let filteredArr = arr.filter(item => item >= 10);
    
    // filteredArr 不是 PowerArray,而是 Array
    alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function
    

    正如你所看到的,现在 .filter 返回 Array。所以扩展的功能不再传递。其他集合,例如 Map 和 Set 的工作方式类似。它们也使用 Symbol.species。

    内建类没有静态方法继承

    内建对象有它们自己的静态方法,例如 Object.keys,Array.isArray 等。

    如我们所知道的,原生的类互相扩展。例如,Array 扩展自 Object。

    通常,当一个类扩展另一个类时,静态方法和非静态方法都会被继承。

    但内建类却是一个例外。它们相互间不继承静态方法。

    例如,Array 和 Date 都继承自 Object,所以它们的实例都有来自 Object.prototype 的方法。但 Array.[[Prototype]] 并不指向 Object,所以它们没有例如 Array.keys()(或 Date.keys())这些静态方法。

    这里有一张 Date 和 Object 的结构关系图:

    正如你所看到的,Date 和 Object 之间没有连结。它们是独立的,只有 Date.prototype 继承自 Object.prototype,仅此而已。

    与我们所了解的通过 extends 获得的继承相比,这是内建对象之间继承的一个重要区别。

    六、类检查 instanceOf 操作符

    instanceof 操作符用于检查一个对象是否属于某个特定的 class。同时,它还考虑了继承。

    在许多情况下,可能都需要进行此类检查。例如,它可以被用来构建一个 多态性(polymorphic) 的函数,该函数根据参数的类型对参数进行不同的处理。

    语法:obj instanceof Class

    如果 obj 隶属于 Class 类(或 Class 类的衍生类),则返回 true。

    可以与构造函数一起使用。new Rabbit() instanceof Rabbit

    let arr = [1, 2, 3];
    alert( arr instanceof Array ); // true
    alert( arr instanceof Object ); // true
    

    arr 同时还隶属于 Object 类。因为从原型上来讲,Array 是继承自 Object 的。instanceof 在检查中会将原型链考虑在内。此外,我们还可以在静态方法 Symbol.hasInstance 中设置自定义逻辑。

    obj instanceof Class 算法的执行过程大致如下:

    1. 如果这儿有静态方法 Symbol.hasInstance,那就直接调用这个方法:
    // 设置 instanceOf 检查
    // 并假设具有 canEat 属性的都是 animal
    class Animal {
      static [Symbol.hasInstance](obj) {
        if (obj.canEat) return true;
      }
    }
    
    let obj = { canEat: true };
    alert(obj instanceof Animal); // true:Animal[Symbol.hasInstance](obj) 被调用
    
    1. 大多数 class 没有 Symbol.hasInstance。在这种情况下,标准的逻辑是:使用 obj instanceOf Class 检查 Class.prototype 是否等于 obj 的原型链中的原型之一。

    换句话说就是,一个接一个地比较:

    obj.__proto__ === Class.prototype?
    obj.__proto__.__proto__ === Class.prototype?
    obj.__proto__.__proto__.__proto__ === Class.prototype?
    ...
    // 如果任意一个的答案为 true,则返回 true
    // 否则,如果我们已经检查到了原型链的尾端,则返回 false
    

    在上面那个例子中,rabbit.proto === Rabbit.prototype,所以立即就给出了结果。

    而在继承的例子中,匹配将在第二步进行:

    class Animal {}
    class Rabbit extends Animal {}
    
    let rabbit = new Rabbit();
    alert(rabbit instanceof Animal); // true
    
    // rabbit.__proto__ === Rabbit.prototype
    // rabbit.__proto__.__proto__ === Animal.prototype(匹配!)
    

    下图展示了 rabbit instanceof Animal 的执行过程中,Animal.prototype 是如何参与比较的:

    这里还要提到一个方法 objA.isPrototypeOf(objB),如果 objA 处在 objB 的原型链中,则返回 true。所以,可以将 obj instanceof Class 检查改为 Class.prototype.isPrototypeOf(obj)。

    这很有趣,但是 Class 的 constructor 自身是不参与检查的!检查过程只和原型链以及 Class.prototype 有关。

    创建对象后,如果更改 prototype 属性,可能会导致有趣的结果。

    就像这样:

    function Rabbit() {}
    let rabbit = new Rabbit();
    
    // 修改了 prototype
    Rabbit.prototype = {};
    
    // ...再也不是 rabbit 了!
    alert( rabbit instanceof Rabbit ); // false
    

    福利:使用 Object.prototype.toString 方法来揭示类型

    大家都知道,一个普通对象被转化为字符串时为 [object Object]:

    let obj = {};
    
    alert(obj); // [object Object]
    alert(obj.toString()); // 同上
    

    这是通过 toString 方法实现的。但是这儿有一个隐藏的功能,该功能可以使 toString 实际上比这更强大。我们可以将其作为 typeof 的增强版或者 instanceof 的替代方法来使用。

    听起来挺不可思议?那是自然,精彩马上揭晓。

    按照 规范 所讲,内建的 toString 方法可以被从对象中提取出来,并在任何其他值的上下文中执行。其结果取决于该值。

    • 对于 number 类型,结果是 [object Number]
    • 对于 boolean 类型,结果是 [object Boolean]
    • 对于 null:[object Null]
    • 对于 undefined:[object Undefined]
    • 对于数组:[object Array]
    • ……等(可自定义)
    // 方便起见,将 toString 方法复制到一个变量中
    let objectToString = Object.prototype.toString;
    // 它是什么类型的?
    let arr = [];
    alert( objectToString.call(arr) ); // [object Array]
    

    这里我们用到了在 装饰器模式和转发,call/apply 一章中讲过的 call 方法来在上下文 this=arr 中执行函数 objectToString。

    在内部,toString 的算法会检查 this,并返回相应的结果。再举几个例子:

    let s = Object.prototype.toString;
    
    alert( s.call(123) ); // [object Number]
    alert( s.call(null) ); // [object Null]
    alert( s.call(alert) ); // [object Function]
    

    Symbol.toStringTag

    可以使用特殊的对象属性 Symbol.toStringTag 自定义对象的 toString 方法的行为。

    例如:

    let user = {
      [Symbol.toStringTag]: "User"
    };
    alert( {}.toString.call(user) ); // [object User]
    

    对于大多数特定于环境的对象,都有一个这样的属性。下面是一些特定于浏览器的示例:

    // 特定于环境的对象和类的 toStringTag:
    alert( window[Symbol.toStringTag]); // Window
    alert( XMLHttpRequest.prototype[Symbol.toStringTag] ); // XMLHttpRequest
    
    alert( {}.toString.call(window) ); // [object Window]
    alert( {}.toString.call(new XMLHttpRequest()) ); // [object XMLHttpRequest]
    

    正如我们所看到的,输出结果恰好是 Symbol.toStringTag(如果存在),只不过被包裹进了 [object ...] 里。

    这样一来,我们手头上就有了个“磕了药似的 typeof”,不仅能检查原始数据类型,而且适用于内建对象,更可贵的是还支持自定义。

    所以,如果我们想要获取内建对象的类型,并希望把该信息以字符串的形式返回,而不只是检查类型的话,我们可以用 {}.toString.call 替代 instanceof。

    总结一下我们知道的类型检查方法:

    用于 返回值
    typeof 原始数据类型 string
    {}.toString 原始数据类型,内建对象,包含 Symbol.toStringTag 属性的对象 string
    instanceof 对象 true/false

    有意思的问题:不按套路出牌的 instanceof

    重要程度: 5

    在下面的代码中,为什么 instanceof 会返回 true?我们可以明显看到,a 并不是通过 B() 创建的。

    function A() {}
    function B() {}
    
    A.prototype = B.prototype = {};
    
    let a = new A();
    
    alert( a instanceof B ); // true
    

    是的,看起来确实很奇怪。

    instanceof 并不关心函数,而是关心函数的与原型链匹配的 prototype。

    并且,这里 a.proto == B.prototype,所以 instanceof 返回 true。

    总之,根据 instanceof 的逻辑,真正决定类型的是 prototype,而不是构造函数。

    七、Mixin模式

    在 JavaScript 中,我们只能继承单个对象。每个对象只能有一个 [[Prototype]]。并且每个类只可以扩展另外一个类。

    但是有些时候这种设定(译注:单继承)会让人感到受限制。例如,我有一个 StreetSweeper 类和一个 Bicycle 类,现在想要一个它们的 mixin:StreetSweepingBicycle 类

    或者,我们有一个 User 类和一个 EventEmitter 类来实现事件生成(event generation),并且我们想将 EventEmitter 的功能添加到 User 中,以便我们的用户可以触发事件(emit event)。

    有一个概念可以帮助我们,叫做 “mixins”。

    根据维基百科的定义,mixin 是一个包含可被其他类使用而无需继承的方法的类

    换句话说,mixin 提供了实现特定行为的方法,但是我们不单独使用它,而是使用它来将这些行为添加到其他类中。

    一个 Mixin 实例

    在 JavaScript 中构造一个 mixin 最简单的方式就是构造一个拥有实用方法的对象,以便我们可以轻松地将这些实用的方法合并到任何类的原型中。

    例如,这个名为 sayHiMixin 的 mixin 用于给 User 添加一些“语言功能”:

    // mixin
    let sayHiMixin = {
      sayHi() {
        alert(`Hello ${this.name}`);
      },
      sayBye() {
        alert(`Bye ${this.name}`);
      }
    };
    
    // 用法:
    class User {
      constructor(name) {
        this.name = name;
      }
    }
    
    // 拷贝方法
    Object.assign(User.prototype, sayHiMixin);
    
    // 现在 User 可以打招呼了
    new User("Dude").sayHi(); // Hello Dude!
    

    这里没有继承,只有一个简单的方法拷贝。所以 User 可以从另一个类继承,还可以包括 mixin 来 "mix-in“ 其它方法,就像这样:

    class User extends Person {
      // ...
    }
    
    Object.assign(User.prototype, sayHiMixin);
    

    Mixin 可以在自己内部使用继承。

    例如,这里的 sayHiMixin 继承自 sayMixin:

    let sayMixin = {
      say(phrase) {
        alert(phrase);
      }
    };
    
    let sayHiMixin = {
      __proto__: sayMixin, // (或者,我们可以在这儿使用 Object.create 来设置原型)
    
      sayHi() {
        // 调用父类方法
        super.say(`Hello ${this.name}`); // (*)
      },
      sayBye() {
        super.say(`Bye ${this.name}`); // (*)
      }
    };
    
    class User {
      constructor(name) {
        this.name = name;
      }
    }
    
    // 拷贝方法
    Object.assign(User.prototype, sayHiMixin);
    
    // 现在 User 可以打招呼了
    new User("Dude").sayHi(); // Hello Dude!
    

    请注意,在 sayHiMixin 内部对父类方法 super.say() 的调用(在标有 (*) 的行)会在 mixin 的原型中查找方法,而不是在 class 中查找。

    这是示意图(请参见图中右侧部分):

    这是因为方法 sayHi 和 sayBye 最初是在 sayHiMixin 中创建的。因此,即使复制了它们,但是它们的 [[HomeObject]] 内部属性仍引用的是 sayHiMixin,如上图所示。

    当 super 在 [[HomeObject]].[[Prototype]] 中寻找父方法时,意味着它搜索的是 sayHiMixin.[[Prototype]],而不是 User.[[Prototype]]。

    EventMixin

    现在让我们为实际运用构造一个 mixin。

    例如,许多浏览器对象的一个重要功能是它们可以生成事件。事件是向任何有需要的人“广播信息”的好方法。因此,让我们构造一个 mixin,使我们能够轻松地将与事件相关的函数添加到任意 class/object 中。

    • Mixin 将提供 .trigger(name, [...data]) 方法,以在发生重要的事情时“生成一个事件”。name 参数(arguments)是事件的名称,[...data] 是可选的带有事件数据的其他参数(arguments)。

    • 此外还有 .on(name, handler) 方法,它为具有给定名称的事件添加了 handler 函数作为监听器(listener)。当具有给定 name 的事件触发时将调用该方法,并从 .trigger 调用中获取参数(arguments)。

    • ……还有 .off(name, handler) 方法,它会删除 handler 监听器(listener)。

    添加完 mixin 后,对象 user 将能够在访客登录时生成事件 "login"。另一个对象,例如 calendar 可能希望监听此类事件以便为登录的人加载日历。

    或者,当一个菜单项被选中时,menu 可以生成 "select" 事件,其他对象可以分配处理程序以对该事件作出反应。诸如此类。

    下面是代码:

    let eventMixin = {
      /**
       * 订阅事件,用法:
       *  menu.on('select', function(item) { ... }
      */
      on(eventName, handler) {
        if (!this._eventHandlers) this._eventHandlers = {};
        if (!this._eventHandlers[eventName]) {
          this._eventHandlers[eventName] = [];
        }
        this._eventHandlers[eventName].push(handler);
      },
    
      /**
       * 取消订阅,用法:
       *  menu.off('select', handler)
       */
      off(eventName, handler) {
        let handlers = this._eventHandlers?.[eventName];
        if (!handlers) return;
        for (let i = 0; i < handlers.length; i++) {
          if (handlers[i] === handler) {
            handlers.splice(i--, 1);
          }
        }
      },
    
      /**
       * 生成具有给定名称和数据的事件
       *  this.trigger('select', data1, data2);
       */
      trigger(eventName, ...args) {
        if (!this._eventHandlers?.[eventName]) {
          return; // 该事件名称没有对应的事件处理程序(handler)
        }
    
        // 调用事件处理程序(handler)
        this._eventHandlers[eventName].forEach(handler => handler.apply(this, args));
      }
    };
    
    1. .on(eventName, handler) — 指定函数 handler 以在具有对应名称的事件发生时运行。从技术上讲,这儿有一个用于存储每个事件名称对应的处理程序(handler)的 _eventHandlers 属性,在这儿该属性就会将刚刚指定的这个 handler 添加到列表中。

    2. .off(eventName, handler) — 从处理程序列表中删除指定的函数。

    3. .trigger(eventName, ...args) — 生成事件:所有 _eventHandlers[eventName] 中的事件处理程序(handler)都被调用,并且 ...args 会被作为参数传递给它们。

    // 创建一个 class
    class Menu {
      choose(value) {
        this.trigger("select", value);
      }
    }
    // 添加带有事件相关方法的 mixin
    Object.assign(Menu.prototype, eventMixin);
    
    let menu = new Menu();
    
    // 添加一个事件处理程序(handler),在被选择时被调用:
    menu.on("select", value => alert(`Value selected: ${value}`));
    
    // 触发事件 => 运行上述的事件处理程序(handler)并显示:
    // 被选中的值:123
    menu.choose("123");
    

    现在,如果我们希望任何代码对菜单选择作出反应,我们可以使用 menu.on(...) 进行监听。

    使用 eventMixin 可以轻松地将此类行为添加到我们想要的多个类中,并且不会影响继承链。

    总结

    Mixin — 是一个通用的面向对象编程术语:一个包含其他类的方法的类。

    一些其它编程语言允许多重继承。JavaScript 不支持多重继承,但是可以通过将方法拷贝到原型中来实现 mixin。

    我们可以使用 mixin 作为一种通过添加多种行为(例如上文中所提到的事件处理)来扩充类的方法。

    如果 Mixins 意外覆盖了现有类的方法,那么它们可能会成为一个冲突点。因此,通常应该仔细考虑 mixin 的命名方法,以最大程度地降低发生这种冲突的可能性。

  • 相关阅读:
    win7下virtualbox遇到的问题
    2.5年, 从0到阿里
    TCP/IP入门(4) --应用层
    TCP/IP入门(3) --传输层
    TCP/IP入门(2) --网络层
    TCP/IP入门(1) --链路层
    Socket编程实践(13) --UNIX域协议
    Socket编程实践(12) --UDP编程基础
    Socket编程实践(10) --select的限制与poll的使用
    Socket编程实践(9) --套接字IO超时设置方法
  • 原文地址:https://www.cnblogs.com/hencins/p/15408204.html
Copyright © 2020-2023  润新知