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

JavaScript闭包详解与作用域链

2021-05-091.7k 阅读

JavaScript 中的作用域

在深入探讨闭包之前,我们先来理解 JavaScript 中的作用域概念。作用域是指程序中定义变量的区域,它决定了变量的生命周期以及访问权限。JavaScript 中有两种主要的作用域类型:全局作用域和函数作用域。

全局作用域

全局作用域是最外层的作用域。在 JavaScript 代码中,任何在函数外部定义的变量都处于全局作用域。例如:

// 全局变量
var globalVariable = 'I am global';

function printGlobal() {
    console.log(globalVariable);
}

printGlobal(); // 输出: I am global

在这个例子中,globalVariable 是一个全局变量,可以在代码的任何地方访问,包括在函数内部。需要注意的是,在浏览器环境中,全局变量会成为 window 对象的属性(在严格模式下,全局变量不会自动成为 window 对象的属性)。例如,在非严格模式下,可以通过 window.globalVariable 来访问上述定义的全局变量。

函数作用域

函数作用域是指在函数内部定义的变量,这些变量只能在函数内部访问。例如:

function localFunction() {
    // 局部变量
    var localVariable = 'I am local';
    console.log(localVariable);
}

localFunction(); // 输出: I am local
console.log(localVariable); // 报错: localVariable is not defined

在这个例子中,localVariablelocalFunction 函数内部的局部变量。它只能在 localFunction 函数内部访问,在函数外部尝试访问会导致错误。

块级作用域(ES6 引入)

在 ES6 之前,JavaScript 并没有真正的块级作用域。块级作用域通常是指由 {} 括起来的代码块,如 if 语句块、for 循环块等。在 ES6 引入 letconst 关键字后,JavaScript 才有了块级作用域的概念。

// 使用 var 声明变量,没有块级作用域
if (true) {
    var varInBlock = 'I am in block with var';
}
console.log(varInBlock); // 输出: I am in block with var

// 使用 let 声明变量,具有块级作用域
if (true) {
    let letInBlock = 'I am in block with let';
}
console.log(letInBlock); // 报错: letInBlock is not defined

在上述例子中,使用 var 声明的变量 varInBlock 没有块级作用域,在 if 块外部依然可以访问。而使用 let 声明的变量 letInBlock 具有块级作用域,在 if 块外部访问会报错。

作用域链

当在 JavaScript 中查找一个变量时,会从当前作用域开始查找,如果在当前作用域中没有找到,就会向上一级作用域查找,直到找到该变量或者到达全局作用域。这种一级一级查找变量的链条就叫做作用域链。

函数内部的作用域链

var globalValue = 'global';

function outer() {
    var outerValue = 'outer';

    function inner() {
        var innerValue = 'inner';
        console.log(globalValue); // 输出: global
        console.log(outerValue); // 输出: outer
        console.log(innerValue); // 输出: inner
    }

    inner();
}

outer();

在这个例子中,inner 函数内部访问变量时,首先在自身作用域查找,找到了 innerValue。然后查找上一级作用域(outer 函数的作用域),找到了 outerValue。最后查找全局作用域,找到了 globalValue。这就形成了一条作用域链:inner 函数作用域 -> outer 函数作用域 -> 全局作用域

作用域链与执行上下文

作用域链与执行上下文密切相关。每当一个函数被调用时,就会创建一个新的执行上下文。执行上下文包含了函数的作用域链,它是一个由变量对象(VO)和活动对象(AO,函数执行时的变量对象)组成的链表。例如,在上述例子中,当 inner 函数被调用时,其执行上下文的作用域链为:inner 函数的活动对象 -> outer 函数的活动对象 -> 全局变量对象

闭包的概念

闭包是 JavaScript 中一个非常重要且强大的特性。简单来说,闭包是指一个函数能够访问并记住其外部作用域的变量,即使在其外部作用域已经执行完毕之后。

闭包的基本示例

function outerFunction() {
    var outerVariable = 'I am outer';

    function innerFunction() {
        console.log(outerVariable);
    }

    return innerFunction;
}

var closure = outerFunction();
closure(); // 输出: I am outer

在这个例子中,outerFunction 返回了 innerFunction。当 outerFunction 执行完毕后,按照常规理解,其内部的变量 outerVariable 应该被销毁。但是由于 innerFunction 形成了闭包,它记住了 outerVariable,所以在调用 closure()(即 innerFunction)时,依然能够访问并输出 outerVariable 的值。

闭包的本质

从本质上讲,闭包是由函数和与其相关的作用域链组合而成的。当一个函数被定义时,它会创建一个作用域链,这个作用域链包含了函数定义时所在的作用域以及其上一级作用域,一直到全局作用域。当函数执行完毕后,其执行上下文会被销毁,但如果该函数被返回或者被赋值给一个外部变量,并且在外部被调用,那么其作用域链依然会被保留,从而形成闭包。

闭包的应用场景

闭包在实际开发中有很多重要的应用场景。

数据封装与私有化

通过闭包,可以实现类似面向对象编程中的数据封装和私有化。例如:

function counter() {
    var count = 0;

    return {
        increment: function() {
            count++;
            return count;
        },
        getCount: function() {
            return count;
        }
    };
}

var myCounter = counter();
console.log(myCounter.increment()); // 输出: 1
console.log(myCounter.increment()); // 输出: 2
console.log(myCounter.getCount()); // 输出: 2

在这个例子中,count 变量被封装在 counter 函数内部,外部无法直接访问。只有通过 incrementgetCount 这两个公开的方法才能操作和获取 count 的值,实现了数据的私有化和封装。

事件处理程序

在事件处理程序中,闭包经常被用到。例如:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
</head>

<body>
    <button id="btn1">Button 1</button>
    <button id="btn2">Button 2</button>

    <script>
        function createButtonClickListener(message) {
            return function() {
                console.log(message);
            };
        }

        var btn1 = document.getElementById('btn1');
        var btn2 = document.getElementById('btn2');

        btn1.addEventListener('click', createButtonClickListener('You clicked Button 1'));
        btn2.addEventListener('click', createButtonClickListener('You clicked Button 2'));
    </script>
</body>

</html>

在这个例子中,createButtonClickListener 函数返回一个闭包。每个按钮的点击事件处理程序都记住了各自传递进来的 message 参数,当按钮被点击时,会输出相应的消息。

模块模式

闭包是实现 JavaScript 模块模式的基础。模块模式允许我们将相关的代码和数据封装在一个单独的模块中,避免全局变量的污染。例如:

var myModule = (function() {
    var privateVariable = 'This is private';

    function privateFunction() {
        console.log(privateVariable);
    }

    return {
        publicFunction: function() {
            privateFunction();
        }
    };
})();

myModule.publicFunction(); // 输出: This is private
console.log(privateVariable); // 报错: privateVariable is not defined

在这个例子中,privateVariableprivateFunction 都被封装在闭包内部,外部无法直接访问。通过返回一个包含公开方法的对象,提供了对内部功能的有限访问,实现了模块的封装。

闭包与内存管理

虽然闭包非常强大,但在使用闭包时需要注意内存管理问题。由于闭包会保留其外部作用域的变量,即使这些变量在外部作用域已经不再使用,它们依然会被闭包所引用,从而导致内存无法释放。

闭包导致的内存泄漏示例

function outer() {
    var largeArray = new Array(1000000).fill(1);

    function inner() {
        console.log('Inner function');
    }

    return inner;
}

var closure = outer();
// 此时 largeArray 仍然被闭包引用,即使 outer 函数执行完毕,内存也不会释放

在这个例子中,largeArray 是一个很大的数组,在 outer 函数执行完毕后,由于 inner 函数形成的闭包引用了 outer 函数的作用域,largeArray 依然占据着内存,可能会导致内存泄漏。

避免闭包导致的内存泄漏

为了避免闭包导致的内存泄漏,当闭包不再需要使用时,应该及时释放对其的引用。例如:

function outer() {
    var largeArray = new Array(1000000).fill(1);

    function inner() {
        console.log('Inner function');
    }

    return inner;
}

var closure = outer();
// 使用完闭包后,将其设置为 null,释放引用
closure = null;

通过将闭包变量设置为 null,可以让垃圾回收机制回收闭包所占用的内存。

闭包的一些常见误解

闭包与函数调用时机

有一种常见的误解是关于闭包中函数的调用时机对变量值的影响。例如:

function createFunctions() {
    var functions = [];
    for (var i = 0; i < 3; i++) {
        functions.push(function() {
            console.log(i);
        });
    }
    return functions;
}

var funcs = createFunctions();
funcs[0](); // 输出: 3
funcs[1](); // 输出: 3
funcs[2](); // 输出: 3

很多人可能期望输出 012,但实际输出都是 3。这是因为在 for 循环中,var 声明的 i 是函数作用域,而不是块级作用域。当循环结束后,i 的值变为 3,每个闭包函数在执行时访问的 i 都是同一个 i,此时它的值已经是 3

要解决这个问题,可以使用 let 关键字,因为 let 具有块级作用域:

function createFunctions() {
    var functions = [];
    for (let i = 0; i < 3; i++) {
        functions.push(function() {
            console.log(i);
        });
    }
    return functions;
}

var funcs = createFunctions();
funcs[0](); // 输出: 0
funcs[1](); // 输出: 1
funcs[2](); // 输出: 2

在这个例子中,每次循环 let 都会创建一个新的块级作用域,每个闭包函数记住的是各自块级作用域中的 i,所以输出符合预期。

闭包与性能

另一个误解是闭包会严重影响性能。虽然闭包会占用额外的内存,因为它需要保留外部作用域的变量,但在现代 JavaScript 引擎中,对于闭包的优化已经做得很好。只有在滥用闭包,比如在循环中大量创建不必要的闭包时,才可能会对性能产生明显影响。在大多数情况下,合理使用闭包带来的代码结构和功能上的优势远远超过其对性能的微小影响。

闭包与 JavaScript 其他特性的结合

闭包与箭头函数

箭头函数也可以形成闭包,并且在处理闭包时与普通函数有一些不同之处。例如:

var outerValue = 'outer';

const arrowFunction = () => {
    console.log(outerValue);
};

arrowFunction(); // 输出: outer

箭头函数没有自己的 thisargumentssupernew.target,它的 this 是在定义时从其上层作用域继承而来。在闭包的场景下,箭头函数同样会记住其定义时的作用域。

闭包与异步操作

在异步操作中,闭包也经常发挥重要作用。例如,在使用 setTimeout 时:

function printNumbers() {
    for (let i = 0; i < 3; i++) {
        setTimeout(() => {
            console.log(i);
        }, i * 1000);
    }
}

printNumbers();
// 分别在 0 秒、1 秒、2 秒后输出 0、1、2

在这个例子中,setTimeout 中的箭头函数形成了闭包,记住了 for 循环中每次 let 声明的 i 的值,从而实现了按照预期的时间间隔输出不同的数字。

闭包在实际项目中的案例分析

前端路由中的闭包应用

在前端路由系统中,闭包常用于处理路由映射和组件渲染。例如,在一个简单的基于哈希路由的应用中:

const routes = {
    '/home': function() {
        document.getElementById('content').innerHTML = 'Home page';
    },
    '/about': function() {
        document.getElementById('content').innerHTML = 'About page';
    }
};

function router() {
    window.addEventListener('hashchange', function() {
        var hash = window.location.hash.slice(1);
        if (routes[hash]) {
            routes[hash]();
        }
    });
    // 首次加载时触发一次
    window.dispatchEvent(new Event('hashchange'));
}

router();

在这个例子中,hashchange 事件处理函数形成了闭包,它记住了 routes 对象。当哈希值发生变化时,能够根据 routes 中的映射找到对应的页面渲染函数并执行,实现了前端路由的功能。

数据持久化与闭包

在一些需要数据持久化的场景中,闭包也可以发挥作用。比如,使用 localStorage 来存储用户设置,并在页面刷新后依然保持这些设置:

function userSettings() {
    var settings = {
        theme: 'light'
    };

    function saveSettings() {
        localStorage.setItem('settings', JSON.stringify(settings));
    }

    function loadSettings() {
        var storedSettings = localStorage.getItem('settings');
        if (storedSettings) {
            settings = JSON.parse(storedSettings);
        }
        return settings;
    }

    return {
        getSettings: loadSettings,
        updateSettings: function(newSettings) {
            Object.assign(settings, newSettings);
            saveSettings();
        }
    };
}

var userSettingsModule = userSettings();
console.log(userSettingsModule.getSettings()); // 输出初始设置
userSettingsModule.updateSettings({ theme: 'dark' });
console.log(userSettingsModule.getSettings()); // 输出更新后的设置

在这个例子中,userSettings 函数返回的对象中的方法形成了闭包,记住了 settings 变量。通过 updateSettings 方法更新设置并保存到 localStoragegetSettings 方法则可以从 localStorage 加载并返回设置,实现了数据的持久化管理。

通过以上对 JavaScript 闭包和作用域链的详细介绍,包括概念、应用场景、内存管理以及与其他特性的结合等方面,希望能帮助你深入理解这两个重要的 JavaScript 概念,并在实际开发中灵活运用。