JavaScript的作用域

作用域控制着变量和函数的可见性和生命周期,和其他语言一样,JS变量的作用域也有全局作用域和局部作用域两种,JS没有块级作用域一说,出了if 或者 for,里面的步进变量依然存在,还有,函数里没有使用var开头定义的变量是全局变量,等等这样的基础知识,读者可以自行百度,这里不再赘述。


JavaScript的作用域链

JavaScript中一切皆对象,函数也不例外。函数对象也有可以通过代码访问的属性和供JavaScript引擎访问的内部属性。其中有个属性就是[[Scope]],由ECMA-262标准定义,[[Scope]]属性包含了函数被创建的作用域中对象的集合,这个集合被称为是函数的作用域链,它决定了哪些数据能被函数访问。


当一个函数创建的时候,它的作用域链会被创建此函数的作用域中可访问的数据对象填充:

function add(a, b) {
    var sum = a + b;
    return sum;
}

在add函数被创建时,它的作用域链中会填入一个全局对象,该全局对象包含了所有全局变量,如下图(图片只列举全部变量中的一部分):

javascript block作用域 javascript作用域链_javascript


var total = add(5, 10);

执行此函数时会创建一个“运行时期上下文(execute context)”的内部对象,运行期上下文定义了函数执行时的环境。每个运行期上下文都有自己的作用域链,用于标识符解析,当运行期上下文被创建时,而它的作用域链初始化为当前运行函数的[[Scope]]所包含的对象。


这些值按照它们出现在函数中的顺序被复制到运行期上下文的作用域链中。它们共同组成了一个新的对象,叫做“活动对象(active object)”,该对象包含了函数所有的局部变量,命名参数,参数集合以及this,然后次对象会被推向作用域的前端。当运行期上下文被销毁,活动对象随之被销毁。新的作用域链如下图所示:

javascript block作用域 javascript作用域链_javascript block作用域_02


在函数执行的过程中,每遇到一个变量,都会经历一次标识符解析过程,以决定从哪里获取和存储数据。该过程从作用域链头部,也就是从活动对象开始搜索,查找同名标识符,如果找到了就使用这个标识符(所以从这里就理解了,局部变量和全局变量重名就以局部变量为准的原因了),如果没找到,继续搜索作用域的下一个对象,如果搜索完所有对象都未找到,则认为标识符未定义。函数执行过程中,每个标识符(变量)都要经历这么个搜索过程。


代码优化

从作用域链的结构可以看出,在运行期上下文的作用域链中,标识符所在的位置越深(理解为全局变量作用域最深),读写速度越慢。上图可以看出,全局变量总是存在于运行期上下文作用域链的最末端,所以在标识符解析的时候,查找全局变量最慢。所以,在编写代码的时候尽量少使用全局变量,尽可能的使用局部变量。我们不妨来个金律:如果一个跨作用域的对象被引用了一次以上,则可以把它存到局部变量里再使用。尤其是DOM操作,避免每次都去访问DOM,大大的优化了性能。
例如:

function modifyColor() {
    document.getElementById('gyBtn').onclick = function() {
        document.getElementById('gyDiv').style.backgroundColor = 'red';
    }
}

上面的函数用了两次全局变量document,查找该变量必须遍历整个作用域链,直到最后在全局对象中找到它,如果把document存到一个局部变量,那么可以直接找到它,而且避免了多次访问DOM结构。

function modifyColor() {
    var doc = document;
    doc.getElementById('gyBtn').onclick = function() {
        doc.getElementById('gyDiv').style.backgroundColor = 'red';
    }
}

再看闭包

有一句话:“只要存在调用内部函数的可能,JS就需要保留被引用的函数。而且JS运行时需要跟踪引用这个内部函数所有的变量,直到最后一个变量废弃,JS的垃圾回收器才能释放相应的内存空间。”现在再来理解这句话就容易了,父函数定义的变量在子函数的作用域链中,只要子函数没被销毁,其作用域链中所有的变量和函数都会被维护,不会被销毁。


有个例子,共n个节点,点击每个节点都弹出此节点的编号。

for (var i = 0; i < nodes.length; i++) {
    nodes[i].onclick = function() {
        alert(i);
    };
}

这个例子的错误就是每次点击节点,弹出的都是一样的数字:length,无论你点击哪个节点。
来看看这段代码为node绑定的click事件处理过程的作用域链是这样的:


javascript block作用域 javascript作用域链_作用域链_03


由于内部函数,也就是绑定的点击函数,时刻都有被调用的可能,所以其作用域链不能被销毁,更何况i还是个全局变量,只能在页面关闭,刷新时销毁,i的值一直保持for循环执行完毕之后的length的值。还可以这么理解:for循环完成之后,由于JS没有块级作用域的概念,i的值还在,也就是length的值,当点击触发点击事件(执行内部函数),要弹出i,一看,i并不在这个click函数中定义也就是不在active object(活动对象)中,于是到作用域链的下一级去找,也就是全局变量,找到了,这时候i的值是length。


for (var i = 0; i < nodes.length; i++) {
    (function(num) {
        nodes[num].onclick = function() {
            alert(num);
        };
    })(i);
}

这样就行了,这时候onclick引用的变量成了num,i当实参传过来,由于立即执行的原因,每个onclick函数在作用域链中分别保持着对应的num (0 ~ length-1),这样就可以了。
参考我的另一篇博客: JavaScript匿名函数和闭包里面‘循环里的匿名函数的取值问题’这一部分。


改变作用域链

函数每次执行时对应的运行期上下文都是独一无二的,所以多次调用同一个函数会导致创建多个运行期上下文,当函数执行完毕,执行上下文会销毁。每一个运行期上下文都和一个作用域链关联。一般情况,在运行期上下文运行的过程中,其作用域链只会被with语句和catch语句影响。使用with,强制with块内的作用域是你指定的作用域,这是极力不推荐的。因为当代码运行到with语句时,运行期上下文的作用域链临时突然被改变了,一个新的可变对象被创建,它包含了参数指定的对象的所有属性,而且这个对象被推入作用域链的最前端,这意味着函数的所有局部变量都让步成为第二级作用域链对象,访问代价很高。


function test() {
    with(document) {
        var bd = body;
        var links = getElementsByTagName('a');
        getElementById('gy').onclick = function() {
            doSomething();
        };
    }
}

注意这几行代码,使用with,的确少写了几次document,实际上性能大打折扣。

javascript block作用域 javascript作用域链_作用域链_04


另一个会改变作用域链的是try-catch语句中的catch语句。如果try代码块中出错了,执行过程会跳到catch语句,然后把异常对象推入一个可变对象并置于作用域的头部,在catch代码块内部,函数的所有局部变量将会被放在第二个作用域链对象。

try {
    dosth();
} catch (ex) {
    console.error(ex);//作用域链在此改变
}

一旦catch语句执行完,作用域链就会返回到之前的状态,因此不建议太常用,但在调试异常处理又很有用,所以该用还得用。可以优化代码。一个很好的模式是将错误委托给一个函数处理。如下:

try {
    dosth();
} catch (ex) {
    handleError(ex.message);//委托给一个函数处理
}

handleError方法是catch子句中唯一执行的代码,该函数接收异常对象为参数,这样你可以灵活处理错误。由于只执行一句,且没有局部变量的访问,作用域链的临时改变不会影响代码性能了。