ES6详解(一)
JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。次年,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为ECMAScript
ECMAScript 和 JavaScript 的关系是,前者是后者的规格,后者是前者的一种实现,另外的 ECMAScript 方言还有 JScript 和 ActionScript
Babel转码器
Babel 是一个广泛使用的 ES6 转码器,可以将 ES6 代码转为 ES5 代码,从而在老版本的浏览器执行。
1 | // 转码前 |
上面的原始代码用了箭头函数,Babel 将其转为普通函数,就能在不支持箭头函数的 JavaScript 环境执行了。
Babel 默认只转换新的 JavaScript 句法(syntax),而不转换新的 API,比如Iterator
、Generator
、Set
、Map
、Proxy
、Reflect
、Symbol
、Promise
等全局对象,以及一些定义在全局对象上的方法(比如Object.assign
)都不会转码。
Babel 也可以用于浏览器环境,使用@babel/standalone模块提供的浏览器版本,将其插入网页。
1 | <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> |
注意,网页实时将 ES6 代码转为 ES5,对性能会有影响。生产环境需要加载已经转码完成的脚本。
Babel 提供一个REPL 在线编译器,可以在线将 ES6 代码转为 ES5 代码。转换后的代码,可以直接作为 ES5 代码插入网页运行。
let 和 const 命令
let
基本用法
ES6 新增了let
命令,用来声明变量。它的用法类似于var
,但是所声明的变量,只在let
命令所在的代码块内有效。
1 | { |
for
循环的计数器,就很合适使用let
命令。
1 | for (let i = 0; i < 10; i++) { |
上面代码中,计数器i
只在for
循环体内有效,在循环体外引用就会报错。
下面的代码如果使用var
,最后输出的是10
。
1 | var a = []; |
上面代码中,变量i
是var
命令声明的,在全局范围内都有效,所以全局只有一个变量i
。每一次循环,变量i
的值都会发生改变,而循环内被赋给数组a
的函数内部的console.log(i)
,里面的i
指向的就是全局的i
。也就是说,所有数组a
的成员里面的i
,指向的都是同一个i
,导致运行时输出的是最后一轮的i
的值,也就是 10。
如果使用let
,声明的变量仅在块级作用域内有效,最后输出的是 6。
1 | var a = []; |
上面代码中,变量i
是let
声明的,当前的i
只在本轮循环有效,所以每一次循环的i
其实都是一个新的变量,所以最后输出的是6
。你可能会问,如果每一轮循环的变量i
都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i
时,就在上一轮循环的基础上进行计算。
另外,for
循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
1 | for (let i = 0; i < 3; i++) { |
上面代码正确运行,输出了 3 次abc
。这表明函数内部的变量i
与循环变量i
不在同一个作用域,有各自单独的作用域(同一个作用域不可使用 let
重复声明同一个变量)。
不存在变量提升
var
命令会发生“变量提升”现象,即变量可以在声明之前使用,值为undefined
。
let
命令改变了语法行为,它所声明的变量一定要在声明后使用,否则报错。
暂时性死区
只要块级作用域内存在let
命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
1 | var tmp = 123; |
上面代码中,存在全局变量tmp
,但是块级作用域内let
又声明了一个局部变量tmp
,导致后者绑定这个块级作用域,所以在let
声明变量前,对tmp
赋值会报错。
ES6 明确规定,如果区块中存在let
和const
命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
“暂时性死区”也意味着typeof
不再是一个百分之百安全的操作。
1 | typeof x; // ReferenceError |
上面代码中,变量x
使用let
命令声明,所以在声明之前,都属于x
的“死区”,只要用到该变量就会报错。因此,typeof
运行时就会抛出一个ReferenceError
。
作为比较,如果一个变量根本没有被声明,使用typeof
反而不会报错。
1 | typeof undeclared_variable // "undefined" |
暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。
不允许重复声明
let
不允许在相同作用域内,重复声明同一个变量。
1 | // 报错 |
块级作用域
为什么需要块级作用域
ES5 只有全局作用域和函数作用域,没有块级作用域。
第一种场景,内层变量可能会覆盖外层变量。
1 | var tmp = new Date(); |
第二种场景,用来计数的循环变量泄露为全局变量。
1 | var s = 'hello'; |
上面代码中,变量i
只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。
ES6的块级作用域
ES6 允许块级作用域的任意嵌套。
1 | {{{{ |
上面代码使用了一个五层的块级作用域,每一层都是一个单独的作用域。第四层作用域无法读取第五层作用域的内部变量。
内层作用域可以定义外层作用域的同名变量。
1 | {{{{ |
块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。
块级作用域与函数声明
ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。
1 | // 情况一 |
上面两种情况实际都能运行,不会报错。
ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。
- 允许在块级作用域内声明函数。
- 函数声明类似于
var
,即会提升到全局作用域或函数作用域的头部。 - 同时,函数声明还会提升到所在的块级作用域的头部。
上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let
处理。
总结:应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
注意:ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。
const
基本用法
const
声明一个只读的常量。一旦声明,常量的值就不能改变。
const
声明的变量不得改变值,这意味着,const
一旦声明变量,就必须立即初始化,不能留到以后赋值。
1 | const foo; |
作用域
const
的作用域与let
命令相同:只在声明所在的块级作用域内有效。
1 | if (true) { |
本质
const
实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const
只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了
1 | const foo = {}; |
上面代码中,常量foo
储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo
指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。
Object.freeze
如果真的想将对象冻结,应该使用Object.freeze
方法。
1 | const foo = Object.freeze({}); |
除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。
1 | var constantize = (obj) => { |
声明变量的六种方法
ES5 只有两种声明变量的方法:var
命令和function
命令。ES6 除了添加let
和const
命令,后面章节还会提到,另外两种声明变量的方法:import
命令和class
命令。
顶层对象的属性
顶层对象,在浏览器环境指的是window
对象,在 Node 指的是global
对象。ES5 之中,顶层对象的属性与全局变量是等价的。
1 | window.a = 1; |
顶层对象的属性与全局变量挂钩,被认为是 JavaScript 语言最大的设计败笔之一。
从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。
1 | var a = 1; |
上面代码中,全局变量a
由var
命令声明,所以它是顶层对象的属性;全局变量b
由let
命令声明,所以它不是顶层对象的属性,返回undefined
。
globalThis
- 浏览器里面,顶层对象是
window
,但 Node 和 Web Worker 没有window
。 - 浏览器和 Web Worker 里面,
self
也指向顶层对象,但是 Node 没有self
。 - Node 里面,顶层对象是
global
,但其他环境都不支持。
同一段代码为了能够在各种环境,都能取到顶层对象,现在一般是使用this
关键字,但是有局限性。
- 全局环境中,
this
会返回顶层对象。但是,Node.js 模块中this
返回的是当前模块,ES6 模块中this
返回的是undefined
。 - 函数里面的
this
,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this
会指向顶层对象。但是,严格模式下,这时this
会返回undefined
。
引入globalThis
作为顶层对象。也就是说,任何环境下,globalThis
都是存在的,都可以从它拿到顶层对象,指向全局环境下的this
。
变量的解构赋值
数组的解构赋值
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构
以前,为变量赋值,只能直接指定值。
1 | let a = 1; |
ES6 允许写成下面这样。
1 | let [a, b, c] = [1, 2, 3]; |
上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。
本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。
1 | let [foo, [[bar], baz]] = [1, [[2], 3]]; |
如果解构不成功,变量的值就等于undefined。
1 | let [foo] = []; |
以上两种情况都属于解构不成功,foo
的值都会等于undefined
。
不完全解构
等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。
1 | let [x, y] = [1, 2, 3]; |
对于 Set 结构,也可以使用数组的解构赋值。
1 | let [x, y, z] = new Set(['a', 'b', 'c']); |
只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。
生成器函数(Generator Function)实现的斐波那契数列生成器
使用生成器函数(Generator Function)实现的斐波那契数列生成器。让我解释一下每一部分的作用:
1 | function* fibs() { |
这是一个生成器函数 fibs
,使用 function*
声明。在函数内部,定义了两个变量 a
和 b
,初始化为斐波那契数列的起始值。然后,使用一个无限循环 while (true)
,通过 yield a
语句产生当前的斐波那契数列的值 a
。接着,使用解构赋值 [a, b] = [b, a + b];
更新 a
和 b
的值,实现斐波那契数列的递进。
1 | let [first, second, third, fourth, fifth, sixth] = fibs(); |
这一行代码通过解构赋值,从生成器函数 fibs
中取得前六个斐波那契数列的值。生成器函数会持续产生数列的值,但解构赋值只会取所需数量的值。
1 |
|
最后一行输出了 sixth
,即斐波那契数列中的第六个值,结果为 5
。因为斐波那契数列的前几个值是 0, 1, 1, 2, 3, 5,所以第六个值是 5
。
默认值
解构赋值允许指定默认值。
只有当一个数组成员严格等于undefined
,默认值才会生效。
1 | let [foo = true] = []; |
下面代码中,如果一个数组成员是null
,默认值就不会生效,因为null
不严格等于undefined
。
1 | let [x = 1] = [undefined]; |
惰性求值
如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。
1 | function f() { |
上面代码中,因为x
能取到值,所以函数f
根本不会执行。
默认值可以引用解构赋值的其他变量,但该变量必须已经声明。(有点抽象,认真看)
1 | let [x = 1, y = x] = []; // x=1; y=1 |
对象的解构赋值
解构不仅可以用于数组,还可以用于对象。
1 | let { foo, bar } = { foo: 'aaa', bar: 'bbb' }; |
对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;
而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
如果解构失败,变量的值等于undefined
。
1 | let {foo} = {bar: 'baz'}; |
对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。
1 | // 例一 |
上面代码的例一将Math
对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。例二将console.log
赋值到log
变量。
难点:
如果变量名与属性名不一致,必须写成下面这样。
1 | let { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
这实际上说明,对象的解构赋值是下面形式的简写
1 | let { foo: foo, bar: bar } = { foo: 'aaa', bar: 'bbb' }; |
也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
1 | let { foo: baz } = { foo: 'aaa', bar: 'bbb' }; |
与数组一样,解构也可以用于嵌套结构的对象。
1 | let obj = { |
注意,这时p
是模式,不是变量,因此不会被赋值。如果p
也要作为变量赋值,可以写成下面这样。
1 | let obj = { |
默认值
1 | var {x = 3} = {}; |
默认值生效的条件是,对象的属性值严格等于undefined
。
1 | var {x = 3} = {x: undefined}; |
上面代码中,属性x
等于null
,因为null
与undefined
不严格相等,所以是个有效的赋值,导致默认值3
不会生效。
注意:
(1)如果要将一个已经声明的变量用于解构赋值,必须非常小心。
1 | // 错误的写法 |
上面代码的写法会报错,因为 JavaScript 引擎会将{x}
理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。
1 | // 正确的写法 |
上面代码将整个解构赋值语句,放在一个圆括号里面,就可以正确执行。
由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。
1 | let arr = [1, 2, 3]; |
上面代码对数组进行对象解构。数组arr
的0
键对应的值是1
,[arr.length - 1]
就是2
键,对应的值是3
。
字符串的解构赋值
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
1 | const [a, b, c, d, e] = 'hello'; |
类似数组的对象都有一个length
属性,因此还可以对这个属性解构赋值。
1 | let {length : len} = 'hello'; |
数值和布尔值的解构赋值
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
1 | let {toString: s} = 123; |
上面代码中,数值和布尔值的包装对象都有toString
属性,因此变量s
都能取到值。
函数参数的解构赋值
1 | function add([x, y]){ |
上面代码中,函数add
的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量x
和y
。对于函数内部的代码来说,它们能感受到的参数就是x
和y
。
默认值
函数参数的解构也可以使用默认值。
1 | function move({x = 0, y = 0} = {}) { |
undefined
就会触发函数参数的默认值。
1 | [1, undefined, 3].map((x = 'yes') => x); |
圆括号问题
对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。
如果模式中出现圆括号怎么处理。ES6 的规则是,只要有可能导致解构的歧义,就不能使用圆括号。
可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。
1 | [(b)] = [3]; // 正确 |
上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是p
,而不是d
;第三行语句与第一行语句的性质一致。
用途
(1)交换变量的值
1 | let x = 1; |
上面代码交换变量x
和y
的值,这样的写法不仅简洁,而且易读,语义非常清晰。
(2)取出函数返回的多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
1 | // 返回一个数组 |
(3)函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来。
1 | // 参数是一组有次序的值 |
(4)提取 JSON 数据
解构赋值对提取 JSON 对象中的数据,尤其有用。
1 | let jsonData = { |
(5)遍历 Map 结构
任何部署了 Iterator 接口的对象,都可以用for...of
循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。
1 | const map = new Map(); |
如果只想获取键名,或者只想获取键值,可以写成下面这样。
1 | // 获取键名 |
(6)输入模块的指定方法
加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。
1 | const { SourceMapConsumer, SourceNode } = require("source-map"); |
总结
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefined
和null
无法转为对象,所以对它们进行解构赋值,都会报错。