先有蛋还是先有鸡?JavaScript 作用域与闭包探析

zzrshuiwuhen 2019-06-21

引子

先看一个问题,下面两个代码片段会输出什么?

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

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

如果了解过 JavaScript 变量提升相关语法的话,答案是显而易见的。本文作为《你不知道的 JavaScript》第一部分的阅读笔记,顺便来总结一下对作用域与闭包的理解。

一、先有蛋还是先有鸡

上面问题的答案是:

  1. -> 2

  2. -> undefined

我们从编译器的角度思考:

  • 引擎会在解释 JavaScript 代码之前首先对其进行编译(没错,JavaScript 也是要进行编译的!),而编译阶段中的一部分工作就是找到所有声明,并用合适的作用域将他们关联起来,即 包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理

  • 当你看到 var a = 2;时可能会认为这是一个声明,但 JavaScript 实际上会将其看成两个声明:var aa = 2,第一个定义声明是在编译阶段进行的,第二个赋值声明会被留在原地等待执行阶段处理。

  • 打个比方,这个过程就好像变量和函数声明从它们的代码中出现的位置被“移动”到了最上面,这个过程就叫做 提升

  • 所以,编译之后上面两个代码片段是这样的:

// Snippet 1 编译后
var a;
a = 2;
console.log(a);    // -> 2

// Snippet 2 编译后
var a;
console.log(a);    // -> undefined
a = 2;

所以结论就是:先有蛋(声明),后有鸡(赋值)

二、编译

实际上,JavaScript 也是一门编译语言。与传统编译语言的过程一样,程序中的一段源代码在执行之前会经过是三个步骤,统称为“编译”:

  • 分词/词法分析(Tokenizing/Lexing)

  • 解析/语法分析(Parsing)

  • 代码生成

简单来说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。

三、作用域

为了理解作用域,可以想象出有以下三种角色:

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

  • 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活。

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

var a = 2; 为例,过程如下:

  • 首先遇到 var a,编译器会询问作用域是否已经有一个名为 a 的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则就会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a.

  • 然后,编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a=2 这个赋值操作。引擎运行时会首选询问作用域,在当前的作用域集合中是否存在一个叫做 a 的变量。如果是,引擎就会使用这个变量;如果否,引擎就会继续查找该变量(一层一层向上查找)。

  • 最后,如果引擎最终找到了a变量,就会将 2 赋值给它,否则引擎就会举手示意并抛出一个异常(ReferenceError)!

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止

四、函数声明式 & 函数表达式

JavaScript 中创建函数有两种方式:

// 函数声明式 
function funcDeclaration() { 
    return 'A function declaration'; 
} 

// 函数表达式 
var funcExpression = function () { 
    return 'A function expression'; 
}

声明式与表达式的差异:

  • 类似于 var 声明,函数声明可以 提升 到其它代码之前,但函数表达式不能,不过允许保留在本地变量范围内;

  • 函数表达式可以匿名,而函数声明不可以。

怎么判断是函数声明式还是函数表达式?

  • 一个最简单的方法是看 function 关键字出现在声明的位置,如果是在第一个词,那么就是函数声明式,否则就是函数表达式。

函数表达式比函数声明式更加有用的地方:

  • 是一个闭包

  • 可以作为其他函数的参数

  • 可以作为立即调用函数表达式(IIFE

  • 可以作为回调函数

五、匿名函数 & 立即调用函数

“在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏起来”,外部作用域就无法访问包装函数内部的任何内容。那么,能否更彻底一些?如果必须声明一个有具体名字的函数,这个名字本身就会“污染”所在作用域;其次,必须显式通过函数名调用这个函数才能运行其中的代码。如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,这就完美了!”——论匿名函数和理解调用函数的诞生。

匿名函数表达式最熟悉的场景就是回调函数:

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

匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但是,它也有几个缺点需要考虑:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。

  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。

  3. 匿名函数省略了对于代码可读性、可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。

所以,始终给函数表达式命名是一个最佳实践:

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

由于函数被包含在一对()括号内部,因此成为了一个表达式,通过在末尾加上另外一个()括号就可以立即执行这个函数,比如:

(function foo(){
    // ...
})()

第一个()将函数变成了表达式,第二个()执行了这个函数。

它有个术语:IIFE,表示:立即执行函数表达式(Immediately Invoked Function Expression)

它有另外一个改进形式:

(function foo(){
    // ...
}())

不同点就是把最后的括号挪进去了,实际上 这两种形式在功能上是一致的,选择哪个全凭个人喜好

至于 IIFE 的另一个非常普遍的进阶用法是 把它们当做函数调用并传递参数进去

var a = 2;
(function foo(global){
    var a = 3;
    console.log(a);    // -> 3
    console.log(global.a);    // -> 2
})(window);    // 传入window对象的引用
console.log(a);    // -> 2

六、再谈提升

现在我们再来谈一谈提升。

// Snippet 3
foo();    // -> TypeError
bar();    // -> ReferenceError
var foo = function bar(){
    console.log(1);
};

为什么会输出上面这两个异常?我们可以从编译器的角度把代码看出这样子:

var foo;    // 声明提升
foo();      // 声明但未定义为 undefined,然后这里进行了函数调用,所以返回 TypeError
bar();      // 无声明抛出引用异常,所以返回 ReferenceError
foo = function bar(){
    console.log(1);    
};

然后再变化一下,同名的函数声明和变量声明在提升阶段会怎么处理:

foo();    // 到底会输出什么?
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 尽管出现在 function foo() 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。即:函数声明和变量声明都会被提升,但函数会首先被提升,然后才是变量(这也从侧面说明了在 JavaScript 中“函数是一等公民”)。

再来:

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

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

七、闭包

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

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

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz();    // -> 2,闭包的效果!

以下是解释说明:

  • 函数 bar() 的词法作用域能够访问 foo() 的内部作用域,然后我们将 bar() 函数本身当做一个值类型紧传递。在这个例子中,我们将 bar() 所引用的函数对象本身当做返回值。

  • foo()执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 baz 并调用 baz(),实际上只是通过不同的标识符引用调用了内部的函数 bar()

  • bar() 显示是可以被正常执行,但是在这个例子中,它在自己定义的词法作用域以外的地方执行。

  • foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

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

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

  • bar() 依然持有对该作用域的引用,而 这个引用就叫闭包

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

再补充一个示例:

function foo() {
    function bar() {
        console.log('1');
    }
    function baz() {
        console.log('2');
    }

    var yyy = {
        bar: bar,
        baz: baz
    }
    return yyy;
}

var kkk = foo();    // kkk通过foo获得了yyy的引用,也就可以调用bar和baz
        
kkk.bar();    // -> 1
kkk.baz();    // -> 2

九、动态作用域

事实上,JavaScript 并不具有动态作用域,它只有 词法作用域(虽然 this 机制某种程度上很像动态作用域)。词法作用域和动态作用域的主要区别为:

  • 词法作用域是在写代码或者定义时确定的,而动态作用域是在运行时确定的;

  • 词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

像下面的代码片段,如果是动态作用域输出的就是3而不是2了:

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

十、参考

相关推荐