你不知道的javascript之作用域(2)

280 阅读15分钟

五、变量提升

Javascript根据声明的位置和方式将变量分配给作用域。函数作用域和块作用域的行为是一样的,可以总结为: 任何声明在某个作用域内的变量,都将附属于这个作用域。但是作用域同其中的变量声明出现的位置有某种微妙的联系。

5.1 由编译器引起的变量提升

习惯上我们认为javascript是由上到下一行一行执行的. 但实际上这并不完全正确, 如下图代码:

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

console.log( b ); // undefined
var b = 2;

我们知道引擎会在解释JavaScript代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起。因此, 正确的思考思路是, 包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

按照上面的理解, 我们的第一个代码片段会以如下形式进行处理:

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

其中第一部分是编译,而第二部分是执行。类似地,我们的第二个代码片段实际是按照以下流程处理的:

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

变量和函数声明从它们在代码中出现的位置被“ 移动”到了所属作用域最上面。这个过程就叫作提升。 只有声明本身会被提升, 而赋值或其他运行逻辑会留在原地。另外值得注意的是,每个作用域都会进行提升操作.

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

如上面代码所示:foo函数的声明( 这个例子还包括实际函数的隐含值)被提升了, 因此第一行中的调用可以正常执行. foo(..)函数自身也会在内部对var a进行提升( 显然并不是提升到了整个程序的最上方, 而是自身作用域的上方.

5.2 变量提升要点

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

foo(); // 不是ReferenceError, 而是TypeError!
var foo = function bar() {    
    // ...
};

上面代码中的变量标识符foo()被提升并分配给所在作用域(在这里是全局作用域),因 此foo()不会导致ReferenceError。但是foo此时并没有赋值( 如果它是一个函数声明而不是函数表达式,那么就会赋值)。foo()由于对undefined值进行函数调用而导致非法操作,因此抛出TypeError异常。

函数优先原则

函数声明和变量声明都会被提升。 但是需要注意的是函数会首先被提升,然后才是变量。如下代码:

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

会输出1而不是2!这个代码片段会被引擎理解为如下形式:

function foo() {    
    console.log( 1 );
}
foo(); // 1
foo = function() {    
    console.log( 2 );
};

var foo 尽管出现在function foo()...的声明之前,但它是重复的声明(因此被忽略了) ,因为函数声明会被提升到普通变量之前.

重复声明以及覆盖

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

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

案例

function test(){
    console.log('outTest')
}
var aTest = 1;
(function (){
    if(false){
        var aTest = 22;
        function test(){
            console.log("in")
        }
    }
    console.log(aTest)  // undedined
    test()  // test is not a function
})()

上面代码中, 立即执行函数表达式中的变量声明提升到该作用域内部最顶端, 即变量 aTest 与函数 test 分别提升至该立即执行函数作用域顶端.

总结

无论作用域中的声明出现在什么地方, 都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明( 变量和函数)都会被“ 移动”到各自作用域的最顶端,这个过程被称为提升。 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。要注意避免重复声明, 特别是当普通的var声明和函数声明混合在一起的时候, 否则会引起很多危险的问题!

六、闭包

闭包并不是一个需要学习新的语法或模式才能使用的工具,它是基于词法作用域书写代码时所产生的自然结果。 闭包的创建和使用在你的代码中随处可见.

6.1 闭包产生

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

让我们观察下面的代码:

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

这是闭包吗?技术上来讲,也许是。但根据前面的定义,确切地说并不是。bar 函数并没有在词法作用域外( foo外 )执行. 最准确地用来解释bar()对a的引用的方法是词法作用域的查找规则, 而这些规则只是闭包的一部分。(但却是非常重要的一部分!

在上面的代码片段中, 函数bar()具有一个涵盖foo()作用域的闭包(事实上, 涵盖了它能访问的所有作用域, 比如全局作用域)。也可以认为bar()被封闭在了foo()的作用域中。为什么呢?因为bar()嵌套在foo()内部。但是通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工作的。 我们可以很容易地理解词法作用域, 而闭包则隐藏在代码之后的神秘阴影里,并不那么容易理解。 那么为了更清晰的展现闭包,我们可以看下修改后的下面代码:

function foo() {
    var a = 2;
    function bar() {         
        console.log( a );    
    }
    return bar;
}
var baz = foo();
baz(); // 2 ——朋友,这就是闭包的效果。

在foo()执行后, 其返回值( 也就是内部的bar()函数 )赋值给变量 baz , 调用baz(),实际上只是通过不同的标识符引用调用了内部的函数bar()。bar()显然是在自己定义的词法作用域以外的地方执行。

我们知道引擎有垃圾回收器用来释放不再使用的内存空间。 在foo()执行后, 由于看上去foo()的内容不会再被使用, 所以很自然地会考虑对其进行回收。而闭包的“ 神奇”之处正是可以阻止这件事情的发生。 这个内部作用域因为bar()本身在使用, 而没有被回收。bar()对这个内部作用域的引用就叫作闭包。

无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。

无论通过何种手段将内部函数传递到所在的词法作用域以外, 它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

6.2 代码中的闭包

回调函数与闭包

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

function wait(message) {    
    setTimeout( function timer() {        
        console.log( message );    
    }, 1000 );
}
wait( "Hello, closure!" );

上面示例代码中, 将一个内部函数( 名为timer)传递给setTimeout(..), timer具有涵盖wait(..)的作用域, 在wait(..)执行1000毫秒后, 它的内部作用域并不会消失,timer函数依然保有wait(..)作用域以及对变量message的引用。

循环与闭包

我们来看一个常见的例子,我们希望分别输出数字1~5,每秒一次,每次一个.

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

这段代码在运行时会以每秒一次的频率输出五次6, 首先解释6这个数字出现的原因, 循环的终止条件是 i不再<=5 即条件首次成立时i的值是6, 延迟函数的回调会在循环结束时才执行,因此输出显示的是循环结束时i的最终值6。

这里引伸出一个更深入的问题, 代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢? 缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“ 捕获”一个i的副本。 但是根据作用域的工作原理, 实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。为了解决这个问题,?我们需要更多的闭包作用域, 特别是在循环的过程中每个迭代都需要一个闭包作用域。

for (var i=1; i<=5; i++) {    
    (function() {
        var j = i;        
        setTimeout( function timer() {            
            console.log( j );        
        }, j*1000 );    
    })();
}
// 将代码进行改进
for (var i=1; i<=5; i++) {    
    (function(j) {  
        setTimeout( function timer() {            
            console.log( j );        
        }, j*1000 );    
    })(i);
}

仔细思考上面问题的解决方案, 我们使用IIFE在每次迭代时都创建一个新的作用域。 换句话说, 每次迭代我们都需要一个块作用域。我们知道 let声明, 可以用来劫持块作用域,并且在这个块作用域中声明一个变量。本质上这是将一个块转换成一个可以被关闭的作用域。 因此代码可以改进为以下形式:

for (var i=1; i<=5; i++) {
    let j = i; // 是的,闭包的块作用域!
    setTimeout( function timer() {        
        console.log( j );    
    }, j*1000 );
}

但是其实还有更简单的书写方法, 在for循环头部, let声明还会有一个特殊的行为。 这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。 随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

模块与闭包

在模块中也有着闭包的身影. 我们看如下代码:

function foo() {
    var something = "cool"; 
    var another = [1, 2, 3];
    function doSomething() {         
        console.log( something );    
    }
    function doAnother() {        
        console.log( another.join( " ! " ) );   
    }
}

表面上看这里并没有明显的闭包, 只有两个私有数据变量something和another,以及doSomething()和doAnother()两个内部函数, 它们的词法作用域( 而这就是闭包)也就是foo()的内部作用域。 接下来看如下这段代码:

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

这个模式在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露. 首先,CoolModule()只是一个函数, 必须要通过调用它来创建一个模块实例。 如果不执行外部函数,内部作用域和闭包都无法被创建。 其次,CoolModule()返回一个用对象字面量语法{ key: value, ... }来表示的对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。 我们保持内部数据变量是隐藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共API。这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API中的属性方法,比如foo.doSomething()。

从模块中返回一个实际的对象并不是必须的, 也可以直接返回一个内部函数。jQuery就是一个很好的例子。jQuery和$标识符就是jQuery模块的公共API,但它们本身都是函数( 由于函数也是对象, 它们本身也可以拥有属性)

如果要更简单的描述,模块模式需要具备两个必要条件。

  • 必须有外部的封闭函数, 该函数必须至少被调用一次( 每次调用都会创建一个新的模块实例) 。
  • 封闭函数必须返回至少一个内部函数, 这样内部函数才能在私有作用域中形成闭包, 并且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。 从方便观察的角度看, 一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

上面示例代码中 CoolModule()叫做独立的模块创建器, 可以被调用任意多次,每次调用都会创建一个新的模块实例。 当只需要一个实例时, 可以对这个模式进行简单的改进来实现单例模式:

var foo = (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    
    };
})();

foo.doSomething(); // cool 
foo.doAnother(); // 1 ! 2 ! 3

现在模块机制

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

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

未来模块机制

基于函数的模块并不是一个能被稳定识别的模式( 编译器无法识别),它们的API语义只有在运行时才会被考虑进来。 因此可以在运行时修改一个模块的API(参考前面关于公共API的讨论)。相比之下,ES6模块API更加稳定(API不会在运行时改变)。ES6中为模块增加了一级语法支持。 但通过模块系统进行加载时,ES6会将文件当作独立的模块来处理。 每个模块都可以导入其他模块或特定的API成员, 同样也可以导出自己的API成员。

//bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;
foo.js
// 仅从"bar"模块导入hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {    
    console.log( hello( hungry ).toUpperCase() );
}
export awesome;
baz.js
// 导入完整的"foo"和"bar"模块
module foo from "foo";
module bar from "bar";

console.log(  bar.hello( "rhino" ) ); // Let me introduce: rhinofoo.awesome(); // LET ME INTRODUCE: HIPPO

七、其他概念

7.1动态作用域与词法作用域

动态作用域是指函数的作用域是在函数调用的时候才决定的。动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心他们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

词法作用域也叫静态作用域,是指函数的作用域在函数定义的时候就决定了.

如下代码运行结果:

var value = 1;
function foo() {
    console.log(value);
}
function bar() {
    var value = 2;
    foo();
}
bar();

假设JavaScript采用静态作用域,分析执行过程:

执行foo()函数,先从foo函数内部查找是否有局部变量value,如果没有,就根据书写的位置查找上一层的代码,也就是value 等于 1,所以结果会打印 1 。

假设JavaScript采用动态作用域,分析执行过程:

执行foo()函数,依然是从foo函数内部查找是否有局部变量value,如果没有,就从调用函数的作用域也就是bar()函数内部查找value变量,所以结果会打印 2 。

JavaScript采用的是词法作用域,所以这个例子结果是1

7.2隐式作用域与显式作用域

要了解这两个概念,我们先看一个旧问题:

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

上面代码实际上输出结果是:5个6,因为延迟定时器的队列原因,即使是1000ms后第一个延迟函数执行,能获取到的i值是整个for循环之后的i值了。 这道题目的核心是要挽留住每次循环的i值,所以想要得到理想的输出1,2,3,4,5.就需要考虑如何挽留的问题了。

第一种:

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

第二种:

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

第三种:

for (var i=1; i<=5; i++) {
    let j = i; // 是的,闭包的块作用域! 
    setTimeout(function timer() {
        console.log( j );
    }, j*1000 );
}    

第四种:

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

以上方法,第一种和第二种属于隐式作用域,并没有新的作用域生成,只是隐式的劫持了已存在的作用域,挽留住了稍纵即逝的i值。

第三种和第四种方法利用了let的特性,let 声明会创建一个显示的作用域并与其进行绑定。显式作用域不仅更加突出,在代码重构时也表现得更加健壮。 在语法上,通过强制性地将所有变量声明提升到块的顶部来产生更简洁的代码。这样更容易判断变量是否属于某个作用域。


如有问题,欢迎探讨,如果满意,请手动点赞,谢谢!🙏

及时获取更多姿势,请您关注!!