javascript之作用域

316 阅读8分钟

一、作用域是什么?

作用域是根据名称查找变量的一套规则,用于确定何处以及如何查找变量。 作用域经常是嵌套的,当一个块或是函数嵌套在另一个块或函数中时,就发生了作用域嵌套。 在当前作用域中无法找到某个变量时,引擎就会向外层嵌套作用域查找,直到找到该变量,或者是抵达最外层作用域(全局作用域)为止,无论找没有找到都会停止。

如果查找的目的是对变量进行赋值,即变量出现在赋值操作符的左侧,那么会使用LHS查询(左侧查询),实际上LHS查询是试图找到变量的容器本身,从而可以对其赋值。如果查找的目的是获取变量的值,那么就会使用RHS查询(右侧查询,即查询变量的值,它不是真正意思上的“赋值操作的右侧”)。赋值操作符会导致LHS查询, =操作符或是调用函数是传入的参数操作都会导致关联作用域的赋值操作。

不成功的RHS引用会导致抛出ReferenceError异常。不成功的LHS引用会自动隐式的创建一个全局变量(非严格模式下),该变量使用LHS引用的目标作为标识符。严格模式下会抛出ReferenceError异常。

如下例子中:

function test(a){
    var b = a;
    return a + b;
}
var c = test(6); 

此代码中共有三处LHS查询:

1. var c =…; 2. test(a) 函数的行参被赋值为6; 3. var b = …;

共四处RHS查询:

1. test(6)此处查询test函数; 2. … = a 获取传递进来的参数a的值; 3. …+ b 获取本地变量b的值; 4. return a + …同2;

二、词法作用域

词法作用域就是定义词法阶段的作用域,它是由你在写代码时把变量和作用域块写在哪里决定的,因此在词法分析处理代码时会保持作用域不变(基本上是如此)。 如下代码:

function foo(i){
    var j = i*2;
    function bar(k){
        console.log(i,j,k);
    }
    bar(1);
}
foo(6);

共三层作用域

1. 全局作用域,包含标识符 foo;

2. 函数foo内部作用域,包含i,j,bar三个标识符;

3. 函数bar内部作用域,包含标识符k;

引擎在查找标识符位置时参考了此种嵌套关系,从编写的位置开始向外查找,在找到第一个匹配的标识符时即停止。在多层嵌套的作用域中,可以定义同名的标识符,当在内部的作用域中匹配到相应的标识符,就不会向外查找,此时外层的标识符就会被遮蔽。

对于全局的同名变量,在访问时可以不通过全局对象的词法名称,而是间接的通过对全局对象属性的引用来对其访问。 如:window.a

三、函数作用域

函数是JavaScript中最常见的作用域单元,声明在一个函数中的变量或是函数会在所处的作用域中被“隐藏”起来,外部作用域无法访问包装函数内部的内容。 如:

var a = 2;
function test(){
    var a = 1;
    console.log(a); //1
}
test(); 
console.log(a); //2

如果函数不需要函数名,我们可以如下编码:

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

包装函数以(function…… 开始,而不仅是funtion……,此时会被当成是函数表达式,而不是一个函数声明来处理。 ** 区分函数声明和函数表达式直接的方法是看function关键字出现在声明中的位置,如果function是第一个词,此时是函数声明,否则就是一个函数表达式。** 上一个例子中的函数写在第一对()内就变成函数表达式,第二对()执行了这个函数表达式,这就是我们常说的立即执行函数表达式。 我们可以在第二对()中传入参数,例如:

var a = 1;
(function(g){
    var a = 2;
    console.log(a);  //2
    console.log(g.a);   //1
})(window);     //传入window对象

或是倒置代码的运行顺序,将要执行的函数放在第二位。如下:

var a = 11;
(function(f){
    f(window);
})(function(g){
    var a = 22;
    console.log(a);     //22
    console.log(g.a);   //11
});

四、作用域和闭包

函数可以记住并访问所在的词法作用域,即便函数在当前词法作用域外执行,都会产生闭包。 在《JavaScript高级程序设计》中定义的闭包是有权访问另一个函数作用域中的变量的函数。 下面来几个直观点的例子。

function foo(){
    var msg = 'inner';
    function printer(){
        console.log(msg);   //inner
    }
    return printer;
}
var bar = foo();
bar(); 

上面的例子中,函数printer的词法作用域能够访问函数foo的内部作用域,我们把函数printer本身当做一个值类型进行传递,即将printer作为函数foo的返回值。在执行函数foo后并将返回值赋值给变量bar,此时通过不同的标识符bar拥有了对内部函数priner的调用权。
在执行bar后,按理说执行会失败,因为调用foo后,引擎的垃圾回收机制就会释放掉不再使用的内存,foo内部的作用域看上去不会使用了,已经被回收了。
然而闭包的神奇之处在于,它能阻止这一情况发生,事实上foo的内部作用域仍然存在,那是因为printer在使用,通过值传递,bar指向内部函数printer(依然持有对foo作用域的引用),这即是闭包。

function foo(){
    var num = 666;
    function bar(){
        console.log(num);
    }
    baz(bar);
}
function baz(f){
    f();    //这也是闭包
}
foo();  //666

把内部函数bar传递给baz当做参数 f,此时的 f 能涵盖 foo 作用域的闭包就产生了。无论使用何种形式进行函数类型的值传递,只要在函数在别处被调用时,都会产生闭包。 另外如下:

function showMsg(msg){
    setTimeout(function innerFn(){
        console.log(msg);
    },3000)
}
showMsg('Hello');

在showMsg调用后的 3秒 中,它的内部作用域不会消失,以上回调函数 innerFn 仍然保有对内部 msg 变量的引用,innerFn就是 showMsg作用域的闭包。

另外常见的面试题中有个关于作用域和闭包的典型例子,如下:

var arr = [];
for (var i = 0; i < 10; i++) {
    arr[i] = function() {
        return i;
    }
}
console.log(arr[0]()); //10
console.log(arr[6]());  //10

以上 实际输出为 10, 而不是预想中的0 和 6,为什么呢? 我们在arr数组中添加了,10个元素,每一个元素指向一个打印 i值的函数,此时的i 是一个全局变量,在循环执行完后其值为 10,我们在 执行 arr[6]是查找到的是这个全局变量 i,所以得到的是10。

常见的一种解决方法如下:

var arr = [];
for (var i = 0; i < 10; i++) {
    arr[i] = (function(j) {
        return j;
    })(i)
}
console.log(arr[0]);  //0
console.log(arr[6]);  //6

在每次迭代中给arr添加的元素指向一个立即执行的闭包,它每次都创建了一个词法作用域来存储每次迭代传进来的 i 的值。

而用新的ES6的let 声明变量的方式,可以在每次迭代中建立独立的块级作用域来生成闭包。

var arr = [];
for (let i = 0; i < 10; i++){
    arr[i] = function(){
        return i;
    }
}
console.log(arr[0]());  //0
console.log(arr[6]());  //6

五、闭包与模块

利用闭包的强大威力,可以构造一种代码模式,模块。

如下所示:

function myModule() {
    var arr = [1, 2, 3, 4, 5, 6];
    var str = "It's cool!";

    function showArr() {
        console.log(arr.join("-"));
    }

    function showStr() {
        console.log(str);
    }
    return {
        showArr,
        showStr
    }
}

var coolMd = myModule();
coolMd.showArr();    //1-2-3-4-5-6
coolMd.showStr();    //It's cool!

仔细观察可看出,我们以上的myModule只是一个包装函数,它返回一个包含了指向内部函数(不是内部数据)的{key:value… }对象,这个对象可以看做是模块的公共API,我们通过调用myModule来创建一个内部的作用域和闭包,并赋值给变量 coolMd,我们就可以通过这个变量来访问API的暴露的属性和方法。

上面例子中的myModule模块可以被调用多次,每次都会创建不同的模块实例,当只需要一个实例时,我们可以将以上模式改成一个单例模式。

var myModule = (function() {
    var arr = [1, 2, 3, 4, 5, 6];
    var str = "It's cool!";

    function showArr() {
        console.log(arr.join("-"));
    }

    function showStr() {
        console.log(str);
    }
    return {
        showArr,
        showStr
    }
})()

myModule.showArr(); //1-2-3-4-5-6
myModule.showStr(); //It's cool!

我们将模块函数换成了立即执行表达式并将返回值赋值给单例模块的实例标识符 myModule。

现代的模块依赖加载器通常把模块定义封装进一个API中。如下:

var myModules = (function creater(){
    var modules = {};
    
    function define(name, deps, impl){
        for(var i=0; i<deps.length; i++){ // 如果deps依赖数组中有值
            //将依赖数组中的每一个依赖项的字符串的值换成该标识符对应的依赖本身,
            //即将调用define时传的依赖数组中对应的标识符deps[i]指向modules中对应的已经定于的方法
            //deps = [{'say': function(name){…}}]
            deps[i] = modules[deps[i]];
        }
        //引入包装函数并传入依赖,返回值为模块的API(一个用名字管理的模块列表)。
        //参数deps数组,deps数组中的依赖被当做实参传入impl中,因此在下面的使用例子中有foo
        modules[name] = impl.apply(impl, deps)
    }
    
    function get(name){
        return modules[name];
    }
    
    return {
        define,
        get
    }
})()

以下是如何使用它来定义模块。

myModules.define("foo", [], function(){
    function say(name){
        return "My name is " + name;
    }
    return {
        say
    };
})

//myModules.define("bar", ["foo"], function(){ // aaa
myModules.define("bar", ["foo"], function(foo){ // bbb
    var name = "Harry"; 
    function sayAnother(){
        // console.log(foo.say(name).toUpperCase()); //对应上面注释aaa处,foo是下面的全局变量foo
        console.log(foo.say(name).toUpperCase()); //对应上面注释bbb处,foo是局部变量foo
    }
    return {
        sayAnother
    };
})

var foo = myModules.get("foo");
var bar = myModules.get("bar");
console.log(foo.say("jack"));   //My name is jack
bar.sayAnother();       //MY NAME IS HARRY