《深入 JavaScript 底层机制:词法作用域、作用域链与闭包全解析》

76 阅读7分钟

JavaScript 的底层工作机制:从作用域到闭包的深度解析

JavaScript 是一门看似简单却内藏玄机的编程语言。很多开发者在日常开发中能写出功能正确的代码,但一旦遇到变量查找、作用域混淆或闭包相关的问题,就容易陷入困惑。要真正掌握 JavaScript,必须深入理解其底层机制——尤其是 V8 引擎如何处理代码、调用栈如何运作、执行上下文如何创建,以及作用域与闭包的本质。

本文将结合具体代码示例,系统讲解 JavaScript 的词法作用域、作用域链、执行上下文和闭包等核心概念,帮助你构建清晰、准确的底层认知模型。


一、V8 引擎与 JavaScript 的执行流程

JavaScript 是一门解释型语言,但它并非“一行一行”直接运行。以 Chrome 浏览器使用的 V8 引擎为例,它会对 JavaScript 代码进行编译优化后再执行。整个过程大致分为两个阶段:

  1. 编译阶段(Compilation Phase) :V8 扫描代码,识别变量声明、函数声明,并建立作用域结构。
  2. 执行阶段(Execution Phase) :按照控制流逐行执行代码,期间会创建执行上下文、压入调用栈等。

这个两阶段模型是理解后续所有机制的基础。


二、执行上下文与调用栈

每当 JavaScript 执行一段代码时,都会创建一个执行上下文(Execution Context) 。全局代码运行时会创建全局执行上下文,而每次调用函数时,会创建一个新的函数执行上下文

这些上下文被管理在一个叫**调用栈(Call Stack)**的数据结构中,遵循“后进先出”原则。例如:

function bar() {
    console.log(myName);
}
function foo() {
    var myName = '极客邦'
    bar() // 运行时
}
var myName = '极客时间';
foo();

foo() 被调用时,调用栈中会先有全局上下文,再压入 foo 的上下文;当 foo 内部调用 bar() 时,又会压入 bar 的上下文。函数执行完毕后,对应的上下文就会从栈顶弹出。

但请注意:调用栈中的顺序 ≠ 作用域链的查找顺序。这是初学者最容易混淆的地方。


三、作用域:变量的“家”

作用域决定了变量在何处可以被访问。JavaScript 主要有两种作用域:

  • 全局作用域:在任何函数外部定义的变量。
  • 局部作用域:包括函数作用域(var)和块级作用域(let/const)。

更重要的是,JavaScript 采用词法作用域(Lexical Scoping) ,也叫静态作用域。这意味着:一个函数能访问哪些变量,在它被定义(写下来)的时候就已经确定了,而不是在它被调用的时候

看下面这段代码:

function bar() {
  console.log(myName);
}
function foo() {
  var myName = '极客邦';
  bar(); // 运行时
}
var myName = '极客时间';
foo();

很多人会误以为 bar() 输出的是 '极客邦',因为它是从 foo() 里调用的。但实际输出是 '极客时间'。为什么?

因为 bar 函数是在全局作用域中定义的,它的词法作用域就是全局。无论它在哪里被调用,它都只能看到自己定义时所在的作用域中的变量。这就是词法作用域的核心规则。


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

当 JavaScript 引擎需要查找一个变量时,它会沿着作用域链(Scope Chain) 向上搜索:

  1. 首先在当前作用域查找;
  2. 如果找不到,就去外层作用域(即定义该函数时的父作用域)查找;
  3. 一直查到全局作用域为止。

作用域链是在编译阶段就确定的,完全由函数的书写位置决定,与调用位置无关。

再看一个更复杂的例子:

function bar () {
  var myName = '极客世界';
  let test1 = 100;
  if (1) {
    let myName = 'Chrome 浏览器';
    console.log(test); // 报错?还是输出?
  }
}
function foo(){
  var myName = '极客邦';
  let test = 2;
  {
    let test = 3;
    bar();
  }
}
var myName = '极客时间';
let test = 1;
foo();

这里 bar() 中的 console.log(test) 会输出:1

原因在于: JavaScript 使用词法作用域,bar 在全局定义,其作用域链只包含自身和全局环境;当它引用未声明的变量 test 时,会沿着定义时的作用域链向上查找,在全局找到 let test = 1,因此输出 1。调用位置(如在 foo 内)不影响变量查找。

这再次印证了:函数能访问什么变量,在它被定义的时候就被确定了,而不是它被调用的时候


五、闭包:带着“背包”旅行的函数

如果说词法作用域是 JavaScript 的骨架,那么闭包(Closure) 就是它的灵魂。

什么是闭包?

闭包是指:一个函数能够访问并“记住”其词法作用域中的变量,即使该函数在其原始作用域之外执行

形成闭包通常需要三个条件:

  1. 函数嵌套函数;
  2. 内部函数引用了外部函数的变量;
  3. 外部函数将内部函数返回(或以某种方式暴露到外部)。

看这个经典例子:

function foo () {
  var myName = '极客时间';
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    getName: function() {
      console.log(test1);
      return myName;
    },
    setName: function(newName) {
      myName = newName;
    }
  };
  return innerBar;
}

var bar = foo();
bar.setName('极客邦');
console.log(bar.getName()); // 输出 "极客邦"

分析过程:

  1. foo() 被调用,创建执行上下文,其中包含 myNametest1 等变量;
  2. innerBar 对象中的两个方法(getNamesetName)都引用了 foo 内部的变量;
  3. foo() 返回 innerBar 后,其执行上下文理应被销毁(从调用栈弹出);
  4. 但由于 getNamesetName 仍然持有对 myNametest1 的引用,V8 引擎不会回收这些变量;
  5. 这些被“保留下来”的变量,就构成了一个闭包环境,像一个专属“背包”,被这两个函数随身携带。

536a315a83aa48b870d03dd921b6c02a.png 因此,即使 foo 已经执行完毕,我们依然可以通过 bar.setName() 修改 myName,并通过 bar.getName() 读取它。

闭包的本质

闭包不是某种特殊语法,而是词法作用域 + 函数作为一等公民 + 垃圾回收机制共同作用的结果。只要内部函数在外部被引用,且引用了外部变量,闭包就自然形成了。


六、自由变量与内存管理

在闭包中,那些被内部函数引用、但定义在外部作用域中的变量,被称为自由变量(Free Variables) 。JavaScript 引擎会确保这些变量在外部函数执行结束后依然存活,直到没有任何引用指向它们为止。

这也意味着:闭包会延长变量的生命周期,可能导致内存占用增加。不过现代引擎(如 V8)非常智能,只会保留真正被引用的变量,未使用的变量仍会被回收。


七、总结:构建正确的 JavaScript 心智模型

要真正掌握 JavaScript,你需要建立以下心智模型:

  1. 词法作用域是静态的:函数能访问哪些变量,由它写在哪里决定,而不是在哪里被调用
  2. 作用域链是查找路径:从当前作用域逐级向外,直到全局,全程在编译阶段确定。
  3. 调用栈 ≠ 作用域链:调用栈反映函数调用顺序,作用域链反映变量定义关系。
  4. 闭包无处不在:只要函数引用了外部变量并在外部使用,闭包就存在。
  5. 闭包 = 函数 + 自由变量的环境:它让函数“记住”出生地的变量,即使远走他乡。

通过理解这些机制,你不仅能写出更可靠的代码,还能在调试复杂作用域问题时游刃有余。JavaScript 的魅力,正在于这种“简单表象下的深刻逻辑”。掌握它,你就真正踏入了高级前端开发的大门。