深入理解 JavaScript 作用域:从编译原理到闭包的完整解析

67 阅读5分钟

前言:为什么你写的代码“不按常理出牌”?

你是否遇到过这样的困惑?

console.log(a); // undefined
var a = 2;

console.log(b); // ReferenceError: b is not defined

为什么 var a 可以“提前”访问,而 b 却直接报错?
为什么函数能“记住”外部变量,即使外层函数已经执行完毕?

这些问题的答案,都藏在 JavaScript 的作用域机制中。

本文将带你从编译原理出发,层层深入,彻底搞懂 JavaScript 的作用域、作用域链、LHS/RHS 查询、以及闭包的本质。


一、JavaScript 也是“编译型”语言?

很多人认为 JavaScript 是“解释执行”的脚本语言,但实际上,它也经历了一个编译过程,只不过这个过程发生在代码执行前的几微秒内。

JavaScript 的编译三步曲

1. 分词 / 词法分析(Tokenizing / Lexing)

将代码字符串分解为有意义的“词法单元(tokens)”。

var a = 2;

会被分解为:

  • var(关键字)
  • a(标识符)
  • =(赋值操作符)
  • 2(数值字面量)
  • ;(语句结束符)

空格是否重要?取决于语言规则。JS 中空格通常可忽略,但像 a++ 不能写成 a ++(虽然语法允许,但可读性差)。

2. 解析 / 语法分析(Parsing)

将词法单元转换为抽象语法树(AST)

例如:

var a = 2;

AST 可能是:

{
  "type": "VariableDeclaration",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": { "type": "Identifier", "name": "a" },
      "init": { "type": "Literal", "value": 2 }
    }
  ],
  "kind": "var"
}

AST 是后续代码生成的基础。

3. 代码生成

将 AST 转换为可执行的机器指令或字节码。

⚡ 注意:JS 的编译发生在执行前的瞬间,所以你感觉不到“先编译再运行”的过程。


二、作用域的本质:变量的“查找规则”

作用域,简单说就是变量和函数的可访问范围

但更准确的定义是:

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。

作用域的两种类型

类型特点
全局作用域在整个程序中都可访问
局部作用域只在函数或块级作用域内可访问
var globalVar = '全局变量';

function foo() {
    var localVar = '局部变量';
    console.log(globalVar); // ✅ 可访问
    console.log(localVar);  // ✅ 可访问
}

foo();
console.log(localVar); // ❌ 报错!localVar is not defined

三、作用域链:变量的“寻亲之旅”

当 JavaScript 引擎查找一个变量时,它不会只在当前作用域中找,而是一层层向上查找,直到全局作用域。

这就是 作用域链(Scope Chain)

示例:

var a = 1;

function outer() {
    var b = 2;
    function inner() {
        var c = 3;
        console.log(a + b + c); // 6
    }
    inner();
}

outer();

inner 函数中访问 ab 的过程:

  1. inner 作用域中找 a → 找不到
  2. 向上到 outer 作用域找 a → 找不到
  3. 向上到全局作用域找 a → 找到,值为 1
  4. 同理找到 b = 2

作用域链是静态的,在函数定义时就确定了,与调用位置无关(词法作用域)。


四、LHS 与 RHS:变量查询的两种方式

JavaScript 引擎在查找变量时,会区分两种查询:

查询类型含义示例
LHS“赋值操作的目标是谁?”a = 2 中的 a
RHS“谁是赋值操作的源头?”console.log(a) 中的 a

重要区别

1. RHS 查询失败 → ReferenceError

console.log(a); // RHS 查询 a
// 如果找不到 a,抛出 ReferenceError

2. LHS 查询失败 → 在全局创建变量(非严格模式)

function foo() {
    b = 2; // LHS 查询 b
}
foo();
console.log(b); // 2!b 被自动创建为全局变量

⚠️ 在 严格模式'use strict')下,LHS 查询失败也会抛出 ReferenceError,避免意外创建全局变量。


五、闭包:函数的“记忆能力”

闭包是 JavaScript 中最强大也最令人困惑的概念之一。

定义:

闭包是指一个函数能够访问其外层作用域中的变量,即使外层函数已经执行完毕。

经典示例:

function outer() {
    var count = 0;
    return function inner() {
        count++;
        console.log(count);
    };
}

var counter = outer(); // outer 执行完毕,但 count 未被销毁
counter(); // 1
counter(); // 2
counter(); // 3

为什么 count 没有被销毁?

  • inner 函数引用了 outer 作用域中的 count
  • 只要 inner 存在,count 就不会被垃圾回收
  • 这就是闭包的“记忆”能力

闭包常用于:模块化、私有变量、函数柯里化、防抖节流等。


六、作用域的实际应用与陷阱

1. 避免全局变量污染

// ❌ 错误:大量全局变量
var a = 1;
var b = 2;
var c = 3;

// ✅ 正确:使用立即执行函数(IIFE)创建私有作用域
(function() {
    var a = 1;
    var b = 2;
    // 外部无法访问 a、b
})();

2. 循环中的闭包问题

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

问题var 没有块级作用域,i 是全局的。

修复方案 1:使用 let

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

修复方案 2:使用 IIFE 创建闭包

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

总结:一张表看懂核心概念

概念说明
编译过程分词 → 解析(AST)→ 代码生成
作用域变量的查找规则
作用域链从内到外逐层查找变量
LHS/RHSLHS 找“赋值目标”,RHS 找“赋值源头”
闭包函数“记住”外层变量的能力

结语:掌握作用域,才能真正理解 JavaScript

作用域是 JavaScript 的基石概念
理解它,你才能明白:

  • 为什么变量能“提前访问”
  • 为什么函数能“记住”外部变量
  • 如何避免常见的 ReferenceError
  • 如何写出更安全、更模块化的代码

建议:动手写几个闭包例子,调试查看作用域链,加深理解。