You Don't Know JS上卷Part1 Chapter3.函数作用域和块作用域

3.1 函数中的作用域

属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(a) {
var b = 2;

// 一些代码

function bar() {
// ...
}

// 更多的代码

var c = 3;
}

foo(..)作用域中包含了标识符(变量、函数)a、b、c和bar。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处的作用域。

全局作用域只包含一个标识符:foo

3.2 隐藏内部实现

最小特权原则(最小授权或最小暴露原则):在软件设计中,应该最小限度地暴露必要内容,而将其他内容都”隐藏“起来,比如某个模块或对象的API设计。

1
2
3
4
5
6
7
8
9
10
11
12
13
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}

var b;

b = a + doSomethingElse( a * 2 );

console.log( b * 3 );
}

doSomething( 2 ); // 15

bdoSomethingElse(..)都无法从外部被访问,而只能被doSomething(..)所控制,设计上将具体内容私有化了。

3.2.1 规避冲突

”隐藏“作用域中的变量和函数带来的另一个好处是可以避免同名标识符之间的冲突。

1
2
3
4
5
6
7
8
9
10
11
function foo() {
function bar(a) {
i = 3; // 修改for循环所属作用域中的i
console.log( a + i );
}

for (var i = 0; i < 10; i++) {
bar( i * 2 ); // 糟糕,无限循环了!
}
}
foo();

bar(..)内部的赋值表达式i = 3意外的覆盖了声明在foo(..)内部for循环中的i。

解决方案:

  • 声明一个本地变量,任何名字都可以,例如var i = 3
  • 采用一个完全不同的标识符名称,例如var j = 3

规避变量冲突的典型例子:

  • 全局命名空间

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

  • 模块管理

    任何库无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显示的导入到另外一个特定的作用域中。

3.3 函数作用域

1
2
3
4
5
6
7
8
9
10
11
var a = 2;

function foo() { // <-- 添加这一行

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

} // <-- 以及这一行
foo(); // <-- 以及这一行

console.log( a ); // 2

上述函数作用域虽然可以将内部的变量和函数定义”隐藏“起来,但是会导致以下2个额外问题。

  • 必须声明一个具名函数foo(),意味着foo这个名称本身”污染“了所在的作用域。
  • 必须显示地通过函数名foo()调用这个函数才能运行其中的代码。

解决方案:

1
2
3
4
5
6
7
8
9
10
var a = 2;

(function foo(){ // <-- 添加这一行

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

})(); // <-- 以及这一行

console.log( a ); // 2

上述代码包装函数的声明以(function...开始,函数会被当做函数表达式而不是一个标准的函数声明来处理。

  • 区分函数声明函数表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。
    • 函数声明:function是声明中的第一个词
    • 函数表达式:不是声明中的第一个词
  • 函数声明函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
    • 第一个片段中,foo被绑定在所在作用域中,可以直接通过foo()来调用它。
    • 第二个片段中,foo被绑定在函数表达式自身的函数中,而不是所在的作用域。(function foo(){ .. }foo只能在..所代表的位置中被访问,外部作用域不行。foo变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
3.3.1 匿名和具名
1
2
3
setTimeout( function() {
console.log("I wait 1 second!");
}, 1000 );

上述是匿名函数表达式,因为function()..没有名称标识符。

函数表达式可以匿名,但函数声明不可以省略函数名。

匿名函数表达式有以下缺点:

  • 在栈追踪中不会显示出有意义的函数名,会使得调试困难。
  • 没有函数名,当函数需要引用自身时只能使用已经过期arguments.callee引用
    • 递归
    • 事件触发后事件监听器需要解绑自身
  • 匿名函数省略了对于代码可读性/可理解性很重要的函数名。

解决方案:

行内函数表达式可以解决上述问题,始终给函数表达式命名是一个最佳实践。

1
2
3
setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
console.log( "I waited 1 second!" );
}, 1000 );
3.3.2 立即执行函数表达式

立即执行函数表达式(IIFE,Immediately Invoked Function Expression)

  • 匿名/具名函数表达式

    第一个( )将函数变成表达式,第二个( )执行了这个函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var a = 2;
    (function IIFE() {

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

    })();

    console.log( a ); // 2
  • 改进型(function(){ .. }())

    用来调用的( )被移进了用来包装的( )中。

  • 当做函数调用并传递参数进去

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    var a = 2;
    (function IIFE( global ) {

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

    })( window );

    console.log( a ); // 2
  • 解决undefined标识符的默认值被错误覆盖导致的异常

    将一个参数命名为undefined,但是在对应的位置不传入任何值,这样就可以保证在代码块中undefined标识符的值真的是undefined

    1
    2
    3
    4
    5
    6
    7
    8
    9
    undefined = true;

    (function IIFE( undefined ) {

    var a;
    if (a === undefined) {
    console.log("Undefined is safe here!");
    }
    })();
  • 倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当做参数传递进去

    函数表达式def定义在片段的第二部分,然后当做参数(这个参数也叫做def)被传递进IIFE函数定义的第一部分中。最后,参数def(也就是传递进去的函数)被调用,并将window传入当做global参数的值。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var a = 2;

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

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

    });

3.4 块作用域

表面上看JavaScript并没有块作用域的相关功能,除非更加深入了解(with、try/catch 、let、const)。

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

上述代码中i会被绑定在外部作用域(函数或全局)中。

1
2
3
4
5
6
7
var foo = true;

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

上述代码中,当使用var声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。

3.4.1 with

块作用域的一种形式,用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

3.4.2 try/catch

ES3规范中规定try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch中有效。

1
2
3
4
5
6
7
8
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}

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

当同一个作用域中的两个或多个catch分句用同样的标识符名称声明错误变量时,很多静态检查工具还是会发出警告,实际上这并不是重复定义,因为所有变量都会安全地限制在块作用域内部。

3.4.3 let

ES6引入了let关键字,可以将变量绑定到所在的任意作用域中(通常是{ .. }内部),即let为其声明的变量隐式地劫持了所在的块作用域。

1
2
3
4
5
6
7
8
9
var foo = true;

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

console.log( bar ); // ReferenceError

存在的问题

let将变量附加在一个已经存在的的块作用域上的行为是隐式的,如果习惯性的移动这些块或者将其包含在其他的块中,可能会导致代码混乱。

解决方案

为块作用域显示地创建块。显式的代码优于隐式或一些精巧但不清晰的代码。

1
2
3
4
5
6
7
8
9
10
11
var foo = true;

if (foo) {
{ // <-- 显式的块
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}

console.log( bar ); // ReferenceError

在if声明内部显式地创建了一个块,如果需要对其进行重构,整个块都可以被方便地移动而不会对外部if声明的位置和语义产生任何影响。

  • 在let进行的声明不会在块作用域中进行提升

    1
    2
    console.log( bar ); // ReferenceError
    let bar = 2;
  • 1、垃圾收集

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function process(data) {
    // 在这里做点有趣的事情
    }

    var someReallyBigData = { .. };

    process( someReallyBigData );

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

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

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

  • 2、let循环

    1
    2
    3
    4
    5
    for (let i = 0; i < 10; i++) {
    console.log( i );
    }

    console.log( i ); // ReferenceError

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

    1
    2
    3
    4
    5
    6
    7
    {
    let j;
    for (j = 0; j < 10; j++) {
    let i = j; // 每个迭代重新绑定!
    console.log( i );
    }
    }
3.4.4 const

ES6引用了const,可以创建块作用域变量,但其值是固定的(常量)

1
2
3
4
5
6
7
8
9
10
11
12
var foo = true;

if(foo) {
var a = 2;
const b = 3; // 包含在if中的块作用域常量

a = 3; // 正常!
b = 4; // 错误!
}

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