面向对象的 JavaScript – 深入了解 ES6 类

栏目: JavaScript · 发布时间: 6年前

内容简介:面向对象的 JavaScript – 深入了解 ES6 类
面向对象的 JavaScript – 深入了解 ES6 类

通常我们需要在我们的程序中代表一个想法或概念 —— 也许是一个汽车引擎,电脑文件,路由器或温度读数。在代码中直接表示这些概念分为两部分:数据表示其状态和函数来表示行为。类给我们一个方便的语法来定义对象的状态和行为,来表示我们的这些概念。它们使我们的代码更安全,保证一个初始化函数能被调用,并且它们使得我们能更容易地定义一组固定的函数,来对数据进行操作并保持有效状态。如果你能把某些事物看成一个独立的实体,你可能应该定义一个类来表示你的程序中的“事物”。

考虑这个 non-class(非类) 代码。 你能找到几个错误? 你如何解决他们?

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

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

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

日期 today 有一些问题, year 丢失了,如果我们有一个不能被遗忘的初始化函数会更好。另请注意,当加 1 天时,我们检查了一个地方,如果我们超过 31 日,但没有检查其他地方。所以,如果我们只通过一组固定的函数与数据交互,并且维护每个有效状态,这样才能更好。

这是使用类后的更正版本。

class SimpleDate {
  constructor(year, month, day) {
    // 检查 (year, month, day) 是否为一个有效的日期
    // ...

    // 如果是, 使用她来初始化 "this" 的日期
    this._year = year;
    this._month = month;
    this._day = day;
  }

  addDays(nDays) {
    // 增加 "this" 日期 
    // ...
  }

  getDay() {
    return this._day;
  }
}

// "today" 被保证是有效和完全初始化的
let today = new SimpleDate(2000, 2, 28);

// 仅通过一组固定的函数操作数据,确保我们保持有效状态
today.addDays(1);

提示:

当函数与类或对象相关联时,我们将其称为“方法”。

当从一个类创建对象时,该对象被认为是该类的“实例”。

构造函数

constructor 方法是指定的,它解决了第一个问题。它的工作是将一个实例初始化为一个有效的状态,它将被自动调用,所以我们不需要记住初始化我们的对象。

保持数据私有

我们试图设计我们的类,使他们的状态始终保存有效。我们提供一个只创建有效值的构造函数,并且我们设计的方法也总是只保留有效值。但是,只要我们把类的所有数据让大家可访问,那么有人会把它弄乱。除了通过我们提供的函数外,我们需要保护数据不可被访问。

提示:保护数据不被访问,称为“封装”。

通过公约实现属性私有化

不幸的是,JavaScript 中不存在私有的对象属性。我们要伪装他们。最常见的方法是遵守一个简单的惯例:在属性名称前加下划线(或者较不常见的是用下划线做后缀),那么它应该被视为是非公开的。我们在早期的代码示例中通常都使用这种方法。一般来说,这个简单的惯例能工作,但数据在技术上仍然可供大家使用,所以我们要靠自己的规范去做正确的事情。

通过特权方法实现属性私有化

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

class SimpleDate {
  constructor(year, month, day) {
    // 检查 (year, month, day) 是否为一个有效的日期
    // ...

    // 如果是, 使用她来初始化 "this" 日期的普通变量
    let _year = year;
    let _month = month;
    let _day = day;

    // 在构造函数中定义的方法捕获闭包中的变量
    this.addDays = function(nDays) {
      // 增加 "this" 日期 
      // ...
    }

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

通过 Symbol 实现属性私有化

Symbol 是 JavaScript 的新功能,他们给我们带来了另一种伪装私有对象属性的方法。代替带下划线的属性名称,我们可以使用唯一的 symbol 对象键,我们的 class(类) 可以在闭包中中捕获这些键。但是有一个漏洞,JavaScript 的另一个新功能是 Object.getOwnPropertySymbols ,它允许外部访问我们试图保持私有的 symbol 键。

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

  class SimpleDate {
    constructor(year, month, day) {
      // 检查 (year, month, day) 是否为一个有效的日期
      // ...

      // 如果是, 使用她来初始化 "this" 日期
      this[_yearKey] = year;
      this[_monthKey] = month;
      this[_dayKey] = day;
    }

    addDays(nDays) {
      // 增加 "this" 日期 
      // ...
    }

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

  return SimpleDate;
}());

通过 WeakMap 实现属性私有化

WeakMap (愚人码头注: MDN中关于弱映射的说明 ) 也是 JavaScript 的新功能。我们可以在使用我们的实例的作为 key 的键/值对中存储私有对象属性,并且我们的 class(类) 可以在闭包中中捕获这些键/值映射。

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

  class SimpleDate {
    constructor(year, month, day) {
      // 检查 (year, month, day) 是否为一个有效的日期
      // ...

      // 如果是, 使用她来初始化 "this" 日期
      _years.set(this, year);
      _months.set(this, month);
      _days.set(this, day);
    }

    addDays(nDays) {
      // 增加 "this" 日期 
      // ...
    }

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

  return SimpleDate;
}());

其他访问修饰符

除了 “private” 之外,你会发现其他语言还有其他级别的属性可见性,如 “protected”, “internal”, “package private”, 或者 “friend”。JavaScript 仍然没有给我们一种方法来强制执行其他级别的可见性。如果你需要它们,你必须依靠公约和规范。

引用当前对象

再看一下 getDay() 。它没有指定任何参数,那么它怎么知道它所调用的对象呢?当函数被作为方法调用时,使用 object.function 表示法,他有一个隐含的参数,用来标识对象,并将该隐式 argument 分配给一个名为 this 的隐式 parameter 。为了说明这一点,我们将明确地而不是隐式地发送对象参数。

// 引用 “getDay” 函数
let getDay = SimpleDate.prototype.getDay;

getDay.call(today); // "this" 指向 "today"
getDay.call(tomorrow); // "this" 指向 "tomorrow"

tomorrow.getDay(); // 与上一行相同,但是 "tomorrow" 被隐式地传递

静态属性和方法

我们有选择可以定义属性和函数,作为类的一部分的,但不作为该类任何一个实例的一部分(愚人码头注:就是说该类的实例不可以访问这些属性和方法)。我们分别称这些为静态属性和静态方法。每个实例只有一个静态属性的副本,而不是一个新的副本。

class SimpleDate {
  static setDefaultDate(year, month, day) {
    // 静态属性可以引用,而实例不可以     
    // 相反,它在类上定义
    SimpleDate._defaultDate = new SimpleDate(year, month, day);
  }

  constructor(year, month, day) {
    // 如果构造没有参数,     
    // 然后通过复制静态默认日期来初始化“this”日期
    if (arguments.length === 0) {
      this._year = SimpleDate._defaultDate._year;
      this._month = SimpleDate._defaultDate._month;
      this._day = SimpleDate._defaultDate._day;

      return;
    }

    // 检查 (year, month, day) 是否为一个有效的日期
    // ...

    // 如果是, 使用她来初始化 "this" 日期
    this._year = year;
    this._month = month;
    this._day = day;
  }

  addDays(nDays) {
    // 增加 "this" 日期 
    // ...
  }

  getDay() {
    return this._day;
  }
}

SimpleDate.setDefaultDate(1970, 1, 1);

let defaultDate = new SimpleDate();

子类

我们经常会发现我们的类之间用共同点 – 重复的代码,我们想避免。子类让我们将另一个类的状态和行为合并到我们自己的类中。这个过程通常被称为“继承”,子类(subclass) 继承的父类,也称为超类(superclass)。继承可以避免重复并简化类的实现,比如当一个类需要使用另一个类相同的数据和函数时。继承还允许我们替换子类,只依靠一个共同的超类提供的接口。

继承避免重复

考虑下面这段 non-inheritance(非继承) 实现的代码。

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 仍然可以跟上面的代码一样工作,但没有重复的代码
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(工作起来像一个什么) 的关系模型。

也就是说, Manager “IS-A(是一个)”,“WORKS-LIKE-A(工作起来像一个)”特定的 Employee ,这样,在我们使用超类实例的任何地方,应该都能够使用一个子类实例替换,并且所有的事情都应该仍然有效。有时,违反和遵守这一原则的区别是微妙的。一个微妙违反原则的典型例子是 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;
  }
}

// 在 Rectangle(长方形) 实例上运行的函数
function f(rectangle) {
  rectangle.width = 5;
  rectangle.height = 4;

  // 验证预期结果
  if (rectangle.width * rectangle.height !== 20) {
    throw new Error("Expected the rectangle's area (width * height) to be 20");
  }
}

// 正方形 IS-A(是一个) 长方形... 对吗?
class Square extends Rectangle {
  set width(w) {
    super.width = w;

    // 保持平方形
    super.height = w;
  }

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

    // 保持平方形
    super.width = h;
  }
}

// 但是可以用正方形代替长方形吗?
f(new Square()); // error

一个正方形可以是数学上的长方形,但一个正方形在行为上不像长方形那样工作。

任何使用超类实例的地方,应该由一个子类实例来代替,这个规则称为 里氏替代原则(Liskov Substitution principle) ,它是面向对象类设计的重要组成部分。(愚人码头注:里氏替换原则的内容可以描述为: “派生类(子类)对象能够替换其基类(超类)对象被使用。” 来自维基百科)

当心过度使用

在任何地方都很容易找到共同点,并且拥有一个提供完整功能的类的前景是很吸引人的,即使对于有经验的开发人员也是如此。但是继承也有缺点。回想一下,我们通过一组小的、固定的函数集来操纵数据,从而确保有效的状态。但是当我们使用继承时,我们增加了可以直接操作数据的一些函数,这些附加的函数也负责维护有效的状态。如果太多的函数可以直接操纵数据,那么数据几乎会和全局变量一样变得非常糟糕。过多的继承会创建单一的类,这些类会降低封装性,更难以纠正,更难以重用。相反,更喜欢设计只包含一个概念的最小类。

让我们再来看一下代码重复问题。我们不用继承可以解决它吗?另一种方法是通过引用来连接对象,以表示部分完整的关系。我们称之为“组合”。

这里是使用组合而不是继承的 manager-employee 的版本。

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);
  }
}

在这里,manager 不是一个单独的类。相反,一个 manager 是一个普通的 Employee 实例, Group 实例保持对其引用。如果继承模型是 IS-A(是一个) 关系,那么组合模型就是 HAS-A(有一个) 的关系。也就是说,一个 Group HAS-A(有一个) manager。(愚人码头注:更多概念可以阅读 JavaScript中的工厂函数 这篇文章。)

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

继承替换子类

继承还允许不同的子类通过通用超类提供的接口来替换使用。期望超类实例作为参数的函数也可以传递一个子类实例,而这个函数不必知道任何子类。

替换具有共同超类的类通常被称为“多态性”。

// 这将是我们的共同超类
class Cache {
  get(key, defaultValue) {
    let 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);
  }

  // 必须重写
  // _doGet()
  // _doSet()
}

// 子类不定义新的公共方法
// 公共接口完全在超类中定义
class ArrayCache extends Cache {
  _doGet() {
    // ...
  }

  _doSet() {
    // ...
  }
}

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

  _doSet() {
    // ...
  }
}

// 函数可以通过与超类接口进行交互,在任何 cache 上进行多态操作
function compute(cache) {
  let cached = cache.get('result');
  if (!cached) {
    let result = // ...
    cache.set('result', result);
  }

  // ...
}

compute(new ArrayCache()); // 通过超类接口使用数组 cache
compute(new LocalStorageCache()); // 通过超类接口使用本地存储的 cache

比语法糖更多

JavaScript 类语法通常被认为是语法糖,在很多方面确实如此,但也有真正的差异 – 我们可以用 ES6 classes 做 ES5 做到不到的事情。

静态属性被继承

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

// 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 classes 通过更改何时和由谁分配对象来解决这个问题。在 ES5 中,对象在调用子类构造函数之前被分配,并且子类将该对象传递给超类构造函数。现在有了 ES6 classes ,在调用超类构造函数之前分配对象,并且超类使该对象可用于子类构造函数。这样,即使我们在子类中调用 newArray 也可以分配一个异乎寻常的对象。

// 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 的新功能,社区现在正在尝试以 新的 和 富有想象力的方式使用这些功能。

通过 Proxies 实现多继承

这里有一个使用 proxies 的实验,一个 JavaScript 的新功能,实现多重继承。 JavaScript 的原型链只允许单一的继承。对象可以 委托 给另一个对象。Proxies 给我们一种方法来委托对多个其他对象的属性访问。

let transmitter = {
  transmit() {}
};

let receiver = {
  receive() {}
};

// 创建一个 proxy 对象,拦截属性访问并发送给每个父对象,
// 返回找到的第一个定义的值
let 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

我们可以扩展这个和 classes 语法配合使用吗?一个类的 prototype (原型) 可以是一个 proxy(代理) ,它可以发送属性到多个其他原型上访问。

JavaScript社区现在正在努力。你能弄清楚吗?加入讨论并分享您的想法。

用 Class 工厂函数实现的多重继承

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()) {}

let inheritsFromMultiple = new InheritsFromMultiple();

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

还有其他想象力的方法来使用这些功能吗?现在是时候把你的足迹留在JavaScript世界了。

结论

希望这篇文章让您了解了如何在 ES6 中使用类,并且已经揭开了围绕它们的一些术语的神秘性。不幸的是,在撰写本文时, 对类的支持不是很好 ,所以如果你想要尝试使用 ES6 中的类语法,你需要使用像 Babel 这样的转译器。尽管如此,我还是很想听听您对 classes 的看法,以及你是否是你会考虑使用的这个 ES6 特性,欢迎在下面的评论。

原文链接: https://www.sitepoint.com/object-oriented-javascript-deep-dive-es6-classes/


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Getting Real

Getting Real

Jason Fried、Heinemeier David Hansson、Matthew Linderman / 37signals / 2009-11-18 / USD 24.99

Getting Real details the business, design, programming, and marketing principles of 37signals. The book is packed with keep-it-simple insights, contrarian points of view, and unconventional approaches......一起来看看 《Getting Real》 这本书的介绍吧!

SHA 加密
SHA 加密

SHA 加密工具

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具