前言: 之前在阅读第四版红宝书时对于闭包的作用机制感觉一知半解,书中的相关章节提到了上下文和变量环境对象,但没有涉及词法环境的概念,于是转而去看现代JS教程,结果刚好互补,现代JS中强调了词法环境但没有讲到变量环境对象…emmm,期末周忙里偷闲学习了李冰老师的专栏《浏览器工作原理与实践》,从引擎编译的角度来解释闭包,并且理清了变量环境和词法环境,顿觉醍醐灌顶,回过头来看发现这三部分就像拼图,拼接之后对JS中的作用域机制和闭包算是有了初步认识,于是作一篇总结。
纯看概念的学习是非常痛苦的,文章开始之前先给出一个最简单的闭包
debugger;
let a = 1;
function test() {
let b = 1;
debugger;
return function foo() {
let c = 2;
debugger;
console.log(b + c);
};
}
let inner = test();
debugger;
inner();
我在代码中打上了4个debugger
,接下来会详解每个debugger
的位置发生了什么。
执行上下文
执行上下文,或者说执行上下文中的内容是我们理解JS函数和闭包的关键。
执行上下文是在编译时产生的,在浏览器中进入一个网页就产生了一个全局上下文,它会一直存在直到关闭当前网页。
JS代码开始执行之前是需要编译的,从全局代码视角出发,编译之后会产生:
- 执行上下文:可以理解为运行环境,其中保存了变量与函数的声明以及
this
与所谓的作用域链
,注意此时的变量是没有赋值的,函数也仅仅保存了声明,函数内部的代码并没有被编译。 - 可执行代码:对于变量的赋值,对于函数的调用等等非声明语句。
这个过程对应了代码中第一个debugger
,此时全局代码已经编译完成开始执行,但是我马上按住了它
我们可以看到此时存在两个作用域,上下文堆栈中只有一个栈帧也就是全局上下文(全局执行上下文是匿名的),脚本作用域之所以存在是因为我们使用了let
关键字,大家可以尝试将let
换成var
脚本作用域就消失了,相信你也看出来了,这个脚本作用域就是最外层的块级作用域。你肯定也发现了这个块级作用域中没有test
函数,因为函数声明是不受块级作用域限制的,它被保存到了全局作用域中,我们可以在全局作用域里搜索到它。
上下文栈
继续调试,前往第二个debugger
的位置
此时test
函数被编译并执行了,可以看到堆栈中推入了test
栈帧,同时作用域监视窗口中新增了test
的本地作用域,这就是JS控制代码执行的机制,当解析到可执行代码中的函数调用时,就会取出函数声明对函数进行编译并执行其中的代码,此时产生的函数执行上下文进入了堆栈,当函数调用结束之后会被弹出,于是控制权就回到了上一个栈帧,在这个例子中就是全局上下文。
作用域链和闭包
继续下一步,来到第三个debugger
,此时闭包已经形成了,inner拿到了返回的函数,作用域监视窗口中更新了它的值
进一步分析前我们确认一点,堆栈中此时只有全局上下文,test
栈帧已经弹出,意味着它的执行上下文已经被销毁。
点开inner
检视它的树状结构,我们可以找到一个内部属性[[Scope]]
。
你可以直接把它理解为作用域链,当inner
函数被调用时它的执行上下文中的作用域链其实就是这个[[Scope]]
中的内容,我们可以看到这是一个列表结构,其中存储的是指针,第一个指针指向Closure(test)
对象,除了通过这个inner
函数没有任何办法能访问到这个对象里的内容。
闭包的形成机制
前进到最后一步
此时我们来到了foo
(inner)函数内部(为了更直观我给了返回的函数一个标识符,否则会推入一个匿名栈帧)
可以观察作用域窗口,此时的作用域就对应了[[Scope]]
中的内容,注意函数执行时会把函数自己的上下文中的内容推到作用域链的最前端。
test
的栈帧早已销毁,这个闭包是怎么存在的?
这个一直指代的上下文中的内容又是什么?
对于v8引擎的内存机制相信大家都有所了解,引用类型会被存储到堆内存里,栈内存中只存储一些轻量的数据。
而一个执行上下文中包含了变量环境和词法环境,它们记录了编译阶段声明的变量,随着代码执行它们保存的值也会更新。
- 变量环境:记录了
var
声明的变量和函数声明 - 词法环境:记录了使用
let
,const
声明的变量
之前一些文章中看见新的标准里将函数声明也保存到了词法环境中,但是我自己暂时没有找到相关描述,无法求证,希望有大佬解答。
正常情况下,在第二个debugger
处test
函数的执行上下文像是这样
但是当test
内部的函数引用了test
中定义的b
变量时,JS引擎会判断这是一个闭包,JS引擎在编译test
函数时就会判断这种引用是否存在,其内部的函数虽然此时没有被编译但是会被进行一次快速的词法扫描,如果这种引用存在就会以闭包来处理。具体方式是在堆中创建一个新的对象来保存这个被内部函数引用的变量,此时test
执行上下文中的词法环境也不再保存初始值了,而是直接指向了这个对象。
这是一张简陋的示意图,(假设地址是1001)
所以这个闭包对象事实上在test
的编译阶段就已经形成了。
这篇总结到这就结束了,希望能帮助你理解闭包和函数的执行机制,如果有错误请路过大佬指出。