写在前面
继《篇一》中写了V8引擎的代码执行机制以后,这次又学习了闭包(closure)的概念。在理解了V8预编译和执行代码的过程后,继续深入了解闭包很有必要,本期我们按:作用域链、闭包的顺序聊一聊。
作用域链(Scope Chain)
闭包的概念,关联着数据在作用域规则下的生命周期问题。 想要搞懂闭包,最好先搞懂作用域和作用域链的概念。
光说作用域链,可能有人不清楚是什么,但是去掉一个字:作用域(Scope),这就是我们都知道的一个概念。在V8引擎对代码进行解析时,作用域就已经被划分确定了,我们可以理解为他决定着各作用域间资源的可见性,主要分为三种:
1.全局作用域:脚本模式下执行全部代码的作用域
2.函数作用域:函数体内的作用域
3.块级作用域:let和const定义的参数所在作用域
其中资源可见性的规则是:内部作用域可以访问外部作用域,反之则不行。这里之所以分内部和外部,就是因为作用域间是存在层级关系的。
我们来看看以下代码:
var a = 1
function foo(){
var b = 3
console.log(a)
}
很显然,第4行输出结果:1,因为执行时V8引擎没有在函数的执行上下文对象AO(Activation Object)中找到变量a,则会到全局的执行上下文对象GO(Global Object)中寻找,GO中恰好有这个值,则输出。
V8引擎开始执行代码,当每执行上下文对象,就会把它们压入调用栈,如上图,箭头轨迹显示了V8引擎如何寻找变量a的值。同时,避免大家忘记,我再带大家回顾一下V8在整个过程做了哪些事:
- 解析阶段:
- 词法分析
- 语法分析
- 确定作用域规则
- 预编译阶段:
- 声明提升
- 创建执行上下文对象
- 补充执行上下文对象的参数
- 执行代码
- 具体的变量以及函数赋值操作
- 垃圾回收
- 收回不再需要的内存空间
我们可以这样理解,V8引擎会创建好执行上下文,在执行代码时,先将全局的执行上下文压入调用栈,全局的执行上下文对象只有一个,而每当遇到一个函数调用时,就将该函数的执行上下文也压入栈,只有当这个函数执行完成后,该执行上下文才会被销毁。
上述代码中console.log(a)能打印出结果正是因为,V8引擎会根据作用域链去找到函数foo的上一级作用域(我们也称为词法作用域,简单来说,函数定义在哪里,哪里就是它的词法作用域),从而找到a的值。
初步总结作用域链: 当前执行上下文和它外层所有执行上下文所构成的一种链式结构。
那么,我们如何确定各执行上下文的外层是谁呢?也就是如何确定作用域链中各执行上下文间的关系。
还是先来看一个例子:
function bar(){
var myName = 'Tom'
let age = 18
if(1){
let myName = 'Jerry'
console.log(age2,myName);
}
}
function foo(){
var myName = 'Jack'
let age2 = 2
{
let age2 = 3
bar()
}
}
var myName = 'Peter'
let age2 = 1
foo()
仔细看完你有答案了吗?正确答案是:1;Jerry。看完是不是觉得很不可思议?没错,按照之前分析的情况age2的值不是应该去foo()的执行上下文里去找吗?那么结果应当是:3;Jerry。我第一次看到这的时候也懵了,难道之前预编译过程是错的?不不不,预编译确实是这样分析,只是我们忽略了如何找到正确的作用域链。先看图:
可以注意到,执行上下文中有两个区域,一个是变量环境:主要放变量和函数;还有一个是词法环境:主要放let和const类的参数。需要注意的是,V8在当前执行上下文中寻找某一变量时,会先从词法环境中寻找,如果找不到,再去变量环境中寻找。
那么,当整个执行上下文中都没有找到该怎么办呢?就该根据作用域链去找外层作用域。以下是确定外层作用域的规则。
- outer:变量环境中默认定义了一个
outer属性,用于指向外层作用域. - 词法作用域:
outer应当指向当前执行上下文对应作用域的词法作用域,即指向声明它的那个作用域。
再回到代码,我们发现原来函数bar()是定义在全局作用域内,只是在函数foo()中调用了,但这并不影响函数bar()的outer属性指向全局作用域,这样一来,V8在函数bar()的指向上下文中找不到时变量age2时,就会去到全局执行上下文中找。所有就会打印:1;Jerry
至此,作用域链中各执行上下文的关系就讲清楚了。这是在学习闭包之前我们应当搞清楚的概念,对后续分析各种情况的代码都十分重要。
闭包(closure)
什么是闭包?
先给出一个概念:根据JS词法作用域的规则,内部函数总是能访问外部函数的变量。当通过调用一个外部函数返回的一个内部函数后,即使外部函数执行已经结束,但是内部函数中引用了外部函数中的某些变量,这些变量依旧需要被保存在内存中,我们把这些变量的集合叫做闭包。
也就是说:闭包的出现是因为内部作用域中还留存着对外部作用域变量的引用。
想要了解闭包,得看看它是怎么形成的。
function foo(){
var age = 18
function bar(){
console.log(age)
}
return bar
}
var bVar = foo()
bVar()
结果:18。这份代码最大的特点就是它 通过调用外部函数返回了一个内部函数,等待外部函数执行完后,再去调用内部函数,还需要注意的是,这里的函数bar()是在函数foo()中声明的,其作用域链应该是bar()=>foo()=>全局。
可是,新问题又出现了。按照之前的步骤分析,函数被调用后会将其执行上下文压入调用栈内,当函数执行完成后销毁栈空间。但是为什么当函数foo()执行完后,变量age2还能被找到并且打印出来呢?毕竟全局作用域中也没有变量age2。看到这,就有两种可能:一是执行上下文没有被真正销毁,二是执行上下文中的某些资源没有被销毁,例如上述变量age2。
对于V8引擎来说,管理栈空间是很重要的工作,栈空间应该得到合理的利用,那么当一个函数执行完毕后自然不可能保留它的执行上下文,因为这样会形成内存泄漏(Memory leak)。
所以,上述问题合理的解释应该是:即使外部函数的执行上下文被销毁,但是由于函数bar()中还需要访问变量age的值,故V8引擎会将变量age2单独放入一个包(bag)里面,这个就是闭包`。
还有一个例子也很有趣:
function makeAdder(x) {
return function (y) {
return x + y;
};
}
var add5 = makeAdder(5);
var add10 = makeAdder(10);
console.log(add5(2)); // 7
console.log(add10(2)); // 12
这里定义了一个makeAdder()函数,它接收了一个形参x,返回一个函数,返回的函数接收形参y,继续返回x+y。值得我们注意的是,第7行与第10行对应,第8行和第11行对应。
在这个例子中,add5和add10的调用会形成一个闭包,这两个闭包中分别保留了传入参数的引用,使其在函数的执行上下文被销毁后仍然能够被调用。
此时:让我们再来回顾一下开头的那段话是不是就很好理解了呢?
根据JS词法作用域的规则,内部函数总是能访问外部函数的变量。当通过调用一个外部函数返回的一个内部函数后,即使外部函数执行已经结束,但是内部函数引用了外部函数中的变量也依旧需要被保存在内存中,我们把这些变量的集合叫做闭包。
至于如何形成闭包?
- 定义一个外部函数
- 在外部函数内定义一个内部函数
- 内部函数引用外部函数的一个变量
- 在外部函数返回内部函数的调用使其在外部可访问
看到这里,不知道在大家认为闭包是的产生是好是坏呢?从前面的案例看来,闭包的形成是为了某些函数的调用能够正常执行,这样是好的一面。但是,其实闭包是一把双刃剑。
闭包的优缺点
优点
- 变量的私有化、共享化:闭包能够控制变量的暴露程度。作用域链的性质使得函数体内某些变量能够长期保留下来,避免了过多全局变量的声明。
js
(function () {
var privateVar = "我是私有变量";
function privateFunction() {
console.log("这是一个私有函数");
}
var sharedVar = "我是共享变量";
// 暴露给外部的公共接口
window.myModule = {
publicMethod: function () {
privateFunction(); // 访问私有函数
console.log("这是一个公共方法");
console.log("共享变量的值:" + sharedVar);
},
updateSharedVar: function (newValue) {
sharedVar = newValue; // 修改共享变量
}
};
})();
上述代码使用立即执行函数声明了私有化变量:privateVar,共有变量:shareVar,公共对象myModule和公共对象里的两个方法:publicMethod和updateSharedVar,是不是很巧妙呢。
-
实现缓存:基于闭包对外部作用域变量‘长期记忆’的特性,对于一些需要频繁重复申请的资源,我们可以考虑将其放入闭包内。
-
封装模块:类似于
jQuery等模块库的封装都选择了闭包的做法来避免申请全局变量,减少全局变量命名冲突的可能性,提高了代码的可维护性。
缺点
- 内存泄露:V8引擎其实并不能直接准确得判断出执行上下文里哪些资源是需要销毁的,只会将它确定我们再也不需要的资源销毁(这一步可以是程序员的主动声明),而这也就导致了大部分实际上不需要的资源被保留在了闭包内,导致内存泄露。
//dom的引用未被销毁
<script>
document.addEventListener('DOMContentLoaded',function(){
function undelete(){
var ele = document.createElement('div')
document.body.appendChild(ele)
//后续忘记删除ele
}
})
undelete()
</script>
上述代码是通过函数undelete()创建了一个div元素,但是后续却没有显式删除该元素的引用,导致后续函数的执行上下文长期保持着该元素的引用,且随着用户的操作,元素会生成越来越多个,只增加却不释放。
又例如console.log(hugeObject),这里需要打印一个巨大的对象,那么由于console.log()会对hugeObject的引用进行保护,导致对象所占空间持续的不到释放。
总结
闭包虽好,但是某些情况下也要慎用。
本期我们讲了:
- 作用域链:当前执行上下文和它外层所有执行上下文所构成的一种链式结构,闭包体现了作用域链间各执行上下文对象中资源的关系。
- 闭包:根据JS词法作用域的规则,内部函数总是能访问外部函数的变量。当通过调用一个外部函数返回的一个内部函数后,即使外部函数执行已经结束,但是内部函数中引用了外部函数中的某些变量,这些变量依旧需要被保存在内存中,我们把这些变量的集合叫做闭包。闭包的存在使得某些资源的生命周期长度超出了其所在的作用域,使其不被垃圾回收。
参考:
闭包 - JavaScript | MDN (mozilla.org)
深入理解JavaScript作用域和作用域链 - 前端工匠公众号 - SegmentFault 思否