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

JavaScript闭包与作用域深入解析

2021-04-236.9k 阅读

一、JavaScript 作用域概述

  1. 什么是作用域 在 JavaScript 中,作用域是变量、函数和对象的可访问范围。它决定了代码块中标识符(变量名、函数名等)的可见性和生命周期。简单来说,作用域就像是一个“围栏”,在这个围栏内,某些变量和函数是可以被访问和操作的,而在围栏外,它们可能就无法被触及。 例如:
function outerFunction() {
    let outerVariable = 'I am from outer function';
    function innerFunction() {
        console.log(outerVariable); // 这里可以访问到 outerVariable
    }
    innerFunction();
}
outerFunction();

在上述代码中,innerFunction 可以访问 outerFunction 中定义的 outerVariable,这是因为 innerFunction 处在 outerFunction 的作用域内。

  1. 全局作用域 全局作用域是最外层的作用域,在 JavaScript 代码中,任何未定义在函数内部的变量都会处于全局作用域。在浏览器环境中,全局作用域通常与 window 对象相关联(在严格模式下,全局变量不会自动成为 window 的属性)。例如:
let globalVariable = 'I am a global variable';
console.log(window.globalVariable); // 在浏览器环境下,可以通过 window 访问全局变量
function globalFunction() {
    console.log('This is a global function');
}
window.globalFunction(); // 同样可以通过 window 调用全局函数

全局作用域有一些特点和潜在的问题。由于全局变量在整个程序中都可以被访问,过度使用全局变量可能会导致命名冲突。比如,两个不同的 JavaScript 文件都定义了一个名为 count 的全局变量,当这两个文件同时在页面中使用时,就会产生冲突,后面定义的 count 会覆盖前面的。

  1. 函数作用域 函数作用域是指函数内部定义的变量和函数的作用范围。在函数内部定义的变量,只能在函数内部访问,函数外部无法直接访问这些变量。例如:
function functionScopeExample() {
    let functionVariable = 'I am a variable in function scope';
    console.log(functionVariable);
}
functionScopeExample();
console.log(functionVariable); // 这里会报错,functionVariable 超出了作用域

函数作用域提供了一种封装机制,使得函数内部的变量和逻辑与外部隔离,减少了命名冲突的可能性。同时,函数作用域在函数执行完毕后,其中定义的局部变量通常会被垃圾回收机制回收,除非有特殊情况(这就涉及到闭包,后面会详细讲解)。

  1. 块级作用域 在 ES6(ECMAScript 2015)之前,JavaScript 没有真正的块级作用域,只有全局作用域和函数作用域。块级作用域是指由 {} 包裹的一段代码区域,在 ES6 引入 letconst 关键字后,JavaScript 才有了块级作用域的概念。例如:
{
    let blockVariable = 'I am a variable in block scope';
    console.log(blockVariable);
}
console.log(blockVariable); // 这里会报错,blockVariable 超出了块级作用域

letconst 定义的变量在块级作用域内有效,而使用 var 定义的变量不会受到块级作用域的限制,它会遵循函数作用域的规则。比如:

{
    var varInBlock = 'I am a var in block';
}
console.log(varInBlock); // 这里不会报错,varInBlock 处于函数作用域(如果在全局代码块,就是全局作用域)

块级作用域在循环等场景下非常有用。例如:

for (let i = 0; i < 5; i++) {
    console.log(i);
}
console.log(i); // 这里会报错,i 只在 for 循环的块级作用域内有效

如果使用 var 定义 i,那么 i 在循环外部仍然可以访问,这可能会导致一些意想不到的结果。

二、作用域链

  1. 作用域链的概念 当 JavaScript 引擎执行一段代码时,它需要查找变量和函数的定义。作用域链就是这种查找机制的基础。作用域链是由多个作用域对象组成的链表,它决定了标识符的查找顺序。 每个函数在创建时,都会创建一个作用域链,这个作用域链包含了函数定义时所处的作用域(通常称为“词法环境”)以及它的所有父级作用域。例如:
let globalValue = 'global';
function outer() {
    let outerValue = 'outer';
    function inner() {
        let innerValue = 'inner';
        console.log(globalValue); // 首先在自身作用域查找,没找到,然后在父级作用域(outer 的作用域)查找,还没找到,最后在全局作用域找到
        console.log(outerValue); // 在父级作用域(outer 的作用域)找到
        console.log(innerValue); // 在自身作用域找到
    }
    inner();
}
outer();

在上述代码中,inner 函数的作用域链包含了 inner 自身的作用域、outer 的作用域以及全局作用域。当 inner 函数查找变量时,会沿着这个作用域链依次查找。

  1. 作用域链的形成过程 函数的作用域链是在函数定义时确定的,而不是在函数调用时。这就是为什么 JavaScript 是词法作用域(也称为静态作用域)语言。例如:
function createFunction() {
    let localVar = 'local';
    return function() {
        console.log(localVar);
    };
}
let newFunction = createFunction();
let localVar = 'global override';
newFunction(); // 输出 'local',而不是 'global override'

createFunction 中返回的匿名函数,它的作用域链在定义时就确定了,包含 createFunction 的作用域和全局作用域。即使在 createFunction 执行完毕后,localVar 所在的作用域仍然被匿名函数的作用域链所引用,所以当调用 newFunction 时,它能访问到 createFunction 中定义的 localVar,而不是后来在全局作用域定义的 localVar

  1. 作用域链与标识符查找 当 JavaScript 引擎需要查找一个标识符(变量名、函数名等)时,它会从当前执行环境的作用域开始,沿着作用域链向上查找。一旦找到匹配的标识符,就会停止查找。如果一直到全局作用域都没有找到,就会抛出 ReferenceError。例如:
function findVariable() {
    console.log(nonExistentVariable); // 抛出 ReferenceError,因为在作用域链上没找到这个变量
}
findVariable();

在查找过程中,如果存在同名变量,会优先使用离当前作用域最近的变量。例如:

let globalVar = 'global';
function scopeLookup() {
    let globalVar = 'local override';
    console.log(globalVar); // 输出 'local override',因为在函数作用域内找到了同名变量,优先使用
}
scopeLookup();

三、JavaScript 闭包基础

  1. 闭包的定义 闭包是 JavaScript 中一个非常重要且强大的特性。简单来说,闭包是指一个函数能够访问并记住其定义时所处的词法作用域,即使这个函数在其他地方被调用。更准确地讲,闭包是由函数和与其相关联的词法环境组合而成的实体。例如:
function outerFunction() {
    let outerVariable = 'I am from outer function';
    function innerFunction() {
        console.log(outerVariable);
    }
    return innerFunction;
}
let closureFunction = outerFunction();
closureFunction(); // 输出 'I am from outer function'

在上述代码中,outerFunction 返回了 innerFunction,当 closureFunction 被调用时,它仍然能够访问 outerFunction 中的 outerVariable,这就是闭包的体现。innerFunctionouterFunction 的词法环境(包含 outerVariable)组合形成了闭包。

  1. 闭包的原理 闭包的实现依赖于函数的作用域链。当一个函数被定义时,它会创建一个作用域链,这个作用域链包含了函数定义时所处的作用域以及其所有父级作用域。即使函数被返回并在其他地方调用,它的作用域链依然保持不变。 在前面的例子中,innerFunctionouterFunction 内部定义,它的作用域链包含了 outerFunction 的作用域和全局作用域。当 outerFunction 执行完毕后,正常情况下其局部变量应该被垃圾回收,但由于 innerFunction 形成了闭包,它的作用域链中仍然引用着 outerFunction 的作用域,所以 outerFunction 的作用域不会被回收,outerVariable 也就得以保留。

  2. 闭包的实际应用场景 - 数据封装与私有变量 闭包在实现数据封装和私有变量方面非常有用。在 JavaScript 中,没有像其他一些语言那样直接的私有变量声明方式,但通过闭包可以模拟私有变量。例如:

function Counter() {
    let count = 0;
    return {
        increment: function() {
            count++;
            return count;
        },
        getCount: function() {
            return count;
        }
    };
}
let myCounter = Counter();
console.log(myCounter.increment()); // 输出 1
console.log(myCounter.getCount()); // 输出 1
console.log(count); // 这里会报错,count 是私有变量,外部无法访问

在上述代码中,Counter 函数返回一个对象,该对象包含两个方法 incrementgetCount。这两个方法形成了闭包,它们可以访问 Counter 函数内部的 count 变量,而外部代码无法直接访问 count,从而实现了数据封装和私有变量的效果。

四、闭包的高级应用

  1. 函数柯里化 函数柯里化是闭包的一个重要应用。它是指将一个多参数函数转换为一系列单参数函数的技术。例如,假设有一个函数 add 用于计算三个数的和:
function add(a, b, c) {
    return a + b + c;
}

使用柯里化可以将其转换为:

function curriedAdd(a) {
    return function(b) {
        return function(c) {
            return a + b + c;
        };
    };
}
let add = curriedAdd(1);
let addWithTwo = add(2);
let result = addWithTwo(3);
console.log(result); // 输出 6

在这个过程中,curriedAdd 返回的内部函数形成了闭包,它们记住了外部函数传入的参数。柯里化的好处在于可以提前固定一些参数,返回一个新的函数,这个新函数只需要传入剩余的参数,从而提高了函数的灵活性和复用性。

  1. 事件绑定与闭包 在前端开发中,经常需要为元素绑定事件处理函数。闭包在事件绑定中也有重要应用。例如:
<!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 createClickHandler(message) {
            return function() {
                console.log(message);
            };
        }
        let btn1 = document.getElementById('btn1');
        let btn2 = document.getElementById('btn2');
        btn1.addEventListener('click', createClickHandler('You clicked button 1'));
        btn2.addEventListener('click', createClickHandler('You clicked button 2'));
    </script>
</body>

</html>

在上述代码中,createClickHandler 返回的函数形成了闭包,它记住了传入的 message 参数。当按钮被点击时,相应的闭包函数被调用,输出正确的消息。如果不使用闭包,可能会出现一些意外的结果,比如所有按钮点击都输出相同的消息。

  1. 模块模式与闭包 模块模式是一种在 JavaScript 中实现模块化的方式,它利用了闭包的特性。通过模块模式,可以将相关的代码封装在一个函数内部,并通过返回一个对象来暴露需要公开的方法和属性。例如:
let myModule = (function() {
    let privateVariable = 'This is private';
    function privateFunction() {
        console.log('This is a private function');
    }
    return {
        publicMethod: function() {
            console.log(privateVariable);
            privateFunction();
        }
    };
})();
myModule.publicMethod(); // 可以访问到模块内部的私有变量和函数

在上述代码中,立即执行函数表达式(IIFE)返回的对象中的 publicMethod 形成了闭包,它可以访问 IIFE 内部的私有变量和函数。外部代码只能通过 publicMethod 来间接访问这些私有内容,实现了模块化和数据封装。

五、闭包与内存管理

  1. 闭包对内存的影响 由于闭包会引用其定义时所处的作用域,这可能会导致相关的作用域不会被垃圾回收机制回收,从而占用额外的内存。如果闭包使用不当,可能会造成内存泄漏。例如:
function memoryLeakExample() {
    let largeDataArray = new Array(1000000).fill('a lot of data');
    return function() {
        console.log('This is a closure');
    };
}
let leakyClosure = memoryLeakExample();

在上述代码中,memoryLeakExample 函数内部定义了一个很大的数组 largeDataArray,返回的闭包函数虽然没有直接使用 largeDataArray,但由于闭包的作用域链引用了 memoryLeakExample 的作用域,largeDataArray 所在的作用域不会被回收,从而导致大量内存被占用。

  1. 避免闭包导致的内存泄漏 为了避免闭包导致的内存泄漏,需要注意以下几点:
    • 及时释放引用:如果闭包不再需要使用,应该及时解除对闭包的引用,以便垃圾回收机制可以回收相关的内存。例如:
function avoidLeak() {
    let data = 'Some data';
    let closure = function() {
        console.log(data);
    };
    // 使用完闭包后,将其设置为 null
    closure();
    closure = null;
}
- **合理设计闭包**:尽量减少闭包中不必要的变量引用。如果闭包只需要使用某个变量的部分数据,尽量不要直接引用整个变量,而是提取需要的数据。例如,如果闭包只需要数组的长度,而不是整个数组,就直接传入数组长度,而不是整个数组。

3. 闭包与垃圾回收机制的关系 JavaScript 的垃圾回收机制通常采用标记 - 清除算法。当一个对象不再被任何引用所指向时,它就会被标记为可回收,并在垃圾回收阶段被回收。对于闭包来说,由于其作用域链会引用外部作用域中的变量和对象,只要闭包存在,这些被引用的对象就不会被垃圾回收。只有当闭包不再被引用,其作用域链中的所有对象也不再被其他地方引用时,相关的内存才会被回收。

六、闭包与作用域的常见问题及解决方法

  1. 循环中的闭包问题 在循环中使用闭包时,经常会遇到一个问题,就是闭包获取到的循环变量不是预期的值。例如:
for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}
// 预期输出 0, 1, 2, 3, 4,但实际输出 5, 5, 5, 5, 5

这是因为 var 定义的变量在循环外部仍然有效,并且 setTimeout 中的回调函数是异步执行的,当回调函数执行时,循环已经结束,i 的值已经变为 5。 解决这个问题的方法有几种: - 使用 let 关键字let 具有块级作用域,每次循环都会创建一个新的块级作用域,let 定义的 i 在每个块级作用域内是独立的。例如:

for (let i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i);
    }, 1000);
}
// 输出 0, 1, 2, 3, 4
- **使用立即执行函数表达式(IIFE)**:通过 IIFE 可以创建一个新的作用域,并将当前的 `i` 值传递进去。例如:
for (var i = 0; i < 5; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(j);
        }, 1000);
    })(i);
}
// 输出 0, 1, 2, 3, 4
  1. 作用域链查找性能问题 随着作用域链的层级增多,标识符的查找性能会受到影响。因为 JavaScript 引擎需要沿着作用域链依次查找,层级越多,查找时间越长。例如,在一个多层嵌套的函数中查找变量:
function outer() {
    let outerVar = 'outer';
    function middle() {
        let middleVar ='middle';
        function inner() {
            let innerVar = 'inner';
            console.log(outerVar); // 作用域链查找需要经过 inner、middle、outer 三层
        }
        inner();
    }
    middle();
}
outer();

为了提高性能,可以尽量减少作用域链的层级,避免不必要的嵌套。同时,对于频繁访问的变量,可以将其提升到更接近使用的作用域。例如:

function outer() {
    let outerVar = 'outer';
    function middle() {
        let middleVar ='middle';
        let localVar = outerVar; // 将 outerVar 提升到 middle 作用域,减少查找层级
        function inner() {
            let innerVar = 'inner';
            console.log(localVar);
        }
        inner();
    }
    middle();
}
outer();
  1. 闭包导致的内存占用问题 如前面提到的,闭包可能会导致内存占用过高。除了前面提到的避免内存泄漏的方法,还可以注意闭包的使用频率。如果一个闭包函数只需要执行一次,并且在执行完毕后不再需要,就应该及时释放其引用。例如,在一些一次性的事件处理中:
<!DOCTYPE html>
<html lang="en">

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

<body>
    <button id="singleClick">Click me once</button>
    <script>
        let btn = document.getElementById('singleClick');
        btn.addEventListener('click', function() {
            console.log('Clicked');
            // 处理完事件后,解除对事件处理函数的引用
            btn.removeEventListener('click', arguments.callee);
        });
    </script>
</body>

</html>

这样可以避免闭包函数一直占用内存,提高内存使用效率。

七、ES6 箭头函数与闭包和作用域

  1. 箭头函数的作用域特性 箭头函数是 ES6 引入的一种简洁的函数定义方式。与传统函数不同,箭头函数没有自己的 thisargumentssupernew.target 绑定。它的 this 取决于其定义时的词法作用域,而不是调用时的作用域。例如:
let obj = {
    name: 'John',
    getFunction: function() {
        return () => {
            console.log(this.name);
        };
    }
};
let func = obj.getFunction();
func(); // 输出 'John'

在上述代码中,箭头函数 () => { console.log(this.name); }this 指向 getFunction 函数的 this,也就是 obj。这是因为箭头函数的 this 是在定义时确定的,它继承了外层函数(这里是 getFunction)的 this

  1. 箭头函数与闭包的关系 箭头函数同样可以形成闭包。例如:
function outerArrow() {
    let outerValue = 'outer arrow value';
    return () => {
        console.log(outerValue);
    };
}
let arrowClosure = outerArrow();
arrowClosure(); // 输出 'outer arrow value'

在这个例子中,箭头函数返回的函数形成了闭包,它记住了 outerArrow 函数中的 outerValue。箭头函数的闭包特性与传统函数类似,但由于其没有自己的 this 等绑定,在使用闭包时可能会有一些不同的行为和注意事项。

  1. 箭头函数在闭包场景中的注意事项 由于箭头函数没有自己的 this,在一些需要动态 this 绑定的闭包场景中,可能会出现问题。例如,在事件处理函数中:
<!DOCTYPE html>
<html lang="en">

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

<body>
    <button id="arrowBtn">Click me with arrow function</button>
    <script>
        let btn = document.getElementById('arrowBtn');
        btn.addEventListener('click', () => {
            console.log(this); // 这里的 this 指向 window(在浏览器环境下),而不是按钮元素
        });
    </script>
</body>

</html>

如果需要在事件处理函数中访问按钮元素的 this,就不能使用箭头函数,而应该使用传统函数。例如:

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

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

<body>
    <button id="normalBtn">Click me with normal function</button>
    <script>
        let btn = document.getElementById('normalBtn');
        btn.addEventListener('click', function() {
            console.log(this); // 这里的 this 指向按钮元素
        });
    </script>
</body>

</html>

所以在使用箭头函数形成闭包时,需要特别注意 this 的指向问题,确保其符合业务需求。

八、闭包和作用域在框架和库中的应用

  1. 在 React 中的应用 在 React 中,闭包和作用域有着广泛的应用。例如,在函数式组件中,经常会使用闭包来保存组件的状态和逻辑。
import React, { useState } from'react';

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

在上述代码中,increment 函数形成了闭包,它记住了 countsetCount。每次点击按钮调用 increment 时,它能够访问并更新 count 的值。这里的闭包确保了状态的正确更新和组件逻辑的封装。

  1. 在 Vue 中的应用 在 Vue 中,闭包也用于数据的封装和方法的定义。例如,在 Vue 组件的 methods 中:
<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 方法形成了闭包,它可以访问组件的 data 中的 message。虽然这里没有像 React 那样直接体现闭包的复杂场景,但本质上方法能够访问组件内部的数据,是基于闭包和作用域的原理。

  1. 在 jQuery 中的应用 在 jQuery 中,闭包常用于事件处理和插件开发。例如,为元素绑定点击事件:
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <script src="https://code.jquery.com/jquery - 3.6.0.min.js"></script>
</head>

<body>
    <button id="jqueryBtn">Click me with jQuery</button>
    <script>
        $(document).ready(function() {
            let localVar = 'Local variable';
            $('#jqueryBtn').click(function() {
                console.log(localVar);
            });
        });
    </script>
</body>

</html>

在上述代码中,click 事件处理函数形成了闭包,它可以访问 $(document).ready 函数中的 localVar。这在 jQuery 插件开发中也很常见,通过闭包来封装插件的内部逻辑和数据,避免全局变量的污染。

九、总结闭包与作用域的重要性及实践建议

  1. 闭包与作用域的重要性 闭包和作用域是 JavaScript 的核心概念,它们对于代码的组织、封装、复用以及性能都有着至关重要的影响。

    • 代码组织与封装:作用域提供了变量和函数的访问控制,使得代码可以按照逻辑模块进行组织。闭包则进一步实现了数据封装和私有变量,提高了代码的安全性和可维护性。例如,模块模式通过闭包将相关的代码和数据封装在一起,对外只暴露必要的接口。
    • 代码复用:函数柯里化等闭包的应用可以提高函数的复用性,通过提前固定一些参数,生成更灵活的函数。在事件绑定等场景中,闭包也使得相同的事件处理逻辑可以应用于不同的元素,提高了代码的复用性。
    • 性能优化:理解作用域链的查找机制和闭包对内存的影响,有助于优化代码性能。合理使用作用域和避免闭包导致的内存泄漏,可以提高程序的运行效率和稳定性。
  2. 实践建议

    • 遵循最佳实践:在定义变量时,尽量使用 letconst 来利用块级作用域,减少意外的变量提升和作用域问题。对于需要封装的逻辑和数据,考虑使用闭包来实现私有变量和数据封装。
    • 注意内存管理:在使用闭包时,要时刻注意内存的使用情况。避免闭包中不必要的变量引用,及时释放不再使用的闭包引用,以防止内存泄漏。
    • 测试与调试:由于闭包和作用域的复杂性,在开发过程中要进行充分的测试,确保代码在不同场景下的正确性。当遇到问题时,利用调试工具分析作用域链和闭包的状态,找出问题所在。
    • 持续学习:随着 JavaScript 的不断发展,新的特性和规范可能会对闭包和作用域产生影响。持续学习和关注相关的技术动态,有助于更好地掌握和应用这些概念。

总之,深入理解 JavaScript 的闭包和作用域是成为一名优秀 JavaScript 开发者的必经之路,它们为编写高质量、高效且可维护的代码提供了强大的支持。通过不断的实践和学习,开发者可以更好地利用闭包和作用域的特性,解决各种复杂的编程问题。