闭包背后的设计哲学:从词法环境到执行上下文

闭包,JavaScript中又一个让人难以理解的概念,我们时常有意或无意的使用到了它,当我们无法分辨出它的时候,有可能对项目引发这些问题:内存泄露、变量污染、代码维护困难和性能消耗。为此,当它出现的时候我们要认出它,理解它的原理,这样它对项目的影响才在我们的掌控范围内。

1、什么是闭包?

闭包JavaScript中的核心概念,它描述了一个函数与其词法作用域的绑定关系,它允许函数在函数定义的作用域范围外调用时,依然可以访问并操作作用域内的变量。

其实我们更应该叫它为作用域闭包 ,因为闭包就是词法作用域中的变量在外部作用域被使用(虽说不能直接修改,须通过内部的函数进行修改)的现象,所以这么叫也更能理解它的原理。

所以弄清楚词法作用域的概念和规则,闭包的神秘面纱也就能轻易揭开了。

2、词法作用域

同大部分语言一样,JavaScript代码在编译的时候第一步就是将代码分词/词法化 ,编译器会将var a = 7这段代码分解为var、a、=、7这些词法单元。词法作用域就是定义在分词阶段的作用域,它会保证词法分析器处理代码时保持作用域不变,它由我们写代码将变量或者块作用域写在哪里决定。

2.1、作用域结构

直接上图,这样很清晰的展示了作用域的结构关系以及查找规则

这段代码很好的展示了多个作用域之间的关系,我们很直观的通过代码的背景色,由浅入深的将这些代码分为了层层嵌套的3个作用域,它们像套娃一样,大的包住小的。正如前面所说,词法作用域也遵循这样的作用域规则,这种结构关系正如我们写这段代码希望它们应该有的结构关系。

2.2、查找规则

还是以上面那段代码为例,具体分析一下console.log(a,b,c)这段代码中的变量查找过程:

  • 首先是a变量,引擎在对a标识符(也就是变量)进行查找时,首先会在使用a的作用域(3号作用域)中进行查找,当前作用域没有a的声明,自然往上一层作用域(2号作用域)找,注意,2号作用域有一个隐式的声明:var a = 2,这是a作为函数的形参在调用时被声明并赋值的。2号作用域将a的值给了引擎。
  • 第二个是b变量,引擎同样在3号作用域中没能找到它,因此也向2号作用域提出查找请求,2号作用域立马就找到了b标识符,取出它的值(6)给到了引擎。
  • 最后是c变量,虽说没有直观的声明语句,但还是同a变量一样,在3号作用域中有个标识符c的隐式声明:var c = 18,引擎立马就拿到,不再向上一层找了。

关于变量的查找过程,我观察到以下几点:

  1. 作用域查找只能是单向的查找,并且只能从内向外找;
  2. 首先会在当前作用域中查找,一旦找到,不再向上层查找;
  3. 函数的形参会作为函数内部作用域中的一员被隐式声明。
  4. 第2点的延伸,我们可以在多层嵌套的作用域中定义同名的标识符,可以达到"遮蔽"效应,内部标识符"遮蔽"外部的标识符。

3、闭包的设计哲学与执行上下文

3.1、闭包的示例

  • 返回函数:

    javascript 复制代码
    function createFun() {
        var age = 17;
        function showAge() {
            console.log(age); 
        }
        return showAge;
    }
    let myAge = createFun();
    myAge(); // 17 ---看,这就是闭包

    通常,在调用完createFun函数后,基于垃圾回收机制,函数内部的属性会被销毁,但是在createFun函数内部我们创建了一个闭包,闭包是由函数内部的函数(showAge)以及函数声明所在的词法作用域组成。这个闭包包含了createFun作用域内的所有局部变量。当我们调用createFun()后,创建了showAge函数的实例引用,这个实例有showAge声明时的词法作用域引用,age变量就在这个词法作用域当中,所以当我们执行myAge()时,console.log(age)被正常执行(基于词法作用域的查找规则)。

  • 函数作为参数传递:

    javascript 复制代码
    function main() {
        var food = 'tofu';
        function showFood() {
            console.log(food); // tofu
        }
        transmit(showFood);
    }
    ​
    function transmit(fn) {
        fn(); // 这也是闭包
    }
    ​
    main();

    transmit(showFood),当执行这一段代码时,将main函数内部作用域中的showFood函数被当做参数传递给外部transimit函数时,同时也将这个词法作用域也传递了出去,因为showFood函数创建了闭包,所以在transmit函数内部执行showFood(现在叫fn)时,可以正常的执行console.log(food)

3.2、闭包的设计思想

  • JavaScript基于函数式编程的核心思想,即函数作为一等公民,能和变量一样被传递和返回,再加上保留词法作用域的引用就设计出了闭包。看看下面这个例子:

  • 通过保留作用域的引用,可以实现以下特性:

    1. 封装性

      闭包允许函数保留函数创建时的词法环境,形成私有作用域,能像类或者模块一样,有属于自己的私有变量

    2. 灵活性

      函数能在自己声明的作用域以外进行调用,支持回调和柯里化等高阶应用。

3.3、执行上下文及词法环境

  • 每个闭包函数在调用时,JavaScript引擎会创建一个执行上下文,包含创建时的词法环境以及调用时的词法环境,形成了作用域链。

  • 执行上下文包含:

    • 词法环境:函数创建时的作用域中的存储变量和函数声明。
    • 外部环境引用:在调用函数的词法环境中指向父级词法环境(函数创建的作用域),形成作用域链。
  • 闭包的本质是函数与其词法环境的绑定。即使外部函数执行完毕,只要内部函数(闭包)仍被引用,其词法环境就不会被垃圾回收。

4、闭包的应用

4.1、模块化

javascript 复制代码
    function counter(a) {
        let count = a; // 私有变量
        return {
            increment: () => count++,
            getCount: () => count,
        };
    }
    const c1 = counter(1);
    const c2 = counter(7);
    c1.increment();
    c1.getCount(); // 2
    c2.increment();
    c2.getCount(); // 8

在本例中,通过counter工厂函数,创建出独立的两个对象c1、c2,他们之间互不相扰,利用闭包原理,在incrementgetCount函数被创建时,它们的作用域不被销毁,生成了独立的词法作用域。

4.2、函数柯里化

javascript 复制代码
    function addition(a) {
        return function(b) {
            return a + b
        }
    }
    var fun1 = addition(1);
    var res1 = fun1(3);
    console.log(res1); // 4
    var fun2 = addition(8)
    var res2 = fun2(7);
    console.log(res2); // 15

利用闭包原理,在返回了引用参数a的函数中,将参数a记录在了独立的词法作用域中,在调用后不被立即销毁,以备后续使用并记录第一次调用时的参数,利用这一原理,将本来用2个参数的函数分解成了两个只包含1个参数的函数。

5、闭包的陷阱及解决方案

  1. 内存泄漏

    • 问题:闭包长期引用大对象(如DOM元素)导致无法回收。
    • 解决 :手动解除引用(如将变量设为null)。
javascript 复制代码
     function leaky() {
         const bigData = new Array(1e6).fill('*');
         return () => console.log('Leaking...');
     }
     const leak = leaky();
     // 不再需要时手动清理 
     leak = null;
  1. 循环中的闭包

    • 问题:循环内异步操作因闭包捕获变量最终值。

      javascript 复制代码
         for(var i = 0;i < 3; i++) {
             setTimeout(() => console.log(i), 100); // 3,3,3
         }
    • 解决 :使用let块级作用域或立即执行函数(IIFE)。

    javascript 复制代码
        for (let i = 0; i < 3; i++) {
            setTimeout(() => console.log(i), 100); // 0,1,2(let修复)
        }

6、闭包的优缺点

优点 缺点
实现数据封装与私有化 内存占用高(需保留作用域链)
支持高阶函数与函数式编程范式 可能引发内存泄漏
灵活传递函数上下文 调试困难(变量来源不直观)

最佳实践

  • 优先使用模块化模式替代全局闭包。
  • 避免在闭包中保留大型对象或DOM引用。
  • 使用工具(如Chrome DevTools Memory面板)检测内存泄漏。

7、结语

闭包无处不在,我们只需要在它出现的时候发现它,利用它,最后销毁它。我认为闭包是一个工具,和我们生活中使用的工具一样,我们得手握工具说明书,认真阅读,并多多使用它,把它发挥到极致。希望本文能帮助各位同仁掌握闭包原理,哪怕是一点点,我就深感欣慰。最后,如本文传达了不正确的知识,还是希望能有朋友和大牛能及时指正,在此谢过。

相关推荐
聪明的墨菲特i19 分钟前
React与Vue:哪个框架更适合入门?
开发语言·前端·javascript·vue.js·react.js
拉不动的猪27 分钟前
v2升级v3需要兼顾的几个方面
前端·javascript·面试
冴羽1 小时前
SvelteKit 最新中文文档教程(20)—— 最佳实践之性能
前端·javascript·svelte
Nuyoah.1 小时前
《Vue3学习手记2》
javascript·vue.js·学习
zpjing~.~1 小时前
css 二维码始终显示在按钮的正下方,并且根据不同的屏幕分辨率自动调整位置
前端·javascript·html
lynx_1 小时前
又一个跨端框架——万字长文解析 ReactLynx 实现原理
前端·javascript·前端框架
夜寒花碎2 小时前
前端基础理论——02
前端·javascript·html
uhakadotcom2 小时前
简单易懂的Storybook介绍:让前端UI组件开发变得更高效
前端·javascript·面试
bnnnnnnnn2 小时前
前端实现多服务器文件 自动同步宝塔定时任务 + 同步工具 + 企业微信告警(实战详解)
前端·javascript·后端
萧门竹巷3 小时前
你可能不知道的 HTML5 新特性——「鲷哥」真的好用!
javascript