你不知道的JavaScript 0x1 作用域和闭包

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/funkstill/article/details/88785524

作用域是什么

    传统编译语言流程:分词/词法分析->解析/语法分析->代码生成

    引擎:从头到尾负责整个JavaScript程序的编译及执行过程

    编译器:负责语法分析及代码生成

    作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套严格的规则,确定当前执行的代码对标识符的访问权限。

    变量赋值过程:首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。

    RHS:获取变量的值,重点在值,多用于使用值或者说获得一个值(如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError异常。值得注意的是, ReferenceError 是非常重要的异常类型)

    LHS:获取存储变量的空间,重点在存储空间,多用于赋值或者说存储一个值。注意!这个过程可能容易被忽略。(当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非“严格模式”下。)

   作用域嵌套

    当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。

词法作用域

     作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域。另外一种叫作动态作用域,仍有一些编程语言在使用(比如 Bash 脚本、 Perl 中的一些模式等)。

    作用域查找会在找到第一个匹配的标识符时停止。 在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

    全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。

     eval

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

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

     with

    with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj =  {
    a:1,
    b:2,
    c:3
};
//普通方法
obj.a = 2;
obj.b = 3;
obj.c = 4;

//简单的方法
with(obj){
    a = 3;
    b = 4;
    c = 5;
}

     with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

function foo(obj){
    with(obj){
        a=2;//LHS引用
    }
}
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被泄露到全局作用域了
/*o2 的作用域、 foo(..) 的作用域和全局作用域中都没有找到标识符 a,因此当 a=2 执行
时,自动创建了一个全局变量(因为是非严格模式)。*/

    JavaScript 中有两个机制可以“欺骗”词法作用域: eval(..) 和 with。前者可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。

 函数作用域

     函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用JavaScript 变量可以根据需要改变值类型的“动态”特性。

 隐藏内部实现

    利用函数声明将一段代码进行包装,产生一个作用域。可用于API设计中,即最小授权或最小暴露。也可以用于规避冲突,比如同名标识符,可以避免被覆盖。

     全局命名空间

    在使用第三方库时,如果第三方没有将内部私有的函数或变量隐藏起来,很容易引发冲突。通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所用需要暴露给外界的功能都会成为这个对象的属性。

    模块管理

   另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
    显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。

    函数作用域

    虽然隐藏可以实现部分需求,但是函数名还是污染了作用域,是否可以不需要函数名呢?

var a = 2;
(function foo(){
    var a = 3;
    console.log(a);//3
})();//函数表达式
console.log(a);

    匿名表达式

setTimeout(function(){
    console.log("I waited 1 second!");
},1000);

    匿名函数表达式缺点:

  • 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  • 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,
    比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑
    自身
  • 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让
    代码不言自明。

    行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:

setTimeout(function timeoutHandler(){
    console.log("I waited 1 second!");
},1000);

    立即执行函数表达式:

var a = 2;
(function foo(){
    var a = 3;
    console.log(a);//3
})();//通过末尾加的()实现立即执行,第一个()将函数变成表达式
console.log(a);//2

    函数名对 IIFE 当然不是必须的, IIFE 最常见的用法是使用一个匿名函数表达式。虽然使用具名函数的 IIFE 并不常见,但它具有上述匿名函数表达式的所有优势,因此也是一个值得推广的实践。

    IIFF进阶用法:

var a = 2;
(function IIFF(global){
    var a = 3;
    console.log(a);//3
    console.log(global.a);//2
})(window);//将window对的引用传递进去,参数命名为global
console.log(a);//2

   还可以用于解决undefined 标识符的默认值被错误覆盖导致的异常

undefined = true;
/*将一个参数命名为 undefined,但是在对应的位置不传入任
何值,这样就可以保证在代码块中 undefined 标识符的值真的
是 undefined*/
(function IIFF(undefined){
    var a;
    if(a===undefined){
        console.log("Undefine is safe here!");
    }
})();

块作用域

    块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。变量的声明应该距离使用的地方越近越好,并最大限度地本地化。

    with

     它不仅是一个难于理解的结构,同时也是块作用域的一个例子(块作用域的一种形式),用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

     try/catch

try{
    undefined();//执行一个非法操作来强制制造一个异常
}
catch(err){//err仅存在于catch分句内部
    console.log(err);//可以正常执行
}
console.log(err);//ReferenceError: err not found

    let

    let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说, let为其声明的变量隐式地了所在的块作用域。

var foo = true;
if(foo){
    //用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。
    let bar = foo*2;
    bar = something(bar);
    console.log(bar);
}
console.log(bar);

    在开发和修改代码的过程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯性地移动这些块或者将其包含在其他的块中,就会导致代码变得混乱。

    可以为块作用域显式地创建块,解决这个问题。

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

    垃圾收集:

    块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。

    let循环:

for(let i=0;i<10;i++){
    /*for 循环头部的 let 不仅将 i 绑定到了 for 
    循环的块中,事实上它将其重新绑定到了循环的
    每一个迭代中,确保使用上一个循环迭代结束时
    的值重新进行赋值。*/
    console.log(i);
}
console.log(i);//ReferenceError

{//每个迭代进行重新绑定
    let j;
    for(j=0;j<10;j++){
        let i = j;
        console.log(i);
    }
}

     const:

    ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。 之后任何试图修改值的操作都会引起错误。

var foo = true;
if(foo){
    var a = 2;
    const b = 3;//包含在if中的块作用域常量
    
    a = 3;//正常
    b = 4;//错误 
}
console.log(a);//3
console.log(b);//ReferenceError

 作用域提升

    声明和赋值的顺序

   直觉上会认为 JavaScript 代码在执行时是由上到下一行一行执行的。但实际上这并不完全正确,有一种特殊情况会导致这个假设是错误的。

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

    鉴于上一个代码片段所表现出来的某种非自上而下的行为特点,可能会认为这个代码片段也会有同样的行为而输出 2。还有人可能会认为,由于变量 a 在使用前没有先进行声明,因此会抛出 ReferenceError 异常。

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

    正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。当看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个声明: var a; 和 a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。

var a;//编译
a = 2;//执行
console.log(a);

var a;//编译
console.log(a);//未执行a = 2;
a = 2;//执行

    这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。每个作用域都会进行提升操作。函数声明会被提升,但是函数表达式却不会被提升。

     函数优先

    函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。

foo();//1
var foo;
function foo(){
    console.log(1);
}
foo = function(){
    console.log(2);
};
/*引擎理解
function foo(){
    console.log(1);
}
foo();//1
foo = function(){
  console.log(2);  
};
*/

     尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

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);//2 RHS
    }
    bar();
}
foo();

     函数 bar() 具有一个涵盖 foo() 作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为 bar() 被封闭在了 foo() 的作用域中。但是通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工作的。

function foo(){
    var a = 2;
    /*函数bar()具有一个涵盖foo()作用域的闭包,
    也可以认为bar()被封闭在了foo()的作用域中*/
    function bar(){
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz();//2  闭包的效果

    函数bar()的作用域能够访问foo()的内部作用域。然后我们将bar()函数本身当做一个值类型进行传递。在foo()执行后,其返回值赋值给变量baz并调用baz(),实际上只是通过不同的标识符引用了内部的函数bar()。bar()显然可以被正常执行。但是在这个例子中,它在自己定义的作用域以外的地方执行。在foo()执行后,通常期待foo()整个内部作用域都被销毁。但是闭包的神奇之处是可以阻止销毁,内部作用域依然存在,因此没有被回收。bar()仍在使用作用域。因为bar()拥有涵盖foo()内部作用域的闭包,使得该作用域一直存活,以供之后使用。bar()依然持有对该作用域的引用,而这个引用就叫闭包。

function foo(){
    var a = 2;
    function baz(){
        console.log(a);//2
    }
    bar(baz);
}
function bar(fn){
    fn();//这也是闭包,实际执行baz()
}

    传递函数也可以是间接的

var fn;
function foo(){
    var a = 2;
    function baz(){
        console.log(a);
    }
    fn = baz;//将baz分配给全局变量
}
function bar(){
    fn();//利用全局变量传递获得闭包
}
foo();
bar();//2

    解析闭包

function wait(message){
    setTimeout(function timer(){
        /*将一个内部函数(timer)传递给setTimeout().
        timer具有涵盖wait()作用域的闭包,因此还保有对
        变量message的引用*/
        console.log(message);
    },1000)
}
/*wait()执行1s后,它的内部作用域不会消失,timer函数依然保有
wait()作用域的闭包。*/
wait("Hello ,closure!");

    深入到引擎的内部原理中,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的 timer 函数,而词法作用域在这个过程中保持完整。

    本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、 Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

    循环和闭包 

for(var i=1;i<=5;i++){
    setTimeout(function timer(){
        console.log(i);
    },i*1000);
}
//运行时会以每秒一次的频率输出五次 6。

    这个循环终止的条件是i>5,即首次条件成立i=6。延迟函数的回调会在循环结束时才执行。当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。简单讲就是,循环都结束了,回调函数才执行,这时候i已经变成6了,因为这里对i是引用,不是复制副本。

   那怎么改进?

for(var i=1;i<=5;i++){
    (function(j){
        setTimeout(function timer(){
            console.log(j);
        },j*1000);
    })(i);//相当于每次对i的值进行复制
}

    再回到块作用域

    var和let,在块作用域中两者的区别较为明显, let只在for()循环中可用,而 var是对于包围for循环的整个函数可用

for(let i=1;i<=5;i++){
    setTimeout(function timer(){
       /*在执行回调函数时,循环已经结束,
       此时的i相当于传参时的复制,而不是引用*/
        console.log(i);
    },i*1000);
}

模块

var MyModules = (function Manager(){
    var modules = {};
    function define(name,deps,impl){
        for(var i=0;i<deps.length;i++){
            deps[i]=modules[deps[i]];
        }
        modules[name]=impl.apply(impl,deps);//核心
    }
    function get(name){
        return modules[name];
    }
    return {
        define:define,
        get:get
    };
})();

    这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管理的模块列表中。

MyModules.define("bar",[],function(){
    function hello(who){
        return "Let me introduce:"+who;
    }
    return {
        hello:hello
    };
});
MyModules.define("foo",["bar"],function(bar){
    var hungry = "hippo";
    function awesome(){
        console.log(bar.hello(hungry).toUpperCase());
    }
    return {
        awesome:awesome
    };
});
var bar = MyModules.get("bar");
var foo = MyModules.get("foo");
console.log(
    bar.hello("hippo")
);//Let me introduce:hippo
foo.awesome();//LET ME INTRODUCE:HIPPO

    "foo" 和 "bar" 模块都是通过一个返回公共 API 的函数来定义的。 "foo" 甚至接受 "bar" 的示例作为依赖参数,并能相应地使用它。

    模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

猜你喜欢

转载自blog.csdn.net/funkstill/article/details/88785524