闭包


闭包的定义

维基百科的解释

  • 闭包(英语:Closure),又称词法闭包(Lexical Closure) 或函数闭包(function closures)
    是在支持 头等函数 的编程语言中,实现词法绑定的一种技术。
  • 闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境(相当于一个符号查找表)。
  • 闭包跟函数最大区别在于,当捕捉闭包的时候,它的 自由变量 会在捕捉时被确定,这样即使脱离了
    捕捉时的上下文,它也能照常运行。
  • 闭包的概念出现于 60 年代,最早实现闭包的程序是 Scheme,那么我们就可以理解为什么 JS 中有闭包。
    因为 JS 中有大量的设计是来源于 Scheme 的;

MDN 的解释

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

总结

  • 一个普通的函数,如果它可以访问外层作用域的自由变量,那么这个函数就是一个闭包.
  • 从广义的角度来说: JS 中的函数都是闭包.
  • 从狭义的角度来说 JS 中一个函数,如果访问了外层作用域的变量,那么它就是一个闭包.

V8 如何实现闭包

  • V8 在执行 JS 代码时,需要经过编译执行两个阶段,编译过程指 V8 将 JS 代码转换为字节码或者二进制辑器代码的阶段,而执行阶段则是指解释器执行字节码,或者是 CPU 直接执行二进制机器码的阶段。

    总的流程:编译(生成 AST、Scope:变量提升)-> bytecode -> 执行

在编译 JS 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,主要原因有以下两点:

  1. 如果一次性解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。
  2. 解析完成的字节码和编译之后的机器码都会放在内存中,如果一次性解析和编译所有的 JavaScript 代码,那么这些中间会一直占用内存

    基于以上的原因,所有主流的 JavaScript 虚拟机都是实现了惰性解析

惰性解析:是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成AST字节码,而仅仅生成顶层代码的 AST 和字节码。

JavaScript 闭包的三个特性

  1. JS 语言允许在函数内部定义新的函数。
function foo() {
  function bar() {}
  bar();
}
  1. 可以在内部函数中访问父函数中定义的变量。
var msg = "hello";

// bar函数的父函数,词法作用域
function foo() {
  var msg = "world";
  // foo的内部函数
  function bar() {
    return msg + "~~~";
  }
  bar();
}
  1. 因为函数是一等公民,所以函数可以作为返回值。
function foo() {
  return function bar(x, y) {
    var z = x + y;
    return z;
  };
  bar();
}
const f = foo();

闭包给惰性解析带来的问题

下面我们就来使用三个特性组装的一段经典的闭包代码:

function foo() {
  var msg = "world";
  return function bar(x, y) {
    var z = x + y + msg;
    return z;
  };
}
var f = foo();

我们可以分析下上面这段代码的执行过程:

  • 当调用 foo 函数时,foo 函数会将它的内部函数 bar 返回给全局变量 f;

  • 然后,foo 函数执行结束,执行上下文被 V8 销毁;

  • 虽然 foo 函数的执行上下文被销毁了,但是依然存活的 bar 函数引用了 foo 函数作用域中的变量 msg;

    按照通用的做法,msg 已经被 V8 销毁了,但是由于存活的函数 bar 依然引用了 foo 函数中的变量 msg,这样会带来两个问题:

  • 当 foo 执行结束时,变量 d 该不该被销毁?如果不应该被销毁,那么应该采用什么策略?

  • 如果采用了惰性分析,那么当执行到 foo 函数时,V8 只会解析 foo 函数,并不会解析内部的 bar 函数,那么这时候 V8 就不知道 bar 函数中是否引用了 foo 函数的变量 msg;

正常做法:应该时 foo 函数的执行上下文虽然被销毁了,但是 bar 函数引用的 foo 函数中的变量却不能被销毁,那么 V8 就需要为这种情况特殊处理,需要保证即使 foo 函数执行结束,但是 foo 函数中的 msg 变量依然保持在内存中,不能随着 foo 函数的执行上下文被销毁掉。

那么怎么处理呢?

在执行 foo 函数的阶段,虽然采取了惰性解析,不会解析和执行 foo 函数中的 bar 函数,但是 V8还是需要判断 bar函数是否引用了 foo函数 中的变量,负责处理这个任务的模块是预解析器

预解析器如何解决闭包所带来的问题?

V8 引入与解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。

  1. 判断当前函数是不是存在一些语法上的错误,如下面这段代码:
function foo(x, y) {
  {msg/}  // 语法错误
}
var x = 1;
var y = 2;
foo(1, 8);

在预解析过程中,预解析器发现了语法错误,那么就会向 V8 抛出语法错误;

  1. 预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

文章作者: PaoMo
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 PaoMo !
  目录