作用域


作用域是引擎执行中寻找变量的区域。

JS使用JIT的编译方式,如:var a = 2;,编译器时只会处理声明var a;,当执行时才会处理赋值a = 2;

引擎在执行代码时会不停地寻找左值(LHS)与右值(RHS),先在最近的作用域寻找,找不到再到外层作用域,直到找到。

function foo(a) {
    console.log(a + b);
    b = a;
}
foo(2);

这段代码中LHS有:a = ...(调用函数时隐含的),b = ..,RHS有:foo(..consolea + .... + b.. = a。左值右值不只是在等号左右的值。函数的定义并没有进行赋值, 只是在调用时才会用到的代码块。

这段代码执行a + b时,引擎在查找右值b时,首先在foo函数作用域中找,没有找到,再到全局查找, 依然没有,这时会抛出ReferenceError异常。而如果是b = a的左值b时,当全局也没找到时, 会创建一个全局变量b。但是,如果在strict mode下执行则也会产生ReferenceError异常。

词法作用域


词法作用域是在编码时就确定下来的,其作用是为了在作用域内查找特定标识符。

function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log( a, b, c );
    }
    bar(b * 3);
}
foo( 2 );

首先假设函数定义确定一个作用域,那么上面的代码定义了3个词法作用域:全局作用域、foo作用域和bar作用域。 函数的参数也是属于该函数内作用域的,如在查找console.log(a, b, c)的标识符a时,首先在最内层bar作用域 寻找,没找到再到外层作用域,直到在foo中找到。这个过程是由内向外的一个冒泡过程,一旦找到就不会再往外层寻找。 但是,有方法可以直接访问到全局变量,window是一个全局对象,通过window.a就可以忽略冒泡过程,直接访问到a

有两种方法可以破坏这种编码定义下来的词法作用域,evalwithwith不要使用,eval不到万不得已不要使用。

eval可以执行一段代码,代码中如果有变量的操作,可能会覆盖掉外层的变量。 with可用来简化对象赋值,也有同样的效果。

function foo(obj) {
    with (obj) {
        a = 2;
        b = 2;
    }
}
var o = {
    a: 3
};
foo( o );
console.log( o.a );

上面的代码with覆盖掉了o.a的值,他在寻找标识符时先从对象o内部开始,没找到b,再在全局寻找, 依然没有就会创建一个全局变量a

不应使用这两个方法的原因不止这些,关键是会降低程序执行的性能。引擎在运行代码前会先进行词法分析确定标识符的作用域, 而这两个方法导致无法确定其中标识符的作用域范围,还需要在运行时重新编译,因此导致代码执行性能下降。

函数作用域


一个函数就定义了一个气泡作用域,作用域中的变量只有在该作用域内部可以访问,外部无法访问会抛出ReferenceError

函数作用域可以用来隐藏私有变量,符合设计模式中的最小特权,将尽量少的接口暴漏给用户,有助于提高软件的结构。

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}
doSomething( 2 );

像这个doSomethingElse函数和变量b都被隐藏在doSomething函数中,外部无法访问,如果接口不变, 内部需要修改,可以只改动doSomething函数内部,而不被用户知道,有利于软件维护。

如果在函数作用域中使用和外部相同的变量名,可能会导致冲突:

function foo() {
    function bar(a) {
        i = 3;
        console.log( a + i );
    }
    for (var i=0; i<10; i++) {
        bar( i * 2 ); // 执行后会将i改为3
    }
}
foo();

这段代码会导致死循环,可以通过更改变量名或者添加作用域解决。

为了避免命名冲突,可以通过命名空间达到隔离效果,只将一个标识符暴露在外面,其他内容以属性方式存在。

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

更加现代的方式,是使用模块管理机制,管理不同模块的依赖,避免冲突。

函数作用域可以用来屏蔽标识符,函数表达式(function(){..}),可以让函数只在其内部被访问到,不会造成变量污染。 函数表达式可以是匿名的,但是最好为他定义个名字,有利于调试、递归和其功能的理解。

函数表达式直接调用的方式叫做IIFE(Immediately Invoked Function Expression),只需给函数表达式后加一个括号即可。 这种调用也可以传递参数,比如传递一个函数表达式:

var a = 2;
(function IIFE( def ){
    def( window );
})(function def( global ){
    var a = 3;
    console.log( a ); // 3
    console.log( global.a ); // 2
});

IIFE还可以用在错误重载undefined时:

undefined = true; // 错误的赋值
(function IIFE( undefined ){
    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }
})();

上面的IIFE不给他传参数,会导致参数undefined的值为undefined,刚好符合其含义,屏蔽了undefined的错误赋值。

withcatchletconst这些关键字提供了块作用域。

变量提升


编译器运行时,变量声明会在编译过程进行处理,引擎执行代码是,才会去解释执行赋值等操作。

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

这段代码可以被理解为:

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

a的声明被提升。

每个变量声明在提升时都只会在其所在作用域生效,函数声明也会被提升,而且函数声明会提升在变量声明之前。 另外,函数表达式不会被提升。

foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
    // ...
};

这段代码被解释为:

var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
    var bar = ...self...
    // ...
}

foo被调用时没有赋值,类型错误;bar只在函数内部有效,所以在全局找不到他的定义。

闭包


闭包就是函数在她的词法作用域之外被调用时,仍然能够访问她的词法作用域中的变量。

function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}
var baz = foo();
baz();

baz创建了bar的引用,当foo执行完后,执行baz时仍然能够访问到变量a。 无论用什么方式访问内部函数,他都会保留一个他声明时的作用域引用,用来访问作用域中的值。

function wait(message) {
    setTimeout( function timer(){
        console.log( message );
    }, 1000 );
}
wait( "Hello, closure!" );

这段代码中timer持有外部作用域wait的引用,当调用wait的时候,回调函数timer会在1s之后执行, 执行时他仍然能够访问到message变量。

var a = 2;
(function IIFE(){
    console.log( a );
})();

IIFE自身并不是闭包,他没有在其词法作用域之外被调用。aIIFE在同一个词法作用域,所以不属于闭包。

for (var i=1; i<=5; i++) {
    setTimeout( function timer(){
        console.log( i );
    }, i*1000 );
}

这段代码是循环与闭包的结合,执行结果是每隔1s输出一个6。理解的关键在于,timer回调函数总是在循环执行完之后才执行, 无论超时时间设置的是多少。也就意味着循环结束之后i已经设置为6,因为5个timer都被闭包在了同一个共享作用域, 共享了变量i

解决这个问题可以通过创建多个闭包作用域达到目的,可以使用IIFE。

for (var i=1; i<=5; i++) {
    (function(){
        var j = i;
        setTimeout( function timer(){
            console.log( j );
        }, j*1000 );
    })();
}

或者更简洁版本

for (var i=1; i<=5; i++) {
    (function(j){
        setTimeout( function timer(){
            console.log( j );
        }, j*1000 );
    })( i );
}

还可以通过使用let定义块作用域达到目标。

for (let i=1; i<=5; i++) {
    setTimeout( function timer(){
        console.log( i );
    }, i*1000 );
}

let会在每一次循环时创建一个块作用域,而且每次循环还会声明一个i

闭包可以用来创建模块:

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join( " ! " ) );
    }
    return {
        doSomething: doSomething,
        doAnother: doAnother
    };
}

var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

如果只实例化一次模块可以用IIFE格式:

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join( " ! " ) );
    }
    return {
        doSomething: doSomething,
        doAnother: doAnother
    };
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

通过返回命名的对象,可以从内部改变模块的实现:

var foo = (function CoolModule(id) {
    function change() {
        // modifying the public API
        publicAPI.identify = identify2;
    }

    function identify1() {
        console.log( id );
    }

    function identify2() {
        console.log( id.toUpperCase() );
    }

    var publicAPI = {
        change: change,
        identify: identify1
    };

    return publicAPI;
})( "foo module" );

foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

ES6提供了新的功能,可以定义文件模块,以文件为区分。这种模块是静态的,不能像publicAPI那样改变。

// bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;

export用于导出方法。

// foo.js
import hello from "bar";
var hungry = "hippo";
function awesome() {
    console.log(
        hello( hungry ).toUpperCase()
    );
}
export awesome;

import .. from ..用来导入模块的方法。

module foo from "foo";
module bar from "bar";

console.log(
    bar.hello( "rhino" )
); // Let me introduce: rhino

foo.awesome(); // LET ME INTRODUCE: HIPPO

module .. from ..用来导入整个模块。

动态作用域


JS是没有动态作用域的,都是静态的词法作用域,他关心的是声明的位置,而不是被调用的位置。

function foo() {
    console.log( a ); // 2,在这个词法作用域中a只能是全局的a
}
function bar() {
    var a = 3;
    foo();
}
var a = 2;
bar();