JavaScript 闭包:强大特性背后的概念、应用与内存考量

在 JavaScript 编程的世界里,闭包是一个既强大又微妙的概念。它能为我们的程序带来诸多便利,但如果使用不当,也可能引发一些棘手的问题。

一、闭包的概念

闭包是函数和其词法作用域的组合。它使内部函数(返回的函数)能够访问并记住其外层函数作用域中的变量,即使是在外部函数执行结束且在其作用域之外进行调用时,依然可以使用这些变量。

来看一个简单的示例代码:

js 复制代码
function createCounter() {
    let count = 0;
    function increment() {
        count++;
        console.log(count);
    }
    return increment;
}

let counter = createCounter();
counter(); // 输出 1
counter(); // 输出 2
counter(); // 输出 3

这个例子中,闭包中的函数为 increment 函数,它在 createCounter 函数内部定义,是内部函数,且作为 createCounter 函数的返回值。

词法作用域的概念

词法作用域,也叫静态作用域,由代码编写位置决定。在该规则下,变量和函数的作用域取决于其定义位置,而非调用位置。函数在定义时就记住了所在作用域,无论在何处调用,都能访问定义时所处作用域的变量。

词法作用域与闭包形成

increment 函数的词法作用域是 createCounter 函数的作用域。由于函数在定义时记住词法作用域,increment 函数在 createCounter 函数内定义,便记住了该作用域。

createCounter 函数执行时,创建局部变量 count 并初始化为 0,同时定义 increment 函数,该函数引用了 count 变量。createCounter 函数返回 increment 函数后,increment 函数与 createCounter 函数的词法作用域结合形成闭包。

闭包对变量的影响

createCounter 函数执行结束,其执行上下文从调用栈移除,按常规作用域规则 count 变量应被销毁。但因 increment 函数形成的闭包对 count 有引用,count 变量所在内存空间不会释放。

increment 函数赋值给 counter 变量,在 createCounter 函数作用域之外调用 counter 函数(即 increment 函数),increment 函数仍能访问并修改 count 变量,每次调用 counter 函数,count 就增加 1 并打印。

二、闭包的用途

(一)封装私有变量

在传统的面向对象编程里,存在访问控制的概念,比如私有成员(private members),这些成员只能在类的内部被访问和修改。不过 JavaScript 原生并没有直接提供这样的私有成员机制,但借助闭包就能模拟实现。

以下是一个简单的封装示例:

js 复制代码
function Person() {
    let name = "John";
    return {
        getName: function() {
            return name;
        },
        setName: function(newName) {
            name = newName;
        }
    };
}
let person = Person();
console.log(person.getName()); // 输出 John
person.setName("Jane");
console.log(person.getName()); // 输出 Jane
  • Person 函数里定义了一个局部变量 name,并将其初始化为 "John"。这个变量仅存在于 Person 函数的作用域内。

  • Person 函数返回了一个对象,该对象包含两个方法:getNamesetName。这两个方法都形成了闭包,因为它们引用了 Person 函数作用域中的 name 变量。

  • 外部代码调用 Person() 时,会得到返回的对象,并将其赋值给 person 变量。

  • 外部代码无法直接访问 name 变量,只能通过 person.getName()person.setName() 这两个方法来获取和修改 name 的值。这样就实现了对 name 变量的封装,保护了数据的安全性和完整性。

(二)数据缓存

在软件开发中,有些计算操作的成本非常高,比如需要大量的 CPU 时间、内存或者网络资源等。如果在程序运行过程中,多次对相同的输入进行这些高成本的计算,会造成资源的浪费,降低程序的性能。而闭包可以用来实现数据缓存,避免重复计算,提高程序的运行效率。

考虑这样一个场景,我们需要频繁计算某个复杂数学函数的结果:

js 复制代码
function expensiveCalculation() {
    let cache = {};
    return function(n) {
        if (cache[n]) {
            return cache[n];
        } else {
            let result = n * n * n; // 这里假设是一个复杂计算
            cache[n] = result;
            return result;
        }
    };
}
let calculate = expensiveCalculation();
console.log(calculate(5)); // 计算并输出 125
console.log(calculate(5)); // 从缓存中获取并输出 125

在这个例子中:

  • expensiveCalculation 函数内部定义了一个空对象 cache,用于存储已经计算过的结果。
  • 当调用 calculate(5) 时,首先检查 cache 对象中是否已经有 5 对应的计算结果。如果没有,就进行计算,并将结果存储到 cache 对象中,然后返回计算结果。
  • 当再次调用 calculate(5) 时,由于 cache 对象中已经有 5 对应的计算结果,所以直接从 cache 对象中获取并返回该结果,避免了重复计算。

(三)实现函数柯里化

函数柯里化指的是把一个多参数函数转换为一系列单参数函数的过程。通过这种转换,函数可以逐步接收参数,利用闭包记住已传入的参数,从而实现更灵活的函数调用和复用。

  • 假设去超市买东西,结账的时候需要做两件事:一是扫码商品,二是付款。原本这两件事可能需要一次性把所有商品信息(价格总和)和付款金额这两个 "参数" 提供给收银员,就像一个多参数函数一样。

  • 而函数柯里化就像是把这个过程拆分开来。你可以先把商品扫码,记录下商品的总价,这就相当于先传入了一个参数;之后再去付款,提供付款金额,这相当于传入了另一个参数。这样原本需要一次性完成的两个操作,现在可以分步进行了。

以一个简单的加法函数为例,来展示如何通过闭包实现柯里化:

js 复制代码
function add(a) {
    return function(b) {
        return a + b;
    };
}
let add5 = add(5);
console.log(add5(3)); // 输出 8
console.log(add(2)(4)); // 输出 6
  • 多参数函数的常规理解 :通常的加法函数可能是 function add(a, b) { return a + b; },调用时需要一次性传入两个参数,比如 add(5, 3),就像在超市一次性把商品总价和付款金额告诉收银员一样。

  • 函数柯里化的过程

  • add 函数接收一个参数 a,此时,add 函数内部定义了一个新的函数,这个新函数引用了外部 add 函数的参数 a,从而形成了闭包。它会把这个 a 值保存起来,即使 add 函数执行完毕,这个值也不会丢失。

  • add 函数返回一个新的函数,这个新函数等待接收另一个参数 b,就像你记录好商品总价后,等待付款时提供付款金额 b。由于闭包的存在,这个新函数能够随时获取之前记录的 a 值。

  • let add5 = add(5); 这一步先传入了参数 5,得到了一个新的函数 add5。这里的 add5 其实就是之前形成闭包的那个新函数,它通过闭包记住了之前传入的 5,即使 add 函数已经完成了它的使命。

  • add5(3) 这一步,你传入了另一个参数 3,新函数 add5 利用闭包的特性,把之前记住的 5 和现在传入的 3 相加,得到结果 8。闭包确保了 add5 函数在任何时候都能准确地使用之前记录的 5 这个值来进行计算。

三、闭包与内存管理

闭包虽功能强大,但在使用中需留意其对内存管理的影响。当闭包形成,内部函数对外部函数作用域变量的引用,会致使这些变量在外部函数执行完毕后依旧驻留在内存中。这一特性在某些场景下,可能会引发内存泄漏问题。

例如在处理 DOM 事件的闭包场景中,如果闭包持续持有对 DOM 元素的引用,即便该元素已从 DOM 树移除,元素所占用的内存也无法正常释放,进而导致内存占用不必要地增加。

避免闭包引发内存问题的方法

使用具名函数作为事件监听器

将事件监听器设为具名函数,而不是匿名函数。这样在需要移除监听器时,可以通过函数名精准操作。

  • 匿名函数没有明确的标识符,当你想要移除它时,无法直接引用该函数。这就会导致即使你不再需要这个事件监听器,它依然会和元素绑定,无法被垃圾回收机制回收,进而造成内存泄漏。
javascript 复制代码
// 具名函数作为事件监听器
function handleClick() {
    console.log('按钮被点击');
}
const button = document.getElementById('myButton');
button.addEventListener('click', handleClick);
// 当不再需要该事件监听器时
button.removeEventListener('click', handleClick);

及时清理闭包引用

若闭包中存在对大对象或不再使用对象的引用,应在合适时机手动将这些引用设为null,以便垃圾回收机制回收内存。如:

javascript 复制代码
function outerFunction() {
    let largeObject = { /* 一个大对象 */ };
    function innerFunction() {
        console.log(largeObject);
    }
    // 在某些条件下,当确定不再需要largeObject时
    innerFunction();
    largeObject = null;
    return innerFunction;
}

控制闭包的生命周期

确保闭包仅在必要时段存在。闭包在函数内部创建时,如果它的功能仅在该函数执行期间有用,就要避免将其返回或赋值给全局变量,否则闭包会因被长期持有,而让其引用的外部变量一直占用内存。

例如:

javascript 复制代码
function processData() {
    let data = [1, 2, 3, 4, 5];
    function inner() {
        // 处理数据
        return data.map(num => num * 2);
    }
    // 仅在processData函数内部使用inner闭包
    let result = inner();
    // inner闭包在processData函数执行结束后不再被引用,其引用的data等变量可被回收
    return result;
}

在上述代码中,processData 函数内的 inner 函数形成闭包,它引用了外部的 data 变量。但 inner 仅在 processData 函数内部被调用,当 processData 执行完毕,inner 不再被引用,data 变量就可被垃圾回收机制回收。

与之相反,如果闭包被长期持有,情况就大不一样。比如:

javascript 复制代码
let globalClosure;
function processData() {
    let data = [1, 2, 3, 4, 5];
    function inner() {
        return data.map(num => num * 2);
    }
    // 将闭包返回并赋值给全局变量
    globalClosure = inner;
    return inner();
}
processData();

这里 inner 闭包被赋值给全局变量 globalClosure,即便 processData 函数执行结束,inner 因被全局变量引用而持续存在,其引用的 data 变量也会一直占用内存,无法被释放,进而可能引发内存泄漏问题 。

使用 WeakMap

  1. 强引用

强引用是最常见的引用类型。当一个变量直接指向一个对象时,就形成了强引用。只要这个变量在作用域内存在且没有被重新赋值或销毁,那么它所引用的对象就会一直存在于内存中,垃圾回收机制不会回收该对象。

例如:

javascript 复制代码
let person = {
    name: "Alice",
    age: 30
};

在上述代码中,person 变量对创建的对象 {name: "Alice", age: 30} 进行了强引用,只要 person 变量还在作用域内,这个对象就会一直占用内存空间。

  1. 弱引用
  • 弱引用不会阻止对象被垃圾回收。当一个对象只被弱引用所指向,而没有其他任何强引用时,在垃圾回收机制运行时,这个对象就会被回收,无论它是否还在被使用。

  • 需要注意的是,在 JavaScript 中,WeakMap 是与弱引用密切相关的一种数据结构,它的键就是基于弱引用实现的。

例如:

javascript 复制代码
const weakRefObj = {};
const weakMap = new WeakMap();
weakMap.set(weakRefObj, { data: "相关数据" });
weakRefObj = null; 
  • 在 JavaScript 中,变量是对对象的引用。当执行 const weakRefObj = {}; 时,创建了一个对象 {},并让 weakRefObj 变量引用它。此时,对象有一个强引用,即 weakRefObj

  • 当执行 weakRefObj = null; 时,weakRefObj 不再指向之前创建的对象,而是被赋值为 null。这意味着原来对象的强引用被移除了,因为 weakRefObj 这个唯一的强引用变量不再引用它。

  • 由于 WeakMap 的键是弱引用,当对象的所有强引用(这里就是 weakRefObj)都被移除后,垃圾回收机制就会认为这个对象可以被回收了。并且,因为 WeakMap 中该对象作为键所关联的整个键值对依赖于这个键对象,当键对象被回收时,与之对应的键值对也会从 WeakMap 中被移除(在垃圾回收机制运行时) 。

相关推荐
墨绿色的摆渡人7 分钟前
论文笔记(七十五)Auto-Encoding Variational Bayes
前端·论文阅读·chrome
今晚吃什么呢?28 分钟前
前端面试题之CSS中的box属性
前端·css
我是大龄程序员30 分钟前
Babel工作理解
前端
《独白》41 分钟前
将图表和表格导出为PDF的功能
javascript·vue.js·ecmascript
CopyLower44 分钟前
提升 Web 性能:使用响应式图片优化体验
前端
南通DXZ1 小时前
Win7下安装高版本node.js 16.3.0 以及webpack插件的构建
前端·webpack·node.js
什码情况1 小时前
微服务集成测试 -华为OD机试真题(A卷、JavaScript)
javascript·数据结构·算法·华为od·机试
你的人类朋友1 小时前
浅谈Object.prototype.hasOwnProperty.call(a, b)
javascript·后端·node.js
Mintopia1 小时前
深入理解 Three.js 中的 Mesh:构建 3D 世界的基石
前端·javascript·three.js
打瞌睡de喵2 小时前
JavaScript 空对象检测
javascript