如果写过一段时间的JS,很可能你对语法已经非常熟悉了。JS确实有很多奇怪的地方,但总体来说它的语法还是合理而直观的,并且JS吸取了其他语言的特点,因此有很多相似之处。
然而ES6增加了不少新的语法形式,需要一点时间来消化。在这章里,我们来一同领略它带来了什么新东西。
提示:本文写作之时,书中讨论的部分特性已经被各种浏览器实现了(Firefox、Chrome等等),但其中一些只是部分实现,还有更多根本的没有被实现。直接运行这些例子可能会得到各式各样的体验。在这种情况下,你可以尝试一些转译器(transpiler),因为这些工具会覆盖绝大部分特性。ES6Fiddle是一个在线交互式的Babel转译器,它很棒、简单易用,你可以在上面直接使用ES6语法。
块作用域声明
你可能已经意识到JavaScript中变量作用域的基本单位是function
。如果你需要创建一个块作用域,最普遍的做法不是使用普通的函数,而是创建一个立即执行函数表达式(IIFE)。例如:
var a = 2;
(function IIFE(){
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
let
声明
而现在,我们可以为任何一个块创建声明,这种语法毫无意外地叫做块作用域。这意味着我们只需要一对花括号{ .. }
就可以创建一个作用域。同时,var
是众所周知地总是把变量绑定在外围函数域(或者全局,如果是顶层),我们可以用let
来替代它:
var a = 2;
{
let a = 3;
console.log( a ); // 3
}
console.log( a ); // 2
在JS里,凭空使用一对花括号{ .. }
还是挺少见的,也不太符合这门语言的习惯,但它还是有用的。从那些有块作用域语言迁徙过来的开发者可以很容易地能识别出这种模式。
我认为用一个专职的{ .. }
是创建块作用域内的变量最好的方式。此外,你应该总是把let
声明放在那段代码的最顶部。如果有多个变量需要声明,我建议只写一个let
。
从代码风格上来说,我甚至喜欢把let
和开头的{
放在同一行,让别人一眼就能看出来这段代码就是为了把这些变量包起来。
{ let a = 2, b, c;
// ..
}
现在开始看起来有点怪了,而且也不太会是其他ES6文档推荐的做法。但是我发这个疯不是没有理由的。
还有一个实验性(未标准化的)方式来使用let
声明,叫做let
块,它看起来是这样的:
let (a = 2, b, c) {
// ..
}
这种形式就是我所说的显示块作用域,鉴于let..
声明形式映射出了var
是更隐式的,因为它好像劫持了它所在的任何{ .. }
。一般来说,比起隐式机制开发人员更喜欢显示的,可以说这就是其中一个例子。
和前面两段代码形式相比,它们是很类似的,对我来说,这两种语法风格都是合格的显示块作用域。但不幸的是,let (..) { .. }
这种最最显示的形式,并没有被ES6采纳。它有可能这未来加入ES6,所以前一种形式是我们目前的最佳选择了,至少在我看来。
为了增强对let
声明的隐式本性的认识,我们来看下面的用法:
let a = 2;
if (a > 1) {
let b = a * 3;
console.log( b ); // 6
for (let i = a; i <= b; i++) {
let j = i + 10;
console.log( j );
}
// 12 13 14 15 16
let c = a + b;
console.log( c ); // 8
}
不要回头看上面的代码快速回答下面的问题:哪些(个)变量只存在于if
声明里面?哪些(个)只存在于for
循环里面?
答案是:if
声明里包含b
和c
两个块作用域变量,for
循环中包含i
和j
两个块作用域变量。
你需要想一会儿吗?i
没有被加到包围它的if
声明范围里,你有没有觉得惊讶?大脑的停顿和质疑——我叫它“智商税”,来源于let
机制不光对我们来说是新东西,而且它还是隐式的。
在最下面的let c = ..
声明也暗藏玄机。和传统的var
变量声明不同,那些会绑定到外层的函数作用域上,不管它在哪儿声明的,let
声明不会在初始时就绑定到块作用域上,而是直到语句出现才初始化。
在let
声明/初始化之前就访问let
声明的变量会引起错误,然而使用var
声明的变量时则没有这个问题(除了代码风格问题以外)。
考虑以下代码:
{
console.log( a ); // undefined
console.log( b ); // ReferenceError!
var a;
let b;
}
警告:因为过早去访问let
声明的引用会导致ReferenceError
错误,技术上被称为暂时性死区(TDZ)错误——表明你正在访问一个已经声明还但没有初始化的变量。这不是唯一能见到TDZ错误的地方——他们出现在ES6中的好几处地方。同时,注意“初始化”并不要求你显式地在代码里给变量赋一个值,像leb b;
这样声明就可以了。变量如果在声明时没有赋值,那么它会被赋上undefined
,所以let b;
和let b = undefined;
是等价的。但无论你用不用显式声明,你都不能在let b
语句前面访问b
。
最后一点小麻烦:和没有声明的变量(或者声明了的!)不一样,对于不同的TDZ变量,typeof
表现也不一样。看下面的例子:
{
// `a`没有声明
if (typeof a === "undefined") {
console.log( "cool" );
}
// `b`声明了,但还在TDZ中
if (typeof b === "undefined") { // ReferenceError!
// ..
}
// ..
let b;
}
a
是没有声明的,所以typeof
是唯一安全地检查它是否存在的方法。但是typeof b
抛出了TDZ错误,因为在下面的代码中有一个let b
声明。哦豁。
你现在应该明白为什么我坚持把所有let
声明放在scope的最上面了吧。这样可以完全避免因为过早访问变量引起的错误,同时也让声明变得更加显式,当你读到一段代码的开头,就能知道这里面有哪些变量。
你的代码(if
语句、while
循环等等)没必要和作用域行为分享他们原有的行为。
代码的显式程度——维护什么样的原则由你自己决定——这样可以免去很多重构时的头痛,也不会搬起石头砸自己的脚。
注意:如果想要了解更多关于`let和块作用域的信息,请参考本系列的第三章《作用域和闭包》。
let
+ for
我推荐的显示let
声明方法有一个例外的情况,就是let
出现在for
循环的顶部的时候。究其原因可能比较微不足道,但我认为这是ES6很重要的一个特性。
考虑如下代码:
var funcs = [];
for (let i = 0; i < 5; i++) {
funcs.push( function(){
console.log( i );
} );
}
funcs[3](); // 3
在for
顶部的let i声明
不仅仅为for
循环定义了一个i
,还为每次循环重新定义了一个新的i
。这也意味着每次迭代创建的闭包只会存在于内部,正如你期待的那样。
如果你用同样的代码,只在for
顶部改用var i
,你就会在最后得到5
而不是3
,因为唯一的一个i
是作用于外部作用域的,而不是每次迭代都使用一个新的i
。
你也可以用更罗嗦的代码完成同样的事情:
var funcs = [];
for (var i = 0; i < 5; i++) {
let j = i;
funcs.push( function(){
console.log( j );
} );
}
funcs[3](); // 3
这里,我们在每个迭代内部强制创建了一个新的j
,然后闭包就会正常工作了。我更倾向于前面的那种做法;这个附加的特殊能力也是我更支持for (let ..)
写法的原因。虽然你可以说这么做有点隐式,但对我来说,这样做足够显式了,也更有用。
let
在for..in
和for..of
(见《for..of
循环》)循环中表现是一样的。
const
声明
我们还有另外一种块作用域声明形式:const
,用它来创建常量。
可到底什么才是常量呢?常量是一种在初始值被设定之后只读的变量。考虑如下代码:
{
const a = 2;
console.log( a ); // 2
a = 3; // TypeError!
}
在声明时赋值之后,你就不能去更改它所存储的值了。const
声明必须使用显式的初始化。如果你希望有一个常量的值是undefined
,那么你必须声明const a = undefined
来达到目的。
常量并没有限制值本身,而是限制了变量和值之间的赋值关系。换句话说,用const
赋予的值并不是固定不可改变的,它只是限制了我们不能再次对同一变量赋值。假设这个值有些复杂,例如一个对象或者数组,值的内容本身是可以改变的。
{
const a = [1,2,3];
a.push( 4 );
console.log( a ); // [1,2,3,4]
a = 42; // TypeError!
}
这个例子里,变量a
并不是真的存储了一个常量数组,而是存储了那个数组的恒定引用。数组本身还是可以随便更改的。
警告:把对象或者数组赋值为常量意味着这个值不能被垃圾回收,直到这个常量的词法作用域结束,因为这个引用永远不能被取消。这也许就是你想要的,但如果你不是有意为之的,就要小心了。
本质上,const
声明强制实施了我们多年来用代码风格作为信号的习惯:当我们用全部大写字母作为一个变量名并为其赋值时,我们会留心不去改变它。var
赋值不会强制这一点,但是现在我们有了const
赋值,就可以帮你避免无意的改变了。
const
可以在for
、for..in
和for..of
循环的变量声明中(见《for..of
循环》一章)。然而,任何重新赋值的尝试,都会抛错,例如for
循环中的典型i++
语句。
const
,用还是不用?
这里也有一些传言,在某些特定的场景下,JS引擎对const
的优化可能比let
和var
更好。理论上,引擎如果知道哪些变量的值/类型不会改变,它就可以减少一些不必要的追踪。
无论const
在这里是否真的有用,它有可能仅仅是我们的一厢情愿,但更重要的决定是你是否需要一致的行为。请记住:源代码最重要的作用是帮助我们清晰无误的沟通,不仅是为你自己,更要为未来的自己和其他代码合作者解释清楚此时此刻写下这些代码的你的意图是什么。
有一些程序员喜欢总是用const
来声明变量,直到他们发现有需要更改这个变量值的时候再把声明改回let
。这是个有趣的想法,但这样做并没有明显地提升代码可读性或者是自我解释性。
很多人认为这并不是一种真正的保护,因为任何后来的程序员谁想要改一下const
声明的变量,只需要不假思索地把const
改成let
就好了。最好的情况也只是它可以防止无意的更改。但是问题又来了,除了我们的直觉和理智以外,好像并没有清晰客观的手段来判断哪些是“意外”或者是需要预防的。对于强制类型也有类似的观念。
我的建议是:为了避免这种会引起混乱的代码,只在你确定肯定以及一定不会改变变量值的时候使用const
。换句话说,不要依靠const
的代码行为,而是使用工具来检查代码风格,当你的想法可以被称作风格的时候。
块作用域函数
从ES6开始,在块内部的函数定义现在只在作用域内有效了。ES6之前,规范并没有这么主张,但很多实现都已经是这样了。所以现在规范只能面对现实。
考虑
{
foo(); // 可以调用!
function foo() {
// ..
}
}
foo(); // ReferenceError
foo()
函数是在{ .. }
内部定义的,从ES6开始它就被绑定在作用域内部了,所以在块外部不能使用它。但需要注意的是它在块作用域内部是被“提升”了的,这和let
声明又不一样,你不会在声明之前调用它时得到TDZ(暂时性死区)错误。
如果你以前就这么写代码并且依赖于没有块作用域的行为,那么块作用域的函数声明可能对你来说是个问题。
if (something) {
function foo() {
console.log( "1" );
}
}
else {
function foo() {
console.log( "2" );
}
}
foo(); // ??
ES6之前的环境,无论something
的值是多少,foo()
都会打印出"2"
。因为两个函数的声明都被提升到了块外面,所以第二个永远都会覆盖第一个。
到了ES6,最后一行就会直接抛ReferenceError
错误了。
展开和其余(Spread/Rest)
ES6引入了一个新的操作符...
,一般被叫做展开(spread)或者其余(rest)操作符,取决于它使用的位置和方式。我们来看一个例子:
function foo(x,y,z) {
console.log( x, y, z );
}
foo( ...[1,2,3] ); // 1 2 3
当...
用在一个数组前面的时候(实际上任何可迭代的数据类型前面,我们会在第三章详细讲),它会把这个数组展开成平铺的值。
你会经常见到上面这种用法,把一个数组展开作为函数的一组参数。这里的...
基本就是apply(..)
的一种简写,在ES6之前经常见到:
foo.apply( null, [1,2,3] ); // 1 2 3
但是...
也可以用来在其他情况下展开一个值,比如在另外一个数组声明的里面:
var a = [2,3,4];
var b = [ 1, ...a, 5 ];
console.log( b ); // [1,2,3,4,5]
在这里...
替代的是concat(..)
,它和[1].concat( a, [5] )
的作用是一样的。
...
另一个比较常见的用法本质上有相反的作用:它不展开一个值,而是聚合一组值放到一个数组中,比如:
function foo(x, y, ...z) {
console.log( x, y, z );
}
foo( 1, 2, 3, 4, 5 ); // 1 2 [3,4,5]
这段代码中的...z
其实是“把其余的参数(如果有的话)放到一个叫z
的数组里面。因为x
已经赋值为1
,y
赋值为2
,所以剩下的3
、4
和5
就被放在z
里面了。
如果你没有命名任何参数,那么...
就会合并所有的参数。
function foo(...args) {
console.log( args );
}
foo( 1, 2, 3, 4, 5); // [1,2,3,4,5]
注意: foo()
函数声明中的...args
通常被称为“其余参数”,因为你在收集参数的剩余部分。我倾向于使用聚合,因为这更符合它做的事情,而不是它包含的东西。
这个用法最好的地方就是它提供了一个稳妥的办法来替代早就废弃了的arguments
数组——事实上它连个真正的数组都不是,而是一个看起来像数组的对象。因为args
是一个真正的数组(你把它命名成什么都可以,很多人喜欢把它叫做r
或rest
),我们再也不用做各种愚蠢的事情去把arguments
转成真正的数组了。
考虑:
// 用ES6的方式来实现
function foo(...args) {
// `args`是真正的数组
// 丢掉`args`数组中的第一个元素
args.shift();
// 把所有`args`里的元素当作参数传给`console.log(..)`
console.log( ...args );
}
// 用ES6之前的老式办法实现
function bar() {
// 先把`arguments`转换为真的数组
var args = Array.prototype.slice.call( arguments );
// 在末尾加上几个元素
args.push( 4, 5 );
// 筛掉奇数元素
args = args.filter( function(v){
return v % 2 == 0;
} );
// 把`args`里的元素当作参数传给`foo(..)`
foo.apply( null, args );
}
bar( 0, 1, 2, 3 ); // 2 4
foo(..)
函数中的...args
聚合了所有参数,然后在调用console.log(..)
的时候...args
又把它们展开了。这个例子可以很好地体现...
操作符两种相反的用法。
...
用法除了可以用在函数声明时使用以外还有其他的用途,我们会在《不多不少刚刚好》一章来详细讲述。
参数默认值
给函数参数设一个默认值恐怕是JavaScript里最常见的写法了。长久以来我们大概都是这么做的,你可能对下面的代码似曾相识:
function foo(x,y) {
x = x || 11;
y = y || 31;
console.log( x + y );
}
foo(); // 42
foo( 5, 6 ); // 11
foo( 5 ); // 36
foo( null, 6 ); // 17
当然,如果以前这样做过,你可能已经知道这样做有利有弊,比如在参数值和false
等价的时候:
foo( 0, 42 ); // 53 <-- 哦豁,不是42喔。
为什么会这样?因为0
是一个假值,所以x || 11
会得到11
,而不会把0
传过去。
为了避免这个小意外,有些人会用稍显罗嗦的办法来实现:
function foo(x,y) {
x = (x !== undefined) ? x : 11;
y = (y !== undefined) ? y : 31;
console.log( x + y );
}
foo( 0, 42 ); // 42
foo( undefined, 6 ); // 17
当然,这么做意味着除了undefined
以外的任何值都可以传进去。然而,undefined
会被当成信号,表示“我没有传这个参数”。这么做没有问题,除非哪天你真的需要把undefined
传进去。
如果是这样,你可以测试这个参数是否真的被忽略了,通过检查它是否出现在了arguments
数组里,代码可能是这样的:
function foo(x,y) {
x = (0 in arguments) ? x : 11;
y = (1 in arguments) ? y : 31;
console.log( x + y );
}
foo( 5 ); // 36
foo( 5, undefined ); // NaN
但是你怎么可能不传点什么值标明“我要跳过这个参数”(连undefined
都不传),就跳过第一个参数x
?
foo(,5)
看起来不错,但这是个语法错误。foo.apply(null,[,5])
看起来好像可以工作,但apply(..)
有个怪癖,这样的参数会被当成[undefined,5]
,也就是说也不会被跳过的。
如果你深挖下去,你会发现你只能省略末尾的参数(即右边的),只需要比所需的参数少传几个就可以了,但你不能跳过参数中间或者前面的几个。就是做不到。
JavaScript设计中有一个重要的原则就是undefined
通常意味着缺失。也就是说undefined
和缺失之间是没区别的,至少在函数参数这里是这样的。
注意:比较混乱的是,JS里也有很多不符合设计模式的地方,比如数组里的空位之类的。见本系列中的《类型和语法》一书。
铺垫了这么多,我们现在来看一个新的ES6语法,它好看又实用,可以高效地帮我们给缺失的参数赋上默认值。
function foo(x = 11, y = 31) {
console.log( x + y );
}
foo(); // 42
foo( 5, 6 ); // 11
foo( 0, 42 ); // 42
foo( 5 ); // 36
foo( 5, undefined ); // 36 <-- `undefined`不见了
foo( 5, null ); // 5 <-- `null`强制转换成了`0`
foo( undefined, 6 ); // 17 <-- `undefined`不见了
foo( null, 6 ); // 6 <-- `null`强制转换成了`0`
注意我们得到的输出,看它们和之前做法的细微差别和相似之处。
比起惯用的x || 11
,函数声明中的x == 11
和x !== undefined ? x : 11
的行为更相似,所以在把前ES6的代码转换成ES6的时候,你要格外小心这些有默认值的语法。
注意: 其余/聚合参数(见《展开和其余》)不能设置默认值。所以,当function foo(...vals=[1,2,3]) {
看起来好像很不错的样子,但实际上这个语法并不成立。这种情况下你还是需要继续手写前面的逻辑来实现。
默认值表达式
函数的默认值可以不仅仅是简单的像31
这种的值;它们可以是任意有效的表达式,甚至可以是函数调用:
function bar(val) {
console.log( "bar called!" );
return y + val;
}
function foo(x = y + 3, z = bar( x )) {
console.log( x, z );
}
var y = 5;
foo(); // "bar called"
// 8 13
foo( 10 ); // "bar called"
// 10 15
y = 6;
foo( undefined, 10 ); // 9 10
如你所见,这些默认值表达式是延迟执行的,这意味着它们只在被调用的时候执行——也就是,只有当函数参数被省略或者是undefined
的时候。
这是个很微妙的细节,但是函数声明里正式参数是在它们自己作用域里的(把它想象成一个用( .. )
包裹起来的作用域气泡),而不是在函数体作用域里。这意味着在默认值表达式里的对一个标识符的引用会首先匹配正式的参数作用域,然后才会去看外部的作用域。详见《作用域和闭包》一书。
参考:
var w = 1, z = 2;
function foo( x = w + 1, y = x + 1, z = z + 1 ) {
console.log( x, y, z );
}
foo(); // ReferenceError
默认值表达式w + 1
中的w
会在正式的参数作用域中寻找w
,但是没找到,它就会退而求其次来使用外部作用域中的w
。接下来,默认值表达式x + 1
也会在正式的参数作用域中寻找x
,幸运的是这时候x
已经初始化了,所以y
的赋值也没有问题。
然而,z + 1
里的z
就没那么好运了,它在那一刻还没有初始化的变量,所以它也不会在外部作用域里费力找z
。
我们在let
声明一章已经提到过,ES6有TDZ(暂时性死区),会阻止我们访问一个还没初始化的变量。在这里,默认值表达式z + 1
就会抛出一个TDZ错误ReferenceError
。
尽管对代码清晰度来说并不是个好的主意,但默认值表达式确实可以写成一个inline的函数表达式调用——一般称为立即执行函数(IIFE):
function foo( x =
(function(v){ return v + 11; })( 31 )
) {
console.log( x );
}
foo(); // 42
这绝对不是默认值表达式和立即执行函数正常的相处方式,如果你发现你在朝这条路上走,那么请退一步重新审视一下自己为什么要这么做!
警告:如果立即执行函数尝试去访问标识符x
,并且还没有声明自己的x
,那么它也会得到一个TDZ错误,和之前提到的一样。
在前面代码里的默认值表达式是一个立即执行函数,通过立即调用的(31)
执行的。如果不小心遗漏了这部分,那么x
的默认值将是函数引用本身,可能就像是一个回调函数。这样么做可能还比较有用,例如:
function ajax(url, cb = function(){}) {
// ..
}
ajax( "http://some.url.1" );
在这种情况下,我们非常希望没赋值的cb
是空操作,如果没有任何其他的指令。这里的函数表达式只是一个引用,并不是函数调用本身(我们省略了末尾的()
),反而达到了我们的目的。
早期的JS里有个不太为人知但是确实有用的技巧:Function.prototype
本身就是一个空的无操作函数。所以上面的表达式可以写成cb = Function.prototype
,这样就可以省掉函数表达式了。
该系列文章翻译自Kyle Simpson的《You don't know about Javascript》,本章原文在此。