作用域链和闭包

作用域链和闭包


通过对比两段代码引出本文讨论的内容:

代码1

function getFs() {
    let fs = [];
    for(var i = 0; i < 5; ++i) {
        fs.push(() => console.log(i));
    }
    return fs;
}

let fs = getFs();

for(f of fs) {
    f();
}

代码1输出结果

5
5
5
5
5

代码2:修改代码1,将for中的i通过let进行声明

function getFs() {
    let fs = [];
    for(let i = 0; i < 5; ++i) { // 唯一修改的地方
        fs.push(() => console.log(i));
    }
    return fs;
}

let fs = getFs();

for(f of fs) {
    f();
}

代码2输出结果:

0
1
2
3
4

仅仅是修改了变量的类型定义,但是为什么输出结果截然不同?带着这个问题往下读。

什么是闭包?

先看一下书中的定义(这里的书指的是《JavaScript高级程序设计》第四版,下文中提到的“书”均指本书):

10.14 闭包

闭包指的是那些引用了另一个函数作用域中变量的函数,通常实在嵌套函数中实现的。

换一种说法,闭包就是引用了“外层”函数作用域中的变量的“内层”函数。

拿上面的两端代码来说,()=>console.log(i)就是闭包,他们本身没有声明变量i,而是引用了外层函数getFs()函数声明的变量。

在MDN文档中,关于闭包的定义有另一种说法:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

很显然,这两种说法不一样,但是有一个共同点:内层函数访问外层函数作用域的变量。没必要纠结哪种定义是正确的,我们更需要关注的是内层函数是怎样访问外层函数作用域的变量。

内层函数怎么获取外层函数的变量?

函数都存在一个作用域链,通过作用域链,内层函数就可以访问到外层函数的变量。分析到这里,还是没有办法解释上面的疑问,因此,还需要指导作用域链到底是怎么创建的。

作用域链的创建

要解释作用域链的创建过程,首先要理清楚几个概念:执行上下文(上下文)活动对象

执行上下文

我们熟悉的大部分语言,处理函数调用时,都是通过栈进行管理。在函数调用时,将函数执行所需要的必要信息(函数使用的变量、调用时传递的参数等)压入栈中,当函数执行完成,则将该函数对应的上下文从栈中弹出。

每一种语言的执行上下文的结构并不相同,对于JS来说,执行上下文中比较重要的属性就是:活动对象、作用域链。

活动对象

JS函数的执行上下文中,会有一个对象用来保存执行过程中使用的变量,这个对象就是活动对象,通过arguments(保存了函数调用时传递的参数)进行初始化。

创建过程

函数执行时,每个执行上下文中都会有一个包含函数变量的对象,被称作活动对象,只在函数执行期间存在。JS代码在开始运行时,首先进入全局环境,创建全局上下文,全局上下文也有一个保存变量的对象(浏览器的环境下,这个全局的变量对象就是window),它在代码执行期间始终存在。在定义函数时,就会为函数创建作用域链,复制定义函数时的上下文的作用域链,并保存在内部的[[scope]]中。在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的[[scope]]创建作用域链,之后将上下文中的活动对象推入作用域链的前端。

下面通过一段代码解释上述创建过程:

function f1() {
    var v1 = 1;
    function f2() {
        var v2 = 2;
        function f3() {
            var v3 = 3;
            v1 + v2 + v3;
        }
        v1 + v2;
        f3();
    }
    f2();
}

f1();

借助浏览器的开发者工具进行调试,可以看到调用时的具体情况,如图所示:

注意:图中的Scope不是我们所说的函数的[[scope]],图中的Scope可以简单的理解为作用域链,也就是函数执行上下文 &函数的[[scope]]

在这里插入图片描述

现在,调用f1时的情况如图所示:

在这里插入图片描述

调用f1时,会复制函数f1的[[scope]](当前仅有一个Global),创建作用域链,然后将f1的活动对象(图中Location对应的对象)推入作用域链的最前端。

接下来,定义函数f2,当前的执行上下文为函数f1的上下文,因此,将f1的上下文的作用域链,复制到函数f2的[[scope]]中;

接下来,调用f2,复制函数f2的[[scope]](其实就是f1的作用域链),然后将f2的活动对象推入作用域链的最前端,如图所示:

在这里插入图片描述

图中的Location表示f2调用时的活动对象,之后的变量都是通过f1的作用域链进行访问。

注意:图中的Scope不是真正意义上的作用域链,它仅在调试时指出了这个变量是怎么来的

接下来,定义函数f3,因为当前的执行上下文为函数f2的上下文,所以将f2的上下文的作用域链,复制到f3的[[scope]]中;

调用f3,复制f3的[[scope]],并把f3的上下文的活动对象推入作用域链最前端,如图所示:

在这里插入图片描述

上面所说的复制,都是“浅拷贝”,仅仅复制了引用,可以通过以下代码进行验证:

function getF(){
    var i = 0;
    return [
        function () {
            i++;
            console.log(i);
        },
        function () {
            i++;
            console.log(i);
        }
    ];
}

let [f1, f2] = getF();
f1();
f2();

上述代码的输出结果为:

1
2

两个匿名函数,都浅拷贝了getF()执行时的上下文的作用域链,因此,两个匿名函数共享同一个变量。

目前为止,简单介绍了作用域链的创建过程,但是还是没有办法解释,请不要急,继续往下看。

作用域链增强

引用书中对于作用域链增强的定义:

虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有其他的方式来增强作用域链。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:

  • try/catch语句的catch块
  • with语句

这两种情况下,都会在作用域链前端添加一个变量对象。对with语句来说,会向作用域链前端添加指定的对象;对catch语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象。

请看代码:

function test(){
    with(location) {
        console.log(location);
    }

    try {
        console.log(a);
    } catch(e) {
        console.log(e);
    }
}

test();

通过调试工具查看:

当没进入with代码块时,作用域链包含全局变量对象(test定义时复制到[[scope]]中),test调用时执行上下文的活动对象。如图所示:

在这里插入图片描述

当进入with代码块后,会将with指定的变量添加到作用域链的最前端。如图所示:

在这里插入图片描述

当退出with代码块后,会发现,with指定的变量已经从作用域链删除。

在这里插入图片描述

因为这里引用了一个不存在的变量a,所以会抛出错误,进入catch代码块,同时将捕获到的异常对象加入到作用域链的最前端。

在这里插入图片描述

退出catch代码块后,将catch到的错误从作用域链中删除。

在这里插入图片描述

真的只有书上说的两种情况?其实,可以把这些都归类到同一种情况:代码块作用域的变量,定义后会将变量添加到作用域链的前端,退出代码块时会从作用域链删除这句话,包括之后的内容,其实都是通过一些代码的实验总结出来的,甚至可以说是猜测,我并没有看过这部分的源码;就目前为止,我总结(猜测)的结论,可以解释一些不是很好理解的代码。如果有看过源码的大佬发现我这里的结论是错误的,希望通过评论或者私信告诉我,我会及时更正。

解决一开始提出的问题

for(var i = 0; i < 5; ++i) {
    fs.push(() => console.log(i));
}


for(let i = 0; i < 5; ++i) {
    fs.push(() => console.log(i));
}

这两个究竟有什么不同?

在函数中,var声明的变量是函数作用域;let声明的变量是块作用域

也就是说,在函数() => console.log(i)定义时,所处的执行上下文的作用域链不同,let声明的i,会在每一次循环开始时加入作用域链,循环结束时,i又会被删除;而定义内部函数的时机则是在删除i之前;所以,定义每一个内部函数时,即使使用浅拷贝复制到[[scope]],因为每次都会删除和重新添加,导致作用域链最前端都有一个不同的i

在这里插入图片描述

如上图,每一次定义内部函数时,所处的上下文环境的作用域链并不同。

var声明的i,由于是函数作用域,所以存放在活动对象中,放在作用域链的最前端。在声明内部函数时,使用浅拷贝复制到[[scope]],每一个内部函数复制得到的作用域链都是相同的。

在这里插入图片描述

如上图,每一次定义内部函数时,所处的上下文环境的作用域链相同,当内部函数访问i时,最终会访问函数getFs的活动对象中的i,而在循环结束后,i的值为5,所以,每次访问得到的值都是5。

如果将let声明提出到for()外面,则也会声明一个函数作用域的i,和var声明的效果一致,即:

for(var i = 0; i < 5; ++i) {
    fs.push(() => console.log(i));
}

// 两种等价的写法

let i;
for(i = 0; i < 5; ++i) {
    fs.push(() => console.log(i));
}

或者,在var声明的情况下,在for()代码块中声明一个块作用域的变量,实现和let声明相同的效果,即:

for(var i = 0; i < 5; ++i) {
    let j = i;
    fs.push(() => console.log(j));
}

// 两种等价的写法

for(let i = 0; i < 5; ++i) {
    fs.push(() => console.log(i));
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
校园悬赏任务平台对字典管理、论坛管理、任务资讯任务资讯公告管理、接取用户管理、任务管理、任务咨询管理、任务收藏管理、任务评价管理、任务订单管理、发布用户管理、管理员管理等进行集化处理。经过前面自己查阅的网络知识,加上自己在学校课堂上学习的知识,决定开发系统选择小程序模式这种高效率的模式完成系统功能开发。这种模式让操作员基于浏览器的方式进行网站访问,采用的主流的Java语言这种面向对象的语言进行校园悬赏任务平台程序的开发,在数据库的选择上面,选择功能强大的Mysql数据库进行数据的存放操作。校园悬赏任务平台的开发让用户查看任务信息变得容易,让管理员高效管理任务信息。 校园悬赏任务平台具有管理员角色,用户角色,这几个操作权限。 校园悬赏任务平台针对管理员设置的功能有:添加并管理各种类型信息,管理用户账户信息,管理任务信息,管理任务资讯公告信息等内容。 校园悬赏任务平台针对用户设置的功能有:查看并修改个人信息,查看任务信息,查看任务资讯公告信息等内容。 系统登录功能是程序必不可少的功能,在登录页面必填的数据有两项,一项就是账号,另一项数据就是密码,当管理员正确填写并提交这二者数据之后,管理员就可以进入系统后台功能操作区。项目管理页面提供的功能操作有:查看任务,删除任务操作,新增任务操作,修改任务操作。任务资讯公告信息管理页面提供的功能操作有:新增任务资讯公告,修改任务资讯公告,删除任务资讯公告操作。任务资讯公告类型管理页面显示所有任务资讯公告类型,在此页面既可以让管理员添加新的任务资讯公告信息类型,也能对已有的任务资讯公告类型信息执行编辑更新,失效的任务资讯公告类型信息也能让管理员快速删除。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值