JS 深入理解词法作用域作用域和闭包

本篇博客来小结一下作用域和闭包相关的一些进阶知识点,均为本人阅读《你不知道的js》以及《js高级程序设计》后的理解。知识点有点多,所以可以直接看自己想要了解的部分。

作用域部分

词法作用域

作用域共有两种主要的工作模型,分别为词法作用域和动态作用域。词法作用域最为普遍,且也是javascript使用的作用域,所以我们平时理解的javascript作用域其实就是javascript的(词法)作用域。

词法作用域是由你在写代码时将变量和块级作用域写在哪里来决定的。比如,无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。所以函数的作用域链也是在一开始写下代码的时候就已经定下来了,而后面的编译阶段才定下来的。

欺骗(修改)词法作用域

如果说词法作用域完全由写代码期间函数所声明的位置来定义,那如何在运行的时候来欺骗(修改)词法作用域呢?

可以使用javascript中的 eval with

eval

详情看文档。具体就是可以给 eval() 函数传入一个字符串参数,然后字符串的内容会被视为在书写时就存在于程序中 eval 这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置一样,以此修改词法作用域的环境。

如下,“var b = 3”会被当做本来就在那里一样来处理。

    function foo(str, a) {
      eval(str)
      console.log(a, b);
    }
    var b = 2
    foo("var b = 3", 1) //1,3

 with

详情看文档。with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复应用对象本身。有一个比较特别的地方在这里需要讲一讲。

一般我们给某个对象添加属性可以这么写:

    var obj = {}
    obj.name = 'jenny'
    console.log(obj); //{ name:'jenny'}

然后我们看一段使用with的代码:

定义一个函数foo,给foo传入一个obj参数,函数内对obj的a属性进行修改。因为o1本身含有a属性,所以o1.a被顺利修改为2。但是o2中没有a属性,所以无法进行修改,o2.a保持原来的undefined。但是奇怪的事情是,虽然没有修改o2.a,但是却新增了一个全局的a,并且a值为2。

    function foo(obj) {
      with(obj) {
        a = 2
      }
    }
    var o1 = {
      a: 3
    }
    var o2 = {
      b: 3
    }
    foo(o1)
    console.log(o1.a); //2
    foo(o2)
    console.log(o2.a); //undefined
    console.log(a); //2 a变量泄漏到全局作用域

这是为什么呢?为什么普通对象就可以通过点运算符来新增或修改属性,而这里用with来对对象本身不具有的属性进行操作,却不能达到“新增”的效果呢?

原来是因为with会将一个对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的标识符。所以,eval( ) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with 声明实际上是根据你传递给它的对象凭空创建了一个新的词法作用域。

所以上面这段代码对o2的操作可以理解为,现在o2作用域中查找a,发现该作用域中并无a,然后继续往外层的全局作用域查找,而全局作用域也没有a,所以便在全局作用域中新声明一个a,然后把a赋值为2,而o2.a保持原来的 undefined。具体可以去了解一下LHS和RHS引用,这里的查找过程属于LHS引用。

性能

这两种方式看似很灵活,但其实有着很大的缺点:性能消耗。

因为javascript引擎会在编译阶段进行数项的性能优化,其中有些优化依赖与能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。

但如果使用了eval或with,引擎只能简单地假设关于标识符位置的判断都是无效的,因为它不能在词法分析阶段明确知道eval会接受到什么代码,也无法知道传递给with用来创建新词法作用域的对象的内容到底是什么。

特别是当我们使用了eval或with后,因为有可能会让引擎所作的优化变得无意义,所以引擎会选择对这些代码完全不做任何优化。总的来说,就是大量使用eval和with会让代码运行变慢。

闭包部分

什么是闭包

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

常见的形式就是,函数在被定义时的词法作用域以外的地方被调用,即无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

闭包举例

闭包的形式不是单一的,下列每一段代码都存在闭包。

例一

foo将内部函数baz作为参数传入外部函数bar,所以在foo外部的bar中依然可以对baz进行调用。

    function bar(fn) {
      fn()
    }

    function foo() {
      var a = 2

      function baz() {
        console.log(a);
      }
      bar(baz)
    }

    foo() //2

当然,也可以间接传递。 

    var fn

    function bar() {
      fn()
    }

    function foo() {
      var a = 2

      function baz() {
        console.log(a);
      }
      fn = baz
    }

    foo()
    bar() //2

例二

这里timer具有涵盖wait作用域的闭包。当把函数作为第一级的值类型传递,就能看到闭包在这些函数中的应用。

所以,在定时器、事件监听器、Ajax请求等任务中,只要使用了回调函数,实际上就是在使用闭包。

    function wait(msg) {
      setTimeout(function timer() {
        console.log(msg);
      }, 1000)
    }
    wait('Hello,closure') //Hello,closure

例三

很经典的一个例子,实现每秒一次分别输出数字1~5。

要实现这个功能,可以用let声明i,来创建块级作用域;也可以用立即执行函数来创建闭包;还可以用箭头函数。

这里用立即执行函数来创建了一个作用域,从而记录了每次迭代时的i值,以便setTimeout的回调函数使用。

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

猜你喜欢

转载自blog.csdn.net/weixin_42207975/article/details/107896805