ES2015系列--块级做用域

关于文章讨论请访问:https://github.com/Jocs/jocs....javascript

当Brendan Eich在1995年设计JavaScript第一个版本的时候,考虑的不是很周到,以致于最第一版本的JavaScript有不少不完善的地方,在Douglas Crockford的《JavaScript:The Good Parts》中就总结了不少JavaScript很差的地方,好比容许!===的使用,会致使隐式的类型转换,好比在全局做用域中经过var声明变量会成为全局对象(在浏览器环境中是window对象)的一个属性,在好比var声明的变量能够覆盖window对象上面原生的方法和属性等。前端

可是做为一门已经被普遍用于web开发的计算机语言来讲,去纠正这些设计错误显得至关困难,由于若是新的语法和老的语法有冲突的话,那么已有的web应用没法运行,浏览器生产厂商确定不会去冒这个险去实现这些和老的语法彻底冲突的功能的,由于谁都不想失去本身的客户,不是吗?所以向下兼容便成了解决上述问题的惟一途径,也就是说在不改变原有语法特性的基础上,增长一些新的语法或变量声明方式等,来把新的语言特性引入到JavaScript语言中。java

早在九年前,Brendan Eich在Firefox中就实现了初版的let.可是let的功能和现有的ES2015标准规定有些出入,后来由Shu-yu Guo将let的实现升级到符合现有的ES2015标准,如今才有了咱们如今在最新的Firefox中使用的let 声明变量语法。git

问题一:没有块级做用域

在ES2015以前,在函数中经过var声明的变量,不论其在{}中仍是外面,其均可以在整个函数范围内访问到,所以在函数中声明的变量被称为局部变量,做用域被称为局部做用域,而在全局中声明的变量存在整个全局做用域中。可是在不少情境下,咱们迫切的须要块级做用域的存在,也就是说在{}内部声明的变量只可以在{}内部访问到,在{}外部没法访问到其内部声明的变量,好比下面的例子:github

function foo() {
    var bar = 'hello'
    if (true) {
        var zar = 'world'
        console.log(zar)
    }
    console.log(zar) // 若是存在块级做用域那么将报语法错误:Uncaught ReferenceError
}

在上面的例子中,若是JavaScript在ES2015以前就存在块级做用域,那么在{}以外将没法访问到其内部声明的变量zar,可是实际上,第二个console却打印了zar的赋值,'world'。web

问题二:for循环中共享迭代变量值

在for循环初始循环变量时,若是使用var声明初始变量i,那么在整个循环中,for循环内部将共享i的值。以下代码:面试

var funcs = []
for (var i = 0; i < 10; i++) {
    funcs.push(function() {
        return i
    })
}
funcs.forEach(function(f) {
    console.log(f()) // 将在打印10数字10次
})

上面的代码并无按着咱们但愿的方式执行,咱们原本但愿是最后打印0、一、2...9这10个数字。可是最后的结果却出乎咱们的意料,而是将数字10打印了10次,究其缘由,声明的变量i在上面的整个代码块可以访问到,也就是说,funcs数组中每个函数返回的i都是全局声明的变量i。也就说在funcs中函数执行时,将返回同一个值,而变量i初始值为0,当迭代最后一次进行累加,9+1 = 10时,经过条件语句i < 10判断为false,循环运行完毕。最后i的值为10.也就是为何最后全部的函数都打印为10。那么在ES2015以前可以使上面的循环打印0、一、二、… 9吗?答案是确定的。编程

var funcs = []
for (var i = 1; i < 10; i++) {
    funcs.push((function(value) {
        return function() {
            return value
        }
    })(i))
}
funcs.forEach(function(f) {
    console.log(f())
})

在这儿咱们使用了JavaScript中的两个很棒的特性,当即执行函数(IIFEs)和闭包(closure)。在JavaScript的闭包中,闭包函数可以访问到包庇函数中的变量,这些闭包函数可以访问到的变量也所以被称为自由变量。只要闭包没有被销毁,那么外部函数将一直在内存中保存着这些变量,在上面的代码中,形参value就是自由变量,return的函数是一个闭包,闭包内部可以访问到自由变量value。同时这儿咱们还使用了当即执行函数,当即函数的做用就是在每次迭代的过程当中,将i的值做为实参传入当即执行函数,并执行返回一个闭包函数,这个闭包函数保存了外部的自由变量,也就是保存了当次迭代时i的值。最后,就可以达到咱们想要的结果,调用funcs中每一个函数,最终返回0、一、二、… 9。数组

问题三:变量提高(Hoisting)

咱们先来看看函数中的变量提高, 在函数中经过var定义的变量,不论其在函数中什么位置定义的,都将被视做在函数顶部定义,这一特定被称为提高(Hoisting)。想知道变量提高具体是怎样操做的,咱们能够看看下面的代码:浏览器

function foo() {
    console.log(a) // undefined
    var a = 'hello'
    console.log(a) // 'hello'
}

在上面的代码中,咱们能够看到,第一个console并无报错(ReferenceError)。说明在第一个console.log(a)的时候,变量a已经被定义了,JavaScript引擎在解析上面的代码时其实是像下面这样的:

function foo() {
  var a
  console.log(a)
  a = 'hello'
  console.log(a)
}

也就是说,JavaScript引擎把变量的定义和赋值分开了,首先对变量进行提高,将变量提高到函数的顶部,注意,这儿变量的赋值并无获得提高,也就是说a = "hello"依然是在后面赋值的。所以第一次console.log(a)并无打印hello也没有报ReferenceError错误。而是打印undefined。不管是函数内部仍是外部,变量提高都会给咱们带来意想不到的bug。好比下面代码:

if (!('a' in window)) {
  var a = 'hello'
}
console.log(a) // undefined

不少公司都把上面的代码做为面试前端工程师JavaScript基础的面试题,其考点也就是考察全局环境下的变量提高,首先,答案是undefined,并非咱们期许的hello。缘由就在于变量a被提高到了最上面,上面的代码JavaScript实际上是这样解析的:

var a
if (!('a' in window)) {
  a = 'hello'
}
console.log(a) // undefined

如今就很明了了,bianlianga被提高到了全局环境最顶部,可是变量a的赋值仍是在条件语句内部,咱们知道经过关键字var在全局做用域中声明的变量将做为全局对象(window)的一个属性,所以'a' in windowtrue。因此if语句中的判断语句就为false。所以条件语句内部就根本不会执行,也就是说不会执行赋值语句。最后经过console.log(a)打印也就是undefined,而不是咱们想要的hello

虽然使用关键词let进行变量声明也会有变量提高,可是其和经过var申明的变量带来的变量提高是不同的,这一点将在后面的letvar的区别中讨论到。

关于ES2015以前做用域的概念

上面说起的一些问题,不少都是因为JavaScript中关于做用域的细分粒度不够,这儿咱们稍微回顾一下ES2015以前关于做用域的概念。

Scope: collects and maintains a look-up list of all the declared identifiers (variables), and enforces a strict set of rules as to how these are accessible to currently executing code.

上面是关于做用域的定义,做用域就是一些规则的集合,经过这些规则咱们可以查找到当前执行代码所需变量的值,这就是做用域的概念。在ES2015以前最多见的两种做用域,全局做用局和函数做用域(局部做用域)。函数做用域能够嵌套,这样就造成了一条做用域链,若是咱们自顶向下的看,一个做用域内部能够嵌套几个子做用域,子做用域又能够嵌套更多的做用域,这就更像一个‘’做用域树‘’而非做用域链了,做用域链是一个自底向上的概念,在变量查找的过程当中颇有用的。在ES3时,引入了try catch语句,在catch语句中造成了新的做用域,外部是访问不到catch语句中的错误变量。代码以下:

try {
  throw new Error()
} catch(err) {
  console.log(err)
}
console.log(err) //Uncaught ReferenceError

再到ES5的时候,在严格模式下(use strict),函数中使用eval函数并不会再在原有函数中的做用域中执行代码或变量赋值了,而是会动态生成一个做用域嵌套在原有函数做用域内部。以下面代码:

'use strict'
var a = function() {
    var b = '123'
    eval('var c = 456;console.log(c + b)') // '456123'
    console.log(b) // '123'
    console.log(c) // 报错
}

在非严格模式下,a函数内部的console.log(c)是不会报错的,由于eval会共享a函数中的做用域,可是在严格模式下,eval将会动态建立一个新的子做用域嵌套在a函数内部,而外部是访问不到这个子做用域的,也就是为何console.log(c)会报错。

经过let来声明变量

经过let关键字来声明变量也经过var来声明变量的语法形式相同,在某些场景下你甚至能够直接把var替换成let。可是使用let来申明变量与使用var来声明变量最大的区别就是做用域的边界再也不是函数,而是包含let变量声明的代码块({})。下面的代码将说明let声明的变量只在代码块内部可以访问到,在代码块外部将没法访问到代码块内部使用let声明的变量。

if (true) {
  let foo = 'bar'
}
console.log(foo) // Uncaught ReferenceError

在上面的代码中,foo变量在if语句中声明并赋值。if语句外部却访问不到foo变量,报ReferenceError错误。

letvar的区别

变量提高的区别

在ECMAScript 2015中,let也会提高到代码块的顶部,在变量声明以前去访问变量会致使ReferenceError错误,也就是说,变量被提高到了一个所谓的“temporal dead zone”(如下简称TDZ)。TDZ区域从代码块开始,直到显示得变量声明结束,在这一区域访问变量都会报ReferenceError错误。以下代码:

function do_something() {
  console.log(foo); // ReferenceError
  let foo = 2;
}

而经过var声明的变量不会造成TDZ,所以在定义变量以前访问变量只会提示undefined,也就是上文以及讨论过的var的变量提高。

全局环境声明变量的区别

在全局环境中,经过var声明的变量会成为window对象的一个属性,甚至对一些原生方法的赋值会致使原生方法的覆盖。好比下面对变量parseInt进行赋值,将覆盖原生parseInt方法。

var parseInt = function(number) {
  return 'hello'
}
parseInt(123) // 'hello'
window.parseInt(123) // 'hello'

而经过关键字let在全局环境中进行变量声明时,新的变量将不会成为全局对象的一个属性,所以也就不会覆盖window对象上面的一些原生方法了。以下面的例子:

let parseInt = function(number) {
  return 'hello'
}
parseInt(123) // 'hello'
window.parseInt(123) // 123

在上面的例子中,咱们看到let生命的函数parsetInt并无覆盖window对象上面的parseInt方法,所以咱们经过调用window.parseInt方法时,返回结果123。

在屡次声明同一变量时处理不一样

在ES2015以前,能够经过var屡次声明同一个变量而不会报错。下面的代码是不会报错的,可是是不推荐的。

var a = 'xiaoming'
var a = 'huangxiaoming'

其实这一特性不利于咱们找出程序中的问题,虽然有一些代码检测工具,好比ESLint可以检测到对同一个变量进行屡次声明赋值,可以大大减小咱们程序出错的可能性,但毕竟不是原生支持的。不用担忧,ES2015来了,若是一个变量已经被声明,不管是经过var仍是let或者const,该变量再次经过let声明时都会语法报错(SyntaxError)。以下代码:

var a = 345
let a = 123 // Uncaught SyntaxError: Identifier 'a' has already been declared

最好的老是放在最后:const

经过const生命的变量将会建立一个对该值的一个只读引用,也就是说,经过const声明的原始数据类型(number、string、boolean等),声明后就不可以再改变了。经过const声明的对象,也不能改变对对象的引用,也就是说不可以再将另一个对象赋值给该const声明的变量,可是,const声明的变量并不表示该对象就是不可变的,依然能够改变对象的属性值,只是该变量不能再被赋值了。

const MY_FAV = 7
MY_FAY = 20 // 重复赋值将会报错(Uncaught TypeError: Assignment to constant variable)
const foo = {bar: 'zar'}
foo.bar = 'hello world' // 改变对象的属性并不会报错

经过const生命的对象并非不可变的。可是在不少场景下,好比在函数式编程中,咱们但愿声明的变量是不可变的,不论其是原始数据类型仍是引用数据类型。显然现有的变量声明不可以知足咱们的需求,以下是一种声明不可变对象的一种实现:

const deepFreeze = function(obj) {
    Object.freeze(obj)
    for (const key in obj) {
        if (typeof obj[key] === 'object') deepFreeze(obj[key])
    }
    return obj
}
const foo = deepFreeze({
  a: {b: 'bar'}
})
foo.a.b = 'zar'
console.log(foo.a.b) // bar

最佳实践

在ECMAScript 2015成为最新标准以前,不少人都认为let是解决本文开始罗列的一系列问题的最佳方案,对于不少JavaScript开发者而言,他们认为一开始var就应该像如今let同样,如今let出来了,咱们只须要根据现有的语法把之前代码中的var换成let就行了。而后使用const声明那些咱们永远不会修改的值。

可是,当不少开发者开始将本身的项目迁移到ECMAScript2015后,他们发现,最佳实践应该是,尽量的使用const,在const不可以知足需求的时候才使用let,永远不要使用var。为何要尽量的使用const呢?在JavaScript中,不少bug都是由于无心的改变了某值或者对象而致使的,经过尽量使用const,或者上面的deepFreeze可以很好地规避这些bug的出现,而个人建议是:若是你喜欢函数式编程,永远不改变已经声明的对象,而是生成一个新的对象,那么对于你来讲,const就彻底够用了。

相关文章
相关标签/搜索