11|通过JS引擎的堆栈了解闭包原理
下载APP
关闭
渠道合作
推荐作者
11|通过JS引擎的堆栈了解闭包原理
2022-10-13 石川 来自北京
《JavaScript进阶实战课》
课程介绍
讲述:石川
时长13:35大小12.41M
你好,我是石川。
在前面讲到编程模式的时候,我们就提到过闭包。
如果说一个函数“出生”的地方是作用域,从出生到被回收的“一生”是它的生命周期,那么闭包则可以突破这种空间和时间上的限制,那它是怎么做到这种突破的呢?
这节课,我们就从 JavaScript 编译原理以及其中的栈和堆的数据结构开始,来看看闭包的原理。
静态和动态作用域
我们先从作用域的空间概念来说。作用域可以分为静态作用域(static scope)和动态作用域(dynamic scope)。
静态作用域,取决于变量和函数在何处声明,这里你可以想象成它的“出生地”,并在它执行之前就已经确定了。所以静态作用域又被称为词法作用域(lexical scope),因为函数的“出生地”是在词法分析时“登记”的。
动态作用域则相反。动态作用域下,函数的作用域是在函数调用的时候才决定的。所以取决于在何处调用,这里你可以想象成它的“居住地”,这个是可以后天修改的。
我们所写的 JavaScript 代码,通常是通过前端浏览器编译后运行的,这个过程是先编译、后执行。所以 JavaScript 代码的作用域是在编译过程中通过分析它在何处声明来确定的,属于静态(词法)作用域。
下面我们来看看函数的作用域在代码编译的阶段是如何定义的,以及它在执行阶段的生命周期。
作用域:代码编译
我们先从作用域说起。下面以 V8 为例,我们先来看一段代码从编译到执行的过程总览。当我们打开一个页面,执行一段代码时,会经历从环境初始化到代码分析,从编译到执行的过程。具体你可以参考下方的图示:
栈是线性连续的数据结构的存储空间,里面主要存有 JavaScript 原始数据类型以及对象等复杂数据类型的地址。除此之外还有函数的执行状态和 this 值。堆是树形非连续的数据结构的存储空间,里面存储了对象、数组、函数等复杂数据类型,还有系统内置的 window 和 document 对象。
说完存储,下面通过一段代码,我们再来看下从词法到语法分析的过程。
当我们输入上面这段代码后,代码像字符串一样被拆分成段,这个是叫做分词或词法分析(tokenizing/lexing)的过程。在这个过程中,比如 var base = 0 会被分为 var 变量、base、赋值表达、数字常量 0。 词法作用域指的就是拆分成词法标记时这段代码所在的作用域。如下图红色虚线框部分所示:
在词法拆分之后,在下一步的解析(parsing)动作中,上面一段段的代码会被转换成一个抽象语法树(AST, Abstract Syntax Tree),这就到了语法分析。在这个语法树的顶端,我们可以看到一个父节点,它就是 var 这个变量的声明。在这个父节点的下面,有两个子节点:一个子节点是标识符 count;另外一个子节点就是等号的赋值表达。在等号赋值表达的节点下面,还有一个子节点就是数字表面量 0。如下图红色虚线框部分所示:
根据流程图中的红色虚线框部分所示,在词法分析后,JavaScript 引擎会在做语法分析的同时,更新全局作用域和创建局部作用域。在这个代码例子中,全局作用域里增加了 base 和 scope 变量,displayVal 里有 base、scope 和 increment 变量,而 addOne 里有 base 变量。
在作用域创建后,上面的代码就会变为中间代码,V8 会混合使用编译器和解释器技术的双轮驱动设计实时编译(JIT Just in Time),这个双轮的一个轮子是直接执行,另一个发现热点代码会优化成机器码再执行,这样做的目的是为了性能的权衡和提升。这个我们在这一讲不需要深入学习,我们只需要知道在这之后就是编译的结束,我们的代码接下来要到执行过程了。
是不是有点晕,没关系,我们抽象总结一下。这里我们从空间角度了解到,函数在创建伊始是存放在堆空间中的,并且通过栈空间中的地址来查找。我们通过编译的过程,了解了作用域在代码未执行的解析阶段就完成了。
生命周期:代码执行
如果说作用域是从“空间”维度思考问题,那么生命周期就是从“时间”维度来看问题。接下来,咱们就来看看在代码执行的阶段,一个函数从调用到结束的过程,也就是它的生命周期。
函数的生命周期
上面我们提到过堆和栈的概念。在 JavaScript 执行的时候,全局执行上下文会在一个类似栈的数据结构里面,根据函数调用链依次执行,所以又称为调用栈。下面我们看看根据上面的代码,按步骤会生成的栈。
一开始,base、scope、addOne、displayVal 都会被记录在变量环境。可执行的代码包含了 base 和 scope 的赋值,还有 displayVal() 函数的调用。当赋值结束就会执行 displayVal 函数。
在执行 displayVal 函数的时候,displayVal 函数相关的全局上下文就会被压入栈内,因为 base 和 scope 都有函数内声明,所以它们在函数内也会有变量提升到 increment 的上面。作为可执行代码,完成 base 和 scope 的赋值。下面执行 addOne 函数的调用。
再后面,需要继续将 addOne 压入栈内,base 变量再次赋值,然后执行返回 base+1 的结果。在此以后,函数 addOne 的上下文会从栈里弹出,作为值返回到 displayVal 函数。
在最后的运行步骤里,displayVal 的 increment 会被赋值为 2,之后函数会返回 2+2 的值,为 4。之后 displayVal 的函数执行上下文也会被弹出,栈中将只剩下全局的执行上下文。addOne 和 displayVal 这两个函数的生命周期就随着执行的结束而结束了,并且会在之后的垃圾回收过程中被回收。
执行时变量查找
前面我们说过,JavaScript 的作用域是在编译过程中的词法分析时决定的,下面我们就来看看从编译到执行的过程中,引擎是如何与作用域互动的。
还是以 var base = 0 为例,在下图的左边,我们可以看到当编译器遇到 var base 的时候,会问作用域这个 base 是否已经存在,如果是的话,会忽略这个声明;如果 base 不存在,则会让作用域创建一个新变量 base,之后会让引擎处理 base=2 的赋值。
这个时候引擎又会回过头来问作用域,在当前执行的作用域当中有没有 base 这个变量,如果有的话,执行赋值,否则会继续寻找,一直到找到为止。
这里还有一个问题值得思考:上面例子中执行的最后一步,我们提到如果引擎在当前执行作用域找不到相关变量,会一直找或返回报错,那么这个“一直找”的顺序是什么呢?答案是它会从内往外地找。我们可以通过下面一个经典的嵌套的作用域例子来看。
在这个例子里,我们可以看到第一层是全局作用域,里面只有一个标识符为 outer 的函数声明。中间第二层是一个函数作用域,里面有 a,也就是函数 outer 的形参;b 是一个变量声明; inner 是一个嵌套函数。然后最里面第三层的函数作用域里有 a、b 和 c。当我们执行 outer(1) 的时候,引擎会从内到外,先从最里边的第三层找起,然后在第二层 inner 的作用域里找,如果找不到,就会在第一层 outer 的作用域里找,在这里可以找到 a。
IIFE:利用作用域封装
通过例子延伸,我们可知,作用域的层级可以分为块儿级、函数和全局这样的嵌套关系。块级作用域和函数级作用域都可以帮助我们对代码进行封装,控制代码的可见性。虽然常见的声明式函数可以帮助我们达到这个目的,但是它有两个问题:
第一个是如果我们以声明式函数为目的来做封装的话,它会间接地创建 foo 这个函数,会对全局作用域造成污染;
第二个问题是我们需要通过一个 foo() 来对它进行调用,解决这个问题的办法就是使用一个立刻调用的函数表达 (IIFE,immediately invoked function expression)。
在下面的例子中,我们可以看到,在使用这种表达方式的时候,我们可以使用第一组括号将函数封装起来,并且通过最后一组括号立刻调用这个函数。
闭包:突破作用域限制
前面我们系统地了解了函数的作用域和生命周期,提到了变量和函数在栈中的调用和执行后的销毁、垃圾回收,我将它归之“守正”。那么接下来,我们就看看如何突破这种限制,也可以谓之“出奇”。
在下面的代码中,我们运用了函数的 3 个特点:
在函数内部生成了一个局部的变量 i;
嵌入了 increment 和 getValue 这两个函数方法;
把函数作为返回值用 return 来返回。
通过上述操作,我们可以看到,当我们执行下面的一段代码时,会发现即使在 createCounter 执行完成后,按道理相关变量 i 应该被销毁了,并完成相关的垃圾回收(garbage collection)。我们仍然可以访问它内部的变量 i,并可以继续调用 increment 和 getValue 方法。当我们尝试增加 i 的值的时候,会看到返回不断增加的结果。
它的原理是什么呢?这要回到我们较早前说到的解析或语法分析步骤。
在这个过程中,当 JavaScript 引擎解析函数的时候,用的是延迟解析,而不是及时解析。这样做的目的是减少解析时间和减少内存使用。所以在语法解析的时候,只会解析到 createCounter 函数这一层,而不会继续解析嵌入的 increment 和 getValue 函数。但是引擎同时又有一个预解析的功能,可以看到 increment 和 getValue 会引用一个外部的变量 i,所以会把这个变量从栈移到堆中,就用了更长的记忆来记录 i 的值。通过这种方式,闭包就做到了守正出奇,以此突破了作用域和生命周期的限制。
但是有一点要注意的是,考虑到性能、内存和执行速度,当使用闭包的时候,我们就要注意尽量使用本地而不要用全局变量。
延伸:提升问题和解决方法
变量和函数提升
下面我们再延伸看一下变量和函数声明的提升。我们先来看一个例子,我们把上面的 var base = 2 这段代码拆开,可以看到第 1 行 base=2 是一个赋值的表达,第 2 行 var base 是一个变量声明。常识会告诉我们,这可能造成 base 返回的是 undefined,因为 base 在还没声明的时候就被赋值了。可当你执行 console.log(base) 时看到返回的结果是 2。
为什么会这样呢?因为在代码的编译执行过程中,var base 这个声明会先被提升,base=2 这个赋值的动作才被执行。
在下面的例子,我们再来看看如果我们先通过 console.log 试图获取 base 的值,然后再写一句 var base = 3 这样的变量声明和赋值,结果是不是按照变量提升原则应该返回 3 呢?答案是 undefined!
在这个编译执行过程中,var base=3 这个声明和赋值会被拆成两个部分,一个是声明 var base,一个是赋值 base=3。变量提升,提升的只是变量 base 的声明;变量 base 的赋值是不会被提升的,而仍然是提升后执行的。下面显示的是它的词法拆分和提升后的顺序。
和变量一样,函数声明也会被提升到顶部,而且如果函数和变量的提升同时发生,函数会被提到变量的前面。另外一点值得注意的是,如我们在前一讲所说,函数提升的只是声明式函数,而表达式函数则和变量赋值一样,不会被提升。
ES6 块级作用域
不过关于变量和函数的提升特点,其实还存在着一定的问题,就是会造成变量的覆盖和污染。从 ES6 开始,除了全局作用域和函数作用域外,还有一个就是块级作用域被引进了 JavaScript,所以在变量声明中,除了 var 以外,加入了 let 和 const,这两个变量和常量就是块级作用域的变量,它们不会被提升。
我们可以尝试下,当我们输入 console.log(base),然后再用 let 声明 base=0 会发现报错。
同样地,在下面的例子里,我们也可以看到在 if esle 大括号里的 count 不会污染到全局作用域。
总结
这节课我们用了较多的篇幅讲了“守正”,也就是一般情况下一个函数的作用域和生命周期;较少的篇幅讲了“出奇”,也就是闭包突破限制的原理。因为只有当我们对函数的实际编译和执行过程有所了解,站在一个函数的角度,和它一起走过一遍生命旅程,从它是怎么创建的,一步步到它的词法和语法分析,编译优化到执行,调用到销毁到回收,我们才能更清楚地了解如何利用规则,或者更近一步突破规则的限制。
同时我们也看到了 JavaScript 本身的变量和函数提升具有一定的反直觉性,虽然我们不能说这是一个 bug,但在一些开发者看来是一种缺陷。所以在后面的 ES6 开始,引进了块级作用域。希望你通过对原理的了解,能够更加清楚它们的使用方法。
思考题
在讲到函数式编程时,我们说到了闭包可以作为和对象相比的数据存储结构,在讲到面向对象编程模式时,我们也说到了它可以用来创建对象的私有属性。那么除了这些例子外,你还能举例说明它的其它作用吗?
欢迎在留言区分享你的积累,我们一起交流讨论。另外,也欢迎你把今天的内容分享给更多的朋友。我们下期再见!
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 2
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
10|JS有哪8种数据类型,你需要注意什么?
下一篇
12|JS语义分析该用迭代还是递归?
精选留言(4)
- 坤海2022-10-15 来自北京老师,全局变量会存到闭包里吗?为什么在闭包里用全局变量会影响性能呢?
作者回复: 全局变量是可以在闭包里引用的。闭包里引用全局变量会影响性能是因为全局变量在函数执行销毁后,不会随着函数销毁,还会存在。
1 - 穷精致2023-02-03 来自北京所以应该怎么理解闭包呢?
- kingsley2022-11-24 来自浙江这一节理解难度++
- 卡卡2022-10-13 来自江苏在函数式编程中,可以用闭包实现partial application