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

变量的作用域到底是如何在 JavaScript 中工作的?这可能是你需要快速理解的一个最基础的事情了。对作用域只有道听途说、模糊不清的理解是不够的。

本文是一篇读书笔记,对应《你所不知道的JavaScript(上卷)》—— “作用域和闭包”一章。

什么是作用域

JS 是一门编译语言。对于传统的编译语言,其编译流程主要分为三个步骤:

  1. 分词/词法分析(Tokenizing/Lexing)
  2. 解析/语法分析(Parsing)
  3. 代码生成

对于 JS 来说,大部分情况下编译发生在代码执行前的几微秒的时间内。我们以下述代码为例,来看看 JS 中编译的详细过程。

1
var a = 2;

首先,编译器会查询作用域中是否已存在名为 a 的变量。如果存在则忽略该声明,不存在则创建该变量。随后,生成运行时所需的代码。运行时,引擎执行后续的赋值操作,检查当前作用域是否存在该变量,存在则使用该变量完成赋值操作;不存在则往上级作用域继续查找。若最终在顶级作用域仍未查询到变量 a,则抛出异常。

在这个过程我们可以看到,作用域主要与变量的查询有关。在引擎查询变量时,涉及到两种查询方式:

  • LHS 查询:变量出现在赋值操作的左侧时进行的查询。该查询需要找到容器本身,目的是对变量进行赋值。在宽松模式下,若未找到对应变量,顶层作用域就会创建一个该名称的变量进行返回;若在严格模式下,则抛出 ReferenceError 错误。
  • RHS 查询:可以认为对不处于赋值操作左侧的变量进行的查询判定为 RHS 查询。该查询的目的是为了获得变量的。若未找到对应变量,则抛出 ReferenceError 错误;如果找到对应的变量,但进行了不合理的操作,则会抛出 TypeError 错误。

LHS 查询和 RHS 查询都是在作用域内进行的变量查询,从当前执行的作用域中开始,如果有需要,就会向上级作用域继续进行查询,直至全局作用域。

因此,通俗来说,作用域就是一套规则,用于确定在何处以及如何查找变量。

词法作用域

脱离 JS 来说,作用域有两种工作模型:词法作用域、动态作用域。JS 采用的模型就是词法作用域。因此,在 JS 内,可以将所提到的“作用域”认为是“词法作用域”。

词法作用域的含义就是指定义在词法阶段的作用域。在你写下代码时就已经决定了这个变量或函数所属于哪一个作用域。不会根据代码的执行而进行改变。理解了这个概念后,对于部分刚入门的新人来说,可以解决掉困扰了许久的函数所属作用域的判定问题:无论函数在哪被调用,如何被调用,它的词法作用域都只由函数被声明时的位置所决定。

对词法作用域的查找中,会在找到第一个匹配的标识符时停止,不会再向上级进行查找。因此,内部的同名变量能够遮蔽外层作用域同名变量的值,这叫做“遮蔽效应”。

同时,书本还谈及了两个欺骗词法的方法:eval() 函数,with 关键字。

函数作用域

JS 有两种方法可以生成作用域:通过创建函数生成的函数作用域;通过部分代码块生产的块作用域。

JS 中可以通过两种方式创建函数:函数声明、函数表达式。区分两者最简单的方法是看关键字 function 在该语句所处的位置,如果处于第一个词那么就是一个函数声明;否则就是一个函数表达式(可以理解到:匿名函数、立即执行函数都属于函数表达式)。

函数声明和函数表达式之间最重要的区别就是它们的名称标志符将会绑定在何处。对与函数声明来说,其名称标志符绑定在所处的作用域中,在该作用域范围内皆可调用执行。而函数表达式,其名称标志符只能被绑定在其函数自身中,只能在函数自身内进行递归调用,无法在函数所属作用域中进行调用。

从这一点来看,我们可以理解到:函数表达式是可以匿名的,而函数声明则不可以省略函数名,否则无名称标志符进行调用。

顺便提一点,立即执行函数表达式有两种写法:(function IIFE() {...})()(function IIFE() { ... }()),这两种写法是等价的,功能表现上没有任何区别。

块作用域

ES6 之前,创建块级作用域方法有限,同时路子比较偏。在 ES6 的 letconst 出来后,块级作用域才大量出现在代码中。

能够创建块级作用域的有以下几种方法:

  • with
  • try/catch 中的 catch 语句
  • let
  • const

提升

提升指的是包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

需要注意的是:只有声明本身会被提升,而赋值或其它运行逻辑会留在原地等待执行。每个作用域都会进行自己作用域内的提升操作。

对于函数来说,函数声明会被提升,但是函数表达式不会被提升。对于同名的函数声明,出现在后面的可以覆盖前面的函数声明。对于函数和变量来说,函数首先被提升,然后才是变量。而对于同名的函数和变量来说,变量声明会被当作重复的变量而直接忽略。

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
foo();

var a = true;
if (a) {
function foo() {
console.log('a');
}
} else {
function foo() {
console.log('b');
}
}

// [console] 'b'

注意:letconst 关键字声明的变量/常量,不会进行提升,会产生暂时性死区。

闭包

对于刚入门前端的朋友来说,都会从一些书籍、文章中了解到 JS 学习过程中存在的一些难点。闭包就是其中一个。

那么闭包是怎么产生的?本书中给出的结论是:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行的。

对于我之前的理解来说,闭包通常是指为了让外部函数访问到内部函数中变量,使内部函数返回一个函数,从而在其中操作内部变量。

现在看来,我之前的理解是不完全准确的,因为除了函数主动返回一个内部函数外,还可以通过传递函数来实现闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 实例1
function foo() {
var a = 2;

function baz() {
console.log(a);
}

bar(baz);
}

function bar(fn) {
fn(); // => 闭包
}

// 实例2
var fn;

function foo() {
var a = 2;

function baz() {
console.log(a);
}

fn = baz;
}

function bar() {
fn(); // => 闭包
}

函数能在定义时的词法作用域之外的地方被调用,同时这个函数持有对该作用域的引用,而这个引用就叫做闭包。

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

实际上,在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其它的异步(或者同步)任务中,只要使用了回调函数,就是在使用闭包。

模块

利用闭包的知识点,实现一个简单的模块实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

在这个实例中,通过 IIFE,立即调用这个函数并将其返回值直接赋值给单例的模块实例标志符 foo。而在 IIFE 内部,通过返回一个函数对象,使得内部函数能在私有作用域中形成闭包,可以访问或者修改内部的私有状态。

我们为模块加上依赖加载器/管理器,来简单的实现一下现代的模块机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
var MyModule = (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(imply, deps);
}

function get(name) {
return modules[name];
}

return {
define: define,
get: get
}
})();

// test
MyModules.define('bar', [], function() {
function hello(who) {
return 'Let me introduce: ' + who;
}

return {
hello: hello
};
});

MyModule.define('foo', ['bar'], function(bar) {
var hungry = 'hippo';

function awesome() {
console.log(bar.hello(hungry).toUpperCase());
}

return {
awesome: awesome
};
});

var bar = MyModules.get('bar');
var foo = MyModules.get('foo');