【笔记】你不知道的Javascript-上卷-第一部分-作用域和闭包

148 阅读11分钟

Github链接

第一章 作用域是什么

1.1 编译原理 

  • 编译过程
  1. 分词/词法分析

           1.1  通过"分词/词法分析"生成词法单元流         2. 解析/语法分析

           2.1  通过”解析/语法分析”生成抽象语法树         3. 代码生成

           3.1  将AST转换成可执行代码过程

  • 扩展知识
    • JIT:js的编译过程通常发生在代码之前几微秒,通过JIT的方式进行优化
    • Babel:是一种编译器,不过是将ES6以上的语法转成原生语法的编译器
    • V8

1.2 理解作用域

  • LHS和RHS
    • LHS:查找变量的位置,并为赋值操作找到目标
    • RHS:查找变量的值
  • 函数的声明在代码生成时,会同时处理声明和赋值,这意味着函数声明并不会像函数赋值那样触发LHS操作。即函数声明与普通的变量赋值和函数赋值不同,它的赋值在编译阶段进行的,而后面两者是在引擎执行代码阶段进行的
  • 编译器编译阶段,对于变量声明,编译器会询问作用域是否存在该变量,如果存在就忽略该次声明,注意是忽略,而不是覆盖

1.3  作用域嵌套

  • 引擎从当前执行作用域开始查找变量,没有找到就一直往上继续查找,直到找到为止或者查找到全局作用域。

1.4 异常


// 代码块1--------------------------

/**

* 代码块1

* 1. 首先是编译器编译(编译器的主要工作内容是:语法分析及代码生成等,其中语法分析时主要是做声明变量的操作,而不做变量赋值

* 操作,"函数声明"除外,因为"函数声明"在代码生成时,会同时处理声明和赋值),当编译到console.log(a + b)和b=a时,发现变

* 量b都没有关键字"var",因此就忽略b的声明,继续往后编译

* 2. 编译完成后引擎开始执行代码,执行到console.log(a + b),开始在作用域中查找变量b及变量b的值,发现没有所有作用域中都

* 没有声明过,因此此时就报错"Uncaught ReferenceError: b is not defined",有同学会问不是下面有b=a吗?原因是现在已

* 经是代码执行阶段了,此时不会有所谓的"声明提升"("声明提升"是在编译阶段进行的) ,而且报错阻止了代码继续执行

*/

function foo(a) {

    console.log(a + b);// Uncaught ReferenceError: b is not defined

    b = a;

}

foo(2);

// 代码块1--------------------------

// 代码块2--------------------------

/**

* 代码块2

* 1. 与代码块1的区别是console.log的位置放在了后面,这样,代码执行到b=a时,遇到了和编译器遇到类似的情形,即没有关键字

* "var",并且在当前作用域没找到变量b有过声明,于是到上级作用域直至顶层作用域去查找b的声明,那么此时如果是非严格模式,则会

* 在顶层作用域(即全局作用域)声明一个全局变量,然后再把a的值赋值给b,因此会输出4,而且如果此时在全局调用b,则b可以输出为2;

* 如果是严格模式,那么此时还是会报"Uncaught ReferenceError: b is not defined"

* 2.注意如果是"var b = a",则b被声明成局部变量,那么此时外面的console.log(b)会报错Uncaught ReferenceError: b

* is not defined   

*/

function foo(a) {   

    b = a;

    console.log(a + b);// 4

}

foo(2)

console.log(b) //2

// 代码块2--------------------------

// 代码块3--------------------------

/**

 * 代码块3

 * 1. 加上var后,在编译器编译阶段,就可以做变量的声明,等到引擎开始执行到console.log(a + b)时,使用RHS查询,找到了之前声明的变量,但是由于没有

 * 赋值,因此此时为undefined,于是执行2+undefined,结果为NaN

 */

function foo(a) {

    console.log(a + b);// NaN

    var b = a;

}

foo(2);

// 代码块3--------------------------

第二章 词法作用域

2.1 词法阶段

  • 变量和代码块的位置决定了词法分析阶段乃至运行时的作用域,但是作用域可以逐层向上查找,直到window
  • 词法作用域查找只会查找一级标识符,比如a、b和c。如果代码中引用了foo.bar.baz,词法作用域查找只会试图查找foo标识符,找到这个变量后,对象属性访问规则会分别接管对bar和baz属性的访问
  • 在es6中,引入let和const定义变量后,使用这两个关键词声明的变量如果如果在顶层作用域找到,那么就会被引擎声明为全局变量,但是不会自动成为全局对象(即通过window.xxx访问到,如果用var声明是可以访问到的)

2.2 欺骗词法

  • 严格模式下,with会被完全禁用;在保留核心功能的前提下,简介或者非安全的使用eval也被禁止,比如非严格模式下eval可以在运行期间修改此法作用域,而严格模式下不能修改,因为它在运行时有其自己的词法作用域

function foo(str){

    "use strict"; //如果不加此句, 那么下面两者都将输出 2

    eval(str);// 自己的词法作用域 2

    console.log("别人的词法作用域",a) // Uncaught ReferenceError: a is not defined

}

foo("var a =2;console.log('自己的词法作用域',a)")
  • 非严格模式下,with会在程序运行时创建新的词法作用域;eval在程序运行时修改作用域
  • eval("代码字符串")、setTimeout("代码字符串"),setInterval("代码字符串"),new Function("代码字符串"),with都会给性能带来影响,因为JS引擎遇到这样的使用语句时,不会做性能优化
  • with会针对其操作对象生成封闭的作用域,但是如果封闭的作用域没有某个变量,那么此时的LHS查询会导致上级作用域被污染,因为如果LHS在封闭的作用域中无法找到该变量,就可能会在顶层作用域创建全局变量

2.3 小结

  • 作用域分为词法作用域和动态作用域,JS遵循的规范是词法作用域
  • 词法作用域:其作用域仅与变量声明的位置或者代码块的位置有关

第三章 函数作用域和块作用域

3.1 函数中的作用域

  • 函数作用域可以访问获取全局作用域,但全局作用域不能访问获取函数(局部)作用域

3.2 隐藏内部实现

  • 规避冲突,防止覆盖
  • 最小特权原则(最小暴露原则)

3.3 函数作用域

  • 函数作用域内部可以访问外部和内部定义的变量,但是外部不能访问函数内部的变量

3.4 块作用域

  • 创建块级作用域的三种方式:with、try…catch的catch块、let和const
  • catch创建的块作用域,只对catch的参数(...catch(e)...比如这个参数e)有效。对于在内部声明的变量,catch并没有创建一个新的作用域,只是一个普通的代码块

function foo(){

            e='aaa'

            try{

                throw new Error('this is an error')

            }catch(e){

                var e;// 这个e会被提上到foo形成的作用域的顶部;如果使用let,那么就会报已定义的错,应为catch的参数里已经做了隐式声明

                console.log(e) // Error: this is an error;    这个e就是catch的参数e

                e='this is not an error'; //  这个e更改的是catch的参数e,注意不是上面var e的e

                console.log(e) // 'this is not an error'

            }

            console.log(e)// 'aaa'

            console.log(window.e)// undefined

        }

   foo()
  • 区分函数声明和表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式

// 代码块1—————————————

/** setTimeout会在执行完同步代码后执行,此时i的值为8。执行console.log(i)时,在setTimeout的作用域中开始找i,找不到变量i的定义,这个时候就把创建这个函数的作用域作为当前作用域,再次寻找,创建这个函数的作用域就是全局作用域,也就是找到了for循环中i,此时i已经是8,因此输出都为8

 */

for(var i=0;i<=8;i++){

    setTimeout(function () {

         console.log(i)//输出全为8

     },0)

}

// 代码块1—————————————

// 代码块2—————————————

/**

 *通过IIFE,将i传入到(function(x)…)形成的函数表达式的函数作用域中,等到后面执行setTimeout时就可以正常获取传入时的值

 */

for(var i=0;i<=8;i++){

    (function (x) {

        setTimeout(function () {

            console.log(x)//分别输出0-8

        },0)

    })(i);

}

// 代码块2—————————————

// 代码块3—————————————

/**

* for循环头部的let不仅将i绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值

*/

for(let i=0;i<=8;i++){     

    setTimeout(function () {          

        console.log(i) //分别输出0-8      

    },0) 

}

该循环可以解释成下面的代码

{

    let j;

    for(j=0;j<=8;j++){

        let i = j;//每次迭代都声明了新的i,并且绑定了let i 所在的作用域

        setTimeout(()=>{

            console.log("i",i)//分别输出0-8

        })

    }

}

// 代码块3—————————————

第四章 提升

4.1 略

4.2 编译器再度来袭

  • js引擎在解析代码时包括编译阶段和执行阶段。变量和函数的声明发生在编译阶段,而变量的赋值发生在执行阶段,即先声明后赋值。

4.3 函数优先

  • 函数声明和变量声明具有提升,并且函数声明优先级高,而函数表达式不具备提升
  • 与函数声明重名的变量声明会被忽略(同名变量声明,后声明的也会被忽略,但是还是会进行赋值),而同名的函数声明则可以覆盖前面的声明

// 代码块1—————————————

foo()//如果这里加foo()则输出为3,因为函数声明会提升到顶部,并且下面的var foo = function 属于变量声明,所以该声明会被忽略

function foo() {

    console.log(1);

}

foo()//如果这里加foo()则输出为3,原因同上

var foo = function() {

    console.log(2);

}

foo()//如果这里加foo()则输出为2,这里var foo = function的函数表达式声明会被忽略,只保留foo=function赋值,再加上函数提升优先级高,所以会输出2

function foo() {

    console.log(3)

}

foo()//如果这里加foo()则输出为2,原因同上

// 代码块1—————————————


// 代码块2—————————————

// 1).if里的函数声明首先会定义一个全局同名变量foo=undefined

// 2).if里的函数赋值会提升到if块作用域顶部

// 3).执行到函数声明语句时,会把块作用域里的foo赋值到全局同名变量foo

// 4).基于行为诡异,不同浏览器实现不同,建议在if里用函数表达式代替函数声明

foo();//如果在这里加foo(),则会报 Uncaught TypeError: foo is not a function,旧版浏览器这里会输出B,新版浏览器,这里会报TypeError错,因为此时调用的foo是全局变量,值是undefined,所以才报TypeError的错;

var a = true

if(a){

    function foo(){console.log("a")}

}else{

    function foo(){console.log("b")}

}

foo();//如果在这里加foo(),则会输出a,原因就是编译if内部函数声明语句时,会顺便把foo的值即function函数赋值给全局变量,因此这里调用foo()会输出a

// 代码块2—————————————

第五章 作用域闭包

5.2 实质问题

  • 当函数可以记住并访问所在的词法作用域即函数是在当前此法作用域之外执行的,这种情况下会产生闭包
  • 闭包可以阻止垃圾回收机制回收变量和参数,因为内部作用域正在被使用
  • 三种方式可以生成闭包
    • 通过return返回内部函数
    • 将一个函数体中的函数作为实参传递,然后再这个函数体重进行调用
    • 定义一个上层作用域的变量(包括全局变量),然后将一个当前作用域里的函数赋值给这个变量,然后进行调用

5.3 现在我懂了

  • 闭包的本质:就是一个函数被使用的时候脱离了原来的作用域,在新的作用域被引用时依然保持着对以前作用域的访问能力
  • IIFE与闭包密切相关,虽然他们不会关闭自己,但是还是会创建作用域,虽然只是在自己定义时所在的作用域中执行,而不在本身之外的作用域执行,从技术上讲是闭包,但是并不是很明显的闭包

5.4 循环与闭包

  • 参考笔记第三章——3.4里的例子,可以看出想要让for循环内的setTimeout函数每次都输出循环时所定义的i值,就需要使用闭包或者块级作用域来实现;
  • 还是第三章——3.4里的例子,IIFE每次都会创建新的作用域,需要在创建时将i值以参数形式传进去,如果不传,那么形成的作用域是一个空的作用域,不能达到隔离变量i的效果。因为如果不传i,那么setTimeout内部的i在执行阶段是通过普通的词法作用域查找而非通过闭包调用(因为IIFE并非严格的闭包,它是在自己定义的作用域中执行,而非外部作用域中执行,前面说了,闭包的特点是可以在外部作用域中执行定义时作用域中的内容),就无法封闭i

5.5 模块

  • 模块需要具备两个必要条件
    • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
    • 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态
  • 模块有两个主要特征:
    • 为创建内部作用域而调用了一个包装函数;
    • 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。