从作用域到闭包:吃透JavaScript变量“保鲜”的核心逻辑 ✨

12 阅读10分钟

前言

作为前端入门的“拦路虎”,作用域、作用域链和闭包常常让新手宝子们头疼😣!其实它们一点都不复杂,我们结合真实代码实例,把这三个知识点讲得明明白白,还会给闭包安排超有意思的案例,保证你看完就能懂、懂了就能用~

一、 先搞懂:什么是作用域?

简单来说,作用域就是变量和函数的可访问范围,控制着变量和函数的可见性和生命周期。作用域就规定了“哪些变量能在哪个区域被访问”,还决定了变量的生命周期(什么时候创建、什么时候销毁)。

JS里有3种常见作用域,每一种都有明确的“访问规则”,结合代码实例一看就懂👇

1.1 全局作用域

最外层的作用域,代码在全局范围内都能访问到,就像家里的客厅,所有人都能使用客厅里的物品。

注意哦⚠️:全局作用域的变量,在任何函数、代码块里都能访问到,除非被局部变量“覆盖”~

1.2 函数作用域

定义在函数内部的作用域,就像自己的卧室,里面的东西(变量、函数)只有在卧室里能用到,出了卧室就找不到啦。

var a = 1 // 全局变量
function fn(){
    var a = 2 // 函数作用域的局部变量
    console.log(a); // 访问局部变量,输出:2
}
fn()
console.log(a); // 访问全局变量,输出:1(局部变量不影响全局)

划重点✅:函数作用域里的变量,只能在函数内部访问,外部无法访问;而且局部变量会“覆盖”同名的全局变量哦~

1.3 块级作用域

ES6之后新增的作用域,用 letconst 声明的变量,在 iffor{} 等代码块里,就是块级作用域,出了代码块就无法访问(和 var 有很大区别!)。

// 用let声明的块级变量
if(1){
    let a = 1 // 块级作用域变量
}
console.log(a); // 报错:a is not defined(出了块级作用域就找不到啦)

// 对比var(没有块级作用域)
if(true){
    var b = 2
}
console.log(b); // 输出:2(var声明的变量会“溢出”到全局)

小提醒🥰:letconst 才有块级作用域,var 没有,这也是为什么现在推荐用 let/const 而不是 var

💡 核心规则:作用域只能“由内到外”访问

不管是哪种作用域,都遵循一个铁律:内部能访问外部的变量,外部不能访问内部的变量!

🐒 再进阶:作用域链是什么?

搞懂了作用域,作用域链就很简单啦!我们可以把它理解为「变量的“查找路线”」—— 当JS引擎在当前作用域找不到某个变量时,就会去“上一级作用域”找,找不到再往上找,层层往上,直到找到全局作用域,如果还是找不到,就会报错。

举个代码实例,一看就懂👇

// 全局作用域
var myName = '米妮'

function bar(){
    console.log(myName); // 当前作用域没有myName,去上一级找
}

function foo(){
    var myName = '米奇' // 函数作用域
    bar() // 调用bar,bar的上一级是全局作用域(不是foo!)
}

foo() // 输出:米妮

这里有个关键知识点📝:函数的“上一级作用域”,是它「声明时的作用域」,不是调用时的作用域!上面的bar函数在全局声明,所以它的上一级是全局作用域,哪怕在foo里调用,也不会去foo的作用域找myName~

JS引擎在编译函数时,会自动用一个 outer 指针,记录该函数的外层作用域是谁,这就是作用域链的底层原理哦!

二、闭包:JavaScript的“变量保鲜术”

2.1 闭包的本质:为什么变量能“续命”? 🤔

先明确一个基础前提:通常情况下,一个函数执行完毕后,它的执行上下文会从调用栈中被销毁,函数内部的变量也会随之释放,无法再被访问。但闭包打破了这个规则 。

闭包的官方定义:有权访问另一个函数作用域中变量的函数。更通俗的解释是:当一个内部函数引用了外部函数的变量,即使外部函数执行完毕,内部函数依然能访问这些变量——因为外部函数的作用域不会被完全销毁,而是会保留一个“变量集合”,专门存放内部函数需要引用的变量,这个集合就是闭包 。

2.2 手把手看懂闭包 💻

枯燥的理论不如有趣的代码,下面结合3个实例,从简单到复杂,带你吃透闭包的用法和原理,所有实例均基于你提供的代码优化,更具趣味性和实用性。

实例1:最基础的闭包——延长变量生命周期(变量 “保鲜”)

这个实例能直观看到闭包的“保鲜”效果,我们用“保存人名”来演示,代码简单易懂,一眼就能看出闭包的作用:

function saveName() {
  var myname = '米奇';
  var age = 18; // 这个变量内部函数没引用,不会被闭包保留
  
  function showName() {
    console.log(myname); // 引用外部函数的myname
  }
  return showName;
}

// 1. 执行外部函数,得到内部函数,此时外部函数已执行完毕
var getSavedName = saveName();
// 2. 调用内部函数,依然能访问到外部函数的myname
getSavedName(); // 输出:米奇

解析:saveName执行完毕后,按常理myname和age都会被销毁,但因为内部函数showName引用了myname,闭包会保留myname这个变量,而age没有被引用,会正常释放。这就是闭包的核心:只保留内部函数需要的变量,不浪费内存 💡。

实例2:闭包解决经典问题——“循环变量污染”

这是面试中高频出现的闭包场景,也是开发中容易踩坑的点。我们用“给数组添加函数,输出正确索引”来演示,对比两种写法,更能体现闭包的价值:

// 需求:创建一个数组,存入5个函数,每个函数执行时输出对应的索引(1-5)
var arr = [];

// 错误写法:没有闭包,所有函数都引用同一个变量i ❌
// for(var i = 1; i <= 5; i++){
//   arr.push(function(){
//     console.log(i); // 所有函数都指向全局的i,最终i=6
//   })
// }

// 正确写法:用闭包“锁住”每一次循环的i值 ✅
for(var i = 1; i <= 5; i++){
  // 外部函数foo,接收每次循环的i作为参数
  function foo(j) {
    // 内部函数,引用外部函数的j(每次循环的i值)
    arr.push(function(){
      console.log(j); 
    })
  }
  // 调用foo,传入当前i值,闭包保留每次的j
  foo(i)
}

// 执行数组中的函数,输出正确的索引
for(let n = 0; n < arr.length; n++){
  arr[n](); // 输出:1 2 3 4 5(完美符合需求)
}

解析:错误写法中,var声明的i是全局变量,循环结束后i变成6,所有函数都引用同一个i,所以输出都是6。而正确写法中,每次循环调用foo(i),foo作为外部函数,j接收当前i值,内部函数引用j,闭包会为每一次循环保留对应的j值,因此每个函数执行时能输出正确的索引。

这个实例也能帮你理解:闭包可以创建独立的作用域,避免变量污染

实例3:闭包的“隐藏技能”——私有变量封装

闭包的一大实用价值的是封装私有变量,让变量只能通过指定方法访问和修改,避免被外部随意篡改。我们用“个人信息保护”来演示,模拟一个简单的用户模块:

// 封装用户信息模块,用闭包创建私有变量 
function createUser() {
  // 私有变量:只能通过内部方法访问,外部无法直接修改
  var username = '米奇';
  var password = '123456'; // 实际开发中会加密,这里仅演示
  
  // 内部方法:操作私有变量
  return {
    // 查看用户名(只读)
    getUsername: function() {
      return username;
    },
    // 修改密码(可控修改)
    setPassword: function(newPwd) {
      if(newPwd.length >= 6) { // 限制密码长度,增加安全性
        password = newPwd;
        return '密码修改成功';
      } else {
        return '密码长度不能小于6位';
      }
    },
    // 查看密码(模拟授权访问)
    getPassword: function(auth) {
      if(auth === 'admin') {
        return password;
      } else {
        return '无访问权限';
      }
    }
  }
}

// 创建用户实例
var user = createUser();

// 测试私有变量访问
console.log(user.username); // 输出:undefined(外部无法直接访问)❌
console.log(user.getUsername()); // 输出:米奇(通过指定方法访问)✅
console.log(user.setPassword('654321')); // 输出:密码修改成功(可控修改)✅
console.log(user.getPassword('test')); // 输出:无访问权限(授权验证)🔒
console.log(user.getPassword('admin')); // 输出:654321(授权后访问)✅

为什么不能直接访问username

核心原因:username是闭包中的私有变量,被作用域限制在createUser函数内部,外部无法直接访问,具体解析如下:

  1. 代码中username是在createUser函数内部用var声明的变量,属于「函数作用域」(仅在createUser内部可访问);
  2. 闭包的封装特性:createUser返回的是内部方法(getUsername、setPassword等),而非username本身,外部只能拿到返回的方法,无法直接触及函数内部的username;
  3. JavaScript 作用域规则:内层作用域的变量,外层无法直接访问(作用域只能由内到外访问),username在createUser内部(内层),外部代码(如user.username)属于外层,因此无法直接访问,输出undefined。

2.3 闭包的优缺点:合理使用才是关键

闭包不是“万能的”,有优点也有缺点,掌握其特性才能避免踩坑。

优点:
  • ✅ 封装私有变量:避免变量污染,提高代码安全性(如实例3);

  • ✅ 延长变量生命周期:让需要长期使用的变量(如缓存数据)不被轻易销毁;

  • ✅ 实现模块化:将相关逻辑和变量封装在一起,提高代码可维护性。

缺点:

最核心的缺点是可能导致内存泄漏 ⚠️。因为闭包会保留外部函数的变量,若闭包长期被引用(如挂载在window上),这些变量会一直占用内存,无法被垃圾回收机制回收,久而久之会导致内存占用过高,影响页面性能。

解决办法:不需要使用闭包时,及时解除引用(如将闭包赋值为null),让垃圾回收机制回收相关变量 。

2.4 补充:闭包与作用域链的关系

很多人会混淆闭包和作用域链,其实两者是“因果关系”:

作用域链是JavaScript的查找规则:V8引擎在当前作用域查找变量时,若找不到,就会去上一级作用域查找,层层往上,直到找到全局作用域。而闭包之所以能访问外部函数的变量,本质就是依靠作用域链——内部函数的作用域链中,包含了外部函数的作用域,即使外部函数执行完毕,这个作用域链依然存在,从而能找到对应的变量。

简单记:作用域链是闭包的“基础”,闭包是作用域链的“高级应用”

三、总结:闭包的核心价值 🌟

闭包的本质,是作用域规则下的“变量保鲜机制”——它让内部函数能“记住”外部函数的变量,打破了“函数执行完毕变量就销毁”的常规。从基础的变量保留,到解决循环污染,再到私有变量封装,闭包在实际开发中应用广泛,也是面试中的高频考点 。

掌握闭包的关键,不是死记硬背定义,而是理解“内部函数引用外部变量”这个核心条件,结合代码实例多练习,就能灵活运用。同时也要注意规避内存泄漏的问题,合理使用闭包,让它成为提升代码质量的工具,而不是踩坑的源头 。