深入理解es6读后总结--块级作用域绑定

1,312 阅读11分钟

var 声明及变量提升机制

在写js代码时,很多人会使用var关键字来声明变量。var关键字声明的变量使得我们无论在函数作用域还是全局作用域中任意地方声明的一个变量都会被当成在当前作用域的顶部声明的,这就是我们常说的变量提升(Hoisting)机制。用一个函数举例子:

function example(flag) {
  console.log(value)  //此处可访问变量value,值为undefined
  if (flag) {
    var value = 'hello'
    console.log(value)  //此处可访问变量value,值为hello
  } else {
    console.log(value)  //此处可访问变量value,值为undefined
  }
}

不了解js的人可能认为只有当flag为true的时候才能访问到value变量,但其实var声明的value变量已经被提升到了example函数作用域的顶部了,这样在此函数的任意位置都是可以访问到value变量的,只不过只有当flag为true是value才会被初始化才有值。在预编译阶段,js引擎会将上面的example 函数解析成下面这样:

function example(flag) {
  var value
  console.log(value)  //此处可访问变量value,值为undefined
  if (flag) {
    var value = 'hello'
    console.log(value)  //此处可访问变量value,值为hello
  } else {
    console.log(value)  //此处可访问变量value,值为undefined
  }
}

刚接触js的开发者通常会花一下时间来习惯变量提升,有时还会因为误解导致程序中出现bug。为此es6中引入块级作用域来强化对变量生命周期的控制。

块级声明

通常若我们需要一个变量只能在指定块中访问时会用到块级声明。块级作用域(也称词法作用域)存在于:

  • 函数内部
  • 块中(“{}”之间的区域)

let声明

let的声明的var的声明语法相同。通过let声明的变量只能在当前作用域只访问到,并且let声明的变量不会被提升,因此开发者通常会把let声明的变量放在作用域顶部以便整个作用域可以访问。以下是let声明的示例:

function example(flag) {
  console.log(value)  //此处不存在变量value
  if (flag) {
    console.log(value)  //此处不存在变量value
    let value = 'hello'
    console.log(value)  //此处可访问变量value,值为hello
  } else {
    console.log(value)  //此处不存在变量value
  }
}

示例中let声明的变量value不会被提升到顶部,执行流离开if块,value就会立刻被销毁,如果flag为false那么value变量将永远不会被声明和初始化。

禁止重声明

如果let关键字声明了重复的某个标识符,那么就会抛出报错,举个例子:

var count = 30
//抛出语法错误
let count = 40

在这个示例中,如上所说let不能重复声明已经存在的标识符,所以此处let声明会报错。但是如果let 声明的count变量的当前作用域被内嵌在另一个作用域就可以被正常访问,比如:

var count = 30
if (flag) {
  //不会抛出语法错误
  let count = 40
}

此处的let 声明的count只在作用域if块中能被访问,并且在if块中let声明的count 会覆盖外面var声明的count变量

const声明

const是用来声明常量的关键字,其值一旦被确定就不能被更改,所以每个通过const声明的常量必须进行初始化。

//有效常量
const maxItems = 30
//语法错误: 常量未初始化
const name;

const与let

const与let声明的都是块级标识符,所以const声明的常量只能在当前作用域访问到也不会被提升到顶部。同样,const也不能声明重复的标识符,无论该标识符时使用var(在全局或函数作用域中),还是let(在块级作用域中)声明的。举例来说:

var maxItems = 30
let name = 'coco'
//这两条语句都会抛出错误
const maxItems = 60
const name = 'koko'

尽管const和let相似之处很多,但是他们还是有一个最大的区别,就是const声明的常量不可以再次被赋值。es6中的常量这一点和其他语言很像,然而,与其他语言不同的是如果js中常量是对象,则对象中的值可以修改。

用const声明对象

const声明不允许修改绑定,但允许修改值,这就意味着用const生命的对象后,可以修改对象的属性值。举个例子:

const dog = {
  name: 'koko'
  }
  //可以修改对象属性的值
  dog.name = 'coco'
  //抛出语法错误
  dog = {
    name: 'coco'
  }

在这段代码中修改dog的属性不会报错,因为修改的是dog包含的值,dog的绑定并没有变。但如果改变的dog的绑定就会报错

临时死区(Temporal Dead Zone)

let和const与var不同,如果要在let或const声明变量之前访问这些变量,即使是相对安全的typeof操作也会触发引用错误,比如下面这段代码:

if (flag) {
  console.log(typeof (value));  //引用错误
  let value = 'b'
}

因为console.log(typeof (value))抛出错误,所以value的声明和初始化不会被执行,此时的value还位于js社区所谓的“临时死区”(TDZ)中,人们常用他来描述let和const的不提升效果。(这里以let为例,但是const也是如此)。js引擎在扫描代码发现变量声明时,要么将他们提升到作用域顶部(遇到var声明),要么将声明放入TDZ中(遇到let和const声明)。访问TDZ中的变量会触发运行时错误。只有执行过变量声明语句后,变量才会从TDZ中移出,然后方可正常访问。
从上例可看出,即便是不容易出错的typeof也无法阻止引擎抛出错误,但是在let声明的作用域外对该变量使用typeof则不会报错,比如:

console.log(typeof (value)); //"undefined"
if (flag) {
  let value = 'b'
}

typeof是在声明变量value代码块外执行的,此时value并不在TDZ中。这也就意味着不存在value这个绑定,typeof操作最终返回‘undefined’。TDZ只是块级绑定的特色之一,而在循环中使用块级绑定也是一个特色。

循环中的块作用域绑定

开发者应该最希望实现for循环中的块级作用域了,因为这样他们可以将随意声明的计数器变量限制在for循环中使用。像下面这种代码在js中是很常见的:

for (var i = 0; i < 10; i++) {
  process(items[i]);
}
console.log(i)  //在这里任然可以访问变量i

示例代码中因为计数器变量i是由var声明的所以变量i会被提升至for循环外的作用域顶部,所以即便是在循环之外也可以访问变量i,但如果换用let声明就可以达到想要的效果:

for (let i = 0; i < 10; i++) {
  process(items[i]);
}
console.log(i)  //在这里不可以访问变量i,抛出错误

在上例中,let声明的变量i只在for循环中可访问。在其它语言中(默认拥有块级作用域的)上面两个示例也可以正常运行,但是变量i始终只在for循环中可访问。

循环中的函数

长久以来,使用var声明会让开发者在循环中创建函数变得很困难,因为每一次的循环的变量到了下一次循环任然可访问。比如:

var funArr = [] 
for (var i = 0; i < 10; i++) {
  funArr.push(function () {
    console.log(i)
  })
}
funArr.forEach(function (func) {
  func()  //输出10次数字10
})

上例中可能你想要的是输出0-9,可是他却一连串输出了10次10。这是因为循环里的每次迭代同时共享着变量i,循环内部创建的函数全都保留了对相同变量的引用(细细品味)。循环结束的时候i最后被赋予的值是10,所以之前循环中内部创建的函数里引用的变量i的值都是10,每次调用console.log(i)时就会输出数字10。
为此,开发者们再循环中使用立即调用函数表达式(IIFE),以强制生成计数器变量的副本,就像这样:

var funArr = [] 
for (var i = 0; i < 10; i++) {
  funArr.push((function (value) {
    return function () {
      console.log(value)  
    }
  }(i)))
}
funArr.forEach(function (func) {
  func()  //输出0-9
})

上例中,IIFE表达式会为每次循环的变量i生成一个副本(参数value变量),变量value替代了每次循环中创建的函数中引用的变量i,且value的值为本次循环中i的值,每次循环生成的value都只在本次循环中可访问。因此调用函数就会生成我们所期望的0-9。es6中let和const提供的块级绑定让我们无需再这么折腾。

循环中的let声明

let声明和上例IIFE所做的事类似,也是每次迭代循环都会创建一个新变量,并以之前迭代中同名变量的值将其初始化。但是代码相对简洁。代码示例:

var funArr = [] 
for (let i = 0; i < 10; i++) {
  funArr.push(function () {
    console.log(i)
  })
}
funArr.forEach(function (func) {
  func()  //输出0-9
})

上例代码中每次循环let声明都会创建一个新变量i并初始化为i的当前值,所以每次循环创建的函数都能得到属于他们自己的i的副本。对于for-in循环和for-of循环来说也是一样的,示例如下:

var funArr = [], 
object = {
  a: true,
  b: true,
  c: true
};
for (let key in object) {
  funArr.push(function () {
    console.log(key)
  })
}
funArr.forEach(function (func) {
  func()  //输出a,b,c
})

上例中for-in循环与上上例中for循环表现得行为一致,也是为每次循环中创建的函数赋予一个新变量(上例中为变量key)。但如果使用var声明则这些函数都会输出‘c’。

let声明在循环内部的行为是标准中专门定义的,他不一定与let的不提升特性相关,理解这一点至关重要。事实上,早期的let 实现不包含这一行为,他是后来加入的。

循环中的const声明

es6中没有明确指明循环中不允许使用const声明,在不同类型的循环中使用const声明它会表现出不同的行为。普通的for循环可以在初始化变量时使用const,但是一旦这个变量的值发生改变就会报错,就像这样:

var funArr = []
//完成一次迭代后抛出错误
for (const i = 0; i < 10; i++) {
  funArr.push(function () {
    console.log(i)
  })
}

在上例中,由const声明的常量i,在循环第一次迭代中,i=0迭代成功,然后执行i++试图改变常量i的值,所以会抛出错误。因此,const声明适用于后续循环不会修改该变量的循环中。
在for-in和for-of循环中使用const时的行为与使用let一致,下面这段代码应该不会产生错误:

var funArr = [], 
object = {
  a: true,
  b: true,
  c: true
};
//不会产生错误
for (const key in object) {
  funArr.push(function () {
    console.log(key)
  })
}
funArr.forEach(function (func) {
  func()  //输出a, b, c
})

这段代码与上上段代码几乎一致,唯一的区别是,循环内不能改变key的值。之前提到过,const声明的常量不可以修改他的绑定,但是可以修改常量的绑定的值(const声明的对象的属性)。之所以可以运用在for-in和for-of循环中,是因为每次迭代不会(像前面for循环的例子一样)修改已有绑定,而是会创建一个新的绑定。

全局块作用域绑定

let和const与var的另一个区别就是他们在全局作用域中的行为。当var被用于全局作用域时,它会创建一个新的变量作为全局对象的属性(全局对象通常是浏览器环境中的window对象)。这意味着var很可能会无意中覆盖一个已经存在的全局属性,就像这样:

//在浏览器中为全局对象window创建全局属性RegExp
var RegExp = "hello";
console.log(window.RegExp);  //“hello”
//全局属性RegExp被覆盖
var RegExp = "hi"
console.log(window.RegExp);  //“hi”

即便全局变量RegExp是定义在全局对象window上,也不能幸免于被var声明覆盖。示例中声明的全局变量RegExp会覆盖一个已经存在的全局属性。js过去一直都是这样。
如果在全局作用域中使用let或const声明,他们会在全局作用域下创建一个新的绑定,但该绑定不会添加为全局对象的属性。换句话说,let和const不会覆盖全局变量,只会遮盖他。示例如下:

let RegExp = "hello";
console.log(RegExp);  //“hello”
console.log(window.RegExp === RegExp) //false
const ncz = "hi";
console.log(ncz);  //“hi”
console.log("ncz" in window) //false

这里let声明的RegExp创建了一个绑定并遮蔽了全局RegExp的变量。结果是window.RegExp和RegExp不相同,但不会破坏全局作用域。const也是一样。如果不想为全局对象创建属性使用let和const声明要安全很多。

如果希望在全局对象下定义变量,任然可以使用var。这种情况常见于跨frame或跨window访问代码。

块级绑定最佳实践的进化

默认使用const,当需要改变变量的值时使用let。因为大部分变量初始化后值不应再改变,而预料外的变量值的改变是很多bug的源头。