ES6学习笔记:块级作用域绑定

发布于 大漠

过去,JavaScript的变量声明机制不像C语言一样,在声明变量的同时也会创建变量(绑定)。在以前的JavaScript中何时创建变量要看怎么声明变量。在以前的变量作用域有全局作用域和局部作用域,但不像其他的程序语言有块作用域一说。在ES6中新引入的块级作用域绑定机制。

var声明及变量提升

在函数作用域或全局作用域中通过var声明的变量,不管是在哪声明的,都会被当成在当前作用域顶部声明的变量,这也被称之为变量提升。拿个示例来说:

function getValue (condition) {
    console.log(value);        // => undefined
    if (condition) {
        var value = 'w3cplus';
        console.log(value);   // => w3cplus 
        return value;         // => 如果condition为true,返回w3cplus
    } else {
        console.log(value);   // => undefined
        return null;          // => 如果condition为false,返回null
    }
    console.log(value);       // => undefined
}
getValue(true);  // => w3cplus
getValue(false); // => null

刚接触JavaScript的时候,一直以为conditiontrue时才会创建value变量。而事实上,不管condition不管是为true还是false都已经创建了value变量。在预编译阶段,JavaScript引擎会将上面的getValue()函数修改成:

function getValue(condition) {
    var value;
    console.log(value);

    if (condition) {
        var value = 'w3cplus';
        console.log(value);
        return value;
    } else {
        console.log(value);
        return null;
    }
    console.log(value);
}

变量value被提升到函数顶部,而初始化操作依旧留在原处执行,这也就是说else {} 中也可以访问到value变量,而且此时的value并未初始化,所以其值为undefined

变量提升,简单的理解,就是把变量提升至函数的最顶部地方。需要说明的是:变量提升只是提升变量的声明,并不会把赋值也提升上来,没有赋值的变量初始值是undefined。所以上面就出现了声明为undefinedvar,因为赋值在后面声明提升在了前面。

还有一点需要注意的是因为JavaScript是函数级作用域,只有函数才会创建新的作用域,而不像其他语言有块级作用域,比如if语句块。就上面的示例而言,不管会不会进入if语句块,函数声明都会提升到当前作用域的顶部,得到执行。在JavaScript并不会创建一个新的作用域。

扩展阅读:

块级声明

把上面的示例做一下调整,如下:

console.log(value);  // => ReferenceError: value is not defined
function getValue(condition) {
    console.log(value); // => undefined
    
    if (condition) {
        var value = 'w3cplus';
        console.log(value); // => 如查condition为true, 输出w3cplus
        return value;
    } else {
        console.log(value); // => 如果condition为false, 输出undefined
        return null;
    }
    console.log(value); // => undefined
}

getValue(true);  // => w3cplus 
getValue(false); // => null

在函数外调用value会报错ReferenceError: value is not defined错误信息。也就是说在函数体内声明的变量,在函数体外是无法调用的。这里就涉及到了全局作用域和局部作用域相关的概念。这里暂且不说。但在函数内部的我们称之为块级作用域。上面的示例也说明,块级里面声明的变量只能经块级作用域中使用,在指定块的作用域之外无法访问块级声明。简而言之,块内声明的变量,在块外无法使用

在JavaScript中块级作用域不仅存在于函数内部,也存在于块中,比如{}iffor这样的语块)。如果在iffor这样的语句块中,使用var声明的变量,在外部(除函数体外)是可以被访问到的,只不过有可能其值是undefined。为了让JavaScript中能像其他的程序语言一样,所以引入了块级作用域,让JavaScript变得更灵活也更普通。

letconst

在ES6中引入了letconst关键词用来声明变量。letvar类似都是用来声明变量的,不同的是,let声明的变量的作用域名限制在当前代码块中。比如文章开头的示例,把if语句块中的var替换成let,结果就又将不一样:

function getValue(condition) {
    console.log(value);  // => ReferenceError: value is not defined
    if (condition) {
        let value = 'w3cplus';
        console.log(value); // => w3cplus
        return value; // => w3cplus
    } else {
        console.log(value); // => 如果condition为false,程序执行到此报错:ReferenceError: value is not defined
        return null;
    }
    console.log(value); // => ReferenceError: value is not defined
}
getValue(true);

let声明的变量,不会像var声明的变量一样被提升至函数顶部。执行流离开if语句块,value会立即被销毁。如果condition的值为false,就永远不会声明并初始化value

let声明的变量没有变量提升

ES6中还提供了const关键词来声明变量,但这个变量的值是不变的,也被称之为常量。其值一旦被设定后不可更改。因此,每个通过const声明的常量必须进行初始化

// 有效的常量
const MAX_ITEMS = 30;

// 语法错误:常量未初始化
const MAX_ITEMS;

constlet类似,声明的变量都只能在块作用域下有效,所以常量也只在当前代码块内有效,一旦执行到块外会立即被销毁。也就是说,每个通过const声明的常量也不会有变量提升

var不同,letconst声明的变量不会被提升到作用域顶部,如果在声明之前访问这些变量,即使是相对安全的typeof操作符也会触发引用错误。

在JavaScript中,使用letconst声明变量有一个重要的特征,大家常称之为临时死区(TDZ),也常用TDZ来描述letconst的不提升效果。

JavaScript引擎在扫描代码发现变量时,要么将它们提升至作用域顶部(遇到var声明的变量),要么将声明放到TDZ中(遇到letconst声明的变量)。访问TDZ中的变量会触发运行时错误。只有执行过变量声明语句后,变量才会从TDZ中移出,然后方可正常访问。

有关于这方面的更多信息,可以阅读下面这些文章:

循环中的块作用域绑定

大家在ES6之前写for循环应该有碰到下面这样的场景:

// 场景一
var arr = [];
for (var i = 0; i < 3; i++) {
    arr.push(function(){
        return i;
    })
}
console.log(arr.map(function(x){return x();})); // => [3, 3, 3]

// 场景二
for (var i = 0; i < 5; ++i) {
    setTimeout(function (){
        console.log(i); // => 输出'5' 五次
    }, 100)
}

这不是我们想要的结果。长久以来,var声明让开发者在循环中创建函数变得异常困难,因为变量到了循环体外还是能被访问。正如上面的代码所示,场景一得到的是[3,3,3],场景二得到连续输出五次的5。这一切都是因为循环里的每次迭代同时共享着变量i,循环内部创建的函数全都保留了对相同变量的引用。循环结束时变量i的值为5,所以每次调用console.log(i)时就会输出数字5(上例中的场景二)。

为了解决这个问题,开发者们在循环中使用立即调用函数表达式( IIFE),以强制生成计数器变量的副本,如下所示:

var arr = [];
for (var i = 0; i < 3; i++) {
    arr.push(function(value){
        return function() {
            console.log(value);
        }
    }(i));
}
arr.map(function(x){
    return x(); // => [0, 1, 2]
})

在循环内部,IIFE表达式为接受的每一个变量i都创建了一个副本并存储为变量value。这个变量的值就是相应迭代创建的函数所使用的值,因此调用每个函数都会从02循环一样得到期望的值。

有关于JavaScript中IIFE相关资料:

在ES6中就不要这么蛋疼了,使用ES6中的letconst提供的块级绑定会让事情简单的多。

循环中的let

let声明模仿了上面所描述的IIFE所做的一切来简化循环过程,每次迭代循环都会创建一个新变量,并以之前迭代中同名变量的值将其初始化。也就是说,上面的代码换成这样即可得到我们想要的值:

var arr = [];
for (let i = 0; i < 3; i++) {
    arr.push(function(){
        return i;
    })
}
console.log(arr.map(function(x){return x();})); // => [0, 1, 2]

循环中运到let声明的变量都会创建一个新的变量i,并将其初始化为i的当前值,所以循环内部创建的每个函数都能得到属于它们自己的i的副本。

除了在for中之外,在for-infor-of循环中作用是类似的。

循环中的const

循环中的let声明的变量能得到IIFE的功效,那么是不是说constfor这样的循环体头部也能达到类似IIFE的功效呢?因为前面也说过,const有点类似于let。至于是不是如此,先把上同的示例换成下面的代码:

var arr = [];
for (const i = 0; i < 3; i++) {
    arr.push(function(){
        return i;
    })
}
console.log(arr.map(function(x){return x();}));

并不如我们所想,如果把for循环中的let直接换成const之后,执行上面的代码会报错:

// => TypeError: Assignment to constant variable.

为什么会如此呢?仔细回忆一下。前面提到过,使用const声明的变量是一个常量,那么在上面的示例中,变量i就声明为常量。在循环的第一次迭代中,i0,迭代执行成功。然后执行i++,代码试图修改常量。如此一来就违背了const的原则。使用const声明的常量,是不能修改的。这样一来就报TypeError错误。所以说,如果后续循环体内不会修改该变量,那么就可以使用const来声明,否则不能使用const声明变量。

for-infor-of循环中使用const时的行为与使用let一致。比如下面的代码就不会报错:

var funcs = [];
var obj = {
    name: 'w3cplus'.
    age: 7,
    job: 'FE'
};

for (const key in obj) {
    funcs.push(function (){
        console.log(key);
    })
}

funcs.map(function(x){
    return x(); // => name, age, job
})

constfor-infor-of循环中能正常运行,那是因为每次迭代不会像for循环一样修改已有绑定,而是会创建一个新绑定。

全局块作用域绑定

letconstvar的另一个区别是它们在全局作用域中的行为。当var被用于全局作用域时,它会创建一个新的全局变量作为全局对象的属性。这意味着用var很可能会无意中覆盖一个已经存在的全局变量,如下:

// 在浏览器中
var RegExp = 'w3cplus';
console.log(window.RegExp); // => w3cplus

var name = 'damo';
console.log(window.name); // => damo

如果在全局作用域中使用letconst,会在全局作用域下创建一个新的绑定,但该绑定不会添加为全局对象的属性。换句话说, letconst不能覆盖全局变量,而只能遮蔽它。如此一来,如果不想为全局对象创建属性,则使用letconst要安全得多。

varlet

简单的概括一下:

  • 通过var声明的变量,它的作用域是在function或任何外部已经被声明的function,是全域的
  • 通过let声明的变量,它的作用域是在一个块

比如:

function varvslet() {
    console.log(i); // i 是 undefined 的,因为变量提升
    // console.log(j); // ReferenceError: j 没有被定义

    for( var i = 0; i < 3; i++ ) {
        console.log(i); // 0, 1, 2
    };

    console.log(i); // 3
    // console.log(j); // ReferenceError: j 没有被定义

    for( let j = 0; j < 3; j++ ) {
        console.log(j);
    };

    console.log(i); // 3
    // console.log(j); // ReferenceError: j 没有被定义
}

两者区别:

  • 变量提升: let不会被提升到整个块的作用域。相比之下,var可以被提升
  • 循环中的闭包: let在每次循环可以重新被绑定,确保在它之前结束的循环被重新赋值,所以在闭名中它被用来避免一些问题

那我们应该用let替代var吗?

不是的,let 是新的块作用域。语法强调在 var 已经是区块作用域时,let 应该替换 var ,否则请不要替换 varlet 改善了在 JavaScript 作用域的选项,而不是取代。var 对于变量依旧是有用的,可被用在整个 function 之中。

块级绑定最佳实践

很多人都认为,在ES6中应该默认使用let而不是var。对于很多JavaScript开发者而言,let实际上与他们想要的var一样,直接替换也符合逻辑。这种情况下,对于需要写保护的变量则要使用const

如果你开始使用ES6的话,默认使用const,只有确实需要改变变量的值时使用let。因为大部分变量的值在初始化之后不应该再改变,而预料外的变量值的改变是很多Bug的源头。

总结

块级作用域绑定的letconst为JavaScript引入词法作用域,它们声明的变量不会提升,而且只可以在声明这些变量的代码块中使用。虽然这个功能给我们带来很多方便之处,但也存在一个副作用:不能在声明变量前访问它们,就算是typeof这样安全的操作符也不行。在声明前访问块绑定会导致错误,因为绑定还在临时死区(TDZ)中。

letconst的行为很多时候和var一致。然而,它们在循环中的行为运不一样。在for-infor-of循环中,letconst都会每次迭代时创建新绑定,从而使循环体内创建的函数可以访问到相应的迭代值,而非最后一次迭代后的值(像使用var一样)。letfor循环中同样如此,但在for循环中使用const声明则有可能会引发错误。

综合所述,在ES6中声明变量时,**默认使用const,只在确实需要改变变量的值时使用let。**这样就可以在某种程度中实现代码的不可变,从而防止某些错误的产生。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/javascript/es6-block-scoping.htmlAir Force 1 High