从作用域链到闭包,原理一次讲透

引言

你是否好奇:为什么有些函数执行完毕后,内部变量会自动消失,而另一些函数却能永久 "记住" 之前的数据?这背后不是魔法,而是 JavaScript 最精妙的两大设计 ------作用域链闭包。一个决定了变量能被谁访问,一个决定了变量能存活多久。读懂它们,你才算真正走进 JavaScript 的底层逻辑。

一、作用域链:闭包的底层根基

想要弄懂闭包,作用域链是地基,先看懂链条逻辑,闭包自然一通百通。

1.什么是作用域链:

  • JS代码在预编译阶段会规划好调用栈 :每当一个函数被调用执行,就会在调用栈中新增一块「执行上下文」;每一块执行上下文由变量环境、词法环境 两部分组成。其中变量环境里自带outer指针,固定指向外层的执行上下文。
  • 当v8引擎查找变量时,会先在当前执行上下文寻找;找不到就顺着outer指针,逐层去往外层上下文继续查找,一路追溯到全局作用域为止。
    这套由outer指针首尾串联、由内向外逐层查找变量的链式结构,就叫做作用域链

补充细节:全局执行上下文没有外层环境,它的outer指向null

2.还是听不懂的话也没关系,让我们来看一个例子:

js 复制代码
function bar() {
  console.log(myName);  
}
function foo() {
  var myName = '小杰'
  bar()
}
var myName = '西索'
foo()

请大家思考一下这个输出的结果是什么呢?

来,让我们看看输出的结果:

3.为什么呢?让我画一张图来帮助大家理解

  • 先看咱们画好的分层结构图:整张大框代表全局,内部从上到下三层分别是:bar 执行上下文(绿框)、foo 执行上下文(黄框)、全局执行上下文(蓝框) ,每个上下文都拆成「变量环境 + 词法环境」两块,黑色箭头就是关键的outer指针。

  • outer 指针核心规则:

    bar函数是在全局作用域定义 的,所以它变量环境里的outer黑色箭头,直接指向【全局执行上下文】,和bar在哪里被调用(foo 内部)毫无关系;foo同样在全局定义,它的outer也指向全局;最底层全局上下文没有外层,outer = null

  • 代码走到bar打印myName,先在bar 自己的绿色变量环境 里找,框内没有这个变量;顺着 bar 的outer黑箭头,直接跳到最下方蓝色全局变量环境 ,找到myName = "西索";黄色 foo 框里虽然存了myName="小杰",但 bar 的outer根本不指向 foo,不在 bar 的作用域链上,访问不到

    ✅ 最终输出:西索

关键结论:作用域链由函数定义位置决定,和函数调用位置无关

二、闭包:作用域链催生的特殊产物

1.闭包的定义

  1. 常规函数销毁规则

    普通函数调用执行结束后,函数内部的执行上下文连同里面定义的局部变量,会被 JS 垃圾回收机制销毁释放,变量无法再被访问。

  2. 闭包诞生的核心条件

    依照 JS 作用域链查找规则:内层函数天生可以访问外层函数的局部变量

如果把内层函数从外层函数内部返回到全局作用域执行 ,即便外层函数早已执行完毕、本该销毁上下文,但因为内层函数持续引用着外层变量,这些被引用的变量就不会被回收;「内层函数 + 被保留的外层变量」这个组合,就是闭包

代码示例:

js 复制代码
function foo() {
  var myName = '张老师'  //外层局部变量
  var age = 18
  function bar() {  //内层函数
    console.log(myName);  //内层引用外层变量myName
  }
  return bar  //把内层函数返回到全局
}

var baz = foo()  //foo执行完毕,正常本该销毁内部变量
baz()  //全局调用内层bar,依然能打印到:张老师

代码运行逻辑:

  1. foo()执行完成后,本身函数体从调用栈销毁,常规情况下myName、age全部被回收;
  2. bar被赋值给全局变量baz,全局随时能调用baz
  3. bar内部只用到了myName,因此只有被引用的 myName 被闭包留存,age 没有被引用直接正常销毁
  4. 后续执行baz(),控制台正常输出:张老师

如图所示

外层 foo 执行完本体销毁,但因为内层 bar 还活着、还在引用myName,JS 单独开辟一块小空间 存放myName;age 没被使用,跟随 foo 原上下文正常回收。

  • 这个小空间就是闭包

2. 闭包优缺点

优点:定义私有变量,防止全局变量污染

JS 原生没有私有变量关键字,利用闭包可以把数据锁在函数内部,外部代码无法直接修改内部变量,只能通过我们主动暴露的函数去读写数据,是早年 JS 实现模块化、数据私有化最主流的方案。

缺点:滥用极易造成内存泄漏

闭包会让被引用的变量长期常驻内存,无法被垃圾回收销毁;如果闭包函数长期闲置不用、开发者又没有手动切断引用,无用数据会一直占用堆内存,日积月累就引发内存泄漏

三、经典实战:闭包解决循环取值陷阱

1. 原代码现象与错误原因

js 复制代码
var arr = []
for (var i = 1; i <= 5; i++) {
  arr.push(function() {
      console.log(i);
  })
}
//  调用全部输出:6、6、6、6、6
for (let n = 0; n < arr.length; n++) {
  arr[n]()
}

输出的结果为五个6。

原因:

  • 全局:因为var i挂载在全局作用域,全程只有 1 个 i 变量 ,循环结束后i=6
  • 五个函数:循环五次往数组存入 5 个函数,所有函数的 outer 指针统一指向全局作用域,没有保存每轮循环瞬时值。
  • 执行调用阶段:函数运行时顺着 outer 去全局查找 i,全局 i 早已变成 6,因此全部打印 6。

核心问题:5 个函数共用同一个全局变量i

2. 解决方案1:使用闭包

js 复制代码
var arr = []
for (var i = 1; i <= 5; i++) {
// 每轮循环生成独立作用域
  function foo(j) {
    arr.push(function() {
      console.log(j);  //输出结果为1、2、3、4、5
    })
  }
  foo(i)  // 将当前i作为实参传入
}

for (let n = 0; n < arr.length; n++) {
  arr[n]()
}

原理:每轮循环靠闭包生成独立私有变量,保存当前循环数值

3.解决方案2:ES6 let 简化写法(底层近似闭包原理)

var i改为let i

js 复制代码
var arr = []

for(let i = 1; i <= 5; i++) {
    arr.push(function(){
        console.log(i);//输出:1,2,3,4,5
    })
}

for(var n = 0; n <arr.length; n++) {
    arr[n]()
}

let在 for 循环中,每次迭代自动生成块级独立绑定,等价于 JS 自动帮我们生成闭包保存每轮 i,无需手动写自执行函数。

相关推荐
白帽小阳8 分钟前
2026前端面试题!(附答案及解析)
javascript·网络·python·安全·web安全·网络安全·护网行动
这是个栗子15 分钟前
前端开发中的常用工具函数(八)
开发语言·前端·javascript
Hilaku26 分钟前
Vue 和 React 真正的差距,不在语法,而在团队犯错成本
前端·javascript·程序员
研☆香37 分钟前
为什么变量声明在赋值前也能使用?—— 深入理解 JavaScript 变量提升与作用域
开发语言·前端·javascript
柯克七七1 小时前
我把 bug 修得太干净了,测试组以为这个模块本来就没问题
前端·javascript·typescript
Null1551 小时前
浏览器唤起桌面端应用(终极篇)
前端·javascript
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十四):后台初始化请求实现
前端·javascript·go
JackieZhengChina2 小时前
零依赖轻量级JS悬浮提示插件title2tip-js的使用教程
开发语言·javascript·ecmascript
大家的林语冰2 小时前
👍 Rust 为 Node 插上翅膀,反超 Bun 和 pnpm,一体化工具我也有!
前端·javascript·node.js
ssshooter3 小时前
Promise 和 async/await 被 try-catch 包裹时的行为差异
前端·javascript·面试