浅谈js中的闭包、作用域和执行上下文

闭包是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代码。具体过程如下:

  1. 如果时创建一个函数环境,那么创建函数的形参和参数的默认值。如果非函数环境,则跳过这个步骤。
  2. 如果时创建全局或者函数环境,就扫描当前代码进行函数声明(function fn() {})(不会扫描其它函数),但是不会扫描箭头函数(() => {})和函数表达式(var fn = function() {})。对于找到的函数声明,将创建该函数,并绑定到与函数名相同的标识符上。如果该标识符已经存在,则它的值将被重写。如果时块级作用域,则跳过。
  3. 扫描当前代码进行变量声明。在函数或全局环境中,找到所有当前函数和在其它函数体外通过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的读者购买阅读。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值