面向对象的JavaScript:深入研究ES6类

翻译自《Object-oriented JavaScript: A Deep Dive into ES6 Classes

一句话总结:类是一种有效优化代码结构的方法。ES6的类至少在形式上提供了正式的类定义方式,降低了在JavaScript中采用面向的对象设计的门槛。

通常我们需要在我们的程序中表示一个想法或概念 - 可能是汽车引擎,计算机文件,路由器或温度读数。直接在代码中表示这些概念分为两​​部分:表示状态的数据和表示行为的函数。ES6类为我们提供了一种方便的语法,用于定义代表我们概念的对象的状态和行为。

ES6类通过保证调用初始化函数使代码更安全,并且可以更容易地定义一组固定的函数,这些函数对该数据进行操作并保持有效状态。如果你可以将某个事物视为一个单独的实体,那么你可能应该定义一个类来表示程序中的“事物”。

考虑这个非类代码。你能找到多少错误?你会如何修理它们?

// set today to December 24
const today = {
  month: 24,
  day: 12,
};

const tomorrow = {
  year: today.year,
  month: today.month,
  day: today.day + 1,
};

const dayAfterTomorrow = {
  year: tomorrow.year,
  month: tomorrow.month,
  day: tomorrow.day + 1 <= 31 ? tomorrow.day + 1 : 1,
};

日期today无效:没有第24个月。此外,today还没有完全初始化:它缺少一年。如果我们有一个不能忘记的初始化函数会更好。另请注意,在添加一天时,我们检查了一个地方,如果我们超过31但在另一个地方错过了该检查。如果我们只通过一组小而固定的函数来处理数据会更好,每个函数都保持有效状态。

这是使用类的更正版本。

class SimpleDate {
  constructor(year, month, day) {
    // Check that (year, month, day) is a valid date
    // ...

    // If it is, use it to initialize "this" date
    this._year = year;
    this._month = month;
    this._day = day;
  }

  addDays(nDays) {
    // Increase "this" date by n days
    // ...
  }

  getDay() {
    return this._day;
  }
}

// "today" is guaranteed to be valid and fully initialized
const today = new SimpleDate(2000, 2, 28);

// Manipulating data only through a fixed set of functions ensures we maintain valid state
today.addDays(1);

作者提示:
当函数与类或对象关联时,我们将其称为方法(method)
当从类创建对象时,该对象被称为该类的实例(instance)

构造函数

constructor方法很特殊,它解决了第一个问题。它的工作是将一个实例初始化为一个有效状态,它将自动调用,所以我们不会忘记初始化我们的对象。

保持数据私密

我们尝试设计我们的类,以确保它们的状态有效。我们提供了一个只创建有效值的构造函数,我们设计的方法也总是只留下有效值。但只要允许每个人都可以访问课程的数据,就总会有人搞砸。我们通过只允许通过给出的方法访问数据来避免这个问题。

作者提示:保持数据私有以保护它称为封装(encapsulation)

通过约定实现私有

不幸的是,JavaScript中不存在私有对象属性。我们必须伪造它们。最常见的方法是遵循一个简单的约定:如果属性名称以下划线为前缀(或者不太常见,后缀为下划线),则应将其视为非公开。我们在前面的代码示例中使用了这种方法。通常这个约定简单约定,但数据在技术上仍然可供所有人访问,因此我们必须依靠自己的规则来做正确的事情。

通过特权方法实现私有

伪造私有对象属性的下一种最常见的方法是在构造函数中使用普通变量,并在闭包中捕获它们。这个技巧为我们提供了外部无法访问的真正私有数据。但为了使它工作,我们的类的方法本身需要在构造函数中定义并附加到实例:

class SimpleDate {
  constructor(year, month, day) {
    // Check that (year, month, day) is a valid date
    // ...

    // If it is, use it to initialize "this" date's ordinary variables
    let _year = year;
    let _month = month;
    let _day = day;

    // Methods defined in the constructor capture variables in a closure
    this.addDays = function(nDays) {
      // Increase "this" date by n days
      // ...
    }

    this.getDay = function() {
      return _day;
    }
  }
}

通过符号(Symbol)实现私有

从ES6开始,符号是JavaScript的一个新特性,它们为我们提供了伪造私有对象属性的另一种方法。我们可以使用唯一符号对象键,而不是下划线属性名称,我们的类可以在闭包中捕获这些键。但是有泄漏。JavaScript的另一个新功能是,它允许外部访问我们试图保密的符号键:Object.getOwnPropertySymbols

const SimpleDate = (function() {
  const _yearKey = Symbol();
  const _monthKey = Symbol();
  const _dayKey = Symbol();

  class SimpleDate {
    constructor(year, month, day) {
      // Check that (year, month, day) is a valid date
      // ...

      // If it is, use it to initialize "this" date
      this[_yearKey] = year;
      this[_monthKey] = month;
      this[_dayKey] = day;
     }

    addDays(nDays) {
      // Increase "this" date by n days
      // ...
    }

    getDay() {
      return this[_dayKey];
    }
  }

  return SimpleDate;
}());

Weak maps实现私有

Weak maps也是JavaScript的新功能。我们可以使用实例作为键来存储键/值对中的私有对象属性,并且我们的类可以在闭包中捕获这些键/值映射:

const SimpleDate = (function() {
  const _years = new WeakMap();
  const _months = new WeakMap();
  const _days = new WeakMap();

  class SimpleDate {
    constructor(year, month, day) {
      // Check that (year, month, day) is a valid date
      // ...

      // If it is, use it to initialize "this" date
      _years.set(this, year);
      _months.set(this, month);
      _days.set(this, day);
    }

    addDays(nDays) {
      // Increase "this" date by n days
      // ...
    }

    getDay() {
      return _days.get(this);
    }
  }

  return SimpleDate;
}());

其他访问范围修饰符

除了“private”之外,还有其他级别的可见性,你可以在其他语言中找到,例如“protected”,“internal”,“package private”或“friend”。JavaScript仍然没有为我们提供强制执行其他级别可见性的方法。如果你需要它们,你将不得不依赖惯例和自律。

引用当前对象

再看一下getDay()方法,它没有指定任何参数,那么它如何知道调用它的对象?当使用符号将函数作为方法调用时,会使用隐式参数来标识对象,并将隐式参数分配给名为this的隐式参数。为了说明,这里是我们如何显式而不是隐式地发送对象参数:

// Get a reference to the "getDay" function
const getDay = SimpleDate.prototype.getDay;

getDay.call(today); // "this" will be "today"
getDay.call(tomorrow); // "this" will be "tomorrow"

tomorrow.getDay(); // same as last line, but "tomorrow" is passed implicitly

静态方法和属性

我们可以选择定义数据和方法作为类的一部分但不属于该类的任何实例,它们分别称为静态属性和静态方法。每个实例只有一个静态属性副本而不是新副本:

静态方法

class SimpleDate {
  static setDefaultDate(year, month, day) {
    // A static property can be referred to without mentioning an instance
    // Instead, it's defined on the class
    SimpleDate._defaultDate = new SimpleDate(year, month, day);
  }

  constructor(year, month, day) {
    // If constructing without arguments,
    // then initialize "this" date by copying the static default date
    if (arguments.length === 0) {
      this._year = SimpleDate._defaultDate._year;
      this._month = SimpleDate._defaultDate._month;
      this._day = SimpleDate._defaultDate._day;

      return;
    }

    // Check that (year, month, day) is a valid date
    // ...

    // If it is, use it to initialize "this" date
    this._year = year;
    this._month = month;
    this._day = day;
  }

  addDays(nDays) {
    // Increase "this" date by n days
    // ...
  }

  getDay() {
    return this._day;
  }
}

SimpleDate.setDefaultDate(1970, 1, 1);
const defaultDate = new SimpleDate();

MDN javascript static

静态属性

目前,并没有实现静态属性的直接方法。虽然,直接在类上指定属性的方法似乎可以模拟静态属性,其实不是。例如:

class A {}
A.p = 'a'

a  = new A()
// 类A的实例并不能访问“静态”属性p,必须要通过类自身访问
console.log(a.p) // undefined
console.log(A.p) // 'a'

class B extends A {}
// 这样定义的属性也不支持继承
console.log(B.p) // undefined

子类

我们经常发现我们定义的类之间存在共性 - 我们想要整合的重复代码。子类允许我们将另一个类的数据和行为合并到我们自己的类中。这个过程通常称为继承(inheritance),我们的子类称为从父类,也称为超类(superclass),“继承”。继承可以避免重复并简化与另一个类相同的数据和函数的实现。继承还允许我们替换子类,仅依赖于公共超类提供的接口

继承以避免重复

考虑这个非继承代码:

class Employee {
  constructor(firstName, familyName) {
    this._firstName = firstName;
    this._familyName = familyName;
  }

  getFullName() {
    return `${this._firstName} ${this._familyName}`;
  }
}

class Manager {
  constructor(firstName, familyName) {
    this._firstName = firstName;
    this._familyName = familyName;
    this._managedEmployees = [];
  }

  getFullName() {
    return `${this._firstName} ${this._familyName}`;
  }

  addEmployee(employee) {
    this._managedEmployees.push(employee);
  }
}

我们的类之间重复数据属性_firstName_familyName方法getFullName。我们可以通过让Manager类继承自Employee类来消除重复。当我们这样做时,Employee班级的状态和行为- 它的数据和功能 - 将被纳入我们的Manager班级。

这是一个使用继承的版本。注意使用super

// Manager still works same as before but without repeated code
class Manager extends Employee {
  constructor(firstName, familyName) {
    super(firstName, familyName);
    this._managedEmployees = [];
  }

  addEmployee(employee) {
    this._managedEmployees.push(employee);
  }
}

IS-A和WORKS-LIKE-A

有一些设计原则可以帮助你确定何时适合继承。继承应始终建立IS-A和WORKS-LIKE-A关系的模型。也就是说,经理“是一个”并且“像一个”特定类型的员工一样工作,这样任何对超类实例的操作,都应该能够在子类实例上进行,并且一切都应该仍然有效。违反和遵守这一原则之间的区别有时可能是微妙的。细微违规的典型示例是Rectangle超类和Square子类:

class Rectangle {
  set width(w) {
    this._width = w;
  }

  get width() {
    return this._width;
  }

  set height(h) {
    this._height = h;
  }

  get height() {
    return this._height;
  }
}

// A function that operates on an instance of Rectangle
function f(rectangle) {
  rectangle.width = 5;
  rectangle.height = 4;

  // Verify expected result
  if (rectangle.width * rectangle.height !== 20) {
    throw new Error("Expected the rectangle's area (width * height) to be 20");
  }
}

// A square IS-A rectangle... right?
class Square extends Rectangle {
  set width(w) {
    super.width = w;

    // Maintain square-ness
    super.height = w;
  }

  set height(h) {
    super.height = h;

    // Maintain square-ness
    super.width = h;
  }
}

// But can a rectangle be substituted by a square?
f(new Square()); // error

正方形可以是数学上的矩形,但是正方形在行为上不像矩形。

任何使用超类实例的地方都能够由子类实例替代的规则称为Liskov替换原则,它是面向对象类设计的重要部分。

小心过度使用

在任何地方都很容易找到共性,即使是经验丰富的开发人员,拥有功能大而全的类的前景也很诱人。但是继承也有缺点。回想一下,我们通过仅通过一组小而固定的函数来操作数据来确保有效状态。但是当我们继承时,我们会增加可以直接操作数据的函数列表,然后这些附加函数也负责维护有效状态。如果太多函数可以直接操作数据,那么该数据几乎与全局变量一样糟糕。太多的继承会产生整体类,它们会稀释封装,更难以正确使用,并且难以重用。相反,最好设计仅包含一个概念的最小类。

让我们重新审视代码重复问题。我们能否在不继承的情况下解决它?另一种方法是通过引用连接对象以表示部分 - 整体关系。我们称这种构成。

这是使用组合而不是继承的经理 - 员工关系的一个版本:

class Employee {
  constructor(firstName, familyName) {
    this._firstName = firstName;
    this._familyName = familyName;
  }

  getFullName() {
    return `${this._firstName} ${this._familyName}`;
  }
}

class Group {
  constructor(manager /* : Employee */ ) {
    this._manager = manager;
    this._managedEmployees = [];
  }

  addEmployee(employee) {
    this._managedEmployees.push(employee);
  }
}

在这里,经理不是一个单独的类。相反,管理器类持有普通Employee实例的引用。如果继承模拟IS-A关系,则组合模拟HAS-A关系。也就是说,一个小组“有一个”经理。

如果继承或组合可以合理地表达我们的程序概念和关系,那么组合更好。

继承替换子类

继承还允许通过公共超类提供的接口交替使用不同的子类。将超类实例作为参数也可以传递给子类实例,而函数不必知道任何子类。替换具有公共超类的类通常称为多态(polymorphism)

// This will be our common superclass
class Cache {
  get(key, defaultValue) {
    const value = this._doGet(key);
    if (value === undefined || value === null) {
      return defaultValue;
    }

    return value;
  }

  set(key, value) {
    if (key === undefined || key === null) {
      throw new Error('Invalid argument');
    }

    this._doSet(key, value);
  }

  // Must be overridden
  // _doGet()
  // _doSet()
}

// Subclasses define no new public methods
// The public interface is defined entirely in the superclass
class ArrayCache extends Cache {
  _doGet() {
    // ...
  }

  _doSet() {
    // ...
  }
}

class LocalStorageCache extends Cache {
  _doGet() {
    // ...
  }

  _doSet() {
    // ...
  }
}

// Functions can polymorphically operate on any cache by interacting through the superclass interface
function compute(cache) {
  const cached = cache.get('result');
  if (!cached) {
    const result = // ...
    cache.set('result', result);
  }

  // ...
}

compute(new ArrayCache()); // use array cache through superclass interface
compute(new LocalStorageCache()); // use local storage cache through superclass interface

不仅仅是语法糖

JavaScript的类语法经常被认为是语法糖,并且在很多方面确实是,但也存在真正的差异 —— 我们可以用ES6类做ES5中做不到的事情。

静态属性是继承的

ES5没有让我们在构造函数之间创建真正的继承。可以创建一个普通的对象,但不是一个函数对象。我们通过手动复制它们来伪造静态属性的继承。现在有了ES6类,我们在子类构造函数和超类构造函数之间得到了一个真正的原型链接:Object.create

// ES5
function B() {}
B.f = function () {};

function D() {}
D.prototype = Object.create(B.prototype);

D.f(); // error
// ES6
class B {
  static f() {}
}

class D extends B {}

D.f(); // ok

内置构造函数可以进行转换

有些对象是“奇异的”,并且不像普通对象那样。例如,数组将其length属性调整为大于最大整数索引。在ES5中,当我们尝试子类化时Array,new运算符将为我们的子类分配一个普通对象,而不是我们超类的异类对象:

// ES5
function D() {
  Array.apply(this, arguments);
}
D.prototype = Object.create(Array.prototype);

var d = new D();
d[0] = 42;

d.length; // 0 - bad, no array exotic behavior

ES6类通过更改分配对象的时间和对象来修复此问题。在ES5中,在调用子类构造函数之前分配了对象,子类将该对象传递给超类构造函数。现在使用ES6类,在调用超类构造函数之前分配对象,并且超类使该对象可用于子类构造函数。这样Array即使在我们new的子类上调用时也可以分配一个奇特的对象。

// ES6
class D extends Array {}

let d = new D();
d[0] = 42;

d.length; // 1 - good, array exotic behavior

其它

还有一小部分其他的,可能不太重要的差异。类构造函数不能被函数调用。这可以防止忘记调用构造函数new。此外,prototype无法重新分配类构造函数的属性。这可以帮助JavaScript引擎优化类对象。最后,类方法没有prototype属性。这可以通过消除不必要的对象来节省内存

以富有想象力的方式运用新特征

此处和其他SitePoint文章中描述的许多功能都是JavaScript的新功能,社区正在尝试以新的和富有想象力的方式使用这些功能。

使用代理进行多重继承

其中一个实验使用代理,这是JavaScript的一个新功能,用于实现多重继承。JavaScript的原型链只允许单继承。对象只能委托给另一个对象。代理为我们提供了一种将属性访问委托给多个其他对象的方法:

const transmitter = {
  transmit() {}
};

const receiver = {
  receive() {}
};

// Create a proxy object that intercepts property accesses and forwards to each parent,
// returning the first defined value it finds
const inheritsFromMultiple = new Proxy([transmitter, receiver], {
  get: function(proxyTarget, propertyKey) {
    const foundParent = proxyTarget.find(parent => parent[propertyKey] !== undefined);
    return foundParent && foundParent[propertyKey];
  }
});

inheritsFromMultiple.transmit(); // works
inheritsFromMultiple.receive(); // works

我们可以扩展它以适应ES6类吗?类prototype可以是将属性访问转发给多个其他原型的代理。JavaScript社区正在努力解决这个问题。你能搞清楚吗?加入讨论并分享您的想法。

类工厂的多重继承

JavaScript社区一直在尝试的另一种方法是按需生成扩展变量超类的类。每个班级仍然只有一个父母,但我们可以用有趣的方式将这些父母联系起来:

function makeTransmitterClass(Superclass = Object) {
  return class Transmitter extends Superclass {
    transmit() {}
  };
}

function makeReceiverClass(Superclass = Object) {
  return class Receiver extends Superclass
    receive() {}
  };
}

class InheritsFromMultiple extends makeTransmitterClass(makeReceiverClass()) {}

const inheritsFromMultiple = new InheritsFromMultiple();

inheritsFromMultiple.transmit(); // works
inheritsFromMultiple.receive(); // works

是否有其他富有想象力的方法来使用这些功能?现在是时候将您的足迹留在JavaScript世界中了。

结论

如下图所示,目前对类的支持非常好

ES6类

希望本文能让您深入了解ES6中的类如何工作,并揭开了围绕它们的一些术语的神秘面纱。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容

  • JavaScript面向对象程序设计 本文会碰到的知识点:原型、原型链、函数对象、普通对象、继承 读完本文,可以学...
    moyi_gg阅读 757评论 0 2
  •   面向对象(Object-Oriented,OO)的语言有一个标志,那就是它们都有类的概念,而通过类可以创建任意...
    霜天晓阅读 2,092评论 0 6
  • 函数和对象 1、函数 1.1 函数概述 函数对于任何一门语言来说都是核心的概念。通过函数可以封装任意多条语句,而且...
    道无虚阅读 4,521评论 0 5
  • class的基本用法 概述 JavaScript语言的传统方法是通过构造函数,定义并生成新对象。下面是一个例子: ...
    呼呼哥阅读 4,068评论 3 11
  • 独坐在窗前,似乎好久不曾这般认真看过雨。路上的行人零零星星遥遥相望,偶尔穿梭的行车,在诉说着雨中的冷清。记忆不...
    遇见苍老阅读 820评论 0 0