作用域与作用域链:JS 的“找东西”逻辑,闭包到底是个啥?

0 阅读6分钟

为什么有的变量在函数里能用,在外面却报错?为什么循环里的i总是最后一个值?今天我们就来聊聊JavaScript的作用域和作用域链,顺便揭开闭包的神秘面纱。保证你看完之后,再也不用背面试题了。

前言

想象一下这样的场景:你在自己房间里找手机,找不到就去客厅找,再找不到就去邻居家借手机打电话。如果所有地方都找不到,那就只能放弃——手机丢了。

JavaScript在查找变量时,也是这么个流程。这个“找东西”的规则,就是作用域链。而变量能在哪些地方被找到,由它的作用域决定。

今天我们就来把这件事彻底捋清楚。

一、作用域:变量的“活动范围”

作用域就是变量能够被访问到的范围。JS中有三种主要作用域:

1. 全局作用域:公共场所

在函数外面定义的变量,或者没加任何关键字直接写的变量(严格模式会报错),都属于全局作用域。

var globalVar = '我是全局的';
let alsoGlobal = '我也是全局的';

function sayHello() {
  console.log(globalVar); // 能访问
}

全局变量就像公共场所的设施,谁都能用,但正因为谁都能改,所以容易出问题。而且全局变量会一直存在,直到页面关闭。

2. 函数作用域:自己家

在函数内部用var声明的变量,只能在这个函数内部访问。外面进不去,里面可以出去(找外面的变量)。

function myHouse() {
  var secret = '我藏起来的零食';
  console.log(secret); // 能访问
}
console.log(secret); // 报错:secret is not defined

函数作用域像自己家,外人不能随便进,但你可以从家里出去(访问全局)。

3. 块级作用域:卧室里的保险柜

ES6新增的letconst带来了块级作用域。块就是大括号{}包起来的地方,比如ifforwhile里面。

if (true) {
  let blockVar = '我只能在块里用';
  var functionVar = '我可以在整个函数用'; // var没有块级作用域
}
console.log(blockVar); // 报错
console.log(functionVar); // 能访问,因为var只有函数作用域

块级作用域就像卧室里的保险柜,只有在这个房间里才能打开。var则像家里的公共区域,虽然写在卧室里,但实际还是公共的。

二、作用域链:找变量的路径

当你在一个作用域里使用变量时,JS引擎会按照这个顺序找:

  1. 当前作用域:先看自己家里有没有。
  2. 外层作用域:没有就去上一层找。
  3. 继续往外:一层一层往上,直到全局作用域。
  4. 全局也没有:那就报错not defined

这种嵌套的作用域形成的链条,就是作用域链

来看个例子:

var global = '全球通';

function outer() {
  var outerVar = '外层的';
  
  function inner() {
    var innerVar = '内层的';
    console.log(innerVar); // 找到自己家的
    console.log(outerVar); // 自己家没有,去外层找
    console.log(global);   // 自己家没有,外层没有,再去全局
  }
  
  inner();
}

outer();

这个过程就像你在家找东西:先翻自己口袋,没有就去客厅找,还没有就去小区便利店,再没有就只能放弃了。

三、闭包:虽然离开了,但我还记得

闭包是JS里一个常考常新、常学常忘的概念。简单来说:闭包就是函数记住了它定义时的作用域,即使这个函数在其他地方执行,也能访问那个作用域里的变量

举个例子:

function createCounter() {
  let count = 0; // count 被闭包记住了
  
  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3

这里createCounter执行后返回了一个函数,按说count应该被销毁了,但返回的函数依然能访问count——这就是闭包的力量。

闭包的生活比喻

想象你从小长大的家,后来搬走了,但你还记得家里的WiFi密码。每次你路过楼下,还能连上那个WiFi。这个“记住密码”的能力,就是闭包。

闭包的用途:

  • 数据私有化(比如上面的计数器,外部无法直接修改count)
  • 函数工厂(生成特定功能的函数)
  • 回调函数中保持状态(比如事件监听)

闭包的坑

闭包虽然好用,但也要注意内存问题。因为被记住的变量不会释放,如果闭包一直存在,这些变量就会一直占用内存。比如上面例子,只要counter这个函数还在,count就不会被垃圾回收。

四、经典面试题:循环中的var

这是JS初学者最容易踩的坑之一:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 100);
}

你期望输出0,1,2,3,4,但实际输出5,5,5,5,5。为什么?

因为var没有块级作用域,循环里的i其实是全局(或函数级)的同一个变量。循环结束后i变成了5,然后setTimeout的回调执行时,访问的都是同一个i,所以全是5。

解决方式:

  1. 用let:let有块级作用域,每次循环都会创建一个新的变量。
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 0,1,2,3,4
  }, 100);
}
  1. 用闭包(老办法):
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, 100);
  })(i);
}

用立即执行函数创建新的作用域,把每次的i传进去保存下来。

五、词法作用域:写在哪就在哪找

JS采用的是词法作用域(也叫静态作用域),也就是说变量的查找范围在代码编写时就决定了,而不是在运行时。

var value = 1;

function foo() {
  console.log(value);
}

function bar() {
  var value = 2;
  foo(); // 输出什么?
}

bar(); // 输出1

这里foo定义在全局,所以它访问的value是全局的1,而不是bar里的2。因为作用域由函数定义的位置决定,而不是调用位置。

这个特性是闭包能工作的基础。

六、执行上下文:运行时的小剧场

作用域是静态的规则,而执行上下文是运行时动态的环境。每当函数执行,都会创建自己的执行上下文,里面包含了变量、参数、以及对外部作用域的引用。

执行上下文有点像每次进家门时拿的钥匙串,上面有自己家的钥匙,还有父母家的钥匙(通过作用域链)。

七、总结:今天你学到了什么?

  • 作用域就是变量的可见范围:全局(公共场所)、函数(自己家)、块级(卧室保险柜)。
  • 作用域链就是找变量的路径:当前 → 外层 → 全局,找不到就报错。
  • 闭包是函数记住了它出生时的环境,即使离开了也能访问那些变量。用途广泛,但要注意内存。
  • 词法作用域意味着变量的查找在写代码时决定,和运行位置无关。
  • 循环中用var容易踩坑,用let或闭包解决。

现在你再看到作用域相关的问题,应该能像老司机一样游刃有余了。明天我们将继续深入,聊聊JavaScript里最让人迷惑的概念之一:闭包的应用场景和内存管理,看看闭包在实际项目中到底怎么用,怎么避免内存泄漏。

如果你觉得今天的文章对你有帮助,点个赞让更多人看到,也欢迎在评论区聊聊你遇到过的作用域坑。我们明天见!