你不知道的JS之作用域和闭包(三)函数 vs. 块级作用域

  原文:你不知道的js系列

在第(二)节中提到的,标识符在作用域中声明,这些作用域就像是一个容器,一个嵌套一个,这个嵌套关系是在代码编写时定义的。

那么到底是什么产生了一个新的作用域,只有函数能做到吗?JavaScript 的其它代码结构能否创建一个作用域呢?

 

函数作用域

观察下面的代码:

function foo(a) {
    var b = 2;

    // some code

    function bar() {
        // ...
    }

    // more code

    var c = 3;
}

在这段代码中,foo 的作用域中包含 a,b,c 和 bar,在一个作用域中,无论声明在什么位置,变量或者函数都属于包含他们的那个作用域。

bar() 也有自己的作用域,全局作用域只有一个标识符 foo。

因为 a,b,c 和 bar 都属于 foo 的作用域,所以他们是无法在 foo 的外部被访问的。

下面这段代码就会产生 ReferenceError 类型的错误,因为这些标识符在全局作用域中不存在。

bar(); // fails

console.log( a, b, c ); // all 3 fail

 

但是这些标识符在 foo 的内部是可以访问,而且在 bar 的内部也可以访问(假设 bar 的内部不存在标识符声明覆盖)。

函数内部的变量,可以在整个函数内部被使用,即使是所嵌套的内部函数的作用域也可以访问所有的变量。这种设计方式可以充分利用 JavaScript 变量的动态特性,根据需要接收不同类型的值。

另一方面,如果你不谨慎预防,存在于整个作用域中的变量可能会导致一些意想不到的陷阱。

 

隐藏在普通作用域

想到函数,通常就是说你声明一个函数,然后在里面添加代码。但是反过来想,随意截取一段写好的代码,用一个函数声明包装起来,就有效地隐藏了这段代码。

实际产生的结果的就是,在这些代码的周围创建了一个作用域,也就是说这段代码中的任何声明就会绑定到这个新的包装函数的作用域。也就是说你可以把变量和函数包围在一个函数中从而“隐藏”他们。

隐藏这些变量和函数有什么用呢?

这种基于作用域的隐藏是有很多原因的。它源自一种叫 “最小特权原则” 的软件设计原则,也可以称为 “最小权限” 或者 “最少暴露”。在软件设计中,你应该只暴露必要的那一小部分,然后隐藏其它所有的细节。

这个原则延伸到这里就是要在哪个作用域中包含这些变量和函数。如果这些 变量/函数 在全局作用域中,那么他们就会被任何作用域访问到,但是这违背了 “最少…” 原则,你暴露的这些变量和还是本应保持私有的状态。而正确的打开方式是防止对这种 变量/函数 的访问。

 

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() 内部实现的私有内容,给予这个外部作用域对 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

现在 b 和 doSomethingElse() 是不会被外部影响到的,只能被 doSomething() 内部控制。

函数的功能和最后的结果没有影响,但是这样保持私有的内容私有化,可以设计出更好的软件。

 

避免冲突

在作用域内部隐藏变量和函数的另外一个好处是,当存在两个名称相同但是使用意图不同的标识符时,可以避免无意的冲突,冲突通常导致值被覆盖。

比如下面这段代码

function foo() {
    function bar(a) {
        i = 3; // changing the `i` in the enclosing scope's for-loop
        console.log( a + i );
    }

    for (var i=0; i<10; i++) {
        bar( i * 2 ); // oops, infinite loop ahead!
    }
}

foo();

在 bar() 中 i = 3 这个赋值语句意外地将在 for 循环中定义的 i 的值覆盖了,这样就导致了无限循环,因为每次循环 i 都被设置为 3,所有 i < 10 这个条件永远成立。

在 bar 中的这个赋值语句需要先声明一个局部变量,无论它的名称是什么。 var i = 3;就可以解决这个问题,对 i 的声明会将外部的变量覆盖掉。在这个声明中,你还可以使用另外一个标识符,比如 var j = 3; 不过你的软件设计过程中可能自然地使用同一个标识符 (比如循环总是使用 i ),所以这种情况下利用作用域隐藏内部声明是最佳也是唯一的选择。

 

全局命名空间 Global “Namespaces”

在全局作用域中会发生特别严重的变量冲突的例子。如果某些代码库没有正确隐藏他们的内部/私有变量和函数,你把他们加载到自己的代码中就会很容易出现冲突。

这些库通常会在全局作用域中创建单独的一个具有足够独特的名字的变量声明,通常是一个对象。然后这个对象将会被当成这个库的命名空间来使用,所有明确暴露出来的方法都会被添加为这个对象的属性,而不是作为顶层词作用域中的标识符。

例如:

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

 

模块管理 Module Management

另外一个避免命名冲突的选择是利用模块机制,使用任何一种依赖管理工具。不会在全局作用域中添加任何标识符,相反,通过这些依赖管理器的各种机制,这些标识符会被明确地添加到指定的作用域中。

这些工具并没有被豁免于词法作用域规则之外,他们只是利用作用域规则,强制保证不会添加任何标识符到任何共享的作用域中,而是保持在私有的,不容易发生冲突的范围之内,从而防止了任何意外的作用域冲突。

因此,你可以预防性地编码,实现和依赖管理工具同样的效果,并不需要去使用他们。如果你想这么做,就需要更多 模块模式(module pattern) 相关的知识。

 

函数作为作用域

我们可以随意截取一段代码然后用函数包装起来,就可以有效地将外部作用域中的这些范围内的变量和函数声明隐藏在这个函数的内部作用域中了。

比如:

var a = 2;

function foo() { // <-- insert this

    var a = 3;
    console.log( a ); // 3

} // <-- and this
foo(); // <-- and this

console.log( a ); // 2

虽然这样是ok的,但并不是特别理想。它引发了几个问题。首先我们要声明一个函数 foo() ,这个标识符 foo 本身就污染了包含它的全局作用域。我们还必须通过调用这个函数才能真正执行内部的代码。

如果这个函数不需要名字,直接运行就更好了。JavaScript 提供了一个解决办法:

var a = 2;

(function foo(){ // <-- insert this

    var a = 3;
    console.log( a ); // 3

})(); // <-- and this

console.log( a ); // 2

这里这个函数语句前后加了括号,这样这个函数就不再是一个声明语句了,而是一个函数表达式。

注:最简单的区别函数声明和函数表达式的方式是,如果 function 关键字出现在语句的最开始,则是一个函数声明,否则就是函数表达式。

这里我们可以发现函数声明和函数表达式的一个关键区别就是它的名字作为标识符被绑定到了什么位置。

在第一段代码中,foo 是被绑定在外部的作用域中,我们可以直接调用 foo(),在第二段中,foo 是没有被绑定在外部的作用域中的,只被绑定在自己的本身的函数中。

换句话说,(function foo(){...}) 作为一个函数表达式,意味着 foo 只绑定在函数内部 ... 指示的范围内,而不是在外部作用域,将 foo 这个名字隐藏在它自己内部,不对外部作用域产生不必要的污染。

 

匿名和命名

你可能已经很熟悉作为回调参数的函数表达式了,比如:

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

这就是匿名函数表达式,因为它没有标识符名称。函数表达式可以是匿名的,但函数声明不能省略函数名。

匿名函数表达式写起来很容易,也是很多工具和库惯用的代码风格,但他们有一些缺点要考虑到:

  1. 调试困难
  2. 需要递归引用自身时,需要使用已经被废弃的 arguments.callee 引用。还有一个例子就是当一个事件处理函数被触发之后需要解除自身的绑定时也需要引用自身。
  3. 降低了代码可读性

行内函数表达式强大且有用,匿名和命名的问题并不会影响这一点,给你的函数表达式加一个名字就可以解决上面的问题,而且没有什么坏处。所以最好的办法就是始终给你的函数表达式命名。

 

立即调用函数表达式

var a = 2;

(function foo(){

    var a = 3;
    console.log( a ); // 3

})();

console.log( a ); // 2

将函数包在一对括号 () 中,我们可以在后面再加一对括号 (),就像 (function foo() {...})() 这样,第一对括号将函数变成一个表达式,第二对括号立即执行这个函数。

这个模式被一致称为 IIFE (Immediately Invoked Function Expression)

IIFE 并不需要命名,但命名有很多好处。

在传统的 IIFE 形式上有一个轻微的变体,有些人更喜欢:(function() {...} ()). 起调用作用的那对括号 () 移动到表达式的括号内部去了。

这两种只是语法偏好,功能是完全一样的。

另外一种变体就是,利用 IIFE 只是函数调用的事实,传入参数。

例如:

var a = 2;

(function IIFE( global ){

    var a = 3;
    console.log( a ); // 3
    console.log( global.a ); // 2

})( window );

console.log( a ); // 2

我们传入了 window 对象作为参数,然后把参数命名为 global,这样对于全局和非全局的引用就有一个明确的划分。当然你可以传入任何外部作用域中的标识符。

这种模式可以解决一个小问题,默认的 undefined 标识符的值可能会被不正确的覆盖掉,产生意想不到的后果。 将一个参数命名为 undefined,然后不给这个参数传入任何值,就可以保证在这个函数内部 undefined 的值就是 undefined。

undefined = true; // setting a land-mine for other code! avoid!

(function IIFE( undefined ){

    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }

})();

 

还有一个 IIFE 的变体将一些事情顺序颠倒了。在下面的代码中,要执行的函数在调用和传给它的参数之后给出。

这种模式在通用模块定义(UMD)项目中使用,虽然有点冗余,但更容易理解。

var a = 2;

(function IIFE( def ){
    def( window );
})(function def( global ){

    var a = 3;
    console.log( a ); // 3
    console.log( global.a ); // 2

});

函数 def表达式被作为参数传给了 IIFE 函数,参数 def (也就是函数 def )被调用,然后 window 被作为参数传给 global。

 

块级作用域

 

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

我们在 for 循环中声明变量 i 的时候,是因为我们只想在循环内部使用这个 i ,却忽略了一个事实就是,变量存在于封闭的作用域,比如函数或者全局作用域中。

块级作用域就是,在它被使用的地方就近且尽量在局部声明,另一个例子:

var foo = true;

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

这里我们只想在 if 语句内部使用变量 bar ,然而这个变量始终是绑定在外部作用域中的,这段代码本质上并没有块级作用域。这需要依赖我们自我限制,不要在这个作用域的其他地方意外地使用 bar。

块级作用域是对之前的 “最少暴露原则” 的扩展。

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

在上面的代码中,为什么仅仅只在循环内部使用的变量 i 要污染整个外部的作用域呢

如果有块级作用域的话,变量 i 就只能在循环内部访问,在函数的其它地方访问 i 将会报错。这将确保变量不会被混淆使用或者难以维护。

 

with

在上一节介绍过,with 可以为绑定它的对象创建一个作用域,这个作用域只存在于 with 内部,不会在外部作用域中。

 

try/catch

一个鲜为人知的事实是,在 ES3 中,在 try/catch 中的 catch 语句中的变量声明被绑定在 catch 语句内部作用域中。

比如:

try {
    undefined(); // illegal operation to force an exception!
}
catch (err) {
    console.log( err ); // works!
}

console.log( err ); // ReferenceError: `err` not found

 

在这里 err 只存在于catch 语句中,在其它位置引用将会报错。

 

let

在 ES6 中,引入了一个新的关键字 let,它是和 var 并列的另一种声明变量的方式。

let 声明的变量被绑定在包含它的括号中,通常是大括号 {}。也就是说, let 隐式地为变量声明绑定了一个块级作用域。

var foo = true;

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

console.log( bar ); // ReferenceError

 

显示创建代码块可以解决一些困惑。如果以后重构代码的的时候也可以很方便的移动,不会影响 if 语句。

var foo = true;

if (foo) {
    { // <-- explicit block
        let bar = foo * 2;
        bar = something( bar );
        console.log( bar );
    }
}

console.log( bar ); // ReferenceError

 

使用 let 声明的变量将不会在块级作用域内部提升,在声明语句之前访问会抛出 ReferenceError 错误

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

 

垃圾回收

另一个块级作用域的好处是闭包和释放内存的垃圾回收机制有关。思考下面的代码:

function process(data) {
    // do something interesting
}

var someReallyBigData = { .. };

process( someReallyBigData );

var btn = document.getElementById( "my_button" );

btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false );

click 事件的回调函数 click 并不需变量 someReallyBigData ,理论上,process() 执行之后,这个变量就会被回收,然而,JS 引擎还是会保留这个数据,因为 click 函数在整个作用域之上有一个闭包。

块级作用域可以解决这个问题,让引擎了解这部分数据不需要再保留了:

function process(data) {
    // do something interesting
}

// anything declared inside this block can go away after!
{
    let someReallyBigData = { .. };

    process( someReallyBigData );
}

var btn = document.getElementById( "my_button" );

btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false );

 

let 循环

 

让 let 眼前一亮的另一个特别的例子就是前面提到的 for 循环:

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

console.log( i ); // ReferenceError

事实上,不只是循环内部。每次循环,变量 i 都会重新绑定一次,确保重新赋值的值是来自前一次循环迭代之后。

下面的代码说明了实际代码运行的过程:

{
    let j;
    for (j=0; j<10; j++) {
        let i = j; // re-bound for each iteration!
        console.log( i );
    }
}

每次迭代都进行绑定。

 

因为 let 声明是绑定在块中的,当代码中有对 var 声明变量的隐式依赖时可能存在一些陷阱,当要将 var 替代为 let 进行重构时要格外小心。

比如:

var foo = true, baz = 10;

if (foo) {
    var bar = 3;

    if (baz > bar) {
        console.log( baz );
    }

    // ...
}

可以很容易重构为:

var foo = true, baz = 10;

if (foo) {
    var bar = 3;

    // ...
}

if (baz > bar) {
    console.log( baz );
}

 

但是下面使用块级作用域的变量就要小心了,要连同 bar 的声明一起移动:

var foo = true, baz = 10;

if (foo) {
    let bar = 3;

    if (baz > bar) { // <-- don't forget `bar` when moving!
        console.log( baz );
    }
}

 

 

const

除了 let,ES6 还引入了 const,用来定义块级作用域中的常量。尝试改变 const 定义的值时会报错!

var foo = true;

if (foo) {
    var a = 2;
    const b = 3; // block-scoped to the containing `if`

    a = 3; // just fine!
    b = 4; // error!
}

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

 

小结:

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

块级作用域

try/catch 中的 catch 语句具有块级作用域。

let 关键字

posted @ 2019-01-24 16:10  隙游尘  阅读(434)  评论(0编辑  收藏  举报

Hi