javascript ·

js中的块级作用域

上一篇中说到了作用域,简单介绍了一下块级作用域,在这里我们来详细介绍一下。

众所周知,在js中函数作用域是常见的单元作用域,也是现行的大多数js中最普遍的设计方案。但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可以实现维护起来更加优秀、简洁的代码。这就是我们现在要说的块级作用域。

我们还用之前已经用过的一个例子

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

我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。这就是块作用域的用处。变量的声明应该距离使用的地方越近越好,并最大限度地本地化。另外一个例子:

var foo = true;
if (foo) {
 var bar = foo * 2;
 bar = something( bar );
 console.log( bar );
}

bar 变量仅在 if 声明的上下文中使用,因此如果能将它声明在 if 块内部中会是一个很有意义的事情。但是,当使用 var 声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。这段代码是为了风格更易读而伪装出的形式上的块作用域,如果使用这种形式,要确保没在作用域其他地方意外地使用 bar 只能依靠自觉性。块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

为什么要把一个只在 for 循环内部使用(至少是应该只在内部使用)的变量 i 污染到整个函数作用域中呢?所以块级作用域在开发中对于变量的管理以及垃圾回收是很有用处的。那么都有什么可以形成块级作用域呢,下面我们来看一下

try/catch

这个东西相信很多人也都用过,但是我们大部分时间写代码都是在try块中写的,不要认为try中写的代码就是块级作用域,其实里面声明的变量也会被声明为全局变量。我们这里想要说的是catch中的变量,下面来看一个例子

try{
    var sex="男";
    throw "oecom"
}catch(e){
    console.log(e)
}
console.log(sex);//男
console.log(e);//Uncaught ReferenceError: e is not defined

正如你所看到的,err 仅存在 catch 分句内部,当试图从别处引用它时会抛出错误。也许 catch 分句会创建块作用域这件事看起来像教条的学院理论一样没什么用处,很多人认为有病才会这么写代码,我为了声明一个块级作用域变量还得强制抛出一个异常。因为catch 分句具有块作用域,因此它可以在 ES6 之前的环境中作为块作用域的替代方案。一些工具可以将 ES6 的代码转换成能在 ES6 之前环境中运行的形式。你可以使用块作用域来写代码,并享受它带来的好处,然后在构建时通过工具来对代码进行预处理,使之可以在部署时正常工作,这就是要说catch作为块级作用域的意义。

let

ES6的出现对于js开发者来说一个非常开心的事情,,其中一点就是他引入了新的 let 关键字,提供了除 var 以外的另一种变量声明方式。let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let为其声明的变量隐式地绑定了所在的块作用域。

var foo = true;
if (foo) {
 let bar = foo * 2;
 console.log( bar );
}
console.log( bar );

js中的块级作用域

用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。在开发和修改代码的过程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯性地移动这些块或者将其包含在其他的块中,就会导致代码变得混乱。

但是隐式的声明块级作用域在代码修改过程中很容易忽略掉他的作用域位置,所以我们在写代码的时候可以显示的声明一下,就是在他的前后添加上{},这样整个代码块的移动不会产生其他的问题。

var foo = true;
if (foo) {
    {
    let bar = foo * 2;
    console.log( bar );
 }
}

只要声明是有效的,在声明中的任意位置都可以使用 { .. } 括号来为 let 创建一个用于绑定的块。当然也包括循环了。

for (let i=0; i<10; i++) {
 console.log( i );
}
console.log( i ); // ReferenceError

最后输出的这个i是会报错的。for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环
的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。其原理如下所示

{
let j;
for (j=0; j<10; j++) {
    let i = j; // 每个迭代重新绑定!
    console.log( i );
 }
}

由于 let 声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域),当代码中存在对于函数作用域中 var 声明的隐式依赖时,就会有很多隐藏的陷阱,如果用let 来替代 var 则需要在代码重构的过程中付出额外的精力。

var foo = true, baz = 10;
if (foo) {
 var bar = 3;
 if (baz > bar) {
 console.log( baz );
 }
 // ...
}

在使用块级作用域的变量时需要注意以下变化

var foo = true, baz = 10;
if (foo) {
 let bar = 3;
 if (baz > bar) { // <-- 移动代码时不要忘了 bar!
 console.log( baz );
 }
}

既然说到了let,不得不说一下变量提升,在之前使用var声明变量时,是有变量提升这么一说的,例如:

console.log(a);
var a = 10;

这样写完全没有问题,会直接输出undefined,因为其过程相当于是

var a;
console.log(a);
a = 10;

但是使用let声明的变量是不存在变量提升的,在声明之前使用会报错。

const

除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。其效果和let差不多,在此不再赘述。

总结

块级作用域非常有用的一点和闭包及回收内存垃圾的回收机制相关。来考虑一下下面这段代码

function process(data) {
 // 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
 console.log("button clicked");
}, false );

这种方式在代码中很常见,click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。

但是块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:

function process(data) {
 // 在这里做点有趣的事情
}
{
    let someReallyBigData = { .. };
    process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
 console.log("button clicked");
}, false );

尽管新版本的js提供了块级作用域,但是我们应该合理的在代码中使用,结合不同场景使用不同的作用域,创造可读、可维护的优良代码。

参与评论