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

JavaScript函数与闭包的深度解析

2023-04-144.6k 阅读

JavaScript 函数基础概念

在 JavaScript 中,函数是一等公民,这意味着函数可以像其他数据类型(如字符串、数字等)一样被使用。函数可以被赋值给变量,作为参数传递给其他函数,并且可以从其他函数中返回。

函数定义方式

  1. 函数声明
    function add(a, b) {
        return a + b;
    }
    
    函数声明具有函数提升特性,即在代码执行之前,函数声明会被提升到其所在作用域的顶部。这意味着可以在函数声明之前调用该函数。
    console.log(add(1, 2));// 输出 3
    function add(a, b) {
        return a + b;
    }
    
  2. 函数表达式
    const subtract = function (a, b) {
        return a - b;
    };
    
    函数表达式不会被提升,所以在定义之前调用会报错。
    console.log(subtract(5, 3));// 报错,subtract is not defined
    const subtract = function (a, b) {
        return a - b;
    };
    
  3. 箭头函数
    const multiply = (a, b) => a * b;
    
    箭头函数语法简洁,没有自己的 thisargumentssupernew.target。它的 this 取决于其定义时的词法作用域。

函数参数

  1. 默认参数 JavaScript 允许为函数参数设置默认值。
    function greet(name = 'Guest') {
        console.log(`Hello, ${name}!`);
    }
    greet();// 输出 Hello, Guest!
    greet('John');// 输出 Hello, John!
    
  2. 剩余参数 剩余参数允许将不确定数量的参数收集到一个数组中。
    function sum(...numbers) {
        return numbers.reduce((acc, num) => acc + num, 0);
    }
    console.log(sum(1, 2, 3));// 输出 6
    
  3. 解构参数 可以对函数参数进行解构。
    function printCoordinates({x, y}) {
        console.log(`x: ${x}, y: ${y}`);
    }
    printCoordinates({x: 10, y: 20});// 输出 x: 10, y: 20
    

函数作用域与执行上下文

作用域

  1. 全局作用域 在 JavaScript 中,全局作用域是最外层的作用域。在全局作用域中定义的变量和函数可以在整个脚本中访问。
    let globalVar = 'I am global';
    function globalFunction() {
        console.log(globalVar);
    }
    globalFunction();// 输出 I am global
    
  2. 函数作用域 函数内部定义的变量具有函数作用域,只能在函数内部访问。
    function localFunction() {
        let localVar = 'I am local';
        console.log(localVar);
    }
    localFunction();// 输出 I am local
    console.log(localVar);// 报错,localVar is not defined
    
  3. 块级作用域 ES6 引入了 letconst 关键字,它们具有块级作用域。块级作用域由 {} 包裹,比如 if 语句块、for 循环块等。
    {
        let blockVar = 'I am in block';
        console.log(blockVar);
    }
    console.log(blockVar);// 报错,blockVar is not defined
    

执行上下文

  1. 创建阶段 当函数被调用时,执行上下文的创建阶段开始。在这个阶段,会进行变量提升、函数提升,并确定 this 的值。
    function example() {
        console.log(a);
        var a = 10;
        console.log(a);
    }
    example();
    // 输出 undefined
    // 输出 10
    
    在创建阶段,变量 a 被提升,但由于只是声明被提升,初始化未提升,所以第一次 console.log(a) 输出 undefined
  2. 执行阶段 在执行阶段,代码逐行执行,变量被赋值,函数被调用等操作在此阶段完成。

闭包的概念

什么是闭包

闭包是指函数能够记住并访问其词法作用域,即使函数在其词法作用域之外被调用。简单来说,闭包就是函数和其周围状态(词法环境)的组合。

function outer() {
    let outerVar = 'I am outer';
    function inner() {
        console.log(outerVar);
    }
    return inner;
}
const closureFunction = outer();
closureFunction();// 输出 I am outer

在上述代码中,inner 函数在 outer 函数内部定义,outer 函数返回 inner 函数。当 outer 函数执行完毕后,其作用域链本应被销毁,但由于 inner 函数对 outerVar 的引用,outer 函数的作用域链得以保留,从而形成了闭包。

闭包的作用

  1. 数据封装与隐藏 闭包可以用于封装数据,将某些变量隐藏在函数内部,外部代码无法直接访问。
    function counter() {
        let count = 0;
        return {
            increment: function () {
                count++;
                return count;
            },
            getCount: function () {
                return count;
            }
        };
    }
    const myCounter = counter();
    console.log(myCounter.increment());// 输出 1
    console.log(myCounter.getCount());// 输出 1
    console.log(count);// 报错,count is not defined
    
    在这个例子中,count 变量被封装在 counter 函数内部,外部只能通过 incrementgetCount 方法来操作和获取 count 的值。
  2. 实现模块模式 闭包在 JavaScript 中用于实现模块模式,将相关的函数和数据封装在一个模块中。
    const myModule = (function () {
        let privateVar = 'This is private';
        function privateFunction() {
            console.log(privateVar);
        }
        return {
            publicFunction: function () {
                privateFunction();
            }
        };
    })();
    myModule.publicFunction();// 输出 This is private
    console.log(privateVar);// 报错,privateVar is not defined
    
    在这个模块模式中,privateVarprivateFunction 是私有的,外部只能通过 publicFunction 来间接使用模块内部的功能。

闭包与内存管理

闭包对内存的影响

由于闭包会保留其词法作用域中的变量,这可能会导致内存占用增加。如果闭包被滥用,可能会造成内存泄漏。

function createLargeArray() {
    let largeArray = new Array(1000000).fill(1);
    function inner() {
        console.log('Inner function accessing largeArray');
    }
    return inner;
}
const closureWithLargeArray = createLargeArray();
// 这里即使 createLargeArray 执行完毕,由于闭包的存在,largeArray 依然存在于内存中

在上述代码中,largeArray 是一个非常大的数组,createLargeArray 函数返回的闭包 innerlargeArray 有引用,导致 largeArray 不会被垃圾回收机制回收,从而占用大量内存。

避免闭包导致的内存问题

  1. 及时释放引用 如果不再需要闭包对某些变量的引用,可以手动将这些变量设置为 null,以便垃圾回收机制回收内存。
    function createLargeArray() {
        let largeArray = new Array(1000000).fill(1);
        function inner() {
            console.log('Inner function accessing largeArray');
        }
        let result = inner;
        largeArray = null;
        return result;
    }
    const closureWithLargeArray = createLargeArray();
    // 这里 largeArray 被设置为 null,有可能被垃圾回收机制回收
    
  2. 合理使用闭包 只在必要时使用闭包,并且确保闭包的生命周期是合理的。不要让闭包长期持有不必要的变量引用。

闭包在实际开发中的应用

事件处理中的闭包

在 JavaScript 事件处理中,闭包经常被使用。

function setupClickListeners() {
    const buttons = document.querySelectorAll('button');
    for (let i = 0; i < buttons.length; i++) {
        buttons[i].addEventListener('click', function () {
            console.log(`Button ${i} clicked`);
        });
    }
}
setupClickListeners();

在这个例子中,addEventListener 的回调函数形成了闭包,它记住了 i 的值。如果这里使用 var 声明 i,会因为 var 的函数作用域特性导致所有按钮点击时输出的都是 Button ${buttons.length} clicked,而使用 let 声明 ilet 的块级作用域特性使得每个闭包都能记住自己的 i 值。

函数柯里化中的闭包

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

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

在上述代码中,add 函数返回的内部函数都形成了闭包,记住了外部函数传入的参数 ab 等,最终实现了柯里化的功能。

闭包与 this 的关系

箭头函数中的闭包与 this

箭头函数没有自己的 this,它的 this 取决于其定义时的词法作用域。

const obj = {
    name: 'John',
    getName: function () {
        return () => console.log(this.name);
    }
};
const getNameClosure = obj.getName();
getNameClosure();// 输出 John

在这个例子中,箭头函数 () => console.log(this.name) 形成闭包,它的 this 指向 obj,因为它是在 obj.getName 函数内部定义的,obj.getName 函数的 this 指向 obj

普通函数中的闭包与 this

普通函数在调用时,this 的值取决于函数的调用方式。

function outer() {
    let self = this;
    function inner() {
        console.log(self);
    }
    return inner;
}
const outerObj = {message: 'Hello'};
const innerFunction = outer.call(outerObj);
innerFunction();// 输出 {message: 'Hello'}

在这个例子中,为了在闭包 inner 中访问到 outer 函数的 this,我们使用 let self = thisthis 的值保存下来,因为 inner 函数在调用时,其 this 可能会改变,而通过保存 this 的值到 self,可以在闭包中正确访问到外层函数的 this 所指向的对象。

闭包的性能考虑

闭包性能开销来源

  1. 内存占用 闭包会保留其词法作用域中的变量,这可能导致额外的内存占用。特别是当闭包持有大对象或大量数据时,对内存的影响更为明显。
  2. 函数调用开销 闭包通常涉及多层函数嵌套和调用,每一次函数调用都会有一定的性能开销,包括创建新的执行上下文、参数传递等操作。

优化闭包性能

  1. 减少不必要的闭包嵌套 尽量避免过度的闭包嵌套,减少不必要的函数调用层次。
    // 不好的示例
    function outer() {
        function middle() {
            function inner() {
                console.log('Inner');
            }
            inner();
        }
        middle();
    }
    outer();
    
    // 优化示例
    function outer() {
        function inner() {
            console.log('Inner');
        }
        inner();
    }
    outer();
    
    在优化示例中,减少了一层不必要的函数嵌套,从而减少了函数调用开销。
  2. 及时释放闭包引用 如前文所述,当闭包不再需要时,及时释放对闭包内部变量的引用,以便垃圾回收机制回收内存,减少内存占用。

深入理解 JavaScript 函数与闭包的内部机制

函数对象与原型

在 JavaScript 中,函数是对象,每个函数都有一个 prototype 属性。

function MyFunction() {}
console.log(MyFunction.prototype);

函数的 prototype 属性指向一个对象,这个对象包含了函数的实例方法。当通过 new 关键字调用函数创建实例时,实例的 __proto__ 属性会指向函数的 prototype

function MyFunction() {}
MyFunction.prototype.sayHello = function () {
    console.log('Hello');
};
const myInstance = new MyFunction();
myInstance.sayHello();// 输出 Hello

理解函数的原型机制对于深入理解闭包也很重要,因为闭包可能会涉及到函数实例与原型之间的关系。

闭包在 V8 引擎中的实现

V8 引擎是 Chrome 浏览器使用的 JavaScript 引擎。在 V8 中,闭包的实现依赖于作用域链和堆内存管理。当一个函数创建闭包时,V8 会在堆内存中创建一个对象来存储闭包所需要的状态(词法环境)。 例如,当一个函数返回另一个函数形成闭包时,V8 会确保返回的函数能够正确访问到外部函数的变量,这涉及到对作用域链的维护和管理。V8 会优化闭包的内存使用,尽量减少不必要的内存占用,但在复杂的闭包场景下,仍然可能出现内存问题,这就需要开发者合理使用闭包。

总结常见的函数与闭包错误及解决方案

函数作用域与闭包相关错误

  1. 变量提升导致的意外结果 由于变量提升,在函数内部变量声明之前访问变量会得到 undefined,而不是预期的值。
    function example() {
        console.log(a);
        var a = 10;
    }
    example();// 输出 undefined
    
    解决方案是在函数开头声明所有变量,或者使用 letconst 关键字,它们没有变量提升特性。
    function example() {
        let a;
        console.log(a);
        a = 10;
    }
    example();// 输出 undefined,但更符合预期的声明顺序
    
  2. 闭包中 this 的错误使用 在普通函数闭包中,this 的值可能会因为函数调用方式的改变而改变,导致意外结果。
    function outer() {
        this.value = 10;
        function inner() {
            console.log(this.value);
        }
        return inner;
    }
    const innerFunction = outer();
    innerFunction();// 输出 undefined,因为这里的 this 指向全局对象(在非严格模式下)
    
    解决方案如前文所述,可以使用 let self = this 保存 this 的值,或者使用箭头函数,箭头函数没有自己的 this,其 this 取决于词法作用域。
    function outer() {
        this.value = 10;
        const self = this;
        function inner() {
            console.log(self.value);
        }
        return inner;
    }
    const innerFunction = outer();
    innerFunction();// 输出 10
    

解决闭包性能问题的实践

  1. 分析内存使用 使用浏览器的开发者工具(如 Chrome DevTools)的性能分析和内存分析功能,查看闭包是否导致了内存泄漏或过度的内存占用。通过分析内存快照,可以确定哪些对象没有被正确释放,进而优化闭包的使用。
  2. 代码审查与优化 在代码审查过程中,仔细检查闭包的使用场景,确保闭包的使用是必要的。对于不必要的闭包,及时进行重构,减少函数嵌套和内存占用。同时,注意闭包与其他代码逻辑的交互,避免因为闭包的存在导致整体性能下降。

通过深入理解 JavaScript 函数与闭包的概念、内部机制、性能影响以及常见错误,开发者能够更有效地编写高质量、高性能的 JavaScript 代码。无论是在前端开发还是后端开发(如 Node.js)中,函数与闭包都是非常重要的概念,掌握它们对于提升编程能力和解决实际问题具有重要意义。