本文着重于对 JavaScript 中的作用域和闭包机制进行剖析和说明,闭包本质上也是作用域的一种类型,因为在 JavaScript 里非常重要,所以我们把它在标题里单独列出来。
作用域(Scope),即有效范围,决定了标识符(包括变量、常量、函数名等)在程序中可以被使用的区域。
作用域存在着嵌套关系,对某段代码来说,可能存在多个同名的标识符,其作用域都覆盖了这段代码,这时,被适用的将是作用域范围最小(即离代码最近)的那个标识符。
作用域有两大类别:
以下面的代码为例,我们来简单的看一下两者的区别和各自的特点。
var i = 1,
f1 = function() {
i = i+1;
console.log(i);
},
f2 = function() {
var i = 2;
f1();
f1();
};
f3 = function() {
f1();
f1();
};
f2();
f3();
静态作用域模式下,f1() 代码里参照的 i 始终是全局变量 i ,其输出如下:
而动态作用域模式下,f1() 代码里参照的 i 取决于函数的调用(执行栈):
其输出如下:
采用动态作用域模式的语言很少,大部分语言采用的都是静态作用域模式,JavaScript 采用的也是静态作用域模式,因此这里我们只针对静态作用域来进行展开。
不限于JavaScript,这里将各个语言有所实现的静态作用域作一个总的类型分类
在 JavaScript 中,全局作用域是最外围的一个执行上下文,可以在代码的任何地方访问到。在浏览器中,我们的全局作用域就是 window。因此在浏览器中,所有的全局变量和函数都是作为 window 对象的属性和方法创建的。
局部作用域和全局作用域正好相反,局部作用域一般只在某个特定的代码片段内可访问到,JavaScript 中的局部作用域分为函数作用域和代码块作用域两类,其中代码块作用域在 ECMAScript6 之前不被支持。
函数作用域的变量不管声明在那个位置,在整个函数内部都可访问。
函数内用var声明的变量都具有函数作用域。
function hi() {
for (var i = 0; i < 10; i++) {
var value = "hi " + i ;
}
console.log(i); // 输出:10
console.log(value);//输出 : hi 9
}
hi();
如上例所示,变量 i 和 value 虽然是声明在循环语句块里,但因为是函数作用域,所以即使出了循环语句块,后面的代码仍可访问。
代码块作用域的变量只在声明变量的代码块内部可见。
ECMAScript6 之后,函数内用 let 声明的变量和 const 声明的常量都属于代码块作用域。
function hi() {
for (let i = 0; i < 10; i++) {
let value = "hi " + i ;
}
try {
console.log(i); // Uncaught ReferenceError: i is not defined
} catch (e){
console.log(e.toString());
}
try {
console.log(value);// Uncaught ReferenceError: value is not defined
} catch (e){
console.log(e.toString());
}
}
hi();
闭包并没有一个明确标准化的定义,一个常见的定义是把闭包当成一个由函数和其创建时的执行上下文组合而成的实体。这个定义本身没有问题,但把闭包理解成函数执行时的作用域环境好像更接近闭包的本质,因此知典对 JavaScript 中的闭包重新做了一个定义:
以下面的代码为例:
var x = 1;
function Counter() {
var n = 0;
var f = function () {
n = n + x;
return n;
};
return f;
}
函数Counter()在内部定义了本地变量 n,并返回一个函数对象 f。
函数对象 f 创建时的局部作用域环境(包含变量 n)被保存起来,成为被返回的函数对象内部关联的闭包。
调用Counter(),获得返回的函数对象:
var a = Counter();
Counter()执行后的环境状态如下:
┌────────────────┐
全局环境 ┌────────────────────────────────┐
x => 1 ←─ Counter()执行时的局部环境(a的闭包) ←─ a();
a => function n => 0 ←─ a();
└────────────────────────────────┘
└────────────────┘
连续4次调用保存在变量a里的返回函数:
console.log("a()=" + a()); //输出:a()=1
console.log("a()=" + a()); //输出:a()=2
console.log("a()=" + a()); //输出:a()=3
console.log("a()=" + a()); //输出:a()=4
如上例所示,a() 每次执行时访问的都是同一个闭包环境里的同一个变量 n。
下面的代码,我们调用两次Counter():
var a = Counter();
var b = Counter();
Counter()两次执行后的环境状态如下:
┌──────────────────┐ ┌───────────────────────────────────────┐
全局环境 ←─ Counter()第1次执行时的局部环境(a的闭包) ←─ a();
x => 1 n => 0 ←─ a();
a => function └───────────────────────────────────────┘
b => function ┌───────────────────────────────────────┐
←─ Counter()第2次执行时的局部环境(b的闭包) ←─ b();
n => 0 ←─ b();
└───────────────────────────────────────┘
└──────────────────┘
然后执行以下代码:
console.log("a()=" + a()); // 输出:a()=1
console.log("a()=" + a()); // 输出:a()=2
console.log("b()=" + b()); // 输出:b()=1
console.log("a()=" + a()); // 输出:a()=3
console.log("b()=" + b()); // 输出:b()=2
如上例所示,Counter() 每次执行所返回的函数对象 f,都是一个不同的函数对象,关联着不同的闭包环境。
JavaScript 中的作用域链有两种: 一种是函数创建时保存在函数对象属性中的、静态存在的作用域链,还有一种是程序执行时,与执行上下文相关联的、动态存在的作用域链,下面对这两种作用域链分别进行说明。
如闭包说明中的截图所示,函数对象有一个内部属性 [[Scope]],包含了函数被创建后的作用域对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数对象执行时的代码访问。
闭包说明的示例代码中所创建的函数对象 a 和 b,各自的作用域链如下图所示:
函数的作用域链自函数对象创建起直至函数对象被释放(不再被访问)为止,即使函数代码不在执行状态始终一直存在,因此我们说函数的作用域链是一个静态的作用域链。
(这里以函数的执行为例进行说明,与函数的执行相比,全局代码执行时的作用域链更为简单,没有函数作用域和闭包作用域。)
执行函数时会创建一个称为“执行上下文(execution context)”的内部对象,执行上下文定义了函数执行时的环境。
每个执行上下文都有自己的作用域链,当执行上下文被创建时,它的作用域链初始化为当前运行函数的 [[Scope]] 属性所包含的作用域对象,这些作用域对象的引用被复制到执行上下文的作用域链中。
另外,执行上下文会为函数创建一个函数作用域对象,JavaScript 里称之为活动对象(activation object),该对象包含了函数的所有局部变量、命名参数、参数集合以及 this,然后此对象会被推入作用域链的前端。新的作用域链如下图所示:
有些语句可以延长作用域链,即在作用域链的前端临时增加一个新的代码块作用域对象,该作用域对象会在代码执行后被移除。
ES6 之后支持代码块作用域,如果代码块里存在 let 定义的变量,即会出现作用域延长的现象。ES5 之前也有两种特殊情况下会发生这种现象:
函数执行结束后,函数的执行上下文对象被释放,其关联的作用域链也会一起被释放。因此我们说执行上下文的作用域链是一个动态的作用域链。
以下面的代码为例:
var x = 1;
function Counter() {
var n = 0;
var f = function () {
console.log("start");
for (let i = 1;i<3;i++) {
debugger;
console.log(i);
}
n = n + x;
return n;
var lvar;
};
return f;
}
var a = Counter();
a();
其执行过程中的作用域链变化如下: