弄清Classs,Symbols,Objects拓展 和 Decorators

本文翻译自 Nicolas Bevacqua 的书籍 《Practical Modern JavaScript》,这是该书的第三章。翻译采用意译并进行一定的删减和拓展,部分内容与原书有所不同。

类(classes)可能是ES6提供的,我们使用最广的新功能之一了,它以原型链为基础,为我们提供了一种基于类编程的模式。Symbol是一种新的基本类型(JS中的第七种基本类型,另外六种为undefinednull、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)),它可以用来定义不可变值。本章,我们将首先讨论类和符号,之后我们还将对ES6对对象的拓展及处于stage2阶段的装饰器进行简单的讲解。

我们知道,JavaScript是一门基于原型链的语言,ES6中的类和其它面向对象语言中的类在本质上有很大的不同,JavaScript中,类实际上是一种基于原型链的语法糖。

虽然如此,JavaScript中的类还是给我们的很多操作带来了方便,比如说可以轻易拓展其它类,通过简单的语法我们就可以拓展内置的Array了,在下文中我们将详细说明如何使用。

类基础

基于已有的知识学习新知识是一种非常好的学习方法,对比学习可以让我们对新知识有更深的印象。由于JS中类实际上是一种基于原型链的语法糖,我们先简单复习基于原型链的JavaScript构造器要怎么使用,然后我们用ES6中类语法实现相同的功能作为对比。

下面代码中,我们新建了构造函数Fruit用以表示某种水果。该构造函数接收两个参数,水果的名称 -- name,水果的卡路里含量 -- calaries。在Fruit构造函数中我们设置了默认的块数 pieces=1 ,通过原型链,我们还为该构造函数添加了两种方法:

  • chop 方法(切水果,每次调用会使得块数加一);

  • bite方法(接收一个名为person的参数,它是一个对象,每次调用,该 person 将吃掉一块水果,person 的饱腹感 person.satiety 将相应的增加,增加值为一块水果的calaries值,水果的总的卡路里值 this.calories将减少相应的值)。

function Fruit(name, calories) {
  this.name = name
  this.calories = calories
  this.pieces = 1
}
Fruit.prototype.chop = function () {
  this.pieces++
}
Fruit.prototype.bite = function (person) {
  if (this.pieces < 1) {
    return
  }
  const calories = this.calories / this.pieces
  person.satiety += calories
  this.calories -= calories
  this.pieces--
}

接下来我们创建一个Fruit构造函数的实例,调用三次 chop 方法将实例 apple 分为四块,新建person对象,传入并调用三次bite方法,把apple 吃掉三块。

const person = { satiety: 0 }
const apple = new Fruit('apple', 140)
apple.chop()
apple.chop()
apple.chop()
apple.bite(person)
apple.bite(person)
apple.bite(person)
console.log(person.satiety)
// <- 105
console.log(apple.pieces)
// <- 1
console.log(apple.calories)
// <- 35

作为对比,接下来我们使用类语法来实现上述代码一样的过程。在类中,我们显式使用constructor方法做为构造方法(其中this指向类的实例),在类中定义方法类似在对象字面量中定义方法,见下述代码中chop,bite的定义。类所有的方法都声明在class的块中,不需要再使用Fruit.prototype这类样本代码,从这个角度看与基于原型的语法比起来,类语法语义清晰,使用起来也显得简洁。

class Fruit {
  constructor(name, calories) {
    this.name = name
    this.calories = calories
    this.pieces = 1
  }
  chop() {
    this.pieces++
  }
  bite(person) {
    if (this.pieces < 1) {
      return
    }
    const calories = this.calories / this.pieces
    person.satiety += calories
    this.calories -= calories
    this.pieces--
  }
}

虽然在类中定义方法和使用对象字面量类似,但是也有一个较大的不同点,那就是类中 方法之间不能使用逗号 ,这是类语法的要求。这种要求帮助我们避免混用对象和类,类和对象本来也不一样,这种要求的另外一个好处在于为未来类的改进做下了铺垫,未来JS的类中可能还会添加publicprivate等。

和普通函数声明不同的是,类声明并不会被提升到作用域的顶部,因此提前调用会报错。

类声明有两种方法,一种是像函数声明和函数表达式一样,声明为表达式,如下代码所示:

const Person = class {
  constructor(name) {
    this.name = name
  }
}

类声明的另外一种语法如下:

const class Person{
  constructor(name) {
    this.name = name
  }
}

类还可以作为函数的返回值,这使得创建类工厂非常容易,如下代码中,箭头函数接收了一个名为name的参数,super()方法把这个参数反馈给其父类Person.这样就创建了一个基于Person的新类:

// 这里实际用到的是类的第一种声明方式
const createPersonClass = name => class extends Person {
  constructor() {
    super(name)
  }
}
const JakePerson = createPersonClass('Jake')
const jake = new JakePerson()

上面代码中的extends关键字表明这里使用到了类继承,稍后我们将详细讨论类继承,在此之前我们先仔细如何在类中定义属性和方法。

类中的属性和方法

类声明中的constructor方法是可选的。如果省略,JS将为我们自动添加,下面用类声明和用常规构造函数声明的Fruit是一样的:

// 用类声明Fruit
class Fruit {
}

// 使用构造函数声明Fruit
function Fruit() {
}

所有传入类的参数,都将做为类中constructor的参数,如下所有传入Log()的参数都将作为Logconstructor的参数,这些参数将用以初始化类的实例:

class Log {
  constructor(...args) {
    console.log(args)
  }
}
new Log('a', 'b', 'c')
// <- ['a' 'b' 'c']

下面的代码中,我们定义了类Counter,在constructor中定义的代码会在实例化类时自动执行,这里我们在实例化时为实例添加了一个count属性,next属性前面添加了get,则表示类Counter的所有实例都有一个next属性,每次某实例访问next属性值时,其值都将+1:

class Counter {
  constructor(start) {
    this.count = start
  }
  get next() {
    return this.count++
  }
}

我们新建了Counter类的实例counter,可以发现每一次counter.next被调用的时,count值增加1。

const counter = new Counter(2)
console.log(counter.next)
//  2
console.log(counter.next)
//  3
console.log(counter.next)
//  4

getter 绑定一个属性,其后为一个函数,每次该属性被访问,其后的函数将被执行;

setter 语法绑定一个属性,其后跟着一个函数,当为该函数设置为某个值时,其后的函数将被调用;

当结合使用gettersetter时,我们可以完成一些神奇的事情,下例中,我们定义了类LocalStorage,这个类使用提供的存储key,在读取data值时,实现了同时在localStorage中存储和取出相关数据。

class LocalStorage {
  constructor(key) {
    this.key = key
  }
  get data() {
    return JSON.parse(localStorage.getItem(this.key))
  }
  set data(data) {
    localStorage.setItem(this.key, JSON.stringify(data))
  }
}

我们看看如何使用类LocalStorage

新建LocalStorage的实例ls,传入lskeygroceries,当我们设置ls.data为某个值时,该值将被转换为JSON对象字符串,并存储在localStorage中;当使用相应的key进行读取时,将提取出之前存储在localStorage中的内容,以JSON的格式进行解析后返回:

const ls = new LocalStorage('groceries')
ls.data = ['apples', 'bananas', 'grapes']
console.log(ls.data)
// <- ['apples', 'bananas', 'grapes']

除了使用getterssetters,我们也可以定义常规的实例方法,继续之前定义过的Fruit类,我们再定义了一个可以吃水果的Person类,我们实例化一个fruit和一个person,然后让 personfruit 。这里我们让person吃完了所有的fruit,结果是personsatiety(饱食度)上升到了40。

class Person {
  constructor() {
    this.satiety = 0
  }
  eat(fruit) {
    while (fruit.pieces > 0) {
      fruit.bite(this)
    }
  }
}
const plum = new Fruit('plum', 40)
const person = new Person()
person.eat(plum)
console.log(person.satiety)
// <- 40

有时候我们可能会希望静态方法直接定义在类上,如果使用ES6之前的语法,我们需要将该方法直接添加于构造函数上,如下面的Person.isPerson:

function Person() {
  this.hunger = 100
}
Person.prototype.eat = function () {
  this.hunger--
}
Person.isPerson = function (person) {
  return person instanceof Person
}

类语法则允许通过添加前缀static来定义静态方法Persion.isPerson

下属代码我们给类MathHelper定义了一个静态方法sum,这个方法将用以计算实例化时所有传入参数的总和。

class MathHelper {
  static sum(...numbers) {
    return numbers.reduce((a, b) => a + b)
  }
}
console.log(MathHelper.sum(1, 2, 3, 4, 5))
// <- 15

类的继承

ES6之前,你可以使用原型链来模拟类的继承,如下代码所示,我们新建了的构造函数Banana,用以拓展上文中定义的Fruit类,为了Banana能够正确初始化,我们需要在Banana中调用Fruit.call(this, 'banana', 105),此外还需要显式的设置Bananaprototype

function Banana() {
  Fruit.call(this, 'banana', 105)
}
Banana.prototype = Object.create(Fruit.prototype)
Banana.prototype.slice = function () {
  this.pieces = 12
}

上述代码一点也称不上简洁,一般JS开发者会使用库来解决继承问题。比如说Node.js就提供了util.inherits

const util = require('util')
function Banana() {
  Fruit.call(this, 'banana', 105)
}
util.inherits(Banana, Fruit)
Banana.prototype.slice = function () {
  this.pieces = 12
}

考虑到,banana除了有确定的namecalories,以及额外的slice方法(用来把banana切为12块)外,Banana构造函数和Fruit构造函数其实没有区别,我们可以在Banana中也执行bite

const person = { satiety: 0 }
const banana = new Banana()
banana.slice()
banana.bite(person)
console.log(person.satiety)
// <- 8.75
console.log(banana.pieces)
// <- 11
console.log(banana.calories)
// <- 96.25

下面我们看看ES6为继承提供的解决方案,下述代码中,这里我们创建了一个继承自Fruit类的名为Banana的类。可以看出,这种语法非常清晰,我们无须彻底弄明白原型的机制就可以获得我们想要的结果,如果想给Fruit类传递参数,只需要使用super关键字即可。super关键字还可以用以调用存在于父类中的方法,比如说super.chop,super`constructor`外面的方法中也可以使用:

class Banana extends Fruit {
  constructor() {
    super('banana', 105)
  }
  slice() {
    this.pieces = 12
  }
}

基于JS函数的返回值可以是任何表达式,下面我们构建一个构造函数工厂,下面的代码定义了一个名为 createJuicyFruit 的函数,通过使用super我们可以给Fruit类传入namecalories,这样就轻松的实现了对createJuicyFruit类的拓展。

const createJuicyFruit = (...params) =>
  class JuicyFruit extends Fruit {
    constructor() {
      this.juice = 0
      super(...params)
    }
    squeeze() {
      if (this.calories <= 0) {
        return
      }
      this.calories -= 10
      this.juice += 3
    }
  }
  
class Plum extends createJuicyFruit('plum', 30) {
}

接下来我们来讲述Symbol,了解Symbol对于之后我们理解迭代至关重要。

Symbols

Symbol是ES6提供的一种新的JS基本类型。 它代表唯一值,和字符串,数值等基本类型的一个很大的不同点在于Symbol没有字符表达形式。Symbol的主要目的是用以实现协议,比如说,使用Symbol定义的迭代协议规定了对象将如何被迭代,关于这个,我们将在[Iterator Protocol and Iterable Protocol.]()这一章详细阐述。

ES6提供的Symbol有如下三种不同类型:

  • local Symbol

  • global Symbol

  • 语言内置Symbol

这三种类型的Symbol存在着一定的不同,我们一种种来讲解,首先看local Symbol

Local Symbol

Local Symbol 通过 Symbol 包装对象创建,如下:

const first = Symbol()

这里有一点特别值得我们注意,在NumberString等包装对象前是可以使用new操作符的,在Symbol前则不能使用,使用了会抛出错误,如下:

const oops = new Symbol()
// <- TypeError, Symbol is not a constructor

为了方便调试,我们可以给新建的Symbol添加描述:

const mystery = Symbol('my symbol')

和数值和字符串一样,Symbol是不可变的,但是和他们不同的是,Symbol是唯一的。描述并不影响唯一性,由具有相同描述的Symbol依旧是不相等的,下面代码说明了这个问题:

console.log(Number(3) === Number(3))
// <- true
console.log(Symbol() === Symbol())
// <- false
console.log(Symbol('my symbol') === Symbol('my symbol'))
// <- false

Symbols的类别为symbol,使用 typeof 可返回其类型:

console.log(typeof Symbol())
// <- 'symbol'
console.log(typeof Symbol('my symbol'))
// <- 'symbol'

Symbols 可以用作对象的属性名,这里我们用计算属性名来说明该如何使用,如下:

const weapon = Symbol('weapon')
const character = {
  name: 'Penguin',
  [weapon]: 'umbrella'
}
console.log(character[weapon])
// <- 'umbrella'

需要注意的是,许多传统的从对象中提取键的方法中对Symbol无效,也就是说他们获取不到Symbol。如下代码中的for...in ,Object,keys,Object.getOwnPropertyNames都不能访问到 Symbol 类型的属性。

for (let key in character) {
  console.log(key)
  // <- 'name'
}
console.log(Object.keys(character))
// <- ['name']
console.log(Object.getOwnPropertyNames(character))
// <- ['name']

Symbol的这方面的特性使得ES6之前的没有使用Symbol的代码并不会由于Symbol的出现而受影响。如下代码中,我们将对象解析为JSON,结果中的符号属性被丢弃了。

console.log(JSON.stringify(character))
// <- '{"name":"Penguin"}'

不过,Symbols绝不是一种用来隐藏属性的安全机制。采用特定的方法,它是可见的,如下所示:

console.log(Object.getOwnPropertySymbols(character))
// <- [Symbol(weapon)]

这意味着,Symbols 并非不可枚举的,只是它对一般方法不可见而已,通过Object.getOwnPropertySymbols我们可以获取任何对象中的所有Symbol

现在我们已经知道了 Symbol 该如何使用,下面我们再讨论下其使用场景。

Symbols的使用实例

Symbol最重要的用途就是用以避免命名冲突了,如下代码中,我们给DOM元素添加了自定义的属性,使用Symbol不用担心属性与其它属性甚至之后JS语言会加入的属性相冲突:

const cache = Symbol('calendar')
function createCalendar(el) {
  if (cache in el) { // does the symbol exist in the element?
    return el[cache] // use the cache to avoid re-instantiation
  }
  const api = el[cache] = {
    // the calendar API goes here
  }
  return api
}

ES6 还提供的一种名为WeakMap的新数据类型,它用于唯一地将对象映射到其他对象。和数组查找表比起来,WeakMap查找复杂度始终为O(1),我们将在 [Leveraging ECMAScript Collections]() 一章和其它ES6新增数据类型一起讨论这个。

使用符号定义协议

前文中,我们说过 Symbol 可以用以定义协议。协议是定义行为的通信契约或约定。

下述代码中,我们给character对象有一个toJSON方法,这个方法,指定了对该对象使用JSON.stringify时被序列化的对象。

const character = {
  name: 'Thor',
  toJSON: () => ({
    key: 'value'
  })
}
console.log(JSON.stringify(character))
// <- '"{"key":"value"}"'

如果toJSON不是函数,对character对象执行JSON.stringify则会有不同的结果,character对象整体将被序列化。有时候这不是我们想要的结果:

const character = {
  name: 'Thor',
  toJSON: true
}
console.log(JSON.stringify(character))
// <- '"{"name":"Thor","toJSON":true}"'

如果toJSON修饰符是Symbol类型,它就不会影响其它的对象属性了,不通过Object.getOwnPropertySymbolsSymbol永远不会暴露出来的,以下代码中我们用Symbol自定义序列化函数stringify

const json = Symbol('alternative to toJSON')
const character = {
  name: 'Thor',
  [json]: () => ({
    key: 'value'
  })
}
function stringify(target) {
  if (json in target) {
    return JSON.stringify(target[json]())
  }
  return JSON.stringify(target)
}
stringify(character)

使用 Symbol 需要我们使用计算属性名在对象字面量中定义 json,这样做我们定义的变量就不会和其它的用户定义的属性或者以后JS语言可能会加入的属性有冲突。

接下来我们继续讲解下一类符号--global symbol,这类符号可以跨代码域访问。

全局符号

代码域指的是任何JavaScript表达式的执行上下文,它可以是你的应用当前运行的页面、页面中的