变量死了吗?当执行上下文消散后,谁在守护自由变量?

19 阅读13分钟

你是否曾遇到这样的代码:

console.log(a);
var a = 1;

输出是 undefined 而不是报错?
或者写了个 for 循环,用 letvar 定义变量,结果行为完全不同?

更离谱的是,函数明明在别的地方调用,却还能访问它“出生地”的变量——这就是传说中的 闭包

今天我们不讲花里胡哨的概念堆砌,而是带你一步步揭开 JavaScript 执行背后的真相:从变量提升、块级作用域,再到作用域链和闭包的本质。你会发现,这一切的背后,其实是 V8 引擎如何管理“执行上下文”和“词法环境”的精密设计。


一、问题起源:为什么 var 会“提升”?

我们先来看一个经典例子:

var n = '苗子';

function sayName() {
  console.log(n); // 输出什么?

  if (false) {
    var n = '小树';
  }

  console.log(n);
}

sayName();

很多人第一反应是:

  • 第一个 console.log(n) 应该输出 '苗子'
  • 因为 if(false) 不会执行,所以 n 没被重新赋值

但实际运行结果是:

undefined
undefined

❓为什么会这样?

答案就是:变量提升(Hoisting)

在编译阶段,JavaScript 引擎会把所有通过 var 声明的变量,提升到当前作用域的顶部,并初始化为 undefined

所以上面的代码,在引擎眼里其实是这样的:

function sayName() {
  var n; // 提升到了函数顶部,值为 undefined
  console.log(n); // → undefined

  if (false) {
    n = '小树'; // 这部分不会执行
  }

  console.log(n); // → 依然是 undefined
}

而外层的 var n = '苗子' 并没有影响函数内部的作用域 —— 函数有自己的局部作用域。

✅ 结论:var 声明存在变量提升 + 函数作用域,导致变量可能提前使用且不易察觉地遮蔽外部变量。

这显然不符合直觉,也容易引发 bug。比如你在函数中间声明了一个变量,以为前面用不到它,但实际上已经“被提升”了。


二、ES6 的更新:let/const 与暂时性死区

为了解决这个问题,ES6 引入了 letconst,它们不再支持变量提升,而是进入了 暂时性死区(Temporal Dead Zone, TDZ)

看这个例子:

let n = '苗子';

function sayName() {
  console.log(n); // 输出什么?

  if (false) {
    let n = '小树';
  }
}

sayName(); // 输出:'苗子'

这次输出的是 '苗子'

❓为什么没有报错?又为什么能访问外层的 n

因为:

  • let n = '小树' 是块级作用域内的声明,只在 {} 内有效。
  • if 块中虽然没执行,但它仍然创建了一个新的块级作用域。
  • 函数内部并没有用 let/var/const 声明 n,所以查找时会沿着作用域链向上找,找到全局的 n = '苗子'

但如果我们在前面加一句:

function sayName() {
  console.log(n); // ReferenceError!
  let n = '小树';
}

这时就会抛出错误:

ReferenceError: Cannot access 'n' before initialization

这就是 暂时性死区 的体现:
从进入作用域开始,到 let 正式声明之前,这个变量都不能被访问。

let/const 不会提升,但在声明前访问会报错(TDZ),比 var 更安全。


三、执行上下文视角:变量环境 vs 词法环境

那么问题来了:JS 是怎么做到 varlet 行为不同的?难道有两个系统在同时工作?

是的!V8 引擎在构建 执行上下文(Execution Context) 时,其实维护了两个关键部分:

组件存储内容特性
变量环境(Variable Environment)var 声明的变量支持变量提升
词法环境(Lexical Environment)let/const 声明的变量支持块级作用域、暂时性死区

再看一个例子:

function foo() {
  var a = 1;
  let b = 2;

  {
    let b = 3;
    var c = 4;
    let d = 5;
  }

  console.log(b);   // 输出 2
  console.log(c);   // 输出 4
  console.log(d);   // 报错:d is not defined
}
foo();

分析一下:

  • var c = 4 虽然在块内,但 var 只认函数作用域,所以提升到 foo 函数顶部 → 可以访问。
  • let b = 3 是块级作用域,出了 {} 就销毁 → 外层 b 仍是 2
  • let d = 5 完全在块内,外面根本看不到 → 报错。

这背后正是 词法环境中的栈结构 在起作用:

  • 每进入一个块级作用域(如 {}),词法环境就压入一个新层级。
  • 查找变量时,从栈顶向下找。
  • 块执行完后,该层级弹出,变量自动释放。

🔍 这就是 ES6 实现块级作用域的底层机制:词法环境是一个栈,每层对应一个作用域


四、作用域链:变量查找的真实路径

现在我们来回答一个灵魂拷问:

函数在哪里被调用,就去那里找变量吗?

错!JS 使用的是 词法作用域(Lexical Scoping),也就是说:

变量查找的路径,是由函数定义的位置决定的,而不是调用的位置。

来看这个例子:

function bar() {
  console.log(myname);
}

function foo() {
  var myname = '极客帮';
  bar(); // 注意:bar 是在这里被调用的
}

var myname = '极客时间';
foo(); // 输出什么?

你可能会想:“bar 是在 foo 里面调用的,应该能看到 myname = '极客帮' 吧?”

但输出结果是:极客时间

为什么?

因为:

  • bar 是在全局定义的,它的外层作用域就是全局作用域。
  • bar 查找 myname 时,沿着词法作用域链向上找,直接找到了全局的 myname
  • 它并不会因为“在 foo 中调用”就去 foo 的作用域里找。

🎯 重点:作用域链是静态的,在函数定义时就已经确定了!

你可以把它想象成一条“出生证明链”:每个函数都记得自己是在哪个环境中诞生的,以后不管跑到哪执行,都会回这条链上去找变量。

PixPin_2025-12-29_20-11-35.png

五、闭包:不只是“能访问外部变量”,更是执行上下文的生命延续

我们常说:“闭包就是函数能够访问其外部作用域中的变量。”
但这只是表象。

真正的闭包,是 JavaScript 执行机制与内存管理共同作用下的高级现象 —— 它打破了“函数执行完就销毁”的直觉,让变量得以跨越时间与空间被持续访问。

让我们从一个熟悉的例子开始:

function foo() {
  var myname = '极客时间';
  let test1 = 1;

  var innerBar = {
    getname: function () {
      console.log(test1); // → 1
      return myname;
    },
    setname: function (newname) {
      myname = newname;
    }
  };

  return innerBar; // 把内部函数暴露出去
}

var bar = foo(); // foo 执行完毕,按理说该回收了

bar.setname('极客帮');
console.log(bar.getname()); // 输出:'极客帮'

❓关键问题:

  • foo() 已经执行结束,它的执行上下文应该已经从调用栈中弹出。
  • 那么 mynametest1 这些局部变量,为什么还能被 getnamesetname 访问到?

这就是闭包最震撼的地方:即使定义它的环境消失了,它依然能记住那个环境里的值。

闭包的本质是:内部函数保留了对外部词法环境的引用,从而延长了自由变量的生命周期。


1. 什么是“自由变量”?

在上面的例子中:

getname: function () {
  return myname; // ← myname 不是参数,也不是自己声明的
}

这里的 myname 就是一个 自由变量(free variable) —— 它既不是形参,也不是函数内部用 let/var/const 声明的,而是来自外层作用域。

JS 引擎在编译阶段就会标记这些自由变量,并建立一条通往它们出生地的作用域链。


2. 普通函数 vs 闭包函数:执行上下文的命运不同

我们来看两种情况的区别:

情况一:普通嵌套函数,未逃逸

function outer() {
  let x = 10;
  function inner() {
    console.log(x);
  }
  inner(); // 立即执行
}
outer(); // 执行完后,x 被回收

分析:

  • innerouter 内部立即执行;
  • outer 出栈时,inner 的作用域链虽然指向 outer 的词法环境;
  • 但由于没有其他引用,整个结构可以被垃圾回收。

✅ 此时不构成“实用意义上的闭包”。

情况二:函数返回并被外部持有

function outer() {
  let x = 10;
  return function inner() {
    console.log(x);
  };
}

const fn = outer();
fn(); // 仍然能输出 10

这时:

  • outer 执行完毕,执行上下文本应销毁;
  • inner 被返回并赋值给全局变量 fn
  • inner 内部引用了 x,所以 JS 引擎必须保留 x 所在的词法环境;
  • 即便 outer 的执行上下文已出栈,这部分数据仍驻留在内存中。

🔥 这才是真正的闭包:函数携带了它诞生时的环境快照


3. 闭包的底层机制:词法环境的“背包理论”

我们可以把闭包想象成一个人背着一个 专属背包(Closure Scope Bag)

这个背包里装的是什么?

包裹内容说明
外部函数的词法环境引用特别是那些被内部函数使用的变量
自由变量的实际值或引用myname, test1
作用域链的静态连接编译时确定的查找路径

当内部函数被返回或存储在某处(如对象属性、数组、全局变量),V8 引擎会检测到它对某些外部变量的依赖,于是不会释放这些变量所在的词法环境,而是将其“打包”附着在函数对象上。

PixPin_2025-12-29_21-06-12.png

💡 你可以通过 Chrome DevTools 查看 Closure 面板,看到这个“背包”里到底有哪些变量。


4. 经典误区澄清:闭包不是“函数”,而是“状态 + 函数”的组合体

很多人误以为“闭包是一个函数”。其实更准确地说:

闭包 = 函数 + 其所能访问的所有自由变量的集合

举个例子帮助理解:

function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    decrement: () => --count,
    value: () => count
  };
}

const counter1 = createCounter();
const counter2 = createCounter();

counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2
console.log(counter2.value()); // 0

你会发现:

  • counter1counter2 是两个独立的计数器;
  • 它们各自背负着自己的“背包”;
  • 每次调用 createCounter(),都会创建一个新的词法环境,进而生成一个独立的闭包。

这说明:每次外层函数执行,都可能产生一个新的闭包实例


5. 实际应用场景:闭包如何改变我们的编程方式

场景一:私有变量模拟

JS 没有 private 关键字,但我们可以通过闭包实现:

function createUser(name) {
  let _name = name; // 外界无法直接访问

  return {
    getName: () => _name,
    setName: (newName) => {
      if (typeof newName === 'string') _name = newName;
    }
  };
}

const user = createUser('苗子');
user.getName();     // '苗子'
user.setName('小树');
user.getName();     // '小树'

// _name 无法从外部修改 → 实现封装

直接暴露封装getter,setter函数,实现属性私有化

场景二:事件回调中的变量绑定

常见于循环绑定事件:

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 全部输出 3
  }, 100);
}

问题原因:var 提升 + 闭包共享同一个 i

解决方案之一就是利用闭包隔离变量:

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => {
      console.log(j); // 输出 0, 1, 2
    }, 100);
  })(i);
}

或者更现代的方式使用 let(块级作用域自动形成闭包):

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出 0, 1, 2
  }, 100);
}

let 在每次迭代时创建新的词法环境,相当于自动形成了多个闭包。


6. 闭包的代价:内存泄漏风险

凡事有利有弊。闭包的强大之处在于延长变量生命周期,但也正因如此,容易造成 内存泄漏

例如:

function heavyTask() {
  const largeData = new Array(1000000).fill('💀'); // 占用大量内存

  return function () {
    console.log('done');
    // 但 largeData 始终无法被回收!
  };
}

const task = heavyTask(); // 返回的函数没用到 largeData?
// 错!只要它在同一作用域内,就被视为潜在可访问 → 不会被回收

除非你显式解除引用:

task = null; // 才可能触发 GC 回收

所以在实际开发中要注意:

  • 避免在闭包中引用不必要的大对象;
  • 及时清理不再需要的函数引用;
  • 使用 WeakMap/WeakSet 替代强引用,减少内存压力。

✅ 闭包总结:一句话+三个条件+一个比喻

一句话定义闭包: 当一个内部函数被外部持有时,它仍然能够访问其定义时所在作用域中的变量,这种现象称为闭包。

形成闭包的三个必要条件:

  1. 函数嵌套;
  2. 内部函数引用了外部函数的变量;
  3. 内部函数被传递到外部作用域并被执行。

一个形象比喻: 闭包就像一个孩子离家外出工作,但他背包里始终装着老家的照片和钥匙。无论走多远,他都能回忆起童年,也能随时“回去看看”。


六、综合实战:复杂作用域场景解析

我们来看一个更复杂的例子:

function bar() {
  var myname = '极客世界';
  let test1 = 100;
  if (1) {
    let myname = 'chrome浏览器';
    console.log(test); // 输出什么?
  }
}
function foo() {
  var myname = '极客帮';
  test = 2; // 注意:这里没有 var/let/const → 全局变量!
  {
    let test = 3;
    bar();
  }
}

var myname = '极客时间';
let test = 5;
foo();

最终输出:1

分析过程:

  1. 在执行的foo函数中调用了bar函数
  2. bar 中的 console.log(test) 查找 test
  3. 被if作用域包含中没有
  4. 外层bar作用域也没有
  5. 此时bar函数作用域根据ourter往外找
  6. 是指向最外层的全局作用域,而不是调用barfoo作用域
  7. 最终找到全局的 test = 1

PixPin_2025-12-29_20-24-37.png

⚠️ 关键点:没有声明符的赋值会创建全局变量,极易造成污染。


七、总结:一张图理清 JS 作用域体系

特性varlet / const
作用域类型函数作用域块级作用域
是否提升是(初始化为 undefined)否(进入 TDZ)
重复声明允许(var 可重复 var)不允许
全局属性是(挂在 window 上)
存储位置变量环境词法环境

执行上下文结构简图:

执行上下文
├── 变量环境(Variable Environment)
│   └── 存放 var 声明 → 支持提升
└── 词法环境(Lexical Environment)
    └── 栈结构,每层对应一个块
        ├── 存放 let/const
        ├── 支持块级作用域
        └── 实现暂时性死区

作用域链查找规则:

  1. 先在当前执行上下文的词法环境中查找(从栈顶往下);
  2. 找不到则顺着外层词法环境查找;
  3. 一直找到全局环境为止;
  4. 整个链条由函数定义位置决定(词法作用域)。

八、写给开发者的建议

  1. 优先使用 letconst,避免 var 带来的不可预测性;
  2. 不要省略声明符,防止意外创建全局变量;
  3. 理解闭包的代价:它延长了变量生命周期,注意防内存泄漏;
  4. 调试时关注“定义位置”而非“调用位置”,这是理解作用域链的关键;
  5. 多思考“这个变量到底存在哪里?” —— 是在变量环境?还是词法环境的哪一层?

结语:语言的设计从来不是偶然

JavaScript 初期为了快速上线、兼容浏览器竞争,牺牲了一些严谨性(比如没有块级作用域、变量提升)。但随着发展,ECMAScript 团队并没有推翻重来,而是在原有基础上引入新的机制(如词法环境栈),实现了向后兼容的同时提升了安全性。

这就像一座城市,老城区有狭窄的巷子(var),新城区有宽阔的高架(let/const)。我们要做的,不是抱怨旧路难走,而是学会看地图,知道什么时候走哪条路最快。

当你下次再看到 undefinedReferenceError,别慌,那是引擎在提醒你:去看看你的作用域链吧。


🌟 延伸阅读建议:

  • 《你不知道的 JavaScript(上卷)》—— 第二部分深入讲解作用域与闭包
  • V8 源码中的 Closure 实现逻辑(src/objects/js-function.h)
  • MDN 文档:Closures

如果你觉得这篇文章讲清楚了“为什么”,不妨点赞收藏,让更多人走出 JS 作用域的迷雾。

掘金不止技术,更是认知的沉淀。