JavaScript--作用域和闭包

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaScript--作用域和闭包相关的知识,希望对你有一定的参考价值。

--摘自《You Don‘t Know JS- Scope, Closures》

对于所有的编程语言,作用域是一个基础的概念。深入了解javascript中的作用域,对正确的使用这个语言有重要的作用。

什么是作用域

作用域是一组变量如何存储和读取的规则,存在两类模型:

  1. 静态作用域(也称作字面作用域、词法作用域)。
  2. 动态作用域。

作用域的操作

对作用域有两类操作:读操作,写操作。

在编译原理中被读取的操作数叫右操作数(RHS),被修改的操作数叫做操作数(LHS)。

这种命名来自赋值表达式: a = b;

a在左为LHS,b在右为RHS。

静态作用域

JavaScript中变量的作用域为静态作用域。

静态作用域在代码书写时决定,并且是可嵌套的。

如下所示,代码片段中存在三个作用域,作用域间存在严格的嵌套关系:

作用域3是作用域2的子集,作用域2是作用域1的子集。

技术分享

function foo(a) { var b = a * 2; function bar(c) { console.log( a, b, c ); } bar(b * 3); } foo( 2 ); // 2 4 12


技术分享

技术分享

 

 

变量的查找

JavaScript执行引擎从当前作用域开始查询操作数(LHS,RHS),若操作数不在当前作用域,则向上一级作用查找,直至全局作用域。

若全局作用域仍未找到操作数,则查询失败。

因此上例代码的输出为 2 4 12。

操作数查询失败的行为

RHS查询失败,抛出ReferenceError异常。

LHS查询失败,则在全局作用域中创建同名变量。(strict模式中,抛出ReferenceError异常)。

HACK作用域

Hack作用域是不推荐的行为,但这有助于深入了解JavaScript的作用域。通过evalwith关键字,可以修改执行代码的作用域。

eval修改已存在的作用域

eval中的代码运行在当前作用域中,并修改或访问当前作用域中可查询到的操作数。

如下所示:

技术分享
function foo(str, a) {
    eval( str ); // cheating!
    console.log( a, b );
} 

var b = 2; 

foo( "var b = 3;", 1 ); // 1, 3
技术分享

"var b = 3;"运行在 function foo() 的作用域中,并在此作用域中声明了变量b。

function foo()作用域中的变量b覆盖了全局作用域中的b,因此程序输出为1,3。

 

strict模式中,eval中的代码运行在新的作用域中,此作用域为当前作用域的子集。

如下所示:

技术分享
function foo(str) {
    "use strict";
    eval( str );
    console.log( a ); // ReferenceError: a is not defined
} 

foo( "var a = 2" );
技术分享

"var a = 2;"运行在独立的作用域中,此作用域为function foo()作用域的子集,因此console.log(a)在查询右操作数a时,抛出ReferenceError异常。

with创建新的作用域

with关键字为对象创建一个独立的作用域,此作用域为当前作用域的子集。

技术分享
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 -- Oops, leaked global!
技术分享

foo(o1)在o1作用域中查找做操作数a并修改其值,因此console.log(o1.a)输出为2。

foo(o2)在o2作用域中查找做操作数a, 查询失败。继续向上级作用域function foo()中查找,同样失败。全局作用域同样没有做操左数a的定义。

跟据左操作数查询规则,JavaScript执行引擎在全局作用域中创建新的变量a,并赋值为2,出现了作用域泄漏的现象。

  函数作用域

ES3之前,函数是创建作用域的唯一途径。每个函数都会创建一个作用域,并嵌套在当前作用域中。

函数作用域中定义的操作数可在此函数任何位置使用,也可在其子作用域中使用,但上级作用域中不可使用。

因此函数作用域可作为命名空间使用,防止多模块的命名冲突。

如下所示:

技术分享
function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    } 

    var b = a + doSomethingElse( a * 2 ); 

    console.log( (b * 3) );
} 

doSomething( 2 ); // 15
技术分享

function doSomethingElse()仅在function doSomething()作用域可访问,函数作用域起到了封装的作用。

 

ES3之前函数是作用域的最小单位。函数中任何位置创建的操作数,其作用域都为此函数。

如下所示:

技术分享
function foo() {
    function bar(a) {
        i = 3; // changing the `i` in the enclosing scope‘s for-loop
        console.log( a + i );
    } 

    for (var i=0; i<10; i++) {
        bar( i * 2 ); // oops, infinite loop ahead!
    }
}
技术分享

这段代码将陷入死循环。虽然"var i = 0"是在for循环中声明,因为函数是作用域的最小单位,i的作用域为function foo()。

function bar()将i修改为3,导致了死循环的发生。

函数表达式

函数创建的作用域对其内部起到了封装作用,防止了命名冲突。但因此函数名便成了命名冲突的主要来源。

函数表达式解决了函数名冲突的问题。

如何区分函数定义和函数表达式

整个语句以function关键字开始,则此语句为函数定义。

反之,function标识的为函数表达式。

如下所示:

function foo1() {....} //函数定义
(function foo2(){....}) //函数表达式

 

函数表达式中函数名的作用域

函数表达式创建了一个新的作用域,此作用域为当前作用域的子集。

以(function foo(){ … })为例, 此函数表达式中的函数名foo,仅在"…"所示位置中可被访问。

块作用域

ES3中规定,try/catch中catch语句定义的变量,作用域为此catch语句。

如下所示:

技术分享
try {
    undefined(); // illegal operation to force an exception!
}
catch (err) {
    console.log( err ); // works!
} 

console.log( err ); // ReferenceError: `err` not founde
技术分享

err的作用域仅为catch语句,因此console.log(err)抛出ReferenceError异常。

let关键字

ES6中引入了let关键字。let与var都用于定义变量。

区别是:

  1. var定义的变量作用域为函数,let定义的变量作用域为代码块。
  2. var定义出现的位置不影响作用域中对其引用,let定义的变量仅在定义后才可引用。

如下所示:

技术分享
var foo = true;
if (foo) {
    { // <-- explicit block
        let bar = foo * 2;
        bar = something( bar );
        console.log( bar );
    }
}
console.log( bar ); // ReferenceError
技术分享
{
   console.log( bar ); // ReferenceError!
   let bar = 2;
}

let循环

let在for循环中定义变量时,此变量作用域为for循环。

如下所示:

for (let i=0; i<10; i++) {
    console.log( i );
}
console.log( i ); // ReferenceError

 

let将i的作用域绑定到for循环,并且每次循环绑定一次,可以用如下代码说明:

技术分享
{
    let j;
    for (j=0; j<10; j++) {
        let i = j; // re-bound for each iteration!
        console.log( i );
    }
}
技术分享

const

ES6中引入的const关键字所定义的变量,作用域为代码块。

与let关键字不同的是,const定义的是常量,不可修改。

如下所示:

技术分享
var foo = true;
if (foo) {
    var a = 2;
    const b = 3; // block-scoped to the containing `if`
    a = 3; // just fine!
    b = 4; // error!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
技术分享

闭包

什么是闭包

闭包是函数在其静态作用域外被执行时,仍能访问其上级作用域的能力。

如下所示:

技术分享
function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}
var baz = foo();
baz(); // 2 -- Whoa, closure was just observed, man.
技术分享

 

foo()函数返回了其内部函数bar()。

函数bar()在其静态作用域之外被调用(baz()),仍能访问其上级作用域中的变量a。

这种行为能力称为闭包。

可以说:bar()的闭包包含了foo()的作用域。原文为:function bar() has a closure over the scope of foo()).

闭包和循环

当闭包和循环纠结到一起时,情况就变的有意思了。

现在实现一段代码输出1 2 3 4 5,每次输出的时间间隔为1秒。

请看如下代码:

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

函数timer()访问了其上级作用域中的变量i,timer()由计时器调用,调用处在其静态作用域外,因此构成了闭包。

代码的输出为五个6。

原因是计时器最早在1秒后运行,此时循环已经执行结束,i的值变为6。由于i的作用域为for循环所在的函数,因此timer()五次执行都获取到了i的最新值。

 

立即调用函数表达式(IIFE)能解决这个问题吗

函数表达式会创建一个新的作用域,但能解决这个问题吗?

请看如下代码:

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

执行后结果仍为五个6。

原因是此函数表达式虽然创建了一个新的作用域,但作用域为空,最终timer()函数还是引用了更上一级作用域中的i。

 

那如果在函数表达式作用域内部存储i的当前值呢?

请看如下代码:

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

执行后结果为1 2 3 4 5

因为函数表达式创建了一个新的作用域,五次循环产生了这个作用域的5个副本,每个副本里j存储了i的不同值。

timer()函数在作用域的不同副本中执行,并打印出不同值。

 

上述代码更简洁的写法为:

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

 

let关键字能解决这个问题吗

let的作用域是块,可以解决这个问题吗?

请看如下代码:

for (var i=1; i<=5; i++) {
    let j = i; // yay, block-scope for closure!
    setTimeout( function timer(){
        console.log( j );
    }, j*1000 );
}

执行结果为1 2 3 4 5。

因为做域是块,所以五次循环的五个副本中j的值不同,因此输出值不同。

 

当用let定义循环变量时,此变量的作用域为for循环,并且每次循环都会绑定一次作用域(产生一个副本),因此上述代码可进一步精简为:

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

以上是关于JavaScript--作用域和闭包的主要内容,如果未能解决你的问题,请参考以下文章

JavaScript学习总结2--作用域和闭包

javaScript——作用域和闭包概念

彻底搞懂JavaScript中的作用域和闭包

JavaScript 作用域和闭包

JavaScript之作用域和闭包

重温JavaScript(lesson3):作用域和闭包