3

本文翻译自 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表达式的执行上下文,它可以是你的应用当前运行的页面、页面中的<iframe>、由eval运行的脚本、任意类型的workerweb worker,service workers或者shared workers)等等。这些执行上下文每一种都有其全局对象,比如说页面的全局对象window,但是这种全局对象不能被其它代码域比如说ServiceWorker使用。相比而言,全局符号则更具全局性,它可以被任何代码域访问。

ES6提供了两个和全局符号相关的方法,Symbol.forSymbol.keyFor。我们看看它们分别该如何使用?

通过Symbol.for(key)获取symbols

Symbol.for(key)方法将在运行时的符号注册表中查找key,如果全局注册表中存在key则返回其对于的Symbol,如果不存在该key对于的Symbol,该方法会在全局注册表中创建一个新的key值为该key值的Symbol。这意味着,Symbol.for(key)是幂等的(多次执行,结果唯一),先进行查找,不存在则新创建,然后返回查找到的或新创建的Symbol。

我们看看使用示例,下面的代码中,

  • 第一次调用Symbol.for创建了一个key为example的Symbol,添加到到注册表,并返回了该Symbol;

  • 第二次调用Symbol.for由于该key已经在注册表中存在,因此返回了之前创建的全局符号。

const example = Symbol.for('example')
console.log(example === Symbol.for('example'))
// <- true

全局的符号注册表通过key标记符号,key还将作为新创建符号的描述信息。考虑到这些符号在运行时是全局的,在符号的key前添加前缀用以区分你的代码可以有效避免潜在的命名冲突。

使用Symbol.keyFor(symbol)来提取符号的key

比如说现存一个名为为symbol的全局符号,使用Symbol.keyFor(symbol)将返回全局注册表中该symbol对应的key值。我们看以下实例:

const example = Symbol.for('example')
console.log(Symbol.keyFor(example))
// <- 'example'

值得注意的是,如果符号非全局符号,该方法将返回undefined

console.log(Symbol.keyFor(Symbol()))
// <- undefined

在全局符号注册表中,使用local Symbol是匹配不到值的,即使它们的描述相同也是如此,local Symbol 不是全局符号注册表的一部分:

const example = Symbol.for('example')
console.log(Symbol.keyFor(Symbol('example')))
// <- undefined

全局符号相关的方法主要就是这两个了,下面我们看看该如何实际使用:

全局符号实践

某符号为全局符号意味着该符号可以被任何代码域获取,且在任何代码域中调用,它们都将返回相同的值。下面的例子,我们使用Symbol.for分别在页面中和<iframe>中查找key 为example 的Symbol,实践表明,它们是相同的。

const d = document
const frame = d.body.appendChild(d.createElement('iframe'))
const framed = frame.contentWindow
const s1 = window.Symbol.for('example')
const s2 = framed.Symbol.for('example')
console.log(s1 === s2)
// <- true

使用全局符号就像我们使用全局变量一样,合理使用在某些时候非常便利,但是不合理使用又会造成灾难。全局符号在符号需要跨代码域使用时非常有用,比如说跨ServiceWorker和浏览器页面,但是滥用会导致Symbol难易管理,容易冲突。

下面我们来看,最后一种Symbol,内置的常用Symbol。

内置的常用Symbol

内置的常用Symbol为JS语言行为添加了钩子,在一定程度上允许你拓展和自定义JS语言。

Symbol.toPrimitive符号,是描述如何通过 Symbols 给语言添加额外的功能的最好的例子,这个Symbol的作用是,依据给定的类型返回默认值。该函数接收一个hint参数,参数可以是string,numberdefault,用以指明默认值的期待类型。

const morphling = {
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') {
      return Infinity
    }
    if (hint === 'string') {
      return 'a lot'
    }
    return '[object Morphling]'
  }
}
console.log(+morphling) // + 号 
// <- Infinity
console.log(`That is ${ morphling }!`)
// <- 'That is a lot!'
console.log(morphling + ' is powerful')
// <- '[object Morphling] is powerful'

另一个常用的内置Symbol是 Symbol.match ,该Symbol指定了匹配的是正则表达式而不是字符串,以.startWith,.endWith.includes,这三个ES6提供新字符串方法为例。

"/bar/".startsWith(/bar/); 
// Throws TypeError, 因为 /bar/ 是一个正则表达式

const text = '/an example string/'
const regex = /an example string/
regex[Symbol.match] = false
console.log(text.startsWith(regex))
// <- true

如果正则表达式没有通过Symbol修改,这里将抛出错误,因为.startWith方法希望其参数是一个字符串而非正则表达式。

内置Symbol不在全局注册表中但是跨域共享

这些内置的Symbol是跨代码域共享的,如下所示:

const frame = document.createElement('iframe')
document.body.appendChild(frame)
Symbol.iterator === frame.contentWindow.Symbol.iterator
// <- true

需要注意的是,虽然语言内置的这些Symbol是跨代码块共享的,但是他们并不在全局符号注册表中,我们在下述代码中想要找到Symbol.iteratorkey值,返回值是undefined就说明了这个问题。

console.log(Symbol.keyFor(Symbol.iterator))
// <- undefined

另外一个常用的符号是Symbol.iterator,它为每一个对象定义了默认的迭代器。我们将在下一章中详细讲述Symbol.iterator的细节内容。

内置对象的改进

我们在ES6 概要一章,已经讲述过ES6中对象字面量语法的改进,这里我们再补充一下内置对象新增的方法。

除了前面讨论过的Object.getOwnPropertySymbols,新增的对象方法还有Object.assign,Object.is以及Object.setPrototypeOf

使用Object.assign来拓展对象

我们在实际开发中常常使用各种库,一些库在允许我们自定义某些行为,不过为了使用方便这些库通常也给出了默认值,而我们的自定义常常就是在默认值的基础上进行的。

假如说现在有这么一个Markdown库。其接收一个 input 参数,依据input代表的Markdown内容,转换其为 Html 是其默认的用法,用户不需要提供其它参数就可以简单使用这个库。不过,该库还支持多个高级的配置,只是默认是关闭的,比如说通过配置可以添加<script><iframe>,可以启用 css 来高亮渲染代码片段。

比如说,该库的默认选项如下:

const defaults = {
  scripts: false,
  iframes: false,
  highlightSyntax: true
}

我们可以使用解构将defaults对象设置为options的默认值,在以前,如果用户想要自定义,用户必须提供每个选项的值。

function md(input, options=defaults) {
}

Object.assign 就是为这种场景而生,这个方法可以非常方便的合并默认值和用户提供的值,如下代码所示,我们传入{}作为Object.assign的第一个参数,之后这个参数将不断与后面的参数对比合并,后面参数中的重复值将覆盖前面以后的值,待所有的比较合并完成,我们将获得最终的值。

function md(input, options) {
  const config = Object.assign({}, defaults, options)
}

理解Object.assign第一个参数的特殊意义

Object.assign的返回值是依据第一个参数而来的,第一个参数最终会修改为返回值,参数可看做(target, ...sources),所有的 sources 都会被应用到target中。

如果这里我们的第一个参数不是一个空对象,而是defaults,那么Object.assign()执行结束之后,defaults对象的值也将被改变,虽然这里我们会得到和前面那个例子中一样的结果,但是由于default值被改变,在别的地方可能也会导致一些意想不到的问题。

function md(input, options) {
  const config = Object.assign(defaults, options)
}

因此,最好把Object.assign的第一个参数始终设置为{}

下面的代码加深你对Object.assign的理解:

const defaults = {
  first: 'first',
  second: 'second'
}
function applyDefaults(options) {
  return Object.assign({}, defaults, options)
}
applyDefaults()
// <- { first: 'first', second: 'second' }
applyDefaults({ third: 3 })
// <- { first: 'first', second: 'second', third: 3 }
applyDefaults({ second: false })
// <- { first: 'first', second: false }

需要注意的是,Object.assign只会考虑可枚举的属性(包括字符串属性和符号属性)。

const defaults = {
  [Symbol('currency')]: 'USD'
}
const options = {
  price: '0.99'
}
Object.defineProperty(options, 'name', {
  value: 'Espresso Shot',
  enumerable: false
})
console.log(Object.assign({}, defaults, options))
// <- { [Symbol('currency')]: 'USD', price: '0.99' }

不过Object.assign也不是万能的,比如说其复制并非深复制,Object.assign不会对对象进行回归处理,值为对象的属性将会被target直接引用。

下例中,你可能希望f属性可以被添加到target.a,而保持b.c,b.d不变,但是实际上,当使用Object.assign时,b.cb.d属性丢失了。

Object.assign({}, { a: { b: 'c', d: 'e' } }, { a: { f: 'g' } })
// <- { a: { f: 'g' } }

同样的,数据也存在类似的问题,以下代码中,如果你期待Object.assign进行递归处理,你将大失所望。

Object.assign({}, { a: ['b', 'c', 'd'] }, { a: ['e', 'f'] })
// <- { a: ['e', 'f'] }

在本书写作过程中,存在一个处于stage 3的ECMAScript提议,用以在对象中使用拓展符,其使用类似于数组等可迭代对象。对对象使用拓展和使用Object.assign的结果类似。

下述代码展示了对象拓展符的使用方法:

const grocery = { ...details }
// Object.assign({}, details)
const grocery = { type: 'fruit', ...details }
// Object.assign({ type: 'fruit' }, details)
const grocery = { type: 'fruit', ...details, ...fruit }
// Object.assign({ type: 'fruit' }, details, fruit)
const grocery = { type: 'fruit', ...details, color: 'red' }
// Object.assign({ type: 'fruit' }, details, { color: 'red' })

该提案也包含对象剩余值,使用和数组剩余值类似。

下面是对象剩余值的使用实例,就像数组剩余值一样,其需要位于结构的最后面:

const getUnknownProperties = ({ name, type, ...unknown }) =>  unknown
getUnknownProperties({
  name: 'Carrot',
  type: 'vegetable',
  color: 'orange'
})
// <- { color: 'orange' }

我们可以利用类似的方法在变量声明时解构对象,下例中,每一个未明确指明的属性都将位于meta对象中:

const { name, type, ...meta } = {
  name: 'Carrot',
  type: 'vegetable',
  color: 'orange'
}
// <- name = 'Carrot'
// <- type = 'vegetable'
// <- meta = { color: 'orange' }

我们将在[Practical Considerations.]()一章再详细讨论对象解构和剩余值。

使用Object.is对比对象

Object.is方法和严格相等运算符===略有不同。主要表现在两个地方,NaN以及,-00

NaNNaN相比较时,严格相等运算符===将返回false,因为NaN和本身也不相等,Object.is则在这种情况下返回true.

NaN === NaN
// <- false
Object.is(NaN, NaN)
// <- true

使用严格相等运算符比较0-0会得到true,而使用Object.is则会返回false.

-0 === +0
// <- true
Object.is(-0, +0)
// <- false

Object.setPrototpyeOf

Object.setPrototypeOf,名如其意,它用以设置某个对象的原型指向的对象。与遗留方法__proto__相比,它是被认可的设置对象原型的方法。

还记得吗,我们在ES5中引入了Object.create,这个方法允许我们以任何传递给Object.create的参数作为新建对象的原型链:

const baseCat = { type: 'cat', legs: 4 }
const cat = Object.create(baseCat)
cat.name = 'Milanesita'

Object.create方法只能在新创建的对象时指定原型,Object.setPrototypeOf则可以用以改变任何已经存在的对象的原型链:

const baseCat = { type: 'cat', legs: 4 }
const cat = Object.setPrototypeOf(
  { name: 'Milanesita' },
  baseCat
)

Object.create比起来,Object.setPrototypeOf具有严重的性能问题,因此在如果你很在乎这个,使用前应好好考虑。

对性能问题的说明

使用Object.setPrototypeOf来改变一个对象的原型是一个昂贵的操作,MDN是这样解释的:
由于现代 JavaScript 引擎优化属性访问所带来的特性的关系,更改对象的 [[Prototype]]在各个浏览器和 JavaScript 引擎上都是一个很慢的操作。其在更改继承的性能上的影响是微妙而又广泛的,这不仅仅限于 obj.__proto__ = ... 语句上的时间花费,而且可能会延伸到任何代码,那些可以访问任何[[Prototype]]已被更改的对象的代码。如果你关心性能,你应该避免设置一个对象的 [[Prototype]]。相反,你应该使用 Object.create()来创建带有你想要的[[Prototype]]的新对象。

装饰器(Decorators)

对于大多数编程语言而言,装饰器不是一个新概念。在现代编程语言中,装饰器模式相当常见,c# 中 有attributes,Java中有annotations,Python中有decorators等等。目前也存在一个处于Stage2 的JavaScript的装饰器提案。

JavaScript中的装饰器语法和Python的非常类似。JavaScript的装饰器可以应用于任何对象或者静态声明的属性前。诸如对象字面量声明或class声明前,或get,set,static前。

@inanimate
class Car {}

@expensive
@speed('fast')
class Lamborghini extends Car {}

class View {
  @throttle(200) // reconcile once every 200ms at most
  reconcile() {}
}

关于装饰器凹凸实验室的一篇文章解释的比较清楚,大家可以参考Javascript 中的装饰器

当装饰器作用于类本身的时候,我们操作的对象也是这个类本身,而当装饰器作用于类的某个具体的属性的时候,我们操作的对象既不是类本身,也不是类的属性,而是它的描述符(descriptor),而描述符里记录着我们对这个属性的全部信息,所以,我们可以对它自由的进行扩展和封装,最后达到的目的呢,就和之前说过的装饰器的作用是一样的。可以看如下两段代码加深理解

作用于类时


function isAnimal(target) {
    target.isAnimal = true;
      return target;
}
@isAnimal
class Cat {
    ...
}
console.log(Cat.isAnimal);    // true

// 相当于
    
Cat = isAnimal(function Cat() { ... });

作用于类属性时

function readonly(target, name, descriptor) {
    discriptor.writable = false;
    return discriptor;
}
class Cat {
    @readonly
    say() {
        console.log("meow ~");
    }
}
var kitty = new Cat();
kitty.say = function() {
    console.log("woof !");
}
kitty.say()    // meow ~

// 相当于
let descriptor = {
    value: function() {
        console.log("meow ~");
    },
    enumerable: false,
    configurable: true,
    writable: true
};
descriptor = readonly(Cat.prototype, "say", descriptor) || descriptor;
Object.defineProperty(Cat.prototype, "say", descriptor);

有用的链接


zhangwang
8k 声望1.8k 粉丝

前端,摄影,阅读,好奇