深入学习JavaScript作用域

1,105 阅读19分钟

本文原创:wangkaidong

1. 作用域相关概念

1.1 编译原理

1.1.1 传统语言的编译流程
  1. 分词/词法分析(Tokenizing/Lexing)
    这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。 例如,考虑程序var a = 2;。这段程序通常会被分解成为下面这些词法单元:vara=2;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。

分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的,主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断a是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。

  1. 解析/语法分析(Parsing)
    这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。

  2. 代码生成
    将AST转换为可执行的代码.

  3. JavaScript的区别

    1. JavaScript引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他语言不同,JavaScript的编译过程不是发生在构建之前的。
    2. 对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。在我们所要讨论的作用域背后,JavaScript引擎用尽了各种办法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳。
    3. 简单地说,任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript编译器首先会对var a = 2;这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。

1.2 作用域的理解

1.2.1 对var a = 2的处理
  1. 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。

  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a的变量。如果否,引擎就会使用这个变量;如果不是,引擎会继续查找该变量

    变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。

1.2.2 引擎的两种查找

编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量a来判断它是否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查找结果。

  1. LHS查询
    试图找到变量的容器本身
a = 2;

这里的引用为LHS引用,是为= 2这个赋值操作找到对应的目标

  1. RHS查询
    基本地查找某个变量的值,意味着"得到XX的值"
console.log(a);

这里为RHS引用,a并没有赋值,需要查找并取得a的值

> 可以理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。

JavaScript引擎首先会在代码执行前对其进行编译,在这个过程中,像var a = 2这样的声明会被分解成两个独立的步骤:

  1. 首先,var a在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。
  2. 接下来,a = 2会查询(LHS查询)变量a并对其进行赋值。

1.3 作用域嵌套

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。


1.4 异常

为什么区分LHS和RHS是一件重要的事情? 因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不同的

function foo(a) {
    console.log( a + b );
    b = a;
}
foo( 2 );
  1. 第一次对b进行RHS查询时是无法找到该变量的。也就是说,这是一个“未声明”的变量,因为在任何相关的作用域中都无法找到它。 如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。值得注意的是,ReferenceError是非常重要的异常类型。
  2. 当引擎执行LHS查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非“严格模式”下。

2. 词法作用域

作用域主要的工作模型之一,被大多数编程语言所采用.

2.1 概念

  1. 词法作用域就是定义在词法阶段的作用域
  2. 词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

2.1.1 查找

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作**“遮蔽效应”**(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的window对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。

window.a

通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。

2.1.2 欺骗词法

欺骗词法作用域会导致性能下降

  1. eval eval(..)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的一样。
  2. with with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
var obj = {
    a: 1,
    b: 2,
    c: 3
};
// 单调乏味的重复"obj"
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

3. 函数作用域和块作用域

3.1 函数中的作用域

含义

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

3.1.1 隐藏内部实现

概念

  1. 从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来了。
  2. 实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们。

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

规避冲突 隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。

应用

  1. 全局命名空间
    当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。
var MyReallyCoolLibrary = {
    awesome: "stuff",
    doSomething: function() {
        // ...
    },
    doAnotherThing: function() {
        // ...
    }
};
  1. 模块管理
    另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。

3.2 函数作用域

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

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

以及

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

函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。 比较一下前面两个代码片段。第一个片段中foo被绑定在所在作用域中,可以直接通过foo()来调用它。第二个片段中foo被绑定在函数表达式自身的函数中而不是所在作用域中。 换句话说,(function foo(){ .. })作为函数表达式意味着foo只能在...所代表的位置中被访问,外部作用域则不行。foo变量名被隐藏在自身中意味着不会非必要地污染外部作用域。

3.2.1 匿名函数

匿名函数的缺点:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
  3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。

解决思路:

行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践

setTimeout( function timeoutHandler() { // <-- 有函数名
    console.log( "I waited 1 second!" );
}, 1000 );
3.2.2 立即执行函数表达式
var a = 2;
(function foo() {
    var a = 3;
    console.log( a ); // 3
})();
console.log( a ); // 2

由于函数被包含在一对( )括号内部,因此成为了一个表达式,通过在末尾加上另外一个( )可以立即执行这个函数,比如(function foo(){ .. })()。第一个( )将函数变成表达式,第二个( )执行了这个函数。

另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。

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

还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当作参数传递进去。

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

4. 作用域链

作用域链本质上是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。

4.1 执行环境和活动对象

在函数执行时,会创建一个叫做执行环境/执行上下文的内部对象。执行环境定义了函数及变量能访问的其他数据。每个执行环境中都有一个相关的变量对象,环境中所定义的所有变量和函数都保存在这个对象中,编译器在处理数据时会访问这个对象。 当执行流进入一个函数时,函数的环境就会被推进一个环境栈,而在函数执行完毕后,栈将环境弹出,把控制权返回给之前的执行环境。

  • 函数每次执行时的执行环境独一无二
  • 多次调用函数就多次创建执行环境
  • 函数执行完毕后,执行环境就会被销毁

closure_0.png

当代码在一个环境中执行时,会创建变量对象的一个作用域链,用于保证对执行环境有权访问的所有变量和函数的有序访问,作用域的前端一直都是当前执行环境的变量对象。如果这个环境是函数的话,那么变量对象就是其活动对象,活动对象最开始只包括arguments对象。作用域链中的下一个变量对象来自其外部环境,以此类推,一直延续到全局变量环境。

4.2 [[scope]]属性

函数内部的[[scope]]属性是虚拟出来的一个属性,保存着这个函数的父作用域的作用域链。这个属性对应的是一个对象的列表,列表中的对象仅能JavaScript内部访问,通过语法访问不到这个属性。

  1. 函数定义时
    在全局环境下定义了一个函数,此时该函数的[[scope]]属性中只包含一个全局对象;当进入函数体执行环境时,在函数体内的函数才被定义,其[[scope]]属性中包含全局对象,及当前活动对象。
  2. 函数调用时
    根据执行环境的定义,在函数被调用时,会创建对应的执行环境,每个执行环境对应一个变量对象。
    1. 首先会创建一个自身的活动对象(包含了this、参数(arguments)、局部变量(包括命名的参数)的定义)和一个变量对象的作用域链[[scope chain]]
    2. 然后,把这个执行环境的[[scope]]按顺序复制到[[scope chain]]里。
    3. 最后把这个活动对象推入到[[scope chain]]的顶部,这样[[scope chain]]就是一个有序的栈,这样保了对执行环境有权访问的所有变量和对象的有序访问。
  3. 函数执行时
    • 遇到标识符,就会根据标识符的名称在执行环境(Execution Context)的作用域链中进行搜索。从作用域链的第一个对象(该函数的活动对象)开始,如果没有找到,就搜索作用域链中的下一个对象,如此往复,直到找到了标识符的定义。
    • 随着函数执行过程,逐步沿作用域链进行赋值与查询操作,在完成并返回后函数执行完毕,此时函数执行环境及作用域从对应的栈中弹出。

函数的生命周期:

closure_1.png

4.3 没有块级作用域

JavaScript是没有块级作用域(if/else/for/while)的,在这些块级作用域上定义的变量和函数是添加到当前执行环境中(函数或全局)的。

5. 闭包

结合作用域链的内容,闭包是通过赋值给全局环境下的变量等手段,将函数内部的作用域链及执行环境保存维持下来,使其活动对象不被回收机制回收,且能访问到这个作用域链上所定义的所有变量及参数的函数。

5.1 闭包的实现

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

函数bar的词法作用域能够访问foo的内部作用域,然后再把bar函数对象当作一个返回值类型进行传递。在foo执行后,其返回值(bar函数对象)赋值给变量baz,本质上是通过不同的标识符引用了调用了内部作用域的函数barbar所声明的位置决定了它可以访问foo的内部作用域,同时其[[scope]]属性也包含作用域链bar->foo->window,这个作用域链在赋值时会被baz保存下来。

closure_2.png

5.2 闭包解决的问题

  • 修正循环中取值问题

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

    上例的输出结果并不是从直观上推断出的1、2、3、4、5,而是输出5个5,其中的原因有两个:

    1. 首先是setTimeout的执行顺序问题,在上述代码中即使将等待时间设定为0,其输出结果仍然是5个5。其原因是setTimeout在执行时会先推到一个等待队列里,在其他处于等待状态的事件执行完毕后,再“同时”、“立即”调用这些setTimeout,其执行顺序与delay时间相关。因此这段循环的执行顺序是先执行了所有的循环累加过程,再同时调用等待队列中的setTimeout
    2. 其次是由于JavaScript不存在块级作用域,因此在代码上看好像是五个不同的i,其实由于这个运行过程是在同一个执行环境中,因此使用的是同一个全局作用域,实际上只有一个i

    解决方案:

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

    利用IIFE声明并立即执行一个函数从而创建起一个作用域,解决了setTimeout的执行延迟问题,同时也将全局执行环境中的i作为每一轮的参数传递给每一个迭代函数,从而解决问题。

  • 模块

    1. 创建私有变量
    2. 模拟块级作用域
  1. 利用闭包实现模块
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

首先,CoolModule()只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行外部函数,内部作用域和闭包都无法被创建。 其次,CoolModule()返回一个用对象字面量语法{ key: value, ... }来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共API。 这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API中的属性方法,比如foo.doSomething()

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。jQuery就是一个很好的例子。jQuery和$标识符就是jQuery模块的公共API,但它们本身都是函数(函数也是对象,也可以拥有属性)。

doSomething()doAnother()函数具有涵盖模块实例内部作用域的闭包(通过调用CoolModule()实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,就已经创造了可以观察和实践闭包的条件。 另外代码中的CoolModule作为模块创建器,每次调用都会创建一个新的模块实例,再需要单例模式时,可以利用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
  1. 现代模块机制 大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的API:
var MyModules = (function Manager() {
    var modules = {};
    function define(name, deps, impl) {
        for (var i=0; i<deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply( impl, deps );
    }
    function get(name) {
        return modules[name];
    }
    return {
        define: define,
        get: get
    };
})();

利用modules[name] = impl.apply(impl, deps)。引入包装函数(可以传入任何依赖),并且将返回值,也就是模块的API,储存在一个根据名字来管理的模块列表中。

MyModules.define( "bar", [], function() {
    function hello(who) {
        return "Let me introduce: " + who;
    }
    return {
        hello: hello
    };
} );
MyModules.define( "foo", ["bar"], function(bar) {
    var hungry = "hippo";
    function awesome() {
        console.log( bar.hello( hungry ).toUpperCase() );
    }
    return {
        awesome: awesome
    };
} );
var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );
console.log(
    bar.hello( "hippo" )
); // <i>Let me introduce: hippo</i>
foo.awesome(); // LET ME INTRODUCE: HIPPO