01 变量声明与解构赋值

柏林已经来了命令,阿尔萨斯和洛林的学校只许教 ES6 了...他转身朝着黑板,拿起一支粉笔,使出全身的力量,写了两个大字:“ES6 万岁!”(《最后一课》)。

阮一峰的《ES6 标准入门》第二版和第三版都有购入,第二版是去年买的实体书,当初大略翻了一遍,今年第三版又出世了,在原来的基础上新增了不少内容,是时候重拾书本学习了。

Any application that can be written in JavaScript will eventually be written in JavaScript. --Jeff Atwood

let 和 const

基本用法

let 和 const 声明变量的三大特性:不存在变量提升、暂时性死区、不允许重复声明

不存在变量提升

let 声明的变量一定要在声明后使用,否则便会报错。

// var的情况
console.log(foo) // 输出 undefined
var foo = 2
// let的情况
console.log(bar) // 报错 ReferenceError
let bar = 2

暂时性死区

ES6 规定,如果区块中存在 let 和 const 命令,则这个区块对这些命令声明的变量从一开始就形成封闭作用域。这在语法上称为“暂时性死区”(temporal dead zone,简称 TDZ)。

var tmp = 123
if (true) {
// TDZ 开始
tmp = 'abc' // ReferenceError
let tmp
// TDZ 结束
console.log(tmp) // undefined
}

“暂时性死区”也意味着 typeof 不再是一个百分之百安全的操作,变量用 let 声明的话,那么在声明之前引用会报错。作为比较,如果一个变量根本没有被声明,使用 typeof 反而不会报错。

typeof x // ReferenceError
let x
typeof undeclared_variable // "undefined"

总之,暂时性死区的本质就是,只要进入当前作用域,所要使用的变量就已经存在,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

不允许重复声明

let 和 const 命令不允许在相同作用域内重复声明同一个变量。

function foo(arg) {
let arg // 报错
}
function bar(arg) {
{
let arg // 不报错
}
}

扩展

扩展 1:const 实际上保证的是变量指向的那个内存地址不得改动。如果真的想将对象冻结,应该使用 Object.freeze 方法。

var constantize = obj => {
Object.freeze(obj)
Object.keys(obj).forEach((key, i) => {
if (typeof obj[key] === 'object') {
constantize(obj[key])
}
})
}

扩展 2:ES5 只有两种声明变量的方法:使用 var 命令和 function 命令。ES6 除了添加了 let 和 const 命令,还有另外两种声明变量的方法:import 命令和 class 命令。所以,ES6 一共有 6 种声明变量的方法

块级作用域

ES5 只有全局作用域和函数作用域,没有块级作用域,容易出现变量覆盖和变量泄露的问题。

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定:在块级作用域之中,函数声明语句的行为类似于 let,在块级作用域之外不可引用

块级作用域的出现,实际上使得获得广泛应用的立即执行匿名函数(IIFE)不再必要了。

但是由于这条规则会对旧代码产生很大影响。为了减轻因此产生的不兼容问题,浏览器可以不遵守这条规则,所以尽量避免在块级作用域内声明函数。

顶层对象的属性

顶层对象在浏览器环境中指的是 window 对象,在 Node 环境中指的是 global 对象。在 ES5 中,顶层对象的属性与全局变量等价。

ES6 为了改变这一点,一方面规定,为了保持兼容性,var 命令和 function 命令声明的全局变量依旧是顶层对象的属性;另一方面规定,let 命令、const 命令、class 命令声明的全局变量不属于顶层对象的属性。

var a = 1
window.a // 1
let b = 1
window.b // undefined

global 对象

ES5 的顶层对象在各种实现中是不统一的。

  • 在浏览器中,顶层对象是 window,但 Node 和 Web Worker 没有 window。

  • 在浏览器和 Web Worker 中,self 也指向顶层对象,但是 Node 没有 self。

  • 在 Node 中,顶层对象是 global,但其他环境都不支持。

同一段代码为了能够在各种环境中都取到顶层对象,目前一般是使用 this 变量,但是也有局限性。

  • 在全局环境中,this 会返回顶层对象。但是在 Node 模块和 ES6 模块中,this 返回的是当前模块。

  • 对于函数中的 this,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this 会指向顶层对象。但是严格模式下,this 会返回 undefined。

  • 不管是严格模式,还是普通模式,new Function('returnthis')() 总会返回全局对象。但是,如果浏览器用了 CSP(Content Security Policy,内容安全政策),那么 eval、new Function 这些方法都可能无法使用。

综上所述,很难找到一种方法可以在所有情况下都取到顶层对象。以下是两种勉强可以使用的方法:

// 方法一
typeof window !== 'undefined'
? window
: typeof process === 'object' && typeof require === 'function' && typeof global === 'object'
? global
: this
// 方法二
const getGlobal = function() {
if (typeof self !== 'undefined') {
return self
}
if (typeof window !== 'undefined') {
return window
}
if (typeof global !== 'undefined') {
return global
}
throw new Error('unable to locate global object')
}

变量的解构赋值

数组的解构赋值

解构(Destructuring)属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。

let [x, y, ...z] = ['a']
x // "a"
y // undefined
z // []
// 不完全解构
let [a, b] = [1, 2, 3]
a // 1
b // 2

对于 Set 结构,也可以使用数组的解构赋值。

let [x, y, z] = new Set(['a', 'b', 'c'])
x // "a"

事实上,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。

function* fibs() {
let a = 0
let b = 1
while (true) {
yield a
;[a, b] = [b, a + b]
}
}
let [first, second, third, fourth, fifth, sixth] = fibs()
sixth // 5

解构赋值允许指定默认值。ES6 内部使用严格相等运算符(===)判断一个位置是否有值。所以,如果一个数组成员不严格等于 undefined,默认值是不会生效的

let [x = 1] = [undefined]
x // 1
let [y = 1] = [null]
y // null

如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到时才会求值。

// x 可以取值,函数不会执行
function f() {
console.log('aaa')
}
let [x = f()] = [1]

默认值可以引用解构赋值的其他变量,但该变量必须已经声明:

let [x = 1, y = x] = [] // x=1; y=1

对象的解构赋值

对象的解构赋值的内部机制是先找到同名属性,然后再赋值给对应的变量。真正被赋值的是后者,而不是前者。

let { foo: baz } = { foo: 'aaa', bar: 'bbb' }
baz // 'aaa'

上面的代码中,foo 是匹配的模式,baz 才是变量。真正被赋值的是变量 baz,而不是模式 foo。

与数组一样,解构也可以用于嵌套结构的对象:

let obj = { p: ['Hello', { y: 'World' }] }
let {
p: [x, { y }]
} = obj

注意,这时 p 是模式,不是变量,因此不会被赋值。如果 p 也要作为变量赋值,可以写成下面这样。

let {
p,
p: [x, { y }]
} = obj

同数组解构一样,对象解构也可以使用默认值,默认值生效的条件是,对象的属性值严格等于 undefined。

注意:如果要将一个已经声明的变量用于解构赋值,必须非常小心。

// 错误的写法
let x
;{ x } = { x: 1 } // SyntaxError: syntax error

上面代码的写法会报错,因为 JavaScript 引擎会将 { x } 理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免将其解释为代码块,才能解决这个问题。

// 正确的写法
let x
;({ x } = { x: 1 })

由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。

let arr = [1, 2, 3]
let { 0: first, [arr.length - 1]: last } = arr
first // 1
last // 3

字符串的解构赋值

对字符串解构时,字符串会被转换为类数组对象。

const [a, b, c, d, e] = 'hello'
a // 'h'
b // 'e'

数值和布尔值的解构赋值

解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。

let { toString: s } = 123
s === Number.prototype.toString // true
let { toString: s } = true
s === Boolean.prototype.toString // true

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于 undefined 和 null 无法转为对象,所以对它们进行解构赋值时都会报错。

let { prop: x } = undefined // TypeError
let { prop: y } = null // TypeError

函数参数的解构赋值

function move({ x, y } = { x: 0, y: 0 }) {
return [x, y]
}
move({ x: 3, y: 8 }) // [3, 8]
move({ x: 3 }) // [3, undefined]
move({}) // [undefined, undefined]
move() // [0, 0]

圆括号问题

解构赋值时,对于编译器来说,一个式子到底是模式还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。

ES6 规定:只要有可能导致解构的歧义,就不得使用圆括号

不能使用圆括号的情况

  1. 变量声明语句

// 全部报错
let [(a)] = [1]
let {x: (c)} = {}
let ({x: c}) = {}
let {(x: c)} = {}
let {(x): c} = {}
let { o: ({ p: p }) } = { o: { p: 2 } }

上面 6 个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。

  1. 函数参数

函数参数也属于变量声明,因此不能使用圆括号。

// 报错
function foo([(z)]) { return z }
  1. 赋值语句的模式

// 全部报错
;({ p: a }) = { p: 42 }
;([a]) = [5]

上面的代码将整个模式放在圆括号之中,导致报错。

// 报错
;[{ p: a }, { x: c }] = [{}, {}]

上面的代码将一部分模式放在圆括号之中,导致报错。

可以使用圆括号的情况

可以使用圆括号的情况只有一种:赋值语句的非模式部分可以使用圆括号。

;[b] = [3] // 正确
;({ p: d } = {}) // 正确
;[parseInt.prop] = [3] // 正确

上面 3 行语句都可以正确执行,因为它们都是赋值语句,而不是声明语句,另外它们的圆括号都不属于模式的一部分:

  • 第 1 行语句中,模式是取数组的第 1 个成员,跟圆括号无关;

  • 第 2 行语句中,模式是 p 而不是 d;

  • 第 3 行语句与第 1 行语句的性质一致。

扩展

任何部署了 Iterator 接口的对象都可以用 for...of 循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值获取键名和键值就非常方便。

// 获取键名
for (let [key] of map) {
// ...
}
// 获取键值
for (let [, value] of map) {
// ...
}