Javascript 深度剖析 —— 闭包

195 阅读6分钟

什么是闭包?

闭包是 JavaScript 中强大而常用的概念,它不仅使函数能够“记住”其创建时的上下文,还赋予代码更大的灵活性。本文将深入剖析闭包的机制、用途和潜在陷阱,揭示如何有效运用闭包提升代码质量,并避免可能导致内存泄漏的问题。

维基百科的解释:

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

MDN 的解释:

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

是不是觉得官方的介绍晦涩难懂?往下看,跟着我深入了解 Javascript 闭包。

闭包简介

一句话通俗的介绍闭包: 闭包是指在一个函数内部定义的函数,它可以访问外部函数的变量和参数,并且在外部函数执行完毕后仍然可以访问这些变量和参数

闭包可以在另一个作用域中调用一个函数的内部函数并访问到该函数的作用域中的成员:

function makeFn() {
  let msg = 'Hello closure';
  return function() {
    console.log(msg)
  }
}
const fn = makeFn();
fn(); // Hello closure

我们也可以修改外部函数的成员:

// 定义一个只会执行一次的函数
function once(fn) {
    let done = false;
    return function() {
        if (!done) {
            done = true;
            return fn.apply(this, arguments)
        }
    }
}
​
// 构造一个实现支付功能的函数,确保这个支付功能只能执行一次
const pay =  once(function (money) {
    console.log(`支付:${money} RMB`)
})
​
​
pay(5);  // 支付:5 RMB
pay(5)  // 什么都不会打印

通过上面两个例子我们可以总结出闭包的作用:

  • 闭包可以用来保护私有变量,实现数据的封装和隐藏;
  • 闭包可以实现模块化开发,将一些私有变量和函数暴露给外部,而隐藏其他的实现细节。

从Javascript运行原理理解闭包

本节内容涉及到 javascript 运行原理,还未了解的小伙伴可以先跳过,待了解后再回来享用。

函数调用时会创建一个执行上下文(Execution Context)进入执行栈中,函数代码执行完毕后会从执行栈上移除,但是堆上作用域(AO)的成员因为被内部函数引用导致空间不能释放,因此内部函数依然可以访问外部函数的变量或参数。

还是以刚刚的代码为例:

function makeFn() {
  let msg = 'Hello closure';
  return function() {
    console.log(msg)
  }
}
​
const fn = makeFn();
fn(); // Hello closure

在 makeFn() 函数被调用时,会创建一个执行上下文进入栈中,栈的变量对象VO(Variable Object)自动关联函数创建的AO对象(Activation Object),里面包含了函数里面定义的变量和接收的参数:

之后执行上下文会执行 makeFn 内经过解析的代码,将 msg 和 匿名函数 function 一一赋值,因为 function 是一个函数,所以在堆内存中又会开辟一块儿新的内存地址并被 function 引用。

由于函数的作用域链(Scope Chain)在定义的那一刻就已经被确定了,所以会指向当前的 AO 对象。

之后代码执行结束,全局变量 fn 也获取了 function 对内存地址,保持对它的引用。

执行原理图如下:

image-20231206153734560.png

此时全局变量 fn 已经引用到 makeFn 返回到函数,接下来 Function Execution Context 会从栈中退出,fn 函数被调用,创建一个执行上下文进栈,产生一个新的 AO。

函数被调用寻找 msg 变量时,会先在本次生成的 AO 中寻找,如果没找到就会沿着作用域链向上查找,进入 makeFn 生成的 AO 中,此时找到了 "Hello closure",之后控制台就会输出打印 "Hellow closure"。

image-20231206160736662.png

代码执行结束后我们发现,由于函数 fn 会持续引用 makeFn 中的 msg,所以这块内存不会被回收,使得 fn 函数仍然可以访问之前 makeFn 定义的变量。

image-20231206161437290.png

闭包的内存泄漏

使用闭包时,需要注意内存泄漏的问题。如果闭包中引用了大量的变量或对象,而这些变量或对象不再使用,却仍然保存在内存中,就会造成内存泄漏。手动解除对闭包的引用可以避免内存泄漏的问题。

我们直接用一个简单的案例演示内存泄漏的危害:

<button class="create">创建数组对象</button>
<button class="destroy">销毁数组对象</button>
function createArray() {
    // 一个数组所占用的内存空间:4byte * 1024 * 1024 = 4kb * 1024 = 4M
    let arr = new Array(1024*1024).fill(100)
​
    function text() {
        console.log(arr);
    }
​
    return text
}
​
let totalArr = []
​
const createBtn = document.querySelector(".create")
const destroyBtn = document.querySelector(".destroy")
​
createBtn.onclick = function () {
    for (let i = 0; i < 100; i++) {
        totalArr.push(createArray())
    }
}
​
destroyBtn.onclick = function () {
    totalArr = null
}

当我们点击创建数组对象时,会向 totalArr 中添加一个包含100个 4M 大小数组的数组,如果不释放,会占用大量内存空间。

image-20231203200151458.png

如果我们点击销毁数组对象按钮,将 totalArr 的设置为 null,AO 对象失去了引用,这时内存就会被释放了。

image-20231203200250526.png

循环引用导致内存泄漏

如果闭包内部引用了外部函数中的变量,而外部函数中的变量又引用了闭包,就会循环引用,这会导致相关对象无法被垃圾回收。

javascriptCopy code
function createClosure() {
  let variable = 'I am in closure';
  return function() {
    console.log(variable);
  };
}
​
const closure = createClosure();
closure();
// 此时 closure 包含对 createClosure 函数作用域中 variable 的引用

防止内存泄漏的措施

为了避免闭包导致的内存泄漏,我们可以考虑以下几点:

  • 及时释放不再需要的引用: 在使用闭包时,确保及时释放不再需要的引用,特别是循环引用的情况。
  • 使用模块模式: 将需要保留的状态封装在模块中,通过模块返回的接口来访问状态,而不是通过闭包直接引用外部作用域的变量。
  • 注意循环引用: 避免形成循环引用的结构,确保闭包引用的变量在不再需要时能够被垃圾回收。

案例:

// 避免内存泄漏的案例function createClosure() {
  let data = 'I am safely encapsulated';
  
  // 使用闭包,但避免循环引用和长时间存活的情况
  let closure = function() {
    console.log(data);
  };
​
  // 返回一个包装了闭包的对象,而非直接返回闭包
  return {
    invokeClosure: closure,
    releaseClosure: function() {
      // 释放对闭包的引用
      closure = null;
    }
  };
}
​
// 使用示例
const closureWrapper = createClosure();
closureWrapper.invokeClosure();  // 输出: I am safely encapsulated// 在不再需要时释放对闭包的引用
closureWrapper.releaseClosure();
​

在这个例子中,通过返回一个包含闭包的对象,我们能够在不再需要时手动释放对闭包的引用。这有助于确保在长时间保留闭包引用的情况下,相应的资源能够被及时释放,从而避免内存泄漏。