MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript闭包的深层理解与应用

2022-07-067.7k 阅读

一、闭包的基础概念

1.1 什么是闭包

在JavaScript中,闭包是指函数可以记住并访问其所在词法作用域,即使函数在该作用域之外执行。简单来说,当一个内部函数在其外部函数返回后仍然存活,就形成了闭包。这是因为内部函数保留了对外部函数作用域的引用,外部函数的作用域不会被垃圾回收机制回收。

来看一个简单的示例:

function outerFunction() {
    let outerVariable = 'I am from outer function';
    function innerFunction() {
        console.log(outerVariable);
    }
    return innerFunction;
}

let inner = outerFunction();
inner(); 

在上述代码中,outerFunction返回了innerFunction。当outerFunction执行完毕后,按照常理其作用域应该被销毁。但由于innerFunctionouterFunction作用域中的outerVariable存在引用,outerFunction的作用域并不会被销毁,这就形成了闭包。inner()执行时能够正确访问并打印出outerVariable的值。

1.2 闭包形成的条件

  1. 函数嵌套:闭包通常在一个函数内部定义另一个函数时产生。内部函数能够访问外部函数的变量和参数。
  2. 内部函数被返回:外部函数返回内部函数,使得内部函数在外部函数执行完毕后依然可以被调用。这样内部函数就持有了对外部函数作用域的引用,从而形成闭包。

例如:

function makeCounter() {
    let count = 0;
    function counter() {
        return ++count;
    }
    return counter;
}

let myCounter = makeCounter();
console.log(myCounter()); 
console.log(myCounter()); 

这里makeCounter函数内部定义了counter函数并返回它。每次调用myCounter(即counter函数)时,它都会访问并修改makeCounter作用域中的count变量,这就是闭包形成的典型表现。

二、闭包与作用域链

2.1 作用域链的概念

JavaScript采用词法作用域,也就是静态作用域。当执行一个函数时,会创建一个执行上下文,该执行上下文包含一个作用域链。作用域链是由当前函数的活动对象(包含函数的局部变量和参数)和所有父级函数的活动对象组成。

当访问一个变量时,JavaScript引擎会首先在当前函数的活动对象中查找,如果找不到,就会沿着作用域链向上查找,直到找到该变量或者到达全局作用域。

例如:

let globalVariable = 'global';
function outer() {
    let outerVariable = 'outer';
    function inner() {
        let innerVariable = 'inner';
        console.log(globalVariable); 
        console.log(outerVariable); 
        console.log(innerVariable); 
    }
    inner();
}
outer();

inner函数执行时,其作用域链包含自身的活动对象(包含innerVariable)、outer函数的活动对象(包含outerVariable)以及全局作用域(包含globalVariable)。

2.2 闭包与作用域链的关系

闭包之所以能够访问外部函数的变量,正是依赖于作用域链。当内部函数形成闭包时,它的作用域链会包含外部函数的活动对象。即使外部函数执行完毕,其活动对象因为被闭包引用而不会被销毁,依然存在于闭包的作用域链中。

以之前makeCounter的例子来说,counter函数(闭包)的作用域链包含自身的活动对象(为空,因为没有局部变量)和makeCounter函数的活动对象(包含count变量)。所以每次调用counter函数时,都能通过作用域链找到并修改count变量。

三、闭包的特性

3.1 数据隐藏与封装

闭包可以实现数据隐藏和封装。通过在外部函数中定义变量,并返回一个内部函数来操作这些变量,外部代码无法直接访问这些变量,只能通过返回的内部函数间接操作。

比如:

function privateData() {
    let secret = 'This is a secret';
    function getSecret() {
        return secret;
    }
    function setSecret(newSecret) {
        secret = newSecret;
    }
    return {
        get: getSecret,
        set: setSecret
    };
}

let data = privateData();
console.log(data.get()); 
data.set('New secret');
console.log(data.get()); 

在这个例子中,secret变量被封装在privateData函数内部,外部代码无法直接访问。只能通过getSecretsetSecret函数来获取和修改secret的值,实现了数据的隐藏与封装。

3.2 状态保持

闭包能够保持其创建时的状态。由于闭包持有对外部函数作用域的引用,外部函数作用域中的变量状态会被保留。

例如:

function createAdder(num) {
    return function (addNum) {
        return num + addNum;
    };
}

let add5 = createAdder(5);
console.log(add5(3)); 
console.log(add5(7)); 

这里createAdder返回的函数记住了num的值为5,每次调用add5时,都会基于这个初始的num值进行加法运算,保持了初始状态。

四、闭包的应用场景

4.1 模块模式

模块模式是闭包在JavaScript中最常见的应用之一。通过使用闭包,我们可以模拟类的私有成员和公有成员,实现模块化开发。

let myModule = (function () {
    let privateVariable = 'private';
    function privateFunction() {
        console.log('This is a private function');
    }
    return {
        publicMethod: function () {
            privateFunction();
            console.log(privateVariable);
        }
    };
})();

myModule.publicMethod(); 

在上述代码中,立即执行函数表达式(IIFE)返回一个对象,该对象包含一个公有方法publicMethodprivateVariableprivateFunction是私有的,只能通过publicMethod访问,实现了模块的封装。

4.2 回调函数

闭包在回调函数中也有广泛应用。例如,在事件处理程序中,闭包可以让我们在回调函数中访问外部函数的变量。

function setupButton() {
    let message = 'Button clicked';
    let button = document.createElement('button');
    button.textContent = 'Click me';
    button.addEventListener('click', function () {
        console.log(message);
    });
    document.body.appendChild(button);
}

setupButton();

在这个例子中,addEventListener的回调函数形成了闭包,它可以访问setupButton函数中的message变量。当按钮被点击时,会打印出message的值。

4.3 函数柯里化

函数柯里化是将一个多参数函数转化为一系列单参数函数的技术,闭包在其中起到关键作用。

function add(a) {
    return function (b) {
        return function (c) {
            return a + b + c;
        };
    };
}

let add1 = add(1);
let add1And2 = add1(2);
let result = add1And2(3);
console.log(result); 

这里add函数返回一个内部函数,内部函数又返回一个内部函数,通过闭包记住了前面传入的参数,实现了柯里化。

五、闭包可能带来的问题

5.1 内存泄漏

如果闭包使用不当,可能会导致内存泄漏。当闭包长时间持有对外部函数作用域的引用,而这些引用的对象不再被其他地方使用时,这些对象占用的内存不会被释放,从而造成内存泄漏。

例如:

function badClosure() {
    let largeObject = { /* 包含大量数据的对象 */ };
    return function () {
        return largeObject;
    };
}

let closure = badClosure();
// 即使不再需要largeObject,但由于闭包的引用,它不会被垃圾回收

在这个例子中,badClosure返回的闭包持有对largeObject的引用,即使badClosure执行完毕,largeObject依然不会被垃圾回收,可能导致内存泄漏。

5.2 性能问题

过多地使用闭包可能会导致性能问题。因为闭包会增加作用域链的长度,每次访问变量时都需要沿着更长的作用域链查找,这会增加查找变量的时间开销。此外,闭包会使得一些对象的生命周期延长,增加内存占用,影响性能。

六、避免闭包问题的方法

6.1 合理管理闭包引用

尽量减少闭包对不必要对象的引用。当不再需要闭包中的某些数据时,手动将相关引用设为null,以便垃圾回收机制回收这些对象。

例如,对于前面可能导致内存泄漏的例子,可以修改为:

function betterClosure() {
    let largeObject = { /* 包含大量数据的对象 */ };
    let result = function () {
        let temp = largeObject;
        largeObject = null; 
        return temp;
    };
    return result;
}

let closure = betterClosure();

这里在返回闭包前将largeObject设为null,避免了闭包长时间持有对largeObject的引用,减少了内存泄漏的风险。

6.2 优化闭包使用

尽量减少闭包的嵌套层数,避免创建过多不必要的闭包。合理规划作用域,减少变量查找的深度,提高性能。同时,在确保功能的前提下,尽量缩短闭包的生命周期,及时释放不再需要的闭包。

例如,对于一些简单的回调函数场景,如果不需要访问外部函数的变量,可以直接使用普通函数,而不是形成闭包。

function setupButton() {
    let button = document.createElement('button');
    button.textContent = 'Click me';
    function handleClick() {
        console.log('Button clicked');
    }
    button.addEventListener('click', handleClick);
    document.body.appendChild(button);
}

setupButton();

在这个例子中,handleClick函数没有形成闭包,减少了作用域链的复杂性,提高了性能。

七、闭包在现代JavaScript框架中的应用

7.1 在React中的应用

在React中,闭包常用于处理组件的状态和事件。例如,在函数式组件中,闭包可以帮助我们记住组件的状态。

import React, { useState } from'react';

function Counter() {
    let [count, setCount] = useState(0);
    function increment() {
        setCount(count + 1);
    }
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={increment}>Increment</button>
        </div>
    );
}

这里increment函数形成了闭包,它能够访问Counter函数作用域中的countsetCount。每次点击按钮时,increment函数通过闭包访问并更新count状态。

7.2 在Vue中的应用

在Vue中,闭包同样用于处理数据和方法。例如,在Vue组件的方法中,闭包可以访问组件的实例数据。

<template>
    <div>
        <p>{{ message }}</p>
        <button @click="updateMessage">Update Message</button>
    </div>
</template>

<script>
export default {
    data() {
        return {
            message: 'Initial message'
        };
    },
    methods: {
        updateMessage() {
            this.message = 'Updated message';
        }
    }
};
</script>

这里updateMessage方法形成了闭包,它能够访问Vue组件实例的message数据。当点击按钮时,通过闭包修改message的值。

八、闭包与ES6箭头函数

8.1 箭头函数的闭包特性

ES6箭头函数同样可以形成闭包。箭头函数没有自己的thisargumentssupernew.target,它的这些值继承自外层作用域。

例如:

function outer() {
    let value = 10;
    let arrowFunction = () => {
        console.log(value);
    };
    return arrowFunction;
}

let inner = outer();
inner(); 

在这个例子中,箭头函数arrowFunction形成了闭包,它可以访问outer函数作用域中的value变量。

8.2 箭头函数闭包与普通函数闭包的区别

虽然箭头函数和普通函数都能形成闭包,但由于箭头函数的this绑定特性,在使用闭包时会有所不同。

普通函数在调用时,this的值取决于调用方式。而箭头函数的this在定义时就已经确定,始终指向外层作用域的this

例如:

function outer() {
    this.value = 20;
    let normalFunction = function () {
        console.log(this.value);
    };
    let arrowFunction = () => {
        console.log(this.value);
    };
    return {
        normal: normalFunction,
        arrow: arrowFunction
    };
}

let result = new outer();
result.normal(); 
result.arrow(); 

在上述代码中,normalFunctionthis在调用时指向result,而箭头函数arrowFunctionthis在定义时指向外层作用域(这里是outer函数的this,即result),所以两者输出相同的值。但如果在不同的调用场景下,普通函数的this可能会改变,而箭头函数的this不会。

九、闭包的高级应用与技巧

9.1 惰性函数

惰性函数是一种利用闭包实现的优化技巧。它会在第一次调用时根据环境动态地改变自身的行为,之后再次调用时就不再进行重复的判断和初始化,提高性能。

function addEvent(element, eventType, handler) {
    if (typeof document.addEventListener === 'function') {
        addEvent = function (element, eventType, handler) {
            element.addEventListener(eventType, handler, false);
        };
    } else if (typeof document.attachEvent === 'function') {
        addEvent = function (element, eventType, handler) {
            element.attachEvent('on' + eventType, handler);
        };
    }
    addEvent(element, eventType, handler);
}

在这个例子中,addEvent函数第一次调用时会根据浏览器是否支持addEventListenerattachEvent来重写自身。之后再次调用addEvent时,就直接执行优化后的代码,避免了重复的判断。

9.2 闭包与递归

闭包在递归函数中也有独特的应用。通过闭包,递归函数可以访问和修改外部函数的变量,实现一些复杂的递归逻辑。

function factorial() {
    let memo = {};
    function innerFactorial(n) {
        if (n === 0 || n === 1) {
            return 1;
        }
        if (!memo[n]) {
            memo[n] = n * innerFactorial(n - 1);
        }
        return memo[n];
    }
    return innerFactorial;
}

let fact = factorial();
console.log(fact(5)); 

这里factorial函数返回的innerFactorial形成了闭包,它可以访问并修改memo对象。memo用于存储已经计算过的阶乘结果,避免了重复计算,提高了递归效率。

十、闭包在实际项目中的调试与优化

10.1 调试闭包

在调试闭包相关问题时,浏览器的开发者工具非常有用。可以使用断点调试功能,查看闭包的作用域链和变量值。

例如,在Chrome浏览器中,在闭包函数内部设置断点,然后触发闭包的执行。在调试面板中,可以展开Scope选项,查看闭包所引用的作用域及其变量,分析闭包是否正确获取和操作了相关变量。

10.2 优化闭包性能

  1. 减少不必要的闭包创建:如前文所述,避免在不需要闭包的地方创建闭包,例如简单的回调函数如果不依赖外部变量,可以使用普通函数。
  2. 优化闭包内的代码:尽量减少闭包内复杂的计算和操作,避免在闭包内进行大量的DOM操作或数据处理,以免影响性能。
  3. 及时释放闭包:当闭包不再需要时,手动解除对闭包的引用,让垃圾回收机制能够回收相关资源。

通过以上方法,可以有效地调试和优化闭包在实际项目中的应用,提高代码的质量和性能。