幸运的兔脚

2018年12月20日

ES6特性学习

1.let & const 命令

let

在 ES6 中,新增了let命令,这个命令的用法与var类似。

在 js 中var作为定义变量的关键字,并不是完美的,而let的出现弥补了var的不足之处。

作用域

在 ES5 中只有全局作用域和函数作用域,没有块级作用域。
这会导致几个不太合理的问题(至少在我看来不太合理)
列个比较典型的作用域问题:同名变量覆盖,这个问题解释起来比较麻烦,直接看例子吧。

var tmp = 10;
function func() {
    console.log(tmp);
    if (false) {
        var tmp = 20;
    }
    return tmp;
}
console.log(func());
--------------------------------
output:
undefined
undefined

为啥输出结果为undefined呢,因为在 ES5 中只有全局作用域的关系,所有的变量都会出现变量提升的现象,即实际上运行的代码会变成这样:

var tmp = 10
function func() {
  var tmp //在这里,tmp被重新赋值为undefined。
  console.log(tmp)
  if (false) {
    tmp = 20
  }
  return tmp
}
console.log(func())

let的出现,主要就是弥补了这个作用域的问题。

块级作用域

let为 JavaScript 新增了块级作用域。
简单讲,就是在{}内,let声明的变量是块内唯一的。
就这样。

不存在变量提升

变量提升,从名称中可以猜个大概:变量上升了。

先看个简单的栗子:

console.log(foo)
var foo = 2

outpit: undefined

变量foo在声明前被使用了,但是没有报错,而是输出了undefined,这是因为变量foo发生了提升现象:即脚本开始运行时,变量foo已经存在了,但是没有值,所以会输出undefined

而通过let定义的变量则不会出现变量提升现象:

console.log(foo)
let foo = 2

output: 报错ReferenceError

假设在定义前使用了变量,就会报错。

PS.js 引擎的工作方式是 ① 先解析代码,获取所有被声明的变量;② 然后在运行。也就是专业来说是分为预处理和执行两个阶段。

暂时性死区

在前面块级作用域中,已经讲过let声明的变量是块内唯一的,那么如果在块外出现了同名变量会怎么样?

在 ES6 中,明确规定了,如果区块中存在let命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
用例子说话:

var tmp = 123

if (true) {
  tmp = 'abc' // ReferenceError
  let tmp
}

简单讲,就是块级作用域会造成类似 C/C++中的变量遮蔽情况,在块内使用块内定义的变量,而块外就使用块外的变量,井水不犯河水。

不允许重复声明

let不允许在相同作用域内,重复声明同一个变量。
这个也和块级作用域有关系,就和前面讲的 ”let声明的变量是块内唯一的” 一样,他不可以重复声明。

const

从使用规则上来说,constlet基本相同,唯一的区别是:const声明一个只读的常量。一旦声明,常量的值就不能改变。

PS.const本质上固定的是地址,所以在定义const对象时需要注意,他只能保证变量指向的对象不被修改,而对象的属性和方法是可以被修改的。

2.模板字符串

通常,使用拼接字符串变量时一般都是使用+来实现的,但是通过模板字符串,就可以省去+的使用。

// 普通字符串
;`In JavaScript '\n' is a line-feed.`

// 多行字符串
;`In JavaScript this is
 not legal.`
console.log(`string text line 1
string text line 2`)

// 字符串中嵌入变量
let name = 'Bob'
let time = 'today'
;`Hello ${name}, how are you ${time}?`

模板字符串(template string)是增强版的字符串,用反引号(`)标识。就如同上面例子所示,它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。

3.对函数的扩展

参数默认值

ES6 之前,不能直接为函数的参数指定默认值,只能采用变通的方法在函数内部进行默认值的赋值。

function log(x, y) {
  y = y || 'World'
  console.log(x, y)
}

log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello World

这种方法并不直观,也比较麻烦,而在 ES6 中允许为函数的参数设置默认值,即直接写在参数定义的后面。

function log(x, y = 'World') {
  console.log(x, y)
}

log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello

除了简洁,ES6 的写法还有两个好处:首先,阅读代码的人,可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本在对外接口中,彻底拿掉这个参数,也不会导致以前的代码无法运行。

需要注意的是,参数变量是默认声明的,所以不能用 let 或 const 再次声明。

function foo(x = 5) {
  let x = 1 // error
  const x = 2 // error
}

rest 参数

ES6 引入rest参数(形式为...变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest参数搭配的变量是一个数组,该变量将多余的参数放入数组中。

这个东西就像 C++中的...运算符,作用是接收函数实参中,多余的值。

比如:

function func(a, ...values) {
  console.log(a, values);
}
func(1, 2, 3, 4, 5);
-------------------------------
output:
1 [ 2, 3, 4, 5 ]

可以看到变量values接收了剩下的2, 3, 4, 5并把他们组合成了应该数组。

如果要使用arguments就会变得麻烦多了,首先arguments对象不是数组,而是一个类似数组的对象,他存储了所有输入的实参。所以为了使用数组的方法,必须使用 Array.prototype.slice.call 先将其转为数组。

// arguments变量的写法
function func(a) {
  console.log(a, Array.prototype.slice.call(arguments).sort());
}
func(1, 2, 3, 4, 5);
----------------------------------------------------------------
output:
1 [ 1, 2, 3, 4, 5 ]

可以看到arguments输出的数组中也含有a的值,这就证实了arguments对象存储了所有输入的实参,如果需要使用多参数的话,使用他就会变得很不便利。

name 属性

函数的 name 属性,返回该函数的函数名。

这个属性早就被浏览器广泛支持,但是直到 ES6,才将其写入了标准。

ES5 与 ES6 中name属性的对比:

var f = function() {}

// ES5
f.name // ""

// ES6
f.name // "f"

对于匿名函数,如果将一个匿名函数赋值给一个变量,ES5 的name属性,会返回空字符串,而 ES6 的name属性会返回实际的函数名。

const bar = function baz() {}

// ES5
bar.name // "baz"

// ES6
bar.name // "baz"

如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的 name 属性都返回这个具名函数原本的名字。

箭头函数

基本用法

ES6 允许使用“箭头”(=>)定义函数。

var f = v => v

// 等同于
var f = function(v) {
  return v
}

如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。

var f = () => 5
// 等同于
var f = function() {
  return 5
}

var sum = (num1, num2) => num1 + num2
// 等同于
var sum = function(num1, num2) {
  return num1 + num2
}

如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用 return 语句返回。

var sum = (num1, num2) => {
  return num1 + num2
}

由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。

// 报错
let getTempItem = id => { id: id, name: "Temp" };

// 不报错
let getTempItem = id => ({ id: id, name: "Temp" });

注意点

  1. 函数体内的this对象,就是定义时所在的对象,而不是使用时所在的对象。
  2. 不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误。

总的来说在箭头函数中对于this的使用要非常注意,因为一般情况下this对象的指向是可变的,而在箭头函数中,它是固定的。如果是普通函数,执行时this应该指向全局对象。但是,箭头函数导致this总是指向函数定义生效时所在的对象。

function foo() {
    console.log(this);
}

let foo2 = () => console.log(this);

foo();
foo2();
--------------------------------------------
output:
Object [global]
{}

可以看到,函数foo中的this指向了全局对象global,而箭头函数foo2中的this则指向了一个空对象。

4.扩展运算符(与解构)

数组的情况

扩展运算符(spread)是三个点(…)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。

console.log(...[1, 2, 3])
// 1 2 3

console.log(1, ...[2, 3, 4], 5)
// 1 2 3 4 5

[...document.querySelectorAll('div')]
// [<div>, <div>, <div>]

有一点需要注意的是,如果把扩展运算符放在括号中,除非是函数调用,否则就会报错。

(...[1,2])
// Uncaught SyntaxError: Unexpected number

console.log(...[1,2])
// output:1 2

简单应用

  • 复制数组

在 js 中,数组是复合的数据类型,如果直接复制的话,只是复制了指向底层数据结构的指针,而不是克隆一个全新的数组。在 ES5 中,通常采用变通方式(克隆)来进行数组的复制:

const a1 = [1, 2]
const a2 = a1.concat()

a2[0] = 2
a1 // [1, 2]

而通过扩展运算符就可以让这个过程变得更加简便。

const a1 = [1, 2]
// 写法一
const a2 = [...a1]
// 写法二
const [...a2] = a1
  • 合并数组

扩展运算符提供了数组合并的新写法。

const arr1 = ['a', 'b']
const arr2 = ['c']
const arr3 = ['d', 'e']

// ES5 的合并数组
arr1.concat(arr2, arr3)
// [ 'a', 'b', 'c', 'd', 'e' ]

// ES6 的合并数组
;[...arr1, ...arr2, ...arr3]
// [ 'a', 'b', 'c', 'd', 'e' ]

需要注意的是,这种方式所作的拷贝是浅拷贝,如果内部还有对象的话,那么修改了原数组的成员,会同步反映到新数组。

  • 与解构赋值结合

扩展运算符可以与解构赋值结合起来,用于生成数组。

下面是一些例子:

const [first, ...rest] = [1, 2, 3, 4, 5]
first // 1
rest // [2, 3, 4, 5]

const [first, ...rest] = []
first // undefined
rest // []

const [first, ...rest] = ['foo']
first // "foo"
rest // []

如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。

  • 字符串

扩展运算符还可以将字符串转为真正的数组。

;[...'hello']
// [ "h", "e", "l", "l", "o" ]

对象的情况

解构赋值

对象的解构赋值用于从一个对象取值,相当于将目标对象自身的所有可遍历的(enumerable)、但尚未被读取的属性,分配到指定的对象上面。所有的键和它们的值,都会拷贝到新对象上面。

let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 }
x // 1
y // 2
z // { a: 3, b: 4 }

上面代码中,变量z是解构赋值所在的对象。它获取等号右边的所有尚未读取的键(ab),将它们连同值一起拷贝过来。

由于解构赋值要求等号右边是一个对象,所以如果等号右边是 undefined 或 null,就会报错,因为它们无法转为对象。

let { x, y, ...z } = null // 运行时错误
let { x, y, ...z } = undefined // 运行时错误

和数组时一样,解构赋值必须是最后一个参数,否则也会报错。

let { ...x, y, z } = obj; // 句法错误
let { x, ...y, ...z } = obj; // 句法错误

PS.注意,解构赋值的拷贝是浅拷贝,即如果一个键的值是复合类型的值(数组、对象、函数)、那么解构赋值拷贝的是这个值的引用,而不是这个值的副本。

let obj = { a: { b: 1 } }
let { ...x } = obj
obj.a.b = 2
x.a.b // 2

上面代码中,x是解构赋值所在的对象,拷贝了对象obja属性。a属性引用了一个对象,修改这个对象的值,会影响到解构赋值对它的引用。

扩展运算符

对象的扩展运算符(…)用于取出参数对象的所有可遍历属性,拷贝到当前对象之中。

let z = { a: 3, b: 4 }
let n = { ...z }
n // { a: 3, b: 4 }

由于数组是特殊的对象,所以对象的扩展运算符也可以用于数组。

let foo = { ...['a', 'b', 'c'] }
foo
// {0: "a", 1: "b", 2: "c"}

和数组一样,扩展运算符也可以用于合并两个对象。

let a = { a: 1, b: 2 }
let b = { c: 1, d: 2 }
let ab = { ...a, ...b }
//{ a:1, b:2, c:1, d:2 }

5.类(class)

ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念。新的 class 写法让对象原型的写法更加清晰、更像面向对象编程的语法,也更加通俗易懂。

关键字 class

基本上,ES6 的class可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到,新的class写法只是让对象原型的写法更加清晰、更像面向对象编程的语法而已。

//ES5的写法
function Point(x, y) {
  this.x = x
  this.y = y
}

Point.prototype.toString = function() {
  return '(' + this.x + ', ' + this.y + ')'
}

var p = new Point(1, 2)
-------------------------------------------------------(
  //ES6的写法
  class Point {
    constructor(x, y) {
      this.x = x
      this.y = y
    }

    toString() {
      return '(' + this.x + ', ' + this.y + ')'
    }
  }
)

上面代码定义了一个“类”,可以看到里面有一个constructor方法,这就是构造方法,而this关键字则代表实例对象。也就是说,ES5 的构造函数Point,对应 ES6 的Point类的构造方法。

Point类除了构造方法,还定义了一个toString方法。注意,定义“类”的方法的时候,前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。另外,方法之间不需要逗号分隔,加了会报错。

  • constructor 方法

constructor方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor方法,如果没有显式定义,一个空的constructor方法会被默认添加。通过调用这个方法,可以返回实例对象(即 this)。

注意点

  • 不存在提升

和 ES5 中,类存在变量提升的情况不同,class 不存在变量提升的情况。

  • name 属性

ES6 的类只是 ES5 的构造函数的一层包装,所以函数的许多特性都被 Class 继承,包括 name 属性。通过 name 属性,可以获得class关键字后面的类名。

关键字 extends

Class 可以通过 extends 关键字实现继承,这比 ES5 的通过修改原型链实现继承,要清晰和方便很多。

class Point {
  constructor(x, y) {
    this.x = x
    this.y = y
  }
}

class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y) // 调用父类的constructor(x, y)
    this.color = color
  }
}

上面代码中,constructor方法之中,出现了super关键字,它在这里表示父类的构造函数,用来新建父类的this对象。

子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为子类自己的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性和方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。

class Point {
  /* ... */
}

class ColorPoint extends Point {
  constructor() {}
}

let cp = new ColorPoint() // ReferenceError

上面代码中,ColorPoint 继承了父类 Point,但是它的构造函数没有调用 super 方法,导致新建实例时报错。

ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this

关键字 super

super这个关键字,既可以当作函数使用,也可以当作对象使用。在这两种情况下,它的用法完全不同。

第一种情况,super作为函数调用时,代表父类的构造函数。ES6 要求,子类的构造函数必须执行一次super函数。

class A {}

class B extends A {
  constructor() {
    super()
  }
}

上面代码中,子类 B 的构造函数之中的 super(),代表调用父类的构造函数。这是必须的,否则 JavaScript 引擎会报错。

第二种情况,super 作为对象时,在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

class A {
  p() {
    return 2
  }
}

class B extends A {
  constructor() {
    super()
    console.log(super.p()) // 2
  }
}

let b = new B()

上面代码中,子类B当中的super.p(),就是将super当作一个对象使用。这时,super在普通方法之中,指向A.prototype,所以super.p()就相当于A.prototype.p()

需要注意的是,因为super指向原型对象,所有定义在父类实例上的方法或属性是无法通过super调用的。

class A {
  constructor() {
    this.p = 2
  }
}

class B extends A {
  get m() {
    return super.p
  }
}

let b = new B()
b.m // undefined

上面代码中,p是父类A实例的属性,super.p就引用不到它。

6.异步回调(Promise)

特点

(1)对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。

(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。

个人理解

Promise是 ES6 对于异步与回调的一个改进语法,通过promisethen的组合,用同步的语法实现异步功能。
举个例子:

function timeout(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(resolve, ms, 'done')
  })
}

timeout(100).then(value => {
  console.log(value)
})

console.log('1')

output: 1
done

上面代码中,timeout方法返回一个Promise实例,表示一段时间以后才会发生的结果。过了指定的时间(ms 参数)以后,Promise实例的状态变为resolved,就会触发then方法绑定的回调函数。

上面这个例子虽然看上去有点脱裤子放屁的感觉,但是我认为他把promisethen的性质很好的表现出来了。首先,从这个例子中可以看出,promise在新建后会立即执行,而then则是他的回调函数,在当前脚本所有的同步任务执行完之后,才会执行。

7.async 函数

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。

基础语法

async函数返回一个Promise对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

async function getStockPriceByName(name) {
  const symbol = await getStockSymbol(name)
  const stockPrice = await getStockPrice(symbol)
  return stockPrice
}

getStockPriceByName('goog').then(function(result) {
  console.log(result)
})

个人的简单理解

console.log(1)
async function func() {
    await console.log(2.0);
    await console.log(2.1);
    await console.log(3);

}
func()
console.log(4)
setTimeout(()=>{console.log(5);},200);
console.log(6)
--------------------------------------------
output:
1
4
6
2.0
2.1
3
5

在函数执行后,首先执行了日志1打印,之后运行函数func,进入后遇到了第一个await,如果await后面跟的是函数的话这时会进行函数的执行,否则func就会返回执行函数体外的的语句,在主线程空闲时,再会过来获取await后面的结果,所以在例子中,先输出了 1,4,6 之后才输出了 2.0。

async中,所有的await都遵循上面的原则。

PS.这个机制貌似和 js 的事件轮询机制相关,具体我也了解的不是很透彻,还需要继续深入学习一下。

学习资料:
ECMAScript 6 入门