弄懂JavaScript的做用域和闭包

《你不知道的JavaScript》真的是一本好书,阅读这本书,我有屡次“哦,原来是这样”的感受,之前自觉得理解了(其实并不是真的理解)的概念,这一次真的理解得更加透彻了。关于本书,我会写好几篇读书笔记用以记录那些让我恍然大悟的瞬间,本文是第一篇《弄懂JavaScript的做用域和闭包》。javascript

看正文以前,先考你几个问你,若是你能清晰的回答,那本文可能对你做用不大,若是有一些疑问,那咱们就一块儿来解开这些疑问吧。java

考考你

  • 标识符是什么?LHSRHS又是什么,其意义何在?编程

  • 什么是词法做用域?javascript语言中那些东西会影响做用域?浏览器

  • 咱们一直都在据说的各类提高(函数提高,变量提高)究竟要怎么理解?安全

  • 在咱们平时的编程中,那些地方用到了闭包?(悄悄告诉你,我以前也能把闭包的概念背的滚瓜乱熟,可是却一直觉得本身平时不多用到闭包,后来才发现,原来一直都在用啊。。)性能优化

正文从这里开始

从浏览器如何编译JS代码提及

好久以来我就在思考,当咱们把代码交给浏览器,浏览器是如何把代码转换为活灵活现的网页的。JS引擎在执行咱们的代码前,浏览器对咱们的代码还作了什么,这个过程对我来讲就像黑匣子通常,神秘而又让人好奇。闭包

理解var a = 2

咱们天天都会写相似var a = 2这样的简单的JS代码,但是浏览器是机器,它可只认识二进制的0和1,var a = 2对它来讲确定比外语对咱们还难。不过有困难没关系,至少咱们如今问题清晰了,要知道它是如何把有意义的人类字符转化为符合必定规则的机器的0 和 1 。异步

想一想咱们是如何阅读一句话的(能够想一想咱们不那么熟悉的外语),咱们不熟悉英语的时候,咱们其实优先去理解的是一个个的词,这些词按照必定的规则就成了有意义的句子。浏览器其实也是如此var a = 2,浏览器其实看到的是var,a,=,2这是一个个的词。这个过程叫作词法解析阶段,换句话说是这个过程会将由字符组成的字符串分解成(对编程语言来讲)有意义的代码块。
就像咱们按照语法规则组合单词为句子同样,浏览器也会把上述已经分解好的代码块组合为表明了程序语法结构的树(AST),这个阶段称为语法分析阶段,AST对浏览器来讲已是有意义的外语了,不过距离它直接理解还差一步代码生成,转换代码为有意义的机器语言(二进制语言)。编程语言

咱们总结一下经历的三阶段模块化

- 词法分析:分解代码为有意义的词语;
* 语法分析:把有意义的词语按照语法规则组合成表明程序语法结构的树(AST);
* 代码生成:将 AST 转换为可执行代码

经过上述三个阶段,浏览器已经能够运行咱们获得的可执行代码了,这三个阶段还有一个合称呼叫作编译阶段。咱们把以后对可执行代码的执行称为运行阶段

JS的做用域在什么时候肯定

编程语言中,做用域通常来讲有两种,词法做用域和动态做用域。词法做用域就是依赖编程时所写的代码结构肯定的做用域,通常来讲在编译结束后,做用域就已经肯定,代码运行过程当中再也不改变。而动态做用域听名字就知道是在代码运行过程当中做用域会动态改变。通常认为咱们的javascript的做用域是词法做用域(说通常,是由于javascript提供了一些动态改变做用域的方法,后文会有介绍)。

词法做用域就是依赖编程时所写的代码结构肯定的做用域,对比一下浏览器在编译阶段作的事情,咱们发现,词法做用域就是在编译阶段肯定的。看到这里是否是忽然理解了为何之前咱们经常听到的“函数的做用域在函数定义阶段就肯定了”这句话了。接下来咱们就来讲明函数做用域是按照什么规则肯定的。

JS中的做用域

做用域是什么?

关于做用域是什么?《You don’t know js》给出了这么一个概念:

使用一套严格的规则来分辨哪些标识符对那些语法有访问权限。

好吧,好抽象的一句话,标识符又是什么呢?做用域到底要怎么理解啊?咱们一个个来看。

标识符:

咱们知道,当咱们的程序运行的时候,咱们的数据(”字符串”,“对象”,“函数”等等都是要载入内存的)。那咱们该如何访问到对应的内存区域呢,标识符就在这时候起做用了,经过它咱们就能找到对应的数据,从这个角度来看,变量名,函数名等等都是标识符。

对标识符的操做
知道了标识符,咱们来想一想,平时咱们会对标识符进行哪些操做。其实无外乎两种,看下面的代码:

// 第一种定义了标识符`a`并把数值2赋值给了`a`这种操做有一个专门的术语叫作`LHS`
var a = 2;

// 第二种,var b = a ,其实对应a ,b 两个操做符是不一样的操做,对b来讲是一个赋值操做,这是LHS,可是对a来讲倒是取到a对应的值,这种操做也有一个专门的术语叫作“RHS”
var b = a;

小结一下,对标识符来讲有如下两种操做

- 赋值操做(LHS);常见的是函数定义,函数传参,变量赋值等等
* 取值操做(RHS);常见包括函数调用,
再回过头来看做用域

明白了标识符及对标识符的两种操做,咱们能够很容易的理解做用域了,做用域其实就是定义了咱们的呈如今运行期,进行标识符操做的范围,对应到实际问题来讲,就是咱们熟悉的函数或者变量能够在什么地方调用。

做用域也能够看作是一套依据名称查找变量的规则。那咱们再细看一下这个规则,在当前做用域中没法找到某个变量时,引擎就会在外层嵌套的做用域中继续查找,直到找到该变量, 或抵达最外层的做用域(也就是全局做用域)为止。

这里提到了嵌套一词,咱们接下来看js中那些因素能够造成做用域。

JS中的做用域类型

函数做用域

函数做用域是js中最多见的做用域了,函数做用域给咱们最直观的体会就是,内部函数能够调用外部函数中的变量。一层层的函数,很直观的就造成了嵌套的做用域。不过只说这一点真对不起本文的标题,还记得咱们经常听到的“若是在函数内部咱们给一个未定义的变量赋值,这个变量会转变为一个全局变量”。对我来讲以前这句话几乎是背下来的,我一直都没能理解。咱们从对标识符的操做的角度来理解这句话。

var a = 1;

function foo(){
// b第一次出如今函数foo中
    b = a ;
}

foo();

// 全局能够访问到b
console.log(b); //1

在咱们调用foo()时,对b实际上是进行了LHS操做(取得a的值并赋值给b),b前面并不存在var let 等,所以浏览器首先在foo()做用域里面查找b这个标识符,结果在b里面没有找到,安装做用域的规则,浏览器会继续在foo()的外层做用域寻找标识符b,结果仍是没有找到,说明在此次查询标识符b的范围内并不存在已经定义的b,在非严格模式下LHS操做会在可查找范围的最外层(也就是全局)定义一个b,所以b也就成了一个全局的变量了(严格模式LHS找不到返回ReferenceError错误)。这样那句话就能够理解了。一样值得咱们注意的是对操做符进行RHS操做会出现不一样的状况,不管严格或者非严格模式RHS找不到对返回ReferenceError错误(对RHS找到的值进行不合理的操做会返回错误TypeError(做用域判别成功,操做非法。))。

闭包:闭包是基于词法做用域书写代码时所产生的天然结果,你甚至不须要为了利用它们而有意 识地建立闭包。闭包的建立和使用在你的代码中随处可见。你缺乏的是根据你本身的意愿 来识别、拥抱和影响闭包的思惟环境。

块做用域

除了函数做用域,JS也提供块做用域。咱们应该明确,做用域是针对标识符来讲的,块做用域把标识符限制在{}中。

ES6 提供的let,const方法声明的标识符都会固定于块中。常被你们忽略的try/catchcatch语句也会建立一个块做用域。

改变函数做用域的方法

通常说来词法做用域在代码编译阶段就已经肯定,这种肯定性实际上是颇有好处的,代码在执行过程当中,可以预测在执行过程当中如何对它们进行查找。可以提升代码运行阶段的执行效率。不过JS也提供动态改变做用域的方法。eval()函数和with关键字.

eval()方法:
这个方法接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,能够在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的同样。

function foo(str,a){
     eval(str);//欺骗做用域,词法阶段阶段foo()函数中并无定义标识符,可是在函数运行阶段却临时定义了一个b;
     console.log(a,b);
 }
 
 var b = 2;
 
 foo("var b =3;",1);//1,3

 // 严格模式下,`eval()`会产生本身的做用域,没法修改所在的做用域
 function foo(str){
     'use strict';
     eval(str);
     console.log(a);//ReferenceError: a is not de ned
 }
 
 foo('var a =2');

eval()有时候挺有用,可是性能消耗很大,可能也会带来安全隐患,所以不推荐使用。

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;
    }

    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被泄漏到全局做用域上了!
    
    // 执行了LHS查询,不存在就在全局建立了一个。
    // with 声明其实是根据你传递给它的对象凭空建立了一个全新的词法做用域。

with也会带来性能的损耗。

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于可以根据代码的词法进行静态分析,并预先肯定全部变量和函数的定义位置,才能在执行过程当中快速找到标识符。

声明提高

做用域关系到的是标识符的做用范围,而标识符的做用范围和它的声明位置是密切相关的。在js中有一些关键字是专门用来声明标识符的(好比var,let,const),非匿名函数的定义也会声明标识符。

关于声明也许你们都据说过声明提高一词。咱们来分析一下形成声明提高的缘由。

咱们已经知道引擎会在解释 JavaScript 代码以前首先对其进行编译。编译阶段中的一部分工做就是找到全部的声明,并用合适的做用域将它们关联起来(词法做用域的核心)。
这样的话,声明好像被提到了前面。
值得注意的是每一个做用域都会进行提高操做。声明会被提高到所在做用域的顶部。

不过并不是全部的声明都会被提高,不一样声明提高的权重也不一样,具体来讲函数声明会被提高,函数表达式不会被提高(就算是有名称的函数表达式也不会提高)。

经过var 定义的变量会提高,而letconst进行的声明不会提高。

函数声明和变量声明都会被提高。可是一个值得注意的细节也就是函数会首先被提高,而后才是变量,也就是说若是一个变量声明和一个函数声明同名,那么就算在语句顺序上变量声明在前,该标识符仍是会指向相关函数。

若是变量或函数有重复声明以会第一次声明为主。

最后一点须要注意的是:
声明自己会被提高,而包括函数表达式的赋值在内的赋值操做并不会提高。

做用域的一些应用

看到这里,我想你们对JS的做用域应该有了一个比较细致的了解。下面说一下对JS做用域的一些拓展应用。

最小特权原则

也叫最小受权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其余内容都“隐藏”起来,好比某个模块或对象的 API 设计。也就是尽量多的把部分代码私有化。

函数能够产生本身的做用域,所以咱们能够采用函数封装(函数表达式和函数声明均可以)的方法来实现这一原则。

// 函数表达式
    var a = 2;
    (function foo() { // <-- 添加这一行 var a = 3;
       console.log(a); // 3 
    })(); // <-- 以及这一行 
    console.log( a ); // 2

这里顺便说明一下如何区分函数表达式和函数声明

若是 function 是声明中 的第一个词,那么就是一个函数声明,不然就是一个函数表达式。
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。函数表达式能够是匿名的,而函数声明则不能够省略函数名——在 JavaScript 的语法中这是非法的。

可使用当即执行的函数表达式(IIFE)的方式来封装。

当即执行的函数表达式(IIFE)

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

函数表达式后面加上一个括号后会当即执行。

(function(){ .. }())是IIFE的另一种表达方式括号加在里面和外面,功能是同样的。

顺便说一下,IIFE 的另外一个很是广泛的进阶用法是把它们看成函数调用并传递参数进去。

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

通常你们都会这么形容闭包。

当一个函数的返回值是另一个函数,而返回的那个函数若是调用了其父函数内部的其它变量,若是返回的这个函数在外部被执行,就产生了闭包。

function foo() {
        var a = 2;
    
        function bar() {
            console.log(a);
        }
        return bar;
    }
    var baz = foo();
    baz(); // 2 —— 这就是闭包的效果。在函数外访问了函数内的标识符
    
    // bar()函数持有对其父做用域的引用,而使得父做用域没有被销毁,这就是闭包

通常来讲,因为垃圾回收机制的存在,函数在执行完之后会被销毁,再也不使用的内存空间。上例中因为看上去 foo()的内容不会再被使用,因此很天然地会考虑对其进行回收。而闭包的“神奇”之处正是能够阻止这件事情的发生(之前总有人说要减小使用闭包,惧怕内存泄漏什么的,其实这个也不大比担忧)。

其实上面这个定义,在很久以前我就知道,不过同时我也误觉得我平时不多用到闭包,由于我真的并无主动去用过闭包,不过其实我错了,无心中,我一直在使用闭包。

本质上不管什么时候何地,若是将函数(访问它们各自的词法做用域)看成第一 级的值类型并处处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax请求、跨窗口通讯、Web Workers或者任何其余的异步(或者同步)任务中,只要使 用了回调函数,实际上就是在使用闭包!
因此你应该知道,你已经用过不少次闭包了。

这里说一个你们可能都遇到过的坑,一个没有正确理解做用域和闭包形成的坑。

for (var i = 1; i <= 5; i++) {
        setTimeout(function timer() {
            console.log(i);
        }, i * 1000);
    }
// 其实咱们想获得的结果是1,2,3,4,5,结果倒是五个6

咱们分析一下形成这个结果的缘由:
咱们试图假设循环中的每一个迭代在运行时都会给本身“捕获”一个 i 的副本。可是根据做用域的工做原理,实际状况是尽管循环中的五个函数是在各个迭代中分别定义的(前面说过以第一次定义为主,后面的会被忽略), 可是它们都被封闭在一个共享的全局做用域中,由于在时间到了执行timer函数时,全局里面的这个i就是6,所以没法达到预期。

理解了是做用域的问题,这里咱们有两种解决办法:

// 办法1
    for (var i = 1; i <= 5; i++) {
        (function(j) {
            setTimeout(function timer() {
                console.log(j);
            }, j * 1000);
        })(i);
    //经过一个当即执行函数,为每次循环建立一个单独的做用域。
    }
    
    // 办法2
    for (var i = 1; i <= 5; i++) {
        let j = i; // 是的,闭包的块做用域! 
          setTimeout( function timer() {
        console.log(j);
        }, j * 1000);
    }
    // let 每次循环都会建立一个块做用域

如今的开发都离不开模块化,下面说说模块是如何利用闭包的。

模块是如何利用闭包的:
最多见的实现模块模式的方法一般被称为模块暴露

咱们来看看如何定义一个模块

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

模块的两个必要条件:

  • 必须有外部的封闭函数,该函数必须至少被调用一次

  • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有做用域中造成闭包,而且能够访问或者修改私有的状态。

文章写到这里也差很少该结束了,谢谢你的阅读,但愿你有所收获。

相关文章
相关标签/搜索