作用域和闭包

90 阅读8分钟

理解作用域

作用域是一套规则,用于确定在何处以及如何查找变量,如果查找的目的是对变量进行赋值,那么使用LHS查询,如果目的是获取变量的值,则使用RHS查询。如果在当前作用域中没有找到,则往上层作用域中寻找,找到了则返回,以此类推,如果直到抵达全局作用域时仍未找到,则抛出错误

词法作用域

词法:大部分标准语言编辑器的第一个工作阶段叫词法化,这个阶段会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义

简单来说,词法作用域就是定义在词法阶段的作用域,词法作用域是由写代码时将变量和块作用域写在哪里决定的(大部分情况下)

//全局作用域————其中只有一个标识符:foo
 function foo(a){
         //局部作用域foo————其中有三个标识符:a,b,bar
          var b=a*2;
          function bar(c){
                  //局部作用域bar————其中只有一个标识符:c
              console.log(a,b,c);
          }
          bar(b*3)
      }
      foo(2);

作用域由其对应的作用域块代码写在哪里决定,它们是逐级包含的,函数也一样,不管在哪里被调用,它的词法作用域都只由函数被声明时所处的位置决定

欺骗词法

有两种办法可以在运行时“欺骗”词法作用域

1.eval

eval()函数可以接受一个字符串作为参数,并将内容视为好像在书写时就存在于程序中的这个位置,这样说有点绕,举例!

function foo(str, a) {
            eval(str); // 欺骗!
            console.log(a, b);
        }
        var b = 2;
        foo("var b = 3", 1); // 结果为1,3

eval(str)这段代码会被当做本来就在那里一样来处理,实际上是在foo函数内部创建了变量b,因此可以在foo作用域同时找到a和b,也就不会去全局下找了

2.with

with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此对对象的属性也会被处理为定义在这个作用域中的词法标识符,但是并不推荐使用,这里不多做介绍了

eval和with这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,所以能不使用就不要使用!

函数作用域和块作用域

函数作用域:属于这个函数的全部变量都可以在这个函数的范围内使用以及复用

隐藏内部实现

一般情况下函数外部没办法访问函数里面的东西,利用这个原理,将一些变量或者函数封装到一个函数作用域中,就可以“隐藏它们”,举例!

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

这样fn1函数外部(也就是全局作用域)无法访问到b和fn2函数,它们只能由fn1来控制,这样可以有效避免同名标识符之间的冲突,但这也导致了一些额外的问题出现,首先必须声明一个函数fn1,fn1这个名称本身也“污染”了全局作用域,而且它必须通过fn1()来调用才能运行其中的代码,解决方式也有,使用立即执行函数表达式

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

块作用域

ES6以前,表面上javascript并没有块作用域的相关功能(但也有些偏门的办法)

1.with关键字从对象中创建出的作用域是一个块级作用域

2.try / catch的catch分句会创建一个块作用域,即声明的变量只在catch内部生效

3.let,ES6新增的的关键字,可以将变量绑定到所在的任意作用域中,不过用let声明的变量不会在快作用域中进行变量提升,声明的代码在被运行前并不“存在”,举例!

        console.log(a);
        let a = 3 // 结果报错:Uatncaught ReferenceError: Cannot access 'a' before initialization at.....

4.const,也是ES6新增的的关键字,同样可以用来创建块作用域变量,但它的值是常量

块作用域还有个非常有用的原因和闭包,垃圾回收机制有关,当一个变量不再被需要,它身处独立的块作用域内时,就会被垃圾回收掉,不会占用多余内存

提升

先有鸡还是先有蛋?到底是声明在前?还是赋值在前?

变量和函数在内的所有声明都会在任何代码被执行之前首先被处理,这种机制叫变量提升,举例!

        var a = 2
        console.log(a); // 结果为2

js是这样处理的:

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

而如果这样写

        console.log(a);
        var a = 2 // 结果为undefined,而不是报错

因为js是这样处理的:

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

在js中,定义声明是在编译阶段进行的,而赋值声明会被留在原地等待执行阶段,代码执行到 console.log(a);的时候, a = 2还没有被执行,此时a的值仍是undefined,所以打印结果为undefined

同样的函数声明也会被提升

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

实际上会被理解为这样的形式:

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

需要注意的是:函数声明会被提升,但函数表达式不会,我的理解是函数名被当成变量提升了,也就是函数名此时还不是一个函数名,只是个未赋值的变量,值为undefined

还需要注意的是,函数会首先被提升,然后才是变量提升,处于后面的函数声明也可以覆盖前面的函数声明,举例!

        foo() // 结果为3
        function foo() {
            console.log(1);
        }
        var foo = function () {
            console.log(2);
        }
        function foo() {
            console.log(3);
        }

作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包即使函数是在当前词法作用域之外执行的。举例!

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

函数bar()的词法作用域能访问函数foo()的内部作用域,bar()函数本身被当做值类型进行传递,这里是当做返回值。函数foo()执行后,它的返回值被赋值给了全局作用域中的变量baz并执行了函数bar(),实际上就是通过baz引用调用了内部函数bar(),函数foo()执行后,它的内容也不会被销毁回收,因为函数bar()本身在使用(我的理解是被baz引用了,baz又没删除,所以bar()也一直存在着),而拜bar()所声明的位置所赐,它能访问foo()内部作用域,所以也使得该作用域一直存活,bar()一直持有对该作用域的引用,这个引用就叫闭包

循环和闭包

直接上例子

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

预期每秒输出一个数字,从0到5,但实际结果是每秒输出一个5,这是因为js异步处理机制,定时器会在for循环后再执行,这个时候i的值已经是5了,尽管五个回调函数是在各个迭代中分别定义的,但它们都被封闭在一个共享的作用域中,因此实际上都是引用的同一个i,下面来改进

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

利用闭包,存储了每个迭代中i的值,这样就能正常工作了,其实需要的就是为每个迭代都生成一个新的作用域,并把新的作用域封闭在每个迭代的内部,这样每个迭代都会含有一个独立的变量来进行计算,块作用域刚好能满足需求,因此可以把代码简化为:

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

模块

以下这个模式在js中被称为模块

        function CoolModule(){
            var something="cool";
            var num=[1,2,3];
            function doSomething(){
                console.log(something);
            }
            function addNum(){
                console.log(num.push("4"));
            }
            return{
                doSomething:doSomething,
                addNum:addNum
            }
        }
        var foo=CoolModule();
        foo.doSomething()
        foo.addNum();

CoolModule()只是一个函数,必须通过调用它来创建一个模块实例,如果不执行外部函数,内部作用域和闭包都无法被创建。它的返回值被赋值给外部的变量foo,然后通过它可以访问函数内部的属性方法。

从上面的例子看的出来模块模式需要两个必要条件:

1.必须要有外部的封闭函数,且至少被调用一次

2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包

当只需要一个实例时,可以改进为单例模式

        var foo = (function CoolModule() {
            var something = "cool";
            var num = [1, 2, 3];
            function doSomething() {
                console.log(something);
            }
            function addNum() {
                console.log(num.push("4"));
            }
            return {
                doSomething: doSomething,
                addNum: addNum
            }
        })()
        foo.doSomething()
        foo.addNum();

立即调用这个函数并将返回值直接赋给单例的模块实例标识符foo

模块也是普通函数,也能接受参数

怎么产生闭包:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这事就产生了闭包。

闭包的作用:

保护作用:保护函数内部变量或函数名不会与函数外部的变量或函数名冲突,避免全局污染

保存作用:将函数内部的值进行保存不被销毁回收

封装作用:函数内部的变量就是私有变量,在外部无法引用,但是可以通过闭包来访问私有变量。