JS核心基础知识总结(二)——作用域和闭包

147 阅读6分钟

作用域

作用域(Scope),指的是变量的生命周期,在它的作用域里变量才能够被使用。

JS和Java/C等语言很不一样的一点在于,JS没有块级作用域(ES 6以后有了letconst可以实现块级作用域),只有全局作用域函数作用域。

除了没有块级作用域,JS还有一个令人讨厌的问题——就是变量提升函数提升

提升(hoist)指的是函数声明和变量声明总是会被移动(即hoist)到它们所在的作用域的顶部

// 变量提升的例子
console.log(a);
var a =1;

// 等价于
var a ;
console.log(a); // undefined
a = 1; 

// 函数提升
b(); // 1

function b() {
    console.log(1);
}

变量和函数提升可能会导致意料之外的行为,变量和函数理论上都应该在声明之后才能被使用。

要解决变量提升,我们可以使用ES 6的letconst来声明变量,同时这两个关键字还能够实现块级作用域。事实上我们可以完全用let替换掉var

而函数提升实际上只会提升使用函数名称声明的函数,如果我们用函数表达式来创建函数就不会提升了。

a(); // 报错
var a = function () {} // 函数表达式

PS:当变量名和函数名相同时,函数优先声明

作用域链

了解作用域链之前我们要知道一下两个概念:

  • 函数的生命周期
  • Activation Object(活动对象)、Variable Object(变量对象)

函数的生命周期

函数的的生命周期分为创建执行两个阶段。

在函数创建阶段,JS解析引擎进行预解析,会将函数声明提前,同时将该函数放到全局作用域中或当前函数的上一级函数的局部作用域中。

在函数执行阶段,JS引擎会将当前函数的局部变量和内部函数进行声明提前,然后再执行业务代码,当函数执行完退出时,释放该函数的执行上下文,并注销该函数的局部变量。

活动对象(AO)/变量对象(VO)

首先说变量对象

其实它的名字已经说清除它的作用了——存储变量的对象。JS的执行上下文中都有这个对象,用来存放执行上下文中所有可被访问、但不能被delete的变量声明、形参、函数标识符等数据,也就是说我们程序中所使用的变量在JS引擎中都是挂在这个对象上供我们访问的。这个对象对应的是函数创建阶段,此时会创建变量对象。

然后到了函数执行阶段时,对应执行上下文中的变量对象就被激活了,成为活动对象

实质上,两者可以说是同一个对象在不同阶段所使用的不同名称,比如停在车库里的车跑在路上的车

两者都是JS规范中所定义的。

现在我们说回作用域链(scope chain)。

当我们尝试访问一个对象时,首先我们会在当前作用域里寻找这个对象,也就是在当前执行代码所在的上下文的变量对象中寻找。如果无法找到这个对象,那我们会在外层作用域里寻找,相当于去外层的变量对象寻找,不断重复这个寻找的过程直到全局作用域。这些被查找的作用域由里到外,构成了一条作用域链,也可以理解为一个个变量对象构成了作用域链。

闭包

概念

闭包是JS中一个很重要的概念,许多高级应用都需要借助闭包实现。

我们先来看一种需求:如何在外部获得函数内部定义的局部变量。

根据上面有关作用域的知识,我们知道变量查找是由内部作用域向外部查找的,即正常情况下无法从外部获取内部函数中定义的变量。

但是我们可以通过一种变通的方式实现——在函数内部再定义一个函数。

function f1 () {
    var n = 1;
    function f1 () {
        alert(n); // 1
    }
}

在上面的代码中,函数f2就被包括在函数f1内部,这时f1内部的所有局部变量,对f2都是可见的。但是反过来就不行,f2内部的局部变量,对f1就是不可见的。这也是我们上面提到的有关作用域链所说明的。

既然f2可以读取f1的变量,那我们将f2返回是不是就可以在外部访问f1的变量了。

function f1 () {
    var n = 1;
    function f1 () {
        alert(n); // 1
    }
    
    return f2;
}

var result=f1();

result(); // 1

此时我们可以将这个有特殊功能的f2称为闭包。

如果在Google中搜索"闭包",会发现闭包的定义非常抽象甚至晦涩,因为它是一个计算机中的重要概念,并不是JS中独有。但我们这里谈论的是JS中的闭包,一般我们使用闭包就是为了访问函数中的局部变量,因此为了方便起见我们将能够在外部访问函数内部变量的函数称为闭包

应用

通过上面的例子,我们知道闭包可以有两个作用:

  1. 在外部访问函数内部变量;
  2. 即使函数返回了,依然把局部变量保留在内存中而不被垃圾回收;

利用1,我们可以使用闭包在JS中实现私有变量

var foo = ( function() {
    var secret = 'secret';
    // “闭包”内的函数可以访问 secret 变量,而 secret 变量对于外部却是隐藏的
    return {
        get_secret: function () {
            // 通过定义的接口来访问 secret
            return secret;
        },
        new_secret: function ( new_secret ) {
            // 通过定义的接口来修改 secret
            secret = new_secret;
        }
    };
} () );

foo.get_secret (); // 得到 'secret'
foo.secret; // Type error,访问不能
foo.new_secret ('a new secret'); // 通过函数接口,我们访问并修改了 secret 变量
foo.get_secret (); // 得到 'a new secret'

利用2,我们可以在内存中维持变量、函数,等待时机成熟的时候再使用,比如实现柯里化反柯里化

function currying(fn){
    var allArgs = [];

    return function next(){
        var args = [].slice.call(arguments);

        if(args.length > 0){
            allArgs = allArgs.concat(args);
            return next;
        }else{
            return fn.apply(null, allArgs);
        }
    } 
}
var add = currying(function(){
    var sum = 0;
    for(var i = 0; i < arguments.length; i++){
        sum += arguments[i];
    }
    return sum;
});

最后要注意的一点是,由于闭包会导致函数内部变量无法及时被回收(虽然V8现在也会尝试回收闭包的内存),为了性能考虑,使用闭包需要谨慎,否则会有内存泄漏的可能。