作用域和闭包--读《你不知道的 JavaScript》

2016-04-29 01:03:48 +08:00
 iambillzhao

点击阅读原文

本文是在看完《你不知道的 JavaScript 》这本书之后整理而成。 本文中所有的代码可以在这里找到。如果你不想读下面的文字,可以直接 clone 代码并运行,然后结合代码和注释,试着去理解作用域和闭包的概念。

词法作用域

JavaScript 采用词法作用域。所谓词法作用域就是代码书写完成之后,作用域随即确定,所写既所得。

看下面的代码;

var printSpace = require('./printspace');
var showmore = true;

var yoho = "yoho!我在全局作用域";

function awfulSayYoho() {
    console.log("*****一般情况*****");
    console.log(yoho);
}

function normalSayYoho() {
    console.log("*****局部变量*****");

    var yoho = "yoho!我在函数作用域";
    console.log(yoho);
}

function advanceSayYoho() {
    // var yoho;   // B 行, 自动提升声明
    console.log("*****自动提升*****");
    console.log(yoho); // C 行,打印的是 B 行的声明,既 undefined

    if (showmore) {
        var yoho = "yoho!我在函数作用域"; // A 行,声明 yoho ,并为其赋值。
        // yoho = "yoho!我在函数作用域"; 执行语句不提升,留在原地

        function sayYohoAgain() {
            console.log(yoho);
        }

        sayYohoAgain();
    }
}

printSpace();

awfulSayYoho();

printSpace();

normalSayYoho();

printSpace();

advanceSayYoho();

printSpace();

上面这些代码的运行结果如下:

*****一般情况*****
yoho!我在全局作用域


*****局部变量*****
yoho!我在函数作用域


*****自动提升*****
undefined
yoho!我在函数作用域

上面这段平淡无奇的代码,相信大多数 JSer 理解起来并不难。值得一提的是声明的自动提升。在 advanceSayYoho 函数里的 A 行,声明了变量 yoho 并为其赋值,既看上去一行代码做了两件事:声明且赋值。但是,在函数被调用时, JS 引擎总是将 A 行这样的"声明且赋值"的语句拆分成声明和赋值两步执行,既

var  yoho = "yoho!我在函数作用域";

被分成了:

var yoho;
yoho = "yoho!我在函数作用域";

其中声明语句

var yoho;

这行被自动提升到函数体的顶部,既相当于自动提升到了 advanceSayYoho 函数的 B 行。

而赋值语句

yoho = "yoho!我在函数作用域";

不会提升,留在原地

由此便不难理解为什么 C 行打印的是 undefined 了。

原始数据类型和引用数据类型

在进一步讨论作用域及作用域对象的之前,先简单的说明一下 JS 里的“赋值”和"引用"。

JS 里有两大类数据类型: 原始数据类型和引用数据类型(及对象数据类型)。下面的代码试图说明这两者的区别:

var printSpace = require('./printspace');

// 值传递
console.log("*****值传递*****");

var num = 10; // num 持有了数字 10
var a = num;  //   a 持有了数字 10
var b = num;  //   b 持有了数字 10

console.log("------初始-----");
console.log(num);
console.log(a);
console.log(b);

num = 8; // num 持有的数字变为了 8

console.log("------num 值改变后-----");
console.log(num);
console.log(a);
console.log(b);

printSpace();

// 对象的引用
console.log("*****对象的引用*****");

var yoho = {     // yoho 引用了对象 {value: “潮流”}
    value: "潮流"
}

var kris = yoho; // kris 引用了对象 {value: “潮流”}
var me = yoho;   // me 引用了对象 {value: “潮流”}

console.log("------初始----");
console.log(yoho.value);
console.log(kris.value);
console.log(me.value);

me.value = "还是潮流"; // A 行, "被 me 引用的对象".value 发生了

console.log("------me.value 改变后-----");
console.log(yoho.value);
console.log(kris.value);
console.log(me.value);

运行 node reference ,可以看到:

*****值传递*****
------初始-----
10
10
10
------num 值改变后-----
8
10
10


*****对象的引用*****
------初始----
潮流
潮流
潮流
------me.value 改变后-----
还是潮流
还是潮流
还是潮流

可能有些新 JSer 不太能理解的是:上面代码的 A 行只修改了 me.value 的值,为什么 yoho.value 和 kris.value 也发生了变化?

其实上面代码中, yoho 、 me 、 kris 这三个变量是等同的,他们都指向同一块内存空间,既对象 { value: “潮流” }所在的内存空间。也就是 yoho 、 me 、 kris 三个变量同时引用了对象{ value: “潮流” }。

所以 me.value 、 kris.value 、 yoho.value 都是同一块内存空间,而那块内存里的值一开始的值是“潮流”, 然后在 A 行,这个值被改为了"还是潮流"。

如果还是不能理解"引用"这个概念,建议可以去看看 C 语言里指针的概念。

变量的生命周期、作用域对象及作用域链

请看下面的代码:

// JS 代码由此开始运行,创建了 globalScope = {}
// 紧接着 JS 引擎对整个代码内容进行检查,搜索出全局变量,并将他们的声明设置为 globalScope 的属性
// 既 globalScope = {
//     yohobuy: yohobuy,
//     PRINTSPACE: PRINTSPACE,
//     sayYoho: sayYoho
// }

// globalScope.PRINTSPACE = require('./printspace');
var PRINTSPACE = require('./printspace');

// globalScope.yohobuy = "YOHO!buy";
var yohobuy = "YOHO!buy";

// globalScope.sayYoho = function() {......}
function sayYoho() {
    // 函数开始执行,创建了 scope = {}
    // 设置 scope chain, 既 scope.parentScope = globalScope
    // 提升开始: scope.yoho = undefined;

    var yoho = "yoho!"; // scope.yoho = "yoho" , 此时 scope = {yoho: "yoho"}

    console.log(yoho);  // 打印的是 scope.yoho

    console.log(yohobuy); //打印的是 scope.parentScope.yohobuy, 既 scope.globalScope.yohobuy
}


PRINTSPACE();

sayYoho();
// 函数调用完成,在函数调用过程中创建的 scope 对象此时没有被任何人引用,那么这个 scope 对象被自动回收

PRINTSPACE();

console.log(yoho); // 打印的是 globalScope.yoho, 而 globalScope 上并没有定义 yoho 属性

运行结果如你所料:

yoho!
YOHO!buy

/Users/bill/Documents/Work/closure/lifecycle.js:36
console.log(yoho); // 打印的是 globalScope.yoho, 而 globalScope 上并没有定义 yoho 属性

ReferenceError: yoho is not defined
    at Object.<anonymous> (/Users/bill/Documents/Work/closure/lifecycle.js:36:13)
    at Module._compile (module.js:413:34)
    at Object.Module._extensions..js (module.js:422:10)
    at Module.load (module.js:357:32)
    at Function.Module._load (module.js:314:12)
    at Function.Module.runMain (module.js:447:10)
    at startup (node.js:140:18)
    at node.js:1001:3

依然是一段有些无聊的代码,但愿你看到他们的时候不要打呵欠或是关掉这个网页:)))让我们来看看这里发生的一些有趣的事情:

  1. JS 代码只要被运行,便会在一开始的时候就创建一个全局的作用域对象,在这里为了方便起见,我把它称为 globalScope 。
  2. globalScope 对象创建完成后,立刻去全局范围内搜索全局变量,并将变量的名字设为自己属性名,同时引用相对应的变量。
  3. sayYoho 函数被调用时,创建了它自己的作用域对象,我把它称为 scope 。
  4. scope 对象会设置自己的父级作用域对象,既当前函数作用域的上一个作用域,在这里就是 globalScope 。
  5. 紧接着, scope 对象开始在函数作用域内部搜索局部变量,并将其设置为自己的属性。值得一提的是,这步只完成了属性名的设置,并未对属性赋值。(这一步既是上文提到的所谓变量的提升)
  6. 函数运行结束后,如果(通常就是这样)scope 对象没有被引用,则其会被 JS 引擎回收。*(反之,如果函数运行结束后,还有变量在引用 scope 对象,则该对象不会被回收)*

闭包

很高兴你能看到这里。我们终于来到了闭包的概念。可是别激动,我还是只能提供一段最稀松平常的闭包代码,你一定看过类似下面这样的代码:

// 此处有 globalScope 对象; .... 不再赘述
var printSpace = require('./printspace');

function outerFunc() {
    // 函数被调用时,产生了新的 outerScope 对象, 既 outerScope = {}
    //
    // 设置 scope chain ,既 outerScope.parentScope = globalScope;
    //
    // 提升开始: outerScope.counter = undefined;

    // outerScope.counter = 0;
    var counter = 0;

    return function() {
        // 函数被调用的话,产生新的 insideScope = {}
        //
        // 设置 scope chain ,既 insideScope.parentScope = outerScope;
        //
        // 此时完整的 scope chain 可以想成这样的 insideScope.parentScope.parentScope
        // 既 insideScope.outerScope.globalScope
        //
        // 函数内部没有 var 定义的变量,所以 insideScope 没有变化

        console.log(counter); // 此处需要打印的是 insideScope.outerScope.counter 。既 outerScope 对象被引用了!!
        counter += 1;
    }
}

var insideFunc = outerFunc(); // 因为 outerFunc 的调用,内部函数被 return 出来
                              // return 出来的内部函数被 insideFunc 引用
                              // 由于 insideFunc 引用的函数内部引用了 outerScope 上的属性 counter,
                              // 所以 outerScope 对象不会被回收,从效果上来讲就是局部变量 counter 一直存活了

/*
 * 不妨假想成下面的代码
 *
 * var insideFunc = function() {
 *     console.log(counter);
 *     counter += 1;
 * }
 *
 * 其中的 counter 变量来自 outerFunc 被调用时所创建的作用域对象
 *
 */
printSpace();

insideFunc();
insideFunc();
insideFunc();
insideFunc();
insideFunc();
insideFunc();

printSpace();

var anotherInsideFunc = outerFunc(); //A 行, outerFunc 又被调用一次,产生了新的 scope chain

anotherInsideFunc();
anotherInsideFunc();
anotherInsideFunc();
anotherInsideFunc();

printSpace();

运行一下 node closure ,看到下面的结果:

0
1
2
3
4
5

0
1
2
3

从结果上来讲,这段代码只是达到了将 counter 这个一般情况下会随着函数运行结束而被销毁的局部变量保留住了的效果。到底是怎么做到这点的呢,简单来说,过程是这样的:

  1. outerFunc 被调用时创造了作用域对象, outerScope ,以及由这个 outerScope 对象做为起点的一条作用域链
  2. outerFunc 运行到最后时 return 了一个匿名函数, 而这个匿名函数使用(既引用)了 outerScope 对象上的属性 counter 。
  3. 此时如果没有其他变量引用 return 的匿名函数,那么将不会有特别的事情发生, outerScope 对象会被回收。
  4. 但是,被 return 的匿名函数又被 innerFunc 这个变量引用,所以为了保证其在随后的代码中能被顺利被执行,必须让 innerFunc 引用的函数内的所有变量都有意义,既 counter 必须被保留,所以相当于 outerScope 对象被引用了, 那么 outerScope 就不会被释放。
  5. A 行中, outerFunc 又被调用了一次,则其又产生了一个新的 outerScope 对象,所以后面 anotherInsideFunc 执行时, counter 又从 0 开始。

总结

为了理解闭包,需要想通下面几点:

  1. 函数运行时会创建作用域对象及以此对象为起点的作用域链。
  2. 借由函数一等公民的身份, JS 函数内部的函数可以被传递给别的变量,既内部函数可以被引用。
  3. 一旦内部函数被引用,并且该内部函数使用了自身作用域链上某个父级作用域对象上的属性,那么该父级作用域对象即使在外部函数运行结束后扔不会被回收。
  4. 外部函数每被调用一次,都会创建新的作用域链。
  5. 作用域链是树形的,下面这张简陋的图也许可以帮助你理解这一概念。

要产生闭包,关键就是:

  1. 至少有一个外部函数,外部函数内至少有一个内部函数。
  2. 外部函数一定要运行至少一次,以此产生作用域对象并将内部函数传递给函数外部变量。

联系我

对本文有任何问题和建议可以在这里一起讨论。

TL; DR;

按照《 JavaScript 权威指南》里的说法,闭包并不是 JS 特有的,它是一种计算机术语,在计算机科学中,将函数和作用域联系起来的这种机制就是闭包,所以理论上所有的 JS 函数都可以被认为是闭包。但是我们平时说的闭包,更多的是指的外部函数运行时产生的作用域对象被保留的现象。

《你不知道的 JavaScript 》这本书在 github 上有英文的开源版本,任何人都可以为其贡献内容,该项目已经有接近 30000star 。目前其中的部分章节被翻译并出版成纸质书籍,感兴趣的话可以去看看

赵彪原创,转载请注明作者和出处

3128 次点击
所在节点    程序员
3 条回复
xuwenmang
2016-04-29 03:18:26 +08:00
维基百科
静态作用域又叫做词法作用域,采用词法作用域的变量叫词法变量。词法变量有一个在编译时静态确定的作用域。词法变量的作用域可以是一个函数或一段代码,该变量在这段代码区域内可见( visibility );在这段区域以外该变量不可见(或无法访问)。词法作用域里,取变量的值时,会检查函数定义时的文本环境,捕捉函数定义时对该变量的绑定。

大多数现在程序设计语言都是采用静态作用域规则,如 C/C++、 C#、 Python 、 Java 、 JavaScript ……

来源: https://zh.wikipedia.org/zh-hans/%E4%BD%9C%E7%94%A8%E5%9F%9F
Mark24
2016-04-29 13:14:10 +08:00
赞,最近正在看这本书,竟然出 Kindle 版了,秒收
Martox
2016-04-29 19:14:08 +08:00
好人一生平安,终于懂了

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/275229

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX