03_闭包的定义和内存模型

JavaScript 中函数是一等公民

在 JavaScript 中,函数是非常重要的,并且是一等公民,既然是一等公民,所必须具备的条件:

  1. 函数可以作为另一个函数的参数
  2. 函数可以作为另一个函数的返回值

函数作为另一个函数的参数

function calc(num1, num2, fn) {
  fn(num1, num2);
}

function add(num1, num2) {
  console.log(num1 + num2);
}

function sub(num1, num2) {
  console.log(num1 - num2);
}

function mul(num1, num2) {
  console.log(num1 * num2);
}

calc(10, 20, add);
calc(10, 20, sub);
calc(10, 20, mul);

函数作为另一个函数的返回值

function foo(num) {
  function bar(count) {
    console.log(num + count);
  }
  return bar;
}

const add5Fn = foo(5);
add5Fn(10);

高阶函数

一个函数如果接收另外一个函数作为参数,或者该函数会返回另外一个函数作为返回值的函数,那么这个函数就被称为高阶函数。

以上两个案例就是属于高阶函数。

区分函数与方法:

  • 函数 function:独立的 function, 称之为函数
  • 方法 method: 当我们的一个函数属于某一个对象时,我们称这个函数就是这个对象的方法

补充一些数组中的高阶函数:

  • filte 方法,返回一个新数组,不改变原数组
  • map 方法,作为映射存在,返回一个新数组
  • forEach 方法,作为迭代存在,没有返回值
  • find/findIndex 方法,返回第一个找到的数组元素或下标
  • reduce 方法,累加方法

JavaScript 中闭包的定义

闭包的概念出现于 60 年代,最早实现闭包的程序是 Scheme,那么我们就可以理解为什么 JavaScript 有闭包:

  • 因为 JavaScript 中有大量的设计是来源于 Scheme 的;

这里先来看一下闭包的定义,分为两个方面:在计算机科学和在 JavaScript 中,因为闭包这个概念不止是在 JavaScript 中存在。

1. 计算机科学中(维基百科)

  • 闭包(Closure), 又称词法闭包(Lexical Closure) 或函数闭包(function Closures);
  • 是在支持头等函数的编程语言中,实现词法绑定的一种技术;
  • 闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境(相当于一个符号查找表);
  • 闭包跟函数最大的区别就在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行

2. MDN 对 JavaScript 闭包的解释

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

3. coderwhy 的理解和总结

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

理解闭包

普通嵌套

先看以下代码:

function foo() {
  function bar() {
    console.log("bar");
  }
  return bar;
}

var fn = foo();
fn();

执行过程:

  1. 首先执行代码开始,有一个执行上下文栈(ECS),还有一个 GO,之后又会创造出 GEC ,在 GEC 中有一个 VO 指向 GO 对象;
  2. 编译阶段 GO 对象中已经有了 Date、String 等对象,然后是 foo 和 fn,fn 的值为 undefined,foo 由于是函数,会开辟一个内存空间,创造出一个函数对象,foo 的值就为内存地址,假设为 0xa00;
  3. 执行阶段,首先要执行函数,会创造 FEC,这里创造的是 foo 的 FEC,FEC 里面有 VO 对象指向的是 AO,要注意的是,AO 对象什么时候创建的呢?应该是在执行之前,因为假如定义了很多个函数但是都没有被使用到,那么肯定不会都给创建 AO 对象的,当要执行到这个函数的时候才会去创建 AO 对象,有了 AO 对象就回去编译代码了;
  4. 编译代码,填补 AO 对象里的内容,这时候 AO 对象就会存在一个 bar 值为 bar 的内存地址,假设为 0xb00,要注意,在编译 GO 阶段的时候并不会去解析这个的 bar 函数,但是会进行预解析;
  5. 执行代码,foo 对象里面返回了 bar,所以这时候 GO 里面原本为 undefined 的 fn 此时变为了 0xb00,至此 foo 的 FEC 已经结束了,接着会把它弹出栈,并释放对应 foo 的 AO 对象和相关的变量;
  6. 继续执行代码,该执行 fn()了,fn 的值为 0xb00,于是继续创建新的属于 bar 的 FEC,重新创建新的 bar 的 AO 对象,但是里面为空,编译阶段结束
  7. 执行阶段,执行 bar 函数里面的 console 语句,输出 bar;
  8. bar 的 FEC 结束,弹出栈,销毁相应的 AO 对象,执行结束;

执行图示:

执行过程1

更进一步

有如下代码:

function foo() {
  var name = "foo";
  function bar() {
    console.log("bar", name);
  }
  return bar;
}

var fn = foo();
fn();

执行过程:

  1. 首先还是有一个全局的 ECS 和 GEC,GEC 中有一个 VO 指向 GO 对象;
  2. GO 对象中包含 foo 值为 0xa00 以及 fn 值为 undefined,于是开始执行代码;
  3. 访问到是函数的执行,创造出 FEC,其中有一个 VO 指向 AO 对象;
  4. AO 对象中存在 name:undefined 和 bar:Oxb00;
  5. 开始执行函数体,将 name 赋值为 foo,fn 的值变为 0xb00;
  6. 至此属于 foo 的 FEC 已经执行完了,随即 FEC 被销毁;
  7. 继续执行下一行,执行函数 fn,找到 0xb00,在自己的 AO 中寻找 name 变量,没找到,去父级作用域中找,也就是 foo 的 AO,输出 foo;
  8. 结束执行,销毁 bar 的 FEC;

这里要注意一下,这里的函数执行上下文在执行完之后是会被销毁的。

这个就是纯正的闭包,具体在哪里呢? —— bar 函数本身以及所访问到的自由变量 name 构成了闭包。

执行过程2

所以说闭包是由两部分组成的: 函数和可以访问到的自由变量。

闭包定义的争论点

  1. 可以访问外层作用域
    如果是可以访问的话,那么 JavaScript 中的所有函数都是闭包,因为它们都可以访问都上层外层作用域的变量
  2. 有访问到外层作用域 如果是有访问到的话,那么只有内层函数有引用到外层作用域的自由变量的时候才有闭包

所以这就是 coderwhy 广义和狭义的针对闭包的说明,还是很清晰的。

闭包中变量销毁的问题

正常情况下的内存表现

有如下代码

function foo() {
  var name = "foo";
  var age = 18;
}

function test() {
  console.log("test");
}

foo();
test();

函数具体执行过程就不细说了,有了前面的铺垫应该很好理解,这里直接看图片吧!

正常情况下的内存表现(还未销毁之前)

这里表示在执行 foo()函数时的内存表现,可以看到在函数执行上下文中有一个指向是指向 AO 对象的,当我们的函数执行完成之后,我们的函数执行上下文被销毁,则这条引用就不存在了,于是我们的 AO 对象就会被垃圾回收机制回收,表现如下图:

正常情况下的内存表现(被销毁)

存在闭包的内存表现

有如下代码:

function foo() {
  var name = "foo";
  var age = 18;

  function bar() {
    console.log(name);
    console.log(age);
  }

  return bar;
}

var fn = foo();
fn();

执行过程如下图,仅执行到 var fn = foo()

闭包的内存表现(销毁之前)

可以看到 FEC 中对 foo 的 AO 是有引用的,并且在 GO 中,因为在执行 foo 的时候返回了 bar,所以 GO 中的 fn 的值就会是 bar 的内存地址,所以这里是有一层引用关系的。

foo 执行完成销毁之后的内存表现:

闭包的内存表现(销毁之后)

那么此时和正常情况下的内存表现一对比就很明显了,存在闭包的 foo 的 AO 对象,由于在 GO 中存在对 bar 函数对象的引用,而 bar 函数对象又对 foo 的 AO 对象存在引用关系(作用域),所以根据标记清除法的规则,从根节点开始查找,是能够找到 foo 的 AO 对象的,所以它并不会被销毁。

内存泄漏

当内存中存在用不到的变量的时候,即存在内存泄漏。

还是上面那里例子,如果 fn 只执行一次的话,那么之后 foo 的 AO 对象和 bar 的函数对象是一直没有被用到的,所以存在内存泄漏。

如何解决呢?

fn = null 将 fn 指向 null 之后,GO 就不会再指向 bar 的函数对象,虽然此时 bar 的函数对象和 foo 的 AO 对象存在互相引用(循环引用),但是没关系,采用的是标记清除法,可以很好解决这个引用计数法不能解决的问题。

Last Updated:
Contributors: hqchqc