闭包是JavaScript的的显著特征,使用闭包可以减少代码量来添加高级特性,下面让我们来探讨下什么是闭包。
1、理解闭包
让我们看下一个简单的闭包:
var outer = 'outer';
function outerFunc() {
console.log(outer);
}
outerFunc();
我们在全局作用域中定义了一个变量outer和函数outerFunc,然后执行outerFunc函数。很明显最终打印出来的结果是'outer',这正是一个闭包的体现,我们可以在outerFunc函数内部访问到外部作用域中的变量。
2、使用闭包
让我们再来看一个例子:
function Num() {
var num = 0;
this.getNum = function() {
return num;
};
this.addNum = function() {
num++;
};
}
var num1 = new Num();
num1.addNum();
console.log(num1.num);
console.log(num1.getNum());
var num2 = new Num();
console.log(num2.getNum());
代码执行结果为
在上面这个例子中,我们创建了一个Num的构造器,在函数内部定义了一个私有变量num。在函数外部创建了一个对象实例赋值给num1,通过num1.getNum()方法可以获取到构造器内部num变量,但是无法通过num1.num直接访问。这是因为生成实例时形成了一个闭包,保证了私有变量num可以被内部函数getNum访问,但是无法通过num1.num直接访问。
3、函数执行上下文
在js中,我们时刻都在使用函数,为了达到某个目的进行多次的函数调用,最终回到最初函数调用的位置。js的代码分成两类:全局代码和函数代码,当我们执行代码时都是在特定的上下文中执行。js在运行之初会为全局代码创建一个全局执行上下文,当执行到某个函数时,会为这个函数创建一个新的执行上下文。一连串的函数调用,就会最终形成一个函数调用栈。让我们来看个简单的例子:
function fn1(msg) {
fn2('fn1' + msg);
}
function fn2(msg) {
console.log(msg);
}
fn1('hello');
通过执行上下文可以准确的跟踪到当前程序的执行位置,下图是在Chrome开发者工具中查看的执行上下文状态:
4、词法环境
词法环境(lexical environment)是JavaScript引擎内部用来跟踪标识符与特定变量之间的映射关系。当全局或者函数代码执行时,会创建执行上下文,同时创建对应的词法环境,并且函数嵌套调用时,会保持对外部函数词法环境对引用。查看如下代码:
var name = 'Jack';
function outer() {
var country = 'China';
function inner() {
var age = 20;
console.log(age);
console.log(country);
console.log(name);
}
inner();
}
outer();
下图是函数执行过程中创建的执行上下文和词法环境:
当在inner函数中打印'age'时,这时候会开始查找这个变量,在当前词法环境中找到了,则停止查找。当打印’country‘时,在当前环境中无法找到,则向上查找outer函数对词法环境,此时可以找到,则停止查找。当打印'name'时,当前词法环境没有name,向上查找,在outer中也没有name,则继续向上查找到全局词法环境中,这时找到了name,则停止查找。
5、js中变量和词法环境的关系
在es6之前,声明变量对方式为var,在es6中新增了let和const两种声明方式。
使用var定义变量时,该变量是在距离最近对函数或者全局词法环境中定义,示例如下:
function test() {
for (var i = 0; i < 3; i++) {
console.log(i);
}
console.log(i);
}
test();
执行上述代码,最终会输出: 0 1 2 3这个结果。可以看到用var声明的变量没有块级作用域的概念,它相当于在当前函数中声明了这个变量。下图时对应的词法环境:
使用let和const定义变量,此时具备块级作用域,示例代码如下:
function test() {
for (let i = 0; i < 3; i++) {
console.log(i);
}
console.log(typeof i === 'undefined');
}
test();
执行上述代码,最终会输出: 0 1 2 true这个结果。可以看到用let声明的变量具备块级作用域的概念,在块级作用域之外无法访问这个变量。下图时对应的词法环境:
理解了词法环境中如何保存标识符的映射后,接下来讨论词法环境中标识符的定义过程。
6、在词法环境中注册标识符
我们在平常工作中常常听到变量提升这个概念,如下列代码所示:
report('hello world');
function report(msg) {
console.log(msg);
}
我们可以在report函数声明之前就调用这个函数,但是js不是逐行执行代码的吗?为什么还没执行到report这个函数,就可以访问到它呢?下面让我们来解答这个问题:
这里要引入一个概念,叫“注册标识符”,什么意思呢?就是在函数执行时,词法环境创建完成后,js代码的执行会分为两个阶段:第一个阶段不执行js代码,但是会注册在当前词法环境中声明的变量。第二阶段开始逐行执行js代码。具体过程如下:
- 如果时创建一个函数环境,那么创建函数的形参和参数的默认值。如果非函数环境,则跳过这个步骤。
- 如果时创建全局或者函数环境,就扫描当前代码进行函数声明(function fn() {})(不会扫描其它函数),但是不会扫描箭头函数(() => {})和函数表达式(var fn = function() {})。对于找到的函数声明,将创建该函数,并绑定到与函数名相同的标识符上。如果该标识符已经存在,则它的值将被重写。如果时块级作用域,则跳过。
- 扫描当前代码进行变量声明。在函数或全局环境中,找到所有当前函数和在其它函数体外通过var声明的变量,并找到所有块级作用域外通过let或const声明的变量。在块级作用域中,仅查找let和const声明的变量。对于查找到的变量,若该标识符不存在,则进行注册并初始化为undefined。若该标识符存在,将保留现在的值,什么也不做。
下面借助几个例子帮助理解上面的过程:
console.log(typeof fn1 === 'function'); // true
console.log(typeof fn2 === 'undefined'); // true
console.log(typeof fn3 === 'undefined'); // true
function fn1() {}
var fn2 = function() {};
var fn3 = () => {};
console.log(typeof fn2 === 'function'); // true
console.log(typeof fn3 === 'function'); // true
上面例子就是对应了上述过程中的第2步。看另外一个例子:
console.log(typeof fn === 'function'); // true
var fn = 1;
function fn() {}
console.log(typeof fn === 'number'); // true
第一个log为true是因为在第一阶段扫描代码时,在第2步发现fn是一个声明函数,则会创建该函数,并赋值给fn。在第3步中发现fn是一个var声明的变量,这时会去词法环境注册这个标识符,但是发现这个标识符已经存在,所以什么都不干,此时fn还是指向函数。 而当第二阶段开始执行代码时,执行到fn = 1这句后,会将fn赋值为数字1,所以最终log出来的fn的type为‘number'。
7、闭包的工作原理
让我们在回到上面第2小节的例子:
function Num() {
var num = 0;
this.getNum = function() {
return num;
};
this.addNum = function() {
num++;
};
}
var num1 = new Num();
num1.addNum();
console.log(num1.num);
console.log(num1.getNum());
var num2 = new Num();
console.log(num2.getNum());
现在让我们看看num1创建完后程序的状态,num1这个实例内部的两个函数保存这Num函数运行时创建的词法环境。
当程序执行到num2创建完成后,程序状态如下:
从上图可以看出,每个实例都维护着自己的词法环境,相互之间不会互相影响。
最后让我们来看下执行console.log(num2.getNum())这句代码时的执行栈细节:
在调用num2.getNum()之前,js正在执行全局代码。此时正处于全局执行上下文的状态,这也是栈里的唯一执行上下文,唯一活跃的词法环境是全局环境。
当我们调用num2.getNum()时,因为getNum是一个函数,所以此时会创建一个执行上下文推入执行栈中,同时创建getNum函数的词法环境。同时getNum函数的词法环境包含了num2这个实例在创建时所处的词法环境。当调用getNum获取num这个变量时,会先从当前词法环境中查找,未找到后向上查找num2这个实例创建时Num函数创建的词法环境。在这个环境中找到了num这个变量,所以最终能够打印出来。
8、小结
通过闭包可以访问创建闭包时所处环境中的所有变量,并基于此实现各种各样的功能。
本文参考<<javascript忍者秘籍>>这本书籍里面的内容,推荐想深入了解javascript的读者购买阅读。