前端基础重建 --- 3.JS中的函数、闭包和垃圾回收

411 阅读9分钟

写在前面

往期内容

函数的声明与执行

声明一个函数

当我们声明了一个函数之后,发生了什么操作呢?

  1. 首先开辟一个堆内存,用来存放要声明的函数
  2. 声明函数的作用域[[scope]]:EC(xxx)
    • 函数是在哪个作用域中创建的,那么函数的作用域就是谁
    • 函数真正的作用域是创建函数时所在的作用域
  3. 将函数中的形参进行声明
  4. 将函数体内的代码当成字符串,存储在函数的堆内存中
    • 当创建函数时,函数执行体中的内容会被存储为一堆字符串
    • 所以函数只要不执行,函数体中的内容就是一堆字符串,没有实际意义
  5. 函数形成之后,把函数的堆内存地址放入栈中供之后调用

:其实 function fn(){}var fn = function(){} ,这两个发生的操作是一样的,区别只是 var 存在变量提升

执行一个函数

函数在执行时,又做了哪些操作呢?

  1. 形成一个全新的私有上下文EC(FN),供函数体中的代码执行
  2. 上下文形成后,在该私有上下文中生成一个存放私有变量的变量对象 AO(FN)
  3. 在函数中代码执行之前要进行:
    1. 初始化函数作用域链scope-chain,链的左侧<EC(FN)(自己的上下文),[[scope]](函数的作用域)>
    2. 初始化THIS(箭头函数中没有THIS
    3. 初始化ARGUMENTS实参集合(箭头函数中没有)
    4. 形参赋值:形参变量是函数的私有变量,需要存储在AO。函数中定义的值也是私有变量
    5. 变量提升(在私有上下文中声明的变量都是私有变量)
  4. 函数进执行栈执行
  5. 代码执行
    1. 将之前在函数堆内存中存储的字符串,在当前私有上下文中执行
    2. 作用域链查找机制:在代码执行中,遇到一个变量,我们先看一下是否为自己的私有变量,如果是自己的私有变量,接下来所有操作都是私有的(和外界没有直接的联系);如果不是自己私有的,则按照scope-chain,向上级上下文中查找(如果此变量是上级的私有变量,接下来的操作都是操作上级上下文中的变量)。。。 一直找到 EC(G)为止
  6. 根据实际情况确定当前上下文是否出栈释放
    1. 一般情况下,为了保证栈内存的大小(内存优化),如果当前函数执行产生的上下文,在进栈且代码执行完成之后,会把此上下文移出栈(上下文释放掉了,之前在上下文中存储的私有变量等信息也就跟着释放了)
    2. 全局上下文是在打开页面时生成的,也需要在关闭页面的时候释放掉(只有页面关闭才会被释放掉)
    3. 特殊情况下,如果当前上下文中的某些内容,被当前上下文以外的东西占用,那么当前上下文就不会被释放的(上下文中存储的变量等信息也被保留下来了)。此时就形成了大家通俗理解上的闭包
    4. 还有一种情况fn(a)(b),这种情况下属于临时占用,上下文临时不释放
  7. 如果函数在执行完一次后二次执行,会形成一个全新的私有上下文,把之前做过的事情原封不动的再执行一次

闭包

很多同学认为只要函数中return一个函数,就形成了一个闭包,这样理解是不准确的。

当然,以下描述我也是赞同的,但是我总感觉没有把原因给解释出来

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

某个函数在定义时的词法作用域之外的地方被调用,闭包可以使该函数继续访问定义时的词法作用域

我个人更加赞同的理解是:

首先明确,闭包是函数运行时产生的一种机制

闭包的作用:保护保存

当函数执行会时,会形成一个全新的私有上下文,这样可以保护该上下文中的私有变量和外界互不干扰(保护机制)。如果当前上下文中的某些内容被当前上下文以外的内容占用,则当前上下文不能被释放,其中的私有变量和值也不会被释放(保存机制)。这种保护保存的机制称为闭包。

以上描述可能同学们觉得不够具体,那我们就拿一段经典代码来分析一下

var a= 100;
var b= 200;
function foo(){
  var a = 0;
  var b = 1;
  function closure(){
    a++;
    b++;
    return a+b
  }
  return closure
}
var x = foo();

此段代码的运行过程为:(忽略执行环境栈ECStackGO、变量提升等内容)

  1. 全局代码运行,形成全局上下文EC(G) , 形成全局变量对象VO(G)VO(G)中生命并定义变量a,b
  2. 函数声明并定义(可以参照上文,<声明一个函数>),在全局变量对象VO(G)中生成变量foo跟函数堆内存地址的对应关系
  3. 函数执行,形成一个私有上下文EC(FOO)(闭包作用域),并进行初始化操作(可以参照上文,<执行一个函数>)
  4. 在此作用域下,又声明并定义了一个函数closure,(会生成一个函数closure的堆内存, 这个函数中的[[scope]]: EC(FOO)),并在私有上下文EC(FOO)的私有变量对象中生成变量closure和函数堆内存地址的对应关系。最后返回closure函数(相当于函数堆内存地址的返回)
  5. var x = foo(),相当于在全局变量对象中声明并定义了一个变量 x,x 和 closure 函数的堆内存地址相关联
  6. 至此,闭包保护保存两种机制都得到了实现:
    • 保护: 在foo函数执行时,形成了一个新的私有上下文,使得其中定义的变量a,b与全局上下文EC(G)中的变量a,b互相之间不干扰
    • 保存:由于全局上下文中的变量x和函数执行上下文EC(FOO)中定义的函数closure有关联关系,所以函数执行上下文EC(FOO)不能被内存回收机制所释放。所以其中的变量和值就被保存了下来
  7. 我们来验证一下,如下图

闭包的应用

  • 实战用途,简单举例:for 循环函数中 i 值问题
  • 高阶编程:柯里化/惰性函数/compose 函数
  • 源码分析:JQ/LODASH/REACT(REDUX/高阶组件/Hooks)
  • 自己封装插件组件的时候
  • 。。。

内存优化和浏览器的垃圾回收机制

首先我们来了解一下 JS 中的内存优化策略。

JS 中的内存优化

  1. 一般情况下,函数执行完,所形成的的上下文会被出栈释放掉
  2. 特殊情况下:当前上下文中某些内容被上下文以外的事务占用了,此时不能出栈释放
  3. 全局上下文:加载页面时创建的,页面关闭的时候才会被释放掉

浏览器垃圾回收机制

那么浏览器是如何做垃圾回收,并实现内存优化的呢?

强推高程 3 上关于垃圾回收机制的内容,对垃圾回收机制讲解的非常棒(P.78)。我们基于这部分内容进行总结

引用计数:

不太常见,只在低版本 IE 浏览器和一些远古浏览器中使用。

含义:
  1. 引用计数的含义是跟踪记录每个值被引用的次数。
  2. 当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1
  3. 如果同一个值又被赋给另一个变量,则该值的引用次数+1
  4. 相反,如果包含对这个值引用的变量又取得了另一个值,则原先的值的引用次数-1
  5. 当某个值的引用次数变成0时,就会被垃圾收集器下次运行时给销毁,释放引用次数为0的值的内存
严重问题:循环引用
function problem(){
  var objA = new Object();
  var objB = new Object();
  objA.a = objB;
  objB.b = objA;
}

以上例子中,objAobjB通过各自的属性相互引用,他们的引用次数都是 2。由于相互引用,它们的引用次数永远不会为 0,如果这个函数多次运行或者上例情况经常发生,就会造成内存泄漏。

标记清除:

JS 中最常用的垃圾收集方法,目前主流浏览器都采用此种方法。

  • 当变量进入环境时,通过某种方法(比方说翻转某个特殊的记录位)来标记一个变量进入环境
  • 当变量离开环境时,则将其标记为离开环境
  • 垃圾收集器在运行的时候,会给所有在内存中的变量都加上标记。然后再去除环境中的变量和所有被环境中变量所引用的变量的标记
  • 其他仍有标记的变量就会被认为是准备要删除掉的变量,因为它们已经无法被访问到了
  • 最后,垃圾收集器完成内存清除工作

总结一下

  1. 引用计数: 在某些情况下会导致计数混乱,这样会造成内存不能被释放(内存泄漏)

  2. 检测引用(标记清除):浏览器在空闲时候会依次检测所有的堆内存,把没有被任何事务占用的堆内存释放掉,借此优化内存

  3. 手动释放内存,其实就是解除占用(解除指针指向),一般是手动赋值为 null(空指针对象)

  4. 。。。

写在最后

欢迎访问我的博客fxflying.com