
什么是闭包?
理解作用域链是理解闭包的基础,而闭包在 JavaScript 中几乎无处不在,同时作用域和作用域链还是所有编程语言的基础。
执行上下文
执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶,当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈,执行相关的垃圾回收逻辑。
作用域(scope)
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。JavaScript 遵循词法作用域规则,因此 JS 中的作用域是由代码中函数声明的位置来决定的,词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
在 ES6 之前,ES 的作用域只有两种:
- 全局作用域:作用域对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域:函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问,函数执行结束之后,函数内部定义的变量会被销毁。
ES6 新增了 let 和 const 两种新的变量声明方式,补齐了块级作用域的短板。
作用域链
在每个 JavaScript 执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。
闭包的概念
在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。
Google V8 如何实现闭包?
V8 执行 JavaScript 代码,需要经过编译和执行两个阶段,其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是 CPU 直接执行二进制机器代码的阶段。
惰性解析
在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:
-
如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。如果一个页面的 JavaScript 代码都有 10m,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。
-
解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。
基于以上的原因,主流的 JavaScript 虚拟机都实现了惰性解析,所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。
闭包给惰性解析带来的问题
function foo() {
var d = 20
return function inner(a, b) {
const c = a + b + d
return c
}
}
const f = foo()
分析上边这段代码的执行过程:
- 当调用 foo 函数时,foo 函数会将它的内部函数 inner 返回给全局变量 f。
- foo 函数执行结束,执行上下文被 V8 销毁。
- 虽然 foo 函数的执行上下文被销毁了,但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d。
按照通用的做法,d 已经被 v8 销毁了,但是由于存活的函数 inner 依然引用了 foo 函数中的变量 d,这样就会带来一个重要问题:
当 foo 执行结束时,变量 d 该不该被销毁?如果销毁,闭包该如何实现?如果不销毁,它将与惰性解析冲突,此时应该采用什么策略?
预解析器的解决方案
V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个:
- 判断当前函数是不是存在一些语法上的错误。
- 除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。
在 Chrome 浏览器中,我们甚至可以查看这一信息: