JavaScript-你可能不了解的块级作用域

8,728 阅读6分钟

一、先来两个"梨子"

尽管你可能连一行带有块级作用风格的代码都没有写过,但是你这种常见的JavaScript代码一定很熟悉:

1.1

for (var i = 0; i < 5; i++) {
    console.log(i);
}

我们在for循环中直接定义了变量i,通常我们只想在循环体内部的上下文环境中使用i,但是事情并不是向着我们希望的发展,i会被隐式的绑定到外面的作用域(函数作用域或者是全局作用域)。

1.2

var a = true;
if (a) {
    var b = a * 2;
    b = func(b);
    console.log(b);
} 
function func(b) {
    return b + 1;
}

我们声明的变量b在代码使用时,仅仅在if声明的上下文使用,如果能将它限制在if的上下文将是一件很有意义的事情,但是"理想很饱满,现在很骨感",使用var声明变量,它在任何地方都是一样的,因为它将属于外部作用域。\color{red}{就好像,你属于地球,但是你隐式的被绑定到了宇宙一样}

上面简单叙述了两个小"梨子"

为什么我们希望变量可以绑定到自己的块级作用域了,不急我们慢慢往下看!

二、块级作用域定义

我相信在座的小伙子,如果每一天有一个面试官坐你对面:

面试官:请简单说一下块级作用域。
你:(思考,首先想一想什么是块级作用域....)???...不知道?

其实可以拆分为两部分来解释:作用域、块级。

  1. 作用域:还可以装个B,不同角度去解释作用域。如果觉得还不够,还可以说一下作用域分类(全局作用域、函数作用域、块级作用域)。
  • 广义:可访问变量、函数、对象的集合,决定代码区域中变量和其他资源的可见性。
  • 狭义:所有编程语言最基本的功能就是存储变量的的值,并且在之后能够访问和修稿它,这种访问或者修改变量的值得能力给程序带来了“状态”,如果没有状态,程序的灵活性会大大降低,在程序中如何存储变量,已经变量的访问,需要一套设计良好的规则,我们称这套规则为作用域。
  1. 块级:javaScript的块级就是{...}大括号内的代码块,我们称之为一个块级。

所以总结一下就是,块级作用域就是包含在{...}中的作用域。在这个作用域中,拥有着和函数作用域相同的行为。

三、如何创建一个块级作用域

就是大家一行ES6代码都没有写过,但是你也可能知道,在包含let、const的代码块中存在一个块级作用域。但是其实有很多种定义块级作用域的方式。早在ES6之前就可以创建块级作用域。

3.1 with

function m(obj) {
    with(obj) {
	    a = 2;
	    console.log(a);
    }
    console.log(obj);
    console.log(obj.a);
}
var obj = {}; m(obj);

with是一个难以理解的结构,JavaScript中有两个机制可以欺骗"词法作用域的方式,with就是其中之一,with本质上通过将一个对象的引用当做作用域来处理,将对象的属性当做作用域的标识符来处理,从而创建一个新的词法作用域(运行时)。

\color{red}{在这里with从对象中创建的作用域仅在with声明中而非外部作用域中有效。}

3.2 try/catch

try{
    undefined();
} catch(err) {
    console.log(err);
}
console.log(err);

非常少的人会注意到JavaScript的ES3规范中规定try/catch的分句会创建一个块级作用域,其中的变量仅在catch内部有效。

3.3 let

到了大家都熟悉的ES6了。

var a = true;
if (a) {
    let b = a * 2;
    b = func(b);
    console.log(b);
} 
function func(b) {
    return b + 1;
}
console.log(b); 

let关键字可以将变量变动到所在任意作用域(通常是{..}内部),换句话说,let为其声明的变量隐式的劫持了所在的作用域。

这里有一个小的知识可能需要大家注意,看如下代码:

function f() {
  console.log(a);
  let a = 2;
}
f(); // ReferenceError: a is not defined

这段代码直接报错a is not defined,let和const拥有类似的特征,阻止了变量提升,当执行console.log(a)的时候变量没有定义

  • MDN中写到:In ECMAScript 2015, let do not support Variable Hoisting, which means the declarations made using "let", do not move to the top of the execution block.

在MDN中认为let不存在变量提升

  • ECMA-262-13.3.1 Let and Const Declarations写到: let and const declarations define variables that are scoped to the running execution context's LexicalEnvironment. The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated.

这说明即使是 block 最后一行的 let 声明,也会影响 block 的第一行。这就是提升(hoisting)

  • ECMA-262: 8.2.1.2 Runtime Semantics: EvalDeclarationInstantiation( body, varEnv, lexEnv, strict)写到: The environment of with statements cannot contain any lexical declaration so it doesn't need to be checked for var/let hoisting conflicts.

这句话也间接的证明 let hoisting 的存在。

\color{red}{在 ECMAScript 2015中, let 也会提升到语句块的顶部。但是,在这个语句块中,在变量声明之前引用这个变量会导致一个 ReferenceError的结果。}

那其实大家会有疑问,为什么上面的代码会报错。其实这并不是由于变量不提示导致的,而是由于TDZ(临时性死区)导致的。

在举个例子:
{
    a = 2;
    let a;
}
这段代码可以解释为:
{
    let a;// 变量提升
    "start TDZ"
    a = 2; // 这里在TDZ中间,所以会导致a = 2 报错
    a;
    "end TDZ"
}

所以破案了:let是不存在变量提升。它“变量提升的行为”,是由于TDZ导致的。

so...总结一下

  • let 声明会提升到块顶部
  • 从块顶部到该变量的初始化语句,这块区域叫做 TDZ(临时死区)
  • 如果你在 TDZ 内使用该变量,JS 就会报错,注意TDZ 跟 hoisting不等价。

3.4 const

处了let以外,ES6还引入了const,同样可以用来创建块级作用域变量,但其值是固定的(常量)。之后任何视图修改\color{red}{值}的操作都会引起错误。

四、块级作用域的好处

4.1 防止内层变量会覆盖外层变量

var tmp = new Date();

function f() {
  console.log(tmp);
  if (false) {
    var tmp = 'hello world';
  }
}

f(); // undefined

上面代码的原意是,if代码块的外部使用外层的tmp变量,内部使用内层的tmp变量。但是,函数f执行后,输出结果为undefined,原因在于变量提升,导致内层的tmp变量覆盖了外层的tmp变量。

4.2let循环

for (let i = 0; i < 5; i++) {
    console.log(i);
}
console.log(i); 

for循环头部的let不仅将i绑定到for循环的块中,事实上它将其重新绑定到了循环的每一个迭代里面,确保使用上一个循环迭代结束时的值重新进行赋值。

4.3垃圾收集

function func(obj) {
    // doSomething
}
var obj = {...};
func(obj);
var bnt = document.getElementById('xxx');
bnt.addEventListener('click', function() {
    // doSomething 
});

在上述代码中,点击元素,触发click事件,在这里并不需要obj对象,理论上,当func执行后,在内存中obj就会被垃圾回收机制回收,但是click函数形成了一个覆盖整个作用域的闭包。JavaScript引擎极有可能依然保持这个结构,而不进行回收。

function func(obj) {
    // doSomething
}
{
    let obj = {...};
    func(obj);
}
var bnt = document.getElementById('xxx');
bnt.addEventListener('click', function() {
    // doSomething 
});

块级作用域可以让引擎清楚的理解到没有必要保持obj的内存,让垃圾回收机制进行回收。

五、总结

希望小伙伴喜欢我的文章,我们一起成长,谢谢大家!

参考:JavaScript变量提升运行机制

参考:es6.ruanyifeng.com/