你不知道的javascript(作用域)

说明:本系列是本人读完《你不知道的javascript》的总结和感想。有兴趣的读者可以去看原书籍。

一. 作用域

编译原理

       虽然通常把javascript分类为“动态”或“解释执行”语言,但事实上是一门编译语言。
       对于传统编译语言,一段源代码在执行之前会经历三个步骤:

  • 分词/词法分析
  • 解析、语法分析
  • 代码生成
           相对只有这三个步骤的语言,javascript引擎要复杂的多。例如其在分词等有特定的步骤对运行性能进行优化。
           这里我们并不对javascript引擎的编译原理进行深入的探讨,有兴趣的读者可以谷歌查询更详细的资料。
           这里我们只需要知道任何javascript代码片段在执行前都要进行编译。

    理解作用域

    例:处理 var a = 2 ; 代码过程。
    在这之前我们我先介绍三个参与处理的角色
    • 引擎:从头到尾负责整个javascript程序的编译以及执行过程。
    • 编译器:负责语词分析以及代码生成等脏活累活。
    • 作用域:负责收集并维护有所有声明的标识符(变量)注册后能够的一系列查询。

开始介绍:
       先是引擎 遇到var a = 2;这段代码,进行解析,然后将 var a;给到 编译器 =>
       编译器 拿到 var a;就会向 作用域 询问是否有已经有该名称的变量存在在作用域的集合中。
       如果是 编译器 就会忽略该声明,否则它会要求 作用域 在当前作用域的集合中声明一个新的变量。
       接下来 编译器 就会为 引擎 生成运行需要的代码,用来处理 a = 2 这个赋值操作。 引擎 运行时会询问作用域是否存在 a 变量,如果是,则使用这个变量;否则 引擎 会继续查找下去。如果查找不到就抛出异常。

####为了进一步理解,接下来我们介绍编译器的一些术语。
       在我们上面的例子中,var a=2;中,引擎会为变量 a 进行 LHS查询 和 RHS查询 。

  • LHS:试图找到变量容器本身。(赋值操作的目标是谁
  • RHS:获取值。(随时赋值操作的源头
    考虑下面的代码:(这里的代码既有LHS也有RHS引用。)
    function foo(a){
       console.log(a);
    }
    foo(2);  
    
    这里最后一行foo函数的调用需要对foo进行 RHS 引用,意味着“去找到foo的值,并把它给我”。
    这里还有一个很重要又很容易被忽略的细节,代码中隐式的 a=2 操作发生在2被当做参数传递到foo()函数,为了给参数a分配值,需进行一次 LHS
    这里console.log(a)也对a进行 RHS 引用。

       LHS和RHS查询都会从当前执行作用域开始查找,如果没有找到所需的标识符,就上向上级作用域查找目标标识符,这样每次升级一次作用域,直到到达全局作用域,无论有无找到都会停止查找。
       不成功的RHS引用会抛出异常,在非严格模式下,会隐式地创建一个全局变量;在严格模式下,抛出ReferenceError异常。

小测:
    function foo(a){
        var b = a;
        return a+b;
    }
    var c = foo(2);
请问这里有多少处LHS和RHS?  



所有的LHS(有三处)
c = ..; a = 2 ;b = ..
所有的RHS(有四处)
foo(2.. 、 =a 、 a.. 、 ..b)

二.词法作用域

  1. 对比动态作用域

           这里需要明确的是: javascript只有词法作用域,没有动态作用域。this机制只是某种程度像动态作用域。
           这里是说明两者的区别,不细说动态作用域。
           主要区别:词法作用域是在写代码或者说定义的时候确定的,而动态作用域实在运行时定义的;词法作用域关注函数在何处声明,而动态作用域光柱函数从何处调用。

    例子:
    function foo(a){
        console.log(a); //2
    }
    function bar(){
        var a=3;
        foo();    
    }
    var a=2;
    bar();
    
    这里词法作用域让foo()中的 a 通过RHS引用到全局作用域总的 a ,因此会输出 2 。
    
  2. 词法阶段

           词法作用域就是定义在词法阶段的作用域。由写代码时变量和快作用域的位置决定。

  3. 欺骗词法

           词法作用域完全有写代码期间函数所声明的位置来定义,怎样才能在运行时来“欺骗”词法作用域。
           这里有两种机制可以实现欺骗词法,但需要注意的,这两种机制会导致性能下降。

    eval

    严格模式下不可用

    javascript中 eval()函数可以接收一个字符串为参数,并将其中的内容视为写在当前位置的代码。
    考虑以下的代码:
    function foo(str,a){
        eval(str);
        console.log(a,b);
    }
    var b = 2;
    foo("var b=3;" , 1);   
    //输出结果为 1,3;
    eval()调用中的 var b=3 这段代码会被当在本来的位置处理,因此在foo()函数中就声明了 b;并遮蔽了全局作用域中的 b;
    

    with()

    严格模式下不可用

    with相对比较难以掌握。同时也可以用多种方法解释with,但由于其也不推荐使用。所以这里并不进行详细解释和介绍。
    (其实就是笔者能力有限,就不进行解释误导读者。)
    

三.块作用域

  1. 介绍启示

    一般来说,我们讲javascript没有块作用域。我们先来看一段代码:

    for(var i=1;i<10;i++){
        console.log(i);
    }
    

    对于已经入门js的开发者来说,对这段代码一定十分熟悉了。其输出 10次 10。虽然对于大多数开发者对这段代码的解释是console.log()是异步操作,但笔者认为,这必然也跟块作用域脱不了关系。ES6中新声明的 let 语法,实现块作用域结果便不同了。在下文会介绍 let 语法。

  2. 实现javascript中块作用域的功能

    with

    用with从对象中创建的作用域仅在with声明中而非外部作用域中有效。
    

    try/catch

    很少人会注意ES3 规范中规定try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。

    例如;
    try{
        undefined(); //执行一个非法操作强行制造一个异常。    
    }
    catch(err){
        console.log(err);  //正常运行
    }
    console.log(err);     //ReferenceError: err not found
    err 仅仅存在catch分句内部,当试图从别处引用它会抛出错误。
    

    let(ES6)

    let关键字可以将变量绑定到所在的任意作用域上(通常是{…}内部)

    var foo = 1;
    if(foo){
        let bar = foo *2;
        console.log(bar);  //2
    }
    console.log(bar);  //ReferenceError
    

    let循环:

    for(let i=0;i<10;i++){
        console.log(i);    // 0,1,2,3,4,5,6,7,8,9        
    }
    for循环头部的let不仅将 i 绑定到for循环的快中,
    事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一次循环迭代结束时值重新进行赋值。
    

    ####const(ES6)

    同样可以创建块作用域变量。但其值是固定的(常量),试图修改值的操作会报错。
    

四.提升

思考一段代码:  
    console.log(a);
    var a = 2;
输出结果为undefined。

其实这段代码处理流程是:
var a;
console.log(a);
a = 2;
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。

函数声明会被提升,而函数表达式却不会被提升。

函数优先

foo();
var foo;
function foo(){
    console.log(1);
}
foo = function(){
    console.log(2);
}
输出的结果为1而不是2;
函数声明优先原则。

五.作用域闭包

提前说明:闭包是js中一块重要,高明的内容,有许多比我优秀的笔者写过更多有关闭包的文章。这里也仅对闭包进行引导性的理解和学习。

实质问题

认识:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数实在当前词法作用域外执行。
function foo(){
    var a=2;
    function bar(){
        console.log(a);
    }    
}
var baz = foo();
baz();  //结果为2   --这就是闭包的效果。

通常来说,foo()执行后,就会期待foo()的整个内部作用域被销毁,因为引擎有垃圾回收机制用来释放不再使用的内存空间。
而闭包却可以阻止这样的事情发生,由于bar()所声明的位置,他拥有涵盖foo()内部作用域的闭包,使得这一作用域一直存活。

记住一句话:无论通过何种手段将内部函数传递到所在的词法作用域以外,他都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
本质上,无论何时何地,如果将函数当做以及的值类型并到处传递,就能看到闭包在这些函数中的引用。在定时器,事件监听器,Ajax请求,跨窗口通信,Web Workers或者任何其他的异步(同步)任务中,只要使用了函数回调,实际上就是使用闭包。

循环和闭包

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

在迭代中使用自运行函数(IIFE)会为每一个迭代都生成一个新的作用域,是的延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值供我们访问。

重返块作用域

闭包和let的联合。
for(let i = 1; i<=5 ; i++){
    setTimeout(function(){
        console.log(i);
    },j*1000)
}