闭包-作用域链-垃圾回收,三者到底怎么理解???

435 阅读10分钟

垃圾回收?

JS中内存的分配和回收都是自动完成的,内存在不使用的时候会被垃圾回收器自动回收。

正因为垃圾回收器的存在,许多人认为JS不用太关心内存管理的问题,但如果不了解JS的内存管理机制,我们同样非常容易成内存泄漏的情况。

什么是内存泄露: 不是指某一块内存真的泄露掉了,而是指某一块内存无法被回收

内存的生命周期:

JS环境中分配的内存, 一般有如下生命周期:

  1. 内存分配
  2. 内存使用
  3. 内存回收:使用完毕,由垃圾回收自动回收不再使用的内存。全局变量一般不会回收, 一般局部变量的的值不用了, 会被自动回收掉
// 为变量分配内存
let i = 11
let s = "ifcode"

// 为对象分配内存
let person = {
  age: 22,
  name: 'ifcode'
}

// 为函数分配内存
function sum(a, b) {
  return a + b;
}

两种垃圾回收算法:

所谓垃圾回收, 核心思想就是如何判断内存是否已经不再会被使用了, 如果是, 就视为垃圾, 释放掉

下面介绍两种常见的浏览器垃圾回收算法: 引用计数 和 标记清除法

引用计数:

IE采用的引用计数算法, 定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。

如果没有任何变量指向它了,说明该对象已经不再需要了。

// 创建一个对象person, person指向一块内存空间, 该内存空间的引用数 +1
let person = {
  age: 22,
  name: 'ifcode'
}

let p = person   // 两个变量指向一块内存空间, 该内存空间的引用数为 2
person = 1       // 原来的person对象被赋值为1,对象内存空间的引用数-1,
// 但因为p指向原person对象,还剩一个对于对象空间的引用, 所以对象它不会被回收

p = null         // 原person对象已经没有引用,会被回收

由上面可以看出,引用计数算法是个简单有效的算法。

但它却存在一个致命的问题:循环引用。

如果两个对象相互引用,尽管函数执行完毕,它们已不再使用,垃圾回收器不会进行回收,导致内存泄露

function cycle() {
  let o1 = {}
  let o2 = {}
  o1.a = o2
  o2.a = o1 
  return "Cycle reference!"
}

cycle()

函数执行完毕之后,变量 o1 和 o2 分配的内存会被回收,但是由于堆中原先开辟的空间还有人在“用”,所以不会被回收。虽然在“用”,你会发现压根就没办法用到它们,所以就会导致那块内存没有被回收,就会导致内存泄漏

标记清除算法:

现代浏览器通用的大多是基于标记清除算法的某些改进算法,总体思想都是一致的:

  • 标记清除算法将“不再使用的对象”定义为“无法达到的对象”。
  • 简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。
  • 凡是能从根部到达的对象,都是还需要使用的。那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念。同时,对于上面那个循环引用的例子,可以发现,函数执行完毕之后,堆中两块内存虽然相互使用,但是从根部到达不了,所以也回收

如何让某块空间不被回收:

根据标记清除算法的定义,某一块空间不想被回收,那么就让它变成可到达

function fn(){
  const a = 10
  const obj = {}
  console.log(a)
}

fn()

这段代码中 fn() 一旦执行完毕,fn() 及里面的东西都会被回收;为了让某块空间不被回收,可以这么干:

function fn(){
  const a = 10
  const obj = {}	// 注意,obj存放的是地址
  console.log(a)
  return obj
}

const res = fn()

在 fn() 中 return 出来 obj 存放的地址,这块地址现在被 res 使用着,所以原先 obj 指向的那块地址就不会被回收

注意,不会被回收的重点不是在 return 出来一块地址,而是在于有人用着这块地址

从垃圾回收看 Vue:

前置知识:JS 中创建一个对象,其实是开辟一块新空间

在 Vue 中,在声明某一个组件内的数据时,会这么干:

<script>
export default {
  data(){
    return {
      name:'zs'
    }
  }
}
</script>

那么,为什么 data 内是要以 return 的形式,而不是直接写成:

<script>
export default {
  data{
      name:'zs'
  }
}
</script>

第一个,和垃圾回收有关:

前者的写法 return 出去一个对象(应该说 return 出去一个地址合适),就能保证组件执行完毕之后不会被回收(因为存放数据的这块地址仍然可到达

第二个,和创建对象时是开辟一块新空间有关:

const obj1 = {
  name:'zs'
}
const obj2 = {
  name:'zs'
}

在这段代码中,虽然 obj1 和 obj2 都是创建了一个含有 name 字段且值为'zs'的对象,但是它们两个仍然是不同的。因为在创建对象时实际上 obj1 和 obj2 拿到的是不同的地址值

回到 Vue 中来,我们说 data 采用 return 出去一个对象的写法还有一个作用就是:

每次 return 的对象的地址都是不同的,这样就能保证组件之间不会使用同一份地址,也就是不同组件不会使用同一份数据

JS 的作用域链:

作用域:

首先讲讲作用域

作用域可分为局部作用域和全局作用域,这一概念其实和 C 语言或是 Java 中的概念是一致的:局部作用域内的东西只能局部用(如函数内的变量只能在函数内部使用);全局作用域全局可以使用(如全局变量函数内还是可以使用)

作用域链:

有没有想过这种“函数内的变量只能在函数内部使用,全局变量函数内还是可以使用”是为什么?答案就是作用域链

官方概念: JavaScript 在执⾏过程中会创建一个个的可执⾏上下⽂(每个函数执行都会创建这么一个可执行上下文)。每个可执⾏上下⽂的词法环境中包含了对外部词法环境的引⽤,可通过该引⽤来获取外部词法环境中的变量和声明等。这些引⽤串联起来,⼀直指向全局的词法环境,形成一个链式结构,被称为作⽤域链。

太过官方,简单来讲,就是: 函数内部可以访问到函数外部作用域的变量, 而外部函数还可以访问到全局作用域的变量,这样的变量作用域访问的链式结构, 被称之为作用域链

<script>
  const count = 100

function outer(){
  const num = 10
  // 可以访问全局变量,但不能访问内部函数的变量
  function inner(){
    // 可以访问外部函数、全局变量
  }
}
</script>

这一点其实和 C 语言中的基本一致,主要是要搞清楚什么是词法环境?什么是可执行上下文?什么又是链式?

下图为由多个可执行上下文组成的调用栈:

  • 栈最底部为全局可执行上下文
  • 全局可执行上下文 之上有多个 函数可执行上下文
  • 每个可执行上下文中包含了指向外部其他可执行上下文的引用,直到 全局可执行上下文 时它指向 null

回顾栈的特点:先进后出。还是下面这段代码:

<script>
  const count = 100

  function outer(){
    const num = 10
    // 可以访问全局变量,但不能访问内部函数的变量
    function inner(){
      // 可以访问外部函数、全局变量
    }
  }
</script>

首先先执行全局的内容,此时全局上下文进栈(里面有全局变量 count);执行 outer(),outer()进栈(里面有变量 num);执行 inner(),inner()进栈,大致形成下图的模样:

其实这里顺带可以和垃圾回收机制联系起来:

假设现在已经执行完了 outer(),那么就会被浏览器回收掉。既然回收掉了,那么全局也就无法再访问到原先 outer() 内的变量了

闭包:

什么是闭包?闭包的作用是什么?闭包和垃圾回收以及作用域链怎么联系起来?

什么是闭包?

MDN的官方解释:闭包是函数和声明该函数的词法环境的组合

简单理解就是:内层函数, 引用外层函数上的变量, 就可以形成闭包

需求: 定义一个计数器方法, 每次执行一次函数, 就调用一次进行计数

let count = 0
function fn () {
  count++
  console.log('fn函数被调用了' + count + '次')
}
fn()

但是这种方案存在一个弊端:count 是全局变量,在哪里都可以修改它,这就导致 count 并不是私有的

为什么 count 是全局变量就会导致在哪里都可以修改它? 作用域链的知识

某些场景下为了让某些变量实现私有,就可以使用闭包

所以闭包的最大作用就是用来实现 变量私有

既然讲了闭包是:内层函数引用外层函数变量,所以可将上述代码改造成:

function fn () {
  let count = 0

  function inner(){
    count++
    console.log('fn函数被调用了' + count + '次')
  }

  return inner
}
const res = fn()

res()		// count = 1
res()		// count = 2
res()		// count = 3
res()		// count = 4

inner() 内层函数调用了外层函数的 count 变量,所以现在在函数外部就不能改变 count 的值了(作用域链)

那么在 fn() 执行完毕之后 fn() 内的所有东东不会被回收掉吗?不会,因为 fn() return 出来了 inner 给 res,相当于根部仍然能够走到 inner 这块地址上,所以 inner 不会被回收;而 inner 还使用着 count,所以 count 也不会被回收(垃圾回收机制)

讲到这里,其实你发现了闭包就已经和作用域链以及垃圾回收机制串起来了,所以它们三者是相互联系的


会被垃圾回收机制回收的执行方式:

function fn() {
  let count = 0

  function inner() {
    count++
    console.log('fn函数被调用了' + count + '次')
  }

  return inner
}
const res = fn()

// 会被回收的执行方式
fn()()	// 执行完这行代码之后被回收
fn()()	// 执行完这行代码之后被回收

打印结果:

你会发现 count 似乎没有实现自增;原因就是每一次执行完 fn()(),这块地址已经变得不可到达,就会被回收;等到下一次再来执行 fn()() ,就是使用了新的 count 值了


为什么要让 fn() 内的 inner() 那一块地址不被回收?

还是以上面的代码为例,假如执行一次,就回收一次,那么执行完一次,下次再次执行时又会开辟新空间,而 count 又会被初始化为 0,这显然与“ 每次执行一次函数, 就调用一次进行计数”相违背