从作用域、上下文、内存管理等角度再看闭包 & 经典闭包栗子

50 阅读9分钟

知识图谱

闭包.jpg

闭包定义

JS红宝书 中有描述:闭包指的是那些引用了另一个函数作用域中变量的函数。简单的一个描述,闭包并非是单一的概念,它涉及到作用域、作用域链、执行上下文、内存管理等概念,需要开发者对闭包有整体的认识。

作用域

作用域决定了代码区块中变量、函数、对象和其他资源的可见性

可分为:全局作用域、函数(局部)作用域 和 块级作用域

函数(局部)作用域:局部变量在函数开始执行时创建,函数执行完后自动销毁

// 表达式函数
let exampleFunction = function () {
    var x = "declared inside function";  // x 只能在 exampleFunction 函数中访问
    console.log(x);
}
exampleFunction();
console.log(x);  // 函数外部无法访问,引发 error:Uncaught ReferenceError: x is not defined

/**
 * 运行结果:
 * 
 * declared inside function
 * Uncaught ReferenceError: x is not defined
 */

执行上面代码,变量 x 是函数 exampleFunction 作用域内,函数体内可以正常访问,函数外访问报错

全局作用域:在任何地方都能访问

let x = "declared global";
exampleFunction();
// 声明式函数
function exampleFunction() {
    console.log(x);
}
console.log(x);

/**
 * 运行结果:
 * 
 * declared global
 * declared global
 */

执行上面代码,变量 x 是在全局作用域,exampleFunction 函数在自身函数作用域内未查找到 x 变量(当前作用域内不存在的变量称为 自由变量 ),但是它会继续向外扩大查找范围,因此可以在全局作用域内找到变量 x变量作用域的查找是一个扩散过程,就像各个环节相扣的链条,逐次递进,形成 作用域链

块级作用域:块语句由一对大括号界定,用于组合零个或多个语句

ES6 中增加了通过 letconst 声明变量的块级作用域

let x = 1;
{
  let x = 2;
  console.log(x); // 输出 2
}
console.log(x); // 输出 1

/**
 * 运行结果:
 * 
 * 2
 * 1
 */

拓展知识

变量提升

使用 var 关键字声明的变量 或者 声明式函数 会被 声明提升

exampleFunction()
// 表达式函数 (声明变量 + 将函数体赋值给变量):变量 exampleFunction 提升,但函数体赋值动作不提升,故最后一行调用函数执行了此函数体
var exampleFunction = function () {
  console.log(x)  // undefined
  var x = 'declared inside expression function' // 变量 x 的声明被提升,故上一行输出为 undefined
}
// 声明式函数: 函数整体提升,故第一行调用函数执行了声明式函数的函数体
function exampleFunction () {
  var x = 'declared inside declarative function'
  console.log(x)
}
exampleFunction()

/**
 * 运行结果
 * 
 * declared inside declarative function
 * undefined
 * */
暂时性死区:TDZ

letconst 声明的变量,在相应花括号形成的作用域中存在一个 死区,起始于函数开头,终止于相关变量声明语句的所在行,在这个范围内无法访问使用 letconst 声明的变量

exampleFunction()

function exampleFunction () {
  console.log(x)
  let x = 'declared inside declarative function'
}

/**
 * 运行结果:
 * 
 * Uncaught ReferenceError: Cannot access 'x' before initialization
 */

执行上下文和调用栈

执行上下文

执行上下文就是当前代码的执行环境/作用域,和前文介绍的作用域相辅相成,但又是两个完全不同的概念。

代码执行的两个阶段:代码预编译阶段 、 代码执行阶段
  • 预编译阶段:预编译阶段是前置阶段,这一阶段由编译器将 JavaScript 代码编译成可执行的代码。在此阶段有一些重要的步骤:

    • 在预编译阶段进行变量声明
    • 在预编译阶段对变量声明进行提升,但是值为 undefined
    • 在预编译阶段对所有非表达式的函数声明进行提升
  • 执行阶段的主要任务是执行代码逻辑,执行上下文在这个阶段会全部创建完成

exampleFunction(10)

// 非表达式函数会在预编译阶段进行函数声明提升,声明的变量作用域在函数外(全局)
function exampleFunction(num){
    console.log(exampleFunction) // 访问的局部变量,声明未赋值,故 undefined
    exampleFunction = num
    console.log(exampleFunction)
    var exampleFunction // 使用 var 声明局部变量,在预编译阶段 声明并提升
}

console.log(exampleFunction) // 访问的全局变量
exampleFunction = 1
console.log(exampleFunction)

/**
 * 运行结果:
 * 
 * undefined
 * 10
 * function foo(num){ console.log(foo) foo = num console.log(foo) var foo }
 * 1
 */

作用域在预编译阶段确定,但是作用域链是在执行上下文的创建阶段生成的,因为函数在调用时才会开始创建对应的执行上下文。

执行上下文包括:变量对象、作用域链及this的指向(见文始知识图谱)

JavaScript 引擎执行机制基本原理:

代码执行的整个过程类似一条生产流水线。第一道工序是在预编译阶段创建变量对象(Variable ObjectVO),此时只是创建,而未进行赋值。到了下一道工序代码执行阶段,变量对象会转为激活对象(Active ObjectAO),即完成 VOAO 的转换。此时,作用域链也将被确定,它由当前执行环境的变量对象和所有外层已经完成的激活对象组成。这道工序保证了变量和函数的有序访问,即如果未在当前作用域中找到变量,则会继续向上查找直到全局作用域

调用栈

在执行一个函数时,如果这个函数又调用了另外一个函数,而这 "另外一个函数" 又调用了另外一个函数,这样便形成了一系列的调用栈

callStack.png

注意: 正常来讲,在函数执行完毕并出栈时,函数内的局部变量在下一个垃圾回收(GC)节点会被回收,该函数对应的执行上下文将会被销毁,这也正是我们在外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数才可以访问该变量,该变量会在预编译阶段被创建,在执行阶段被激活,在函数执行完毕后,其相关上下文会被销毁

再谈闭包

闭包并不是 JavaScript 中特有的概念,社区中对闭包的定义也并不完全相同。简单可以理解为:函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在外层函数的外面可以被访问,进而形成闭包。

闭包的基本原理

正常情况下外界是无法访问函数内部变量的,函数执行之后,上下文即被销毁。但是在外层函数中,如果我们返回了另一个函数,且这个返回的函数使用了外层函数内的变量,那么外界便能够通过这个返回的函数获取外层函数内部的变量值

内存管理

内存管理

内存管理指对内存生命周期的管理,而内存生命周期为:分配内存、读写内存、释放内存

let example = "TEST" // 分配内存
console.log(example) // 读写内存
example = null // 释放内存

JavaScript 是在创建变量时自动进行了分配内存,并且在不适用它们时"自动"释放,释放的过程称为垃圾回收。

  • 在上文介绍 调用栈 时了解到,函数执行完毕后出栈,函数内的局部变量会在下一个垃圾回收节点被自动回收。

    • 当存在闭包时,闭包会存储在堆内存中,内存并不会被主动释放,需要根据实际情况手动释放内存
  • 全局变量的声明周期直至浏览器卸载页面才会结束,即全局变量不会被当做垃圾回收,必要时需要手动释放

垃圾回收算法

垃圾回收机制会自动寻找"不再需要"的内存,从而释放内存空间。目前存在两种垃圾回收算法:

  • 引用计数垃圾收集:此算法把 "对象是否不再需要" 简化定义为 "对象有没有其他对象引用到它"。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

    • 局限性:循环引用(两个对象相互引用,形成一个循环),引用计数算法考虑它们相互都有至少一次引用,即使函数调用完毕,已经出栈,它们依旧不会被回收
  • 标记 - 清除算法(2012年起,现代浏览器都使用了此算法):此算法把 "对象是否不再需要" 简化定义为 "对象是否可以获得"。这个算法假定设置一个叫做根(root)的对象(在JavaScript里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始的引用,然后找这些对象引用的对象...,从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象

    • 局限性:那些无法从根对象查询到的对象都将被清除(实践中很少碰到这种情况)

综上,目前最新的垃圾回收算法,也无法智能判断某变量是否"不再需要"。

内存泄漏

内存泄漏是指任何对象"不再需要"时,依旧在内存中占用空间,未能释放,造成内存浪费。

对于持续运行的服务进程,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

常见内存泄漏:

  • 未及时释放的全局变量
  • 未删除的定时器(setInterval 等)和回调函数(addEventListener 等)
  • 闭包,确认不再需要使用,需手动置为 null ,释放内存,或者用其他方式替代闭包

闭包栗子

// 最简单,最经典的闭包
const exampleFunction = (function () {
    let v = 0;
    return () => {
        // 引用自由变量 
        return v++
    }
}()) // 立即执行函数
for(let i = 0; i < 10; i++){
    exampleFunction()
}
console.log(exampleFunction)
console.log(exampleFunction())
/**
 * 运行结果:
 * 
 * f () => {
 *    return v++
 *   }
 * 10
 * */
// 循环中闭包,需正确理解变量作用域,否则易引起bug
const exampleFunction = () => {
    var arr = []
    let i
    for(i = 0; i < 10; i++){
        arr[i] = function () {
            return i
        }
    }
    return arr[0]
}

console.log(exampleFunction())
console.log(exampleFunction()())
/**
 * 运行结果(结果显然不是我们想要的):
 * 
 * ƒ () {
 *    console.log(i)
 *   }
 * 10
 */

// 改进方法一:通过函数创建一个新的作用域,根据作用域链的规则访问到作用域内的变量
const exampleFunction = () => {
  var arr = []
  let i
  for(i = 0; i < 10; i++){
    arr[i] = (function (i) {
      return function(){
        return i
      }
    })(i)
  }
  return arr[0]
}

console.log(exampleFunction())
console.log(exampleFunction()())

/**
 * 运行结果:
 *
 * ƒ () {
 *    console.log(i)
 *   }
 * 0
 */

// 改进方法二:使用let的块级作用域,每次循环都创建一个块级作用域
const exampleFunction = () => {
  var arr = []
  for(let i = 0; i < 10; i++){
    arr[i] = function () {
      return i
    }
  }
  return arr[0]
}

console.log(exampleFunction())
console.log(exampleFunction()())

/**
 * 运行结果:
 *
 * ƒ () {
 *    console.log(i)
 *   }
 * 0
 */

综上,涵盖了闭包的知识点,欢迎指正!