前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >面向 JavaScript 开发人员的 ECMAScript 6 指南(3):JavaScript 中的类

面向 JavaScript 开发人员的 ECMAScript 6 指南(3):JavaScript 中的类

作者头像
疯狂的技术宅
发布2019-03-28 11:32:09
6450
发布2019-03-28 11:32:09
举报
文章被收录于专栏:京程一灯京程一灯

在 第 2 部分 中,您学习了 ECMAScript 6 中的函数增强,包括新的箭头和生成器函数。将函数元素集成到 JavaScript 代码中意味着要重新考虑某些因素,但变化没有您想象的那么大。事实上,在多年来提出的所有变化中,ECMAScript 6 中最有争议的新元素或许就是面向对象的元素。

JavaScript 中一直缺少传统的基于类的语法,但 ECMAScript 6 改变了这种状况。在本期文章中,将学习如何在 JavaScript 中定义类和属性,以及如何使用原型链向 JavaScript 程序引入继承。

对象简史

JavaScript 最初被设想和宣传为 Java 的轻量型版本,所以它通常被认为是一种面向对象的传统语言。得益于 new 关键词,它似乎在语法上类似于过去常常在 Java 或 C++ 中看到的语法。

事实上,JavaScript 不是基于类的环境,而是一个基于对象的环境。如果您不熟悉或仅偶尔参与面向对象的开发,JavaScript 可能对您无关紧要,但理解它们的区别仍然很重要。在基于对象的环境中,不存在类。每个对象是从另一个现有对象克隆而来的,而不是来自类。当克隆一个对象时,会保持对其原型对象的隐式引用。

在基于对象的环境中工作有其优势,但在没有基于类的概念(比如属性和继承)的情况下能执行的操作上也存在局限。ECMAScript 技术委员会曾经试图将面向对象的元素集成到 JavaScript 中,而不牺牲它的独特风格。在 ECMAScript 6 中,该委员会最终找到了实现途径。

类定义

从一开始就采用 class 关键字可能是最容易的实现途径。如下所示,此关键字表示一个新 ECMAScript 类的定义:

清单 1. 定义新类

代码语言:javascript
复制
class Person{}let p = new Person();

空类本身不是很有趣。毕竟,每个人都有姓名和年龄,Person 类应该反映出这一点。我们可以在构造类实例时,通过引入构造函数来添加这些细节:

清单 2. 构造类实例

代码语言:javascript
复制
class Person{  constructor(firstName, lastName, age)  {    this.firstName = firstName;    this.lastName = lastName;    this.age = age;  }}let ted = new Person("Ted", "Neward", 45);console.log(ted);

构造函数是一个特殊函数,会在构造过程中被调用。任何作为 new 运算符的一部分而传递给 type 的参数都被传递给构造函数。但是不要误解:constructor 仍然是 ECMAScript 函数。您可以利用它类似 JavaScript 的灵活参数,以及隐式的 arguments 参数,就象这样:

清单 3. 灵活的参数和隐式参数

代码语言:javascript
复制
class Person{  constructor(firstName, lastName, age)  {    console.log(arguments);    this.firstName = firstName;    this.lastName = lastName;    this.age = age;  }}let ted = new Person("Ted", "Neward", 45);console.log(ted);let cher = new Person("Cher");console.log(cher);let r2d2 = new Person("R2", "D2", 39, "Astromech Droid");console.log(r2d2);

尽管该语言委员的目的显然是让 JavaScript 开发人员能够编写更加传统的面向类的代码,但他们会还想支持 ECMAScript 目前所具有的灵活性和开放性。理想情况下,这意味着开发人员能各取所长。

属性和封装

无法公开和维护其状态的类不是一个真正的类。因此,ECMAScript 6 现在允许开发人员定义伪装为字段的属性函数。这为我们设定了 ECMAScript 中的各种封装风格。

考虑 Person 类。firstName、lastName 和 age 作为成熟的属性是合理的,我们将它们定义如下:

清单 4. 定义属性

代码语言:javascript
复制
class Person{  constructor(firstName, lastName, age)  {    console.log(arguments);    this.firstName = firstName;    this.lastName = lastName;    this.age = age;  }  get firstName() { return this._firstName; }  set firstName(value) { this._firstName = value; }  get lastName() { return this._lastName; }  set lastName(value) { this._lastName = value; }  get age() { return this._age; }  set age(value) { this._age = value; }}

请注意 getter 和 setter(根据 ECMAScript 规范中的官方规定)是如何引用字段名称的,字段名称添加了一条下划线作为前缀。这意味着 Person 现在有 6 个函数和 3 个字段 — 每个属性有 2 个函数和 1 个字段。不同于其他语言,ECMAScript 中的 property 语法不会在创建属性时静默地引入后备存储字段。(后备存储 是存储数据的地方 — 换句话说,是实际字段本身。)

属性不需要逐个地直接反映类的内部状态。事实上,属性的封装性质很大程度上是为了部分或完整地隐藏内部状态:

清单 5. 封装隐藏状态

代码语言:javascript
复制
class Person{  // ... as before  get fullName() { return this._firstName + " " + this._lastName; }  get surname() { return this._lastName; }  get givenName() { return this._firstName; }}

但是,请注意,属性语法没有消除您直接获取字段的能力。您仍然可以使用熟悉的 ECMAScript 原理,枚举一个对象来获得它的内部结构:

清单 6. 枚举一个对象

代码语言:javascript
复制
for (let m in ted) {  console.log(m,ted[m]);    // prints    //   "_firstName,Ted"    //   "_lastName,Neward"    //   "_age,45"}

您还可以使用 Object 定义的 getAllPropertyNames() 函数来检索同一个列表。 现在有一个有趣的问题:如果对象本身上没有 firstName、lastName 和 age 的 getter 和 setter 函数,那么如何在没有严格的解释器能力的情况下解析类似 “ted.firstName” 的表达式?

答案既简单又优雅:ted(Person 的实例)保留了与它的类 Person 的原型链接。

原型链

从最初开始,JavaScript 就保留着从一个对象到另一个对象的原型链。您可能认为,原型链类似于 Java 或 C++/C# 中的继承,但两种技术之间只有一个真正的相似点:当 JavaScript 需要解析一个没有直接包含在对象上的符号时,它会沿原型链查找可能的匹配值。

这不太容易理解,所以我要再说明一下。想象您使用旧式 JavaScript 样式定义了一个非常简单的对象:

清单 7. 旧式 JavaScript 对象

代码语言:javascript
复制
var obj = {};

现在,您需要获取该对象的字符串表示。通常,toString() 方法会为您完成这项工作,但 obj 上没有定义该函数,事实上,它之上什么都没有定义。该代码不仅能运行,还会返回结果:

清单 8. 结果字符串

代码语言:javascript
复制
var obj = {};console.log(obj.toString()); // prints "[object Object]"

当解释器寻找 toString 作为 obj 对象上的名称时,它没有找到匹配值。它没有立即找到该对象的原型对象,所以它在原型中搜索 toString。如果仍然没有找到匹配值,那么它会查找原型的原型,依此类推。在这种特定情况下,obj 的原型(Object 对象)上定义了一个 toString。

现在让我们返回到 Person 类。您应该很清楚具体的情形:对象 ted 有一个对对象 Person 的原型引用,Person 拥有方法对 firstName、lastName 和 age,它们被定义为 getter 和 setter。当使用一个 getter 或 setter 时,该语言会尊重原型,代表 ted 实例本身来执行它。

Person 类上定义的所有方法均如此,您在我们添加新方法时就会看到:

清单 9. 将一个方法添加到 Person

代码语言:javascript
复制
class Person{  // ... as before  getOlder() {    return ++this.age;  }}

新方法允许以 Person 为原型的实例优雅地老化,如下所示:

清单 10. 沿原型链查找

代码语言:javascript
复制
ted.getOlder();console.log(ted);// prints Person { _firstName: 'Ted', _lastName: 'Neward', _age: 46 }

getOlder 方法是在 Person 对象上定义的,所以在调用 ted.getOlder() 时,解释器会沿原型链从 ted 查找到 Person。然后它会找到该方法并执行它。

对于大多数 Java 或 C++/C# 开发人员,可能需要一段时间才能习惯类实际上是对象的概念。对于 Smalltalk 开发人员,始终会遇到这种情况,所以他们想知道是什么耽误了我们其余人这么长时间。如果有助于您更快地解释该概念,可以尝试将 ECMAScript 中的类视为类型对象:为提供类型定义的外观而存在的对象实例。

原型继承

作为一种模式,“跟随原型链” 使 ECMAScript 6 的继承规则非常容易理解。如果您创建一个扩展另一个类的类,很容易想到在派生类上调用该实例方法时发生的情况。

清单 11. 调用实例方法

代码语言:javascript
复制
class Author extends Person{  constructor(firstName, lastName, age, subject)  {    super(firstName, lastName, age);    this.subject = subject;  }  get subject() { return this._subject; }  set subject(value) { this._subject = value; }  writeArticle() {    console.log(this.firstName,"just wrote an article on",this.subject);  }}let mark = new Author("Mark", "Richards", 55, "Architecture");mark.writeArticle();

实例本身首先会处理调用。如果失败,那么它会检查类型对象(在本例中为 Author)。接下来,将会检查类型对象的 “扩展” 对象 (Person),依此类推,直到返回到最初的类型对象,该对象始终是 Object。

此外,从清单 11 中的 Author 构造函数可以看到,关键字 super 显然会在原型链中向上调用给定方法的原型版本。在本例中,调用了构造函数,让 Person 构造函数有机会执行发挥自己的作用。如果仅跟随原型链,那么原理很简单。

我对原型委托使用得越多,就越欣赏此解决方案的优雅之处。所有方面都遵循一个概念,“旧规则” 仍在发挥其作用。如果希望以元对象方式继续使用 ECMAScript 对象,在对象本身上添加和删除方法,您仍然可以这么做:

清单 12. 旧式对象委托

代码语言:javascript
复制
mark.favoriteLanguage = function() {  return "Java";}mark.favoriteBeverage = function() {  return "Scotch";}console.log(mark.firstName,"prefers writing", mark.subject,  "using",mark.favoriteLanguage(),"and",mark.favoriteBeverage());

在我看来,新的基于类的语法很容易掌握;在本例中,会让您使用 Java 并一直使用同一种语言。

静态属性和字段

如果不考虑回避 对面向对象的讨论,任何面向对象的讨论都是不完整的。当开始在代码中使用类时,知道如何处理全局变量和/或函数至关重要。在大多数语言中,这些变量和函数被认为是静态的(或整体式的),如果您喜欢使用概模式。

ECMAScript 6 没有隐式配备静态属性或字段,但根据我们上面的讨论和对 ECMAScript 对象的工作原理的一些了解,不难想象可以如何实现静态值:

清单 13. 引入静态值

代码语言:javascript
复制
class Person{  constructor(firstName, lastName, age)  {    console.log(arguments);    // Just introduce a new field on Person itself    // if it doesn't already exist; otherwise, just    // reference the one that's there    if (typeof(Person.population === 'undefined'))      Person.population = 0;    Person.population++;    this.firstName = firstName;    this.lastName = lastName;    this.age = age;  }  // ... as before}

因为 Person 类实际上是一个对象,所以 ECMAScript 中的静态字段实质上是 Person 类型对象上的字段。因此,尽管没有显式的语法来定义静态字段,但可以直接在类型对象上引用字段。在上面的示例中,Person 构造函数首先检查 Person 是否已有一个 population 字段。如果没有,它会将 population 设置为 0,隐式地创建该字段。如果有一个 population 字段,那么它会递增该值。

因此,沿原型链一直到 Person 的任何实例都可以引用 population 字段,无论是直接引用还是按名称引用 Person 类(或类型对象),后者是首选方法:

清单 14. 引用类

代码语言:javascript
复制
console.log(Person.population);console.log(ted.population);

定义字段很容易,但 ECMAScript 6 规范使定义静态方法变得有点复杂。要定义静态方法,需要在类声明中使用 static 关键字来定义函数:

清单 15. 定义静态方法

代码语言:javascript
复制
class Person{  // ... as before  static haveBaby() {    return Person.population++;  }}

同样地,可以通过实例或通过类本身来调用静态方法。您可能会发现,如果始终通过类名称调用静态方法,很容易跟踪在何处定义了什么对象。

结束语

ECMAScript 技术委员会在其发展过程中遇到了一些严峻的挑战,但这些挑战都没有向 JavaScript 引入类那么艰难。目前,似乎新语法获得了成功,满足了大多数面向对象的开发人员的期望,而且从整体上讲没有丢弃 ECMAScript 的基础原则。 该委员会没有集成 TypeScript 等语言中提供的稳健的静态类型检查,但这从来都不是他们考虑的目标。值得称赞的是,该委员会没有试图强迫这么做,至少在这一轮改进中没有这么做。

请关注本系列的最后一期文章!我们将探索 ECMAScript 6 库的一些增强,包括显式声明和使用模块的新语法。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-08-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 京程一灯 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 对象简史
  • 类定义
  • 属性和封装
  • 原型链
  • 原型继承
  • 静态属性和字段
  • 结束语
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档