作用域与闭包 - 你不知道的 JS

  1. 实质
  2. 懂?
  3. 循环和闭包
    1. 块作用域

闭包的神话故事。需要先看“词法作用域”。

闭包是基于词法作用域书写代码时所产生的 ** 自然结果 **,你甚至不需要为了利用它们而有意识地创建闭包。
闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。

实质

当函数可以记住并访问所在的词法作用域时,就产生了闭包,** 即使函数是在当前的词法作用域之外执行 **
看下面代码:

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

基于词法作用域的查找规则,函数 bar() 可以访问外部作用域中的变量 a。
这就是闭包吗?
根据前面的定义,确切地说 ** 并不是 *。(原因后面有解答)
解释 bar() 对 a 的引用的方法是:词法作用域的查找规则,这些规则的确是闭包的 *
一部分 **。
在上面的代码片段中,函数 bar() 具有一个涵盖 foo() 作用域的闭包。
为什么呢?原因简单明了,因为 bar() 嵌套在 foo() 内部。

你之前学得可能只有以上那么多。
但是通过这种方式定义的闭包并不能直接进行 ** 观察 **,也无法明白在这个代码片段中闭包是
如何工作的。
我们可以很容易地理解词法作用域,而闭包则隐藏在代码之后的神秘阴影里,并不那么容易理解。

一段真正的闭包代码:

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

函数 bazz() 的 ** 词法作用域 ** 能够访问 foo() 的内部作用域。这和前面的例子一样。
然后,我们将 bar 函数本身当作一个值类型作为返回值。
foo()执行后,返回值是内部的 bar 函数,赋值给了 bazz 变量并调用 bazz(),实际上是通过不同的标识符引用去调用 foo 函数内部的函数 bar。

重点是,bar()函数虽然被正常执行,但是是 ** 在定义时的词法作用域以外的地方被执行 **

通常外部函数被执行后,我们会认为其内部作用域的相关变量,函数在某个时间嘛就会被 JS 引擎的垃圾回收器释放。foo 函数内的变量看上去不会再被使用,所以 JS 引擎会考虑对其进行回收。

事实是,闭包的 “神奇” 之处正是可以阻止这件事情的发生。内部作用域依然存在,因此没有被回收。
谁在使用这个内部作用域?显然是 bar() 本身在使用。

拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。bar() 永久持有对该作用域的引用,而这个引用就叫作闭包。

bazz()执行时,就可以访问 bar 函数定义时的 ** 词法作用域 **,因此可以自由地访问变量 a。

闭包使得函数可以继续访问定义时的 ** 词法作用域 *,即使函数已经不在 * 词法作用域 ** 内。

一开始的代码不是真正的闭包的原因,现在可以解答了,是因为 bar 的执行还是在 bar 的 ** 词法作用域 ** 内。

闭包并不一定非得像上面那样,无论何种方式对函数类型的值进行传递皆可以。
例如

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log(++a);
    }
    fn = baz; // 将 baz 分配给全局变量
}
foo();
fn();  // 3
fn(); // 4

懂?

setTimeout 就是闭包的例子。

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

函数 timer 在函数 wait 内部,将内部函数 timer 传递给了 setTimeout。此时 timer 是在 wait 的词法作用域内,可以使用 message 变量。
wait( "Hello, closure!" ); 被执行 1000ms 后,timer 函数任然保有 wait 作用域的闭包。所以此时 setTimeout 被触发执行时,可以正常打印 message 变量。
在引擎内部,工具函数 setTimeout 的实现内,持有对一个参数的引用 (可能叫 fnfunc)。1000ms 后,轮到计时器被执行时,引擎会调用这个函数,而词法作用域在这个过程中保持完整。
这就是闭包。

使用了回调函数,实际上就是在使用闭包!定时器、事件监听器、Ajax 请求等就是闭包。

循环和闭包

经典代码:

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

为什么打印五次 6 ?

仔细想一下,这好像又是显而易见的,延迟函数的回调会在循环结束时才执行。

尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。所有函数共享一个 i,以上代码实际上就是:

var i = 6;
setTimeout( function timer() {
    console.log( i );
}, i * 1000 );
setTimeout( function timer() {
    console.log( i );
}, i * 1000 );
// ...

引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?

作用域问题

我们需要更多的作用域,要在循环中每个迭代中创建一个闭包作用域。

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

这样显然不行,仔细看一下,我们的 IIFE 只是一个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。
这个闭包作用域需要有自己的变量,用来在每个迭代中存储 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

或者:

for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function timer() {
            console.log( j );
        }, j*1000 );
    })( i );
}
// 依次打印 1,2,3,4,5

块作用域

块作用域是一个可以被关闭的作用域。

因此,下面这些看起来很酷的代码就可以正常运行了:

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

此外,for 循环头部的 let 声明还会有一个特殊的行为,就是在那里声明的变量在循环过程中的 ** 每次迭代都会声明 **。每个迭代都会使用上一个迭代结束时的值来初始化这次迭代中声明的变量。

for (let i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}
// 依次打印 1,2,3,4,5

如果你把 let 声明写 for 循环外面,就 GG 了

let i = 1;
for (; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, i * 10);
}
// 打印五个 6

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论。
我的空间