Chapter 3: Function vs. Block Scope

"You Don't Know JS: Scope & Closures"

Posted by Wolfdu on July 12, 2017

本文为You don’t know JavaScript学习笔记

函数作用域和块作用域

在上一章中我们学习了,作用域包含了一系列的气泡,每一个气泡都可以作为容器,其中包含了标识符的定义。

但是究竟是什么生成了一个新的气泡?只有函数能生成新气泡吗?JavaScript中还有其他结构能生成气泡吗?

1. 函数中的作用域

分析如下代码:

function foo(a){
    var b = 2;

    //some code

    function bar(){
        //...
    }

    //more code

    var c = 3;
}

如上代码中,a, b, c, bar 都属于foo(…)的作用域气泡,在foo(…)外部无法访问,但是这些标识符在 foo(…)内部是都可以访问的,同样在bar(…)内部也是可以访问的(前提bar内部没有同名标识符)。

所以,函数作用域的含义是:属于这个函数的全部变量都可以在整个函数范围内使用及复用。

这样就能够充分利用JavaScript变量可以根据需要改变值类型的“动态”特性,同时如果不细心处理 那些可以在全局范围访问的变量,可能会带来意想不到的问题。

2. 隐藏内部实现

在代码中挑选任意一段代码,然后用函数声明对他进行包装,实际上就是把这些代码“隐藏”起来了。

实际结果是,在这段代码周围创建了一个作用域气泡,也就是说这段代码中的任何声明都将绑定在这个 新创建的包装函数的作用域中。

这里涉及到软件设计中的最小特权原则

在软件设计中,应该最小限度的暴露必要的内容,而将其他的内容都“隐藏”起来,比如某个模块对象的 AIP设计。

设想一下如果所有的变量和函数都在全局作用域中,那么所有的内部嵌套的作用域都能够访问的到。 这样就破坏了上面所说的最小特权原则,这样可能会暴露了过多本应该是私有的函数或变量。

例如:

function doSomething(a){
    b = a + doSomethingElse(a * 2);

    console.log(b * 3);
}

function doSomethingElse(a){
    return a - 1;
}

var b;

doSomething(2);//15

在上述代码中,b和函数doSomethingElse(...)应该是doSomething(...)内部具体实现的私有 内容。这样写就给了外部作用域访问权限,不仅没有必要而且还可能是危险的。

因此更“合理”的设计将会将这些私有的具体的内容隐藏在doSomething(...)内部。

例如:

function doSomething(a){

    function doSomethingElse(a){
        return a - 1;
    }

    var b;

    b = a + doSomethingElse(a * 2);

    console.log(b * 3);
}

doSomething(2);//15

2.1 规避冲突

“隐藏”作用域中的变量和函数的另外一个好处就是避免了同名标识符间的冲突。

2.1.1 全局命名空间

当程序中加载了多个第三方库时,如果他们没有将内部的私有函数和变量隐藏起来,就会引发冲突。

这些库通常在全局中声明一个名字足够特别的变量,通常为一个对象。这个对象就是库的命名空间, 所有要暴露给外界的功能都会成为这个对象的属性,而不是将自己的标识符暴露在顶级的词法作用域中。

如:

var MyReallyCoolLibrary = {
    awesome: 'stuff',
    doSomething: function(){//...},
    doAnotherThing: function(){//...}
};

2.1.2 模块管理

另外一种避免冲突的办法是,使用模块管理工具,通过依赖管理器的机制将库的标识符显示导入到另外一个 特定的作用域中。

这些工具只是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有,无冲突的 作用域中,这样就有效的规避了意外的冲突。

后面详细介绍模块模式。

3. 函数作用域

分析如下代码:

var a = 2;

function foo(){
    var a = 3;
    console.log(a);//3
}

foo();

console.log(a);//2

如上代码,通过函数的声明将foo()将内部的变量隐藏了起来,但是这样做,你首先需要声明一个 具名函数foo(),这样foo本身就污染了所在的作用域。其次你必须通过foo()函数名才能调用这个函数。

如果函数不需要函数名,并且不需要调用,自运行就更加理想了。

重构如下:

var a = 2;

(function foo(){
    var a = 3;
    console.log(a);//3
})();

console.log(a);//2

代码中包装函数声明以(function...开始,函数会被当做函数表达式而不是函数声明来处理。

区分函数声明和函数表达式最简单的方法是,看function在整个声明中出现的位置。如果关键字 是声明中的第一个词那么就是一个声明函数,否则是一个函数表达式。

函数声明和函数表达式之间最重要的区别是他们的名称标识符绑定的位置。

重构前的代码,foo被绑定在了所在的作用域中,可以通过foo()来调用他。

重构后的代码,foo被绑定在了函数表达式自身的函数中而不是所在的作用域(foo只能在函数内部访问)。

3.1 匿名函数和具名函数

setTimeout(function(){
    console.log('I waited 1 second');
},1000);

如上代码中的回调函数就是匿名函数表达式。

函数表达式可以是匿名的,但是函数声明则不可以省略函数名,在JavaScript中这是非法的语法。

因为匿名函数的属性简单快捷,在很多库和工具中都倾向鼓励使用这种风格的代码,但是他有如下缺点:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,不便调试。
  2. 没有函数名,当函数需要引用自身的时候只能使用arguments.callee.
  3. 函数名的省略降低了代码的可阅读性。

因此始终给函数表达式一个清晰可读的名字是最佳实践。

3.2 立即执行函数

IIFE:Immediately Invoked Function Expression

IIFE具有匿名函数表达式的所有优势。

在函数表达式末尾加上一个()可以立即执行这个函数,比如:(function foo(){...})(),不过还有 另外一种写法:(function(){...}()),具体使用那种写法全凭个人喜好。

4. 块作用域

变量的声明应该距离使用的地方越近越好,并且最大限度的本地化。

分析如下代码:

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

我们在for循环的头部定义i,通常是因为我们只想在for循环内部的上下文中使用i,然而实际上 i会绑定到外部作用域中。

还有更多常见的代码风格是为了易读而伪装出来的形式上的块作用域,如果要使用这种形式,就要确保 没有在作用域的其他地方意外的使用i,这只能靠自觉了。

块作用域是一个用来将最小特权原则进行拓展的工具,将代码从在函数中隐藏信息拓展为在块中隐藏。

但是很可惜,表面上看JavaScript并没有块作用域。(需要更加深入的研究)

在前面学习过的with就是块作用域的一个例子,还有try{..}catch(err){..}中的err变量只在 catch块中有效。

4.1 let

ES6中引入了新的关键字let关键字,提供了var以外的声明方式。

let关键字可以将变量绑定到所在的任意的作用域中(通常是块作用域内部)。也就是说,let为其声明的 变量隐式的劫持了所在的块作用域。

如下:

if(true){
    let bar = 1;
    bar = something(bar);
    console.log(bar);
}

console.log(bar);//ReferenceError

上述代码中let将变量附加在一个已经存在的块作用域上是隐式的行为。

如下显示的创建块,使变量的附属关系更加清晰:

if(true){
    {//<-- 显示块
        let bar = 1;
        bar = something(bar);
        console.log(bar);
    }
    console.log(bar);//ReferenceError
}

console.log(bar);//ReferenceError

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

通常来讲,显示的代码要优于隐式或一些精巧设计却不清晰的代码。

另外,使用let进行的声明的变量不会在块作用域中进行提升。

{
    console.log(bar);//ReferenceError
    let bar = 1;
}

4.2 const

ES6中除了引入了let还引入了const,同样是可以用来创建块作用域变量,但其值是固定的(常量)。

之后任何试图修改值的操作都会引起错误。

if(true){
    var a = 1;
    const b = 2;

    a = 3;
    b = 4;//TypeError
}

console.log(a);//3
console.log(b);//ReferenceError

小结

函数是JavaScript中最常见的作用域单元。

函数作用域不是唯一的作用域单元,还有块作用域(通常指{…}内部)。

ES6中引入了let,用来在任意代码块中声明变量。

有些人认为块作用域不应该完全替代函数作用域。两种功能应该同事存在,开发者应该根据需要选择任何 种作用域,创造可读,可维护的优良代码。