深入理解JavaScript词法作用域与作用域链

30 阅读7分钟

为什么 JavaScript 的函数总能清楚地"记住"变量在哪里被定义?为什么闭包如此神奇?这一切的答案都隐藏在"词法作用域"这个核心概念中。

前言:从一道经典面试题说起

var a = 1;
function outer() {
    var a = 2;
    function inner() {
        console.log(a);
    }
    return inner;
}

var innerFunc = outer();
innerFunc(); // 输出什么?为什么?

大多数前端开发者都知道输出结果为:2,但能完整解释"为什么"的人却不多。本篇文章就来彻底揭开JavaScript作用域的神秘面纱。

什么是词法作用域?

静态作用域 vs 动态作用域

词法作用域(Lexical Scope),也称为静态作用域,是 JavaScript 采用的作用域模型。它的核心特点是:

函数的作用域在函数定义时就确定了,而不是在函数调用时确定。

这与动态作用域形成鲜明对比。让我们通过代码理解两者的区别:

var value = "global";

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

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

bar(); 

上述代码的输出结果是:global。因为 foo() 函数在定义时,它的作用域链就已确定,包含全局作用域。所以它访问的是全局的 value 变量,而不是调用位置的 value

如果JavaScript动态作用域(实际上不是),又会发生什么呢?

var value = "global";

function foo() {
    console.log(value); // 动态作用域下:访问调用位置的value
}

function bar() {
    var value = "local";
    foo(); // 动态作用域下会输出:"local"
}
bar();

关键区别总结

  • 词法作用域:函数的作用域由定义位置决定。
  • 动态作用域:函数的作用域由调用位置决定。

词法环境的结构

在 JavaScript 引擎内部,每个执行上下文都有一个关联的词法环境(Lexical Environment)。词法环境由两部分组成:环境记录器(EnvironmentRecord)和对外部词法环境的引用(Outer)。

LexicalEnvironment = {
    EnvironmentRecord: {
        // 1. 环境记录器:存储变量和函数声明
        // 包含:声明式环境记录、对象环境记录
    },
    Outer: null | <父级词法环境引用>  // 2. 对外部词法环境的引用
}

// 实际代码示例
var globalVar = "global";

function outer() {
    var outerVar = "outer";
    
    function inner() {
        var innerVar = "inner";
        // inner函数的词法环境:
        // {
        //     EnvironmentRecord: { innerVar: "inner" },
        //     Outer: <outer函数的词法环境>
        // }
    }
}

作用域链的形成过程

作用域链就是由这些词法环境通过 Outer 引用连接起来的链式结构。

作用域链的查找机制

变量查找的完整流程

当 JavaScript 引擎需要访问一个变量时,它会按照以下步骤进行查找:

// 多层嵌套作用域示例
var a = "global a";
var b = "global b";
var c = "global c";
function level1() {
    var a = "level1 a";
    var b = "level1 b";   
    function level2() {
        var a = "level2 a";
        function level3() {
            var a = "level3 a";
            console.log(a); // "level3 a" - 找到最近的a
            console.log(b); // "level1 b" - 向上两层找到b
            console.log(c); // "global c" - 向上三层找到c
        }
        level3();
    }
    level2();
}
level1();

查找变量c的过程如下:

  1. 检查level3的环境记录 → 没有c
  2. 通过Outer引用检查level2的环境记录 → 没有c
  3. 通过Outer引用检查level1的环境记录 → 没有c
  4. 通过Outer引用检查全局环境记录 → 找到c = "global c"
  5. 如果一直找到最外层都没找到:undefined

图解:作用域链的树状结构

让我们用可视化方式理解作用域链:

全局词法环境 (Global Lexical Environment)
├─ EnvironmentRecord: { a: "global a", b: "global b", c: "global c" }
├─ Outer: null
│
├─ level1函数词法环境 (调用时创建)
│  ├─ EnvironmentRecord: { a: "level1 a", b: "level1 b" }
│  ├─ Outer: 引用 → 全局词法环境
│  │
│  ├─ level2函数词法环境 (调用时创建)
│  │  ├─ EnvironmentRecord: { a: "level2 a" }
│  │  ├─ Outer: 引用 → level1词法环境
│  │  │
│  │  ├─ level3函数词法环境 (调用时创建)
│  │  │  ├─ EnvironmentRecord: { a: "level3 a" }
│  │  │  ├─ Outer: 引用 → level2词法环境
│  │  │  └─ 变量查找路径:level3 → level2 → level1 → 全局
│  │  └─ 
│  └─ 
└─

作用域链的关键特性

  1. 静态性(词法作用域):作用域链在函数定义时就已经确定,而不是在调用时确定的。
  2. 链式结构:像链条一样一环扣一环,从当前作用域指向外层作用域。
  3. 单向性:只能从内层作用域访问外层作用域的变量,不能反向访问。
  4. 与执行上下文相关:每次函数调用都会创建新的执行上下文,但作用域链基于函数定义位置确定。

闭包与作用域链的持久化

闭包的本质就是:函数记住了它被创建时的词法环境

function createCounter() {
    let count = 0;  // 这个变量本该在函数执行后销毁
    return function() {
        count++;  // 保持对外部变量的引用,这就是闭包
        return count;
    };
}

const counter = createCounter();

// 即使createCounter执行完毕,它的词法环境也不会被销毁
// 因为返回的内部函数仍然引用着它
console.log(counter()); // 1
console.log(counter()); // 2

块级作用域的实现原理

ES5作用域的问题

在ES5中,只有两种作用域:全局作用域和函数作用域。这导致了一些问题:

// ES5的问题:变量提升和缺少块级作用域
function problematic() {
    console.log(i); // undefined,而不是ReferenceError
    
    for (var i = 0; i < 3; i++) {
        // i在整个函数内都可见
        setTimeout(function() {
            console.log(i); // 全部输出3
        }, 100);
    }
    console.log(i); // 3,循环结束后的i
}

problematic();

let/const带来的块级作用域

ES6引入的 let/const 带来了真正的块级作用域:

// 块级作用域示例
function withBlockScope() {
    if (true) {
        // 块级作用域开始
        let blockScoped = "只在块内有效";
        const constantValue = "常量";
        {
            // 嵌套块级作用域
            let nestedBlock = "嵌套块";
            console.log(blockScoped); // 可以访问外层块的变量
        }
        // console.log(nestedBlock); // ReferenceError
    }
    // console.log(blockScoped); // ReferenceError
}

let/const的实现原理:

  1. 在编译阶段,let/const 声明的变量被记录在词法环境中
  2. 在变量声明之前访问会抛出错误(暂时性死区)
  3. 每个 {} 代码块都会创建一个新的词法环境

块级作用域的嵌套结构

// 多层块级作用域
{
    let a = "外层块 a";
    const b = "外层块 b";
    
    {
        let a = "内层块 a"; // 可以重新声明,因为不同块
        console.log(a);    // "内层块 a"
        console.log(b);    // "外层块 b" - 可以访问外层
        
        {
            console.log(a); // "内层块 a"
            console.log(b); // "外层块 b"
        }
    }
    
    console.log(a); // "外层块 a"
}

其词法环境结构如下:

 外层块词法环境: { a: "外层块 a", b: "外层块 b", Outer: 全局 }
   ↓
 内层块词法环境: { a: "内层块 a", Outer: 外层块词法环境 }
   ↓
 最内层块词法环境: { Outer: 内层块词法环境 }

暂时性死区(Temporal Dead Zone)

{
  // TDZ开始
  console.log(myVar); // undefined
  console.log(myLet); // ReferenceError

  var myVar = "var变量";
  let myLet = "let变量";
  // TDZ结束
}

上述实际执行过程(简化):

  1. 进入块级作用域,创建词法环境
  2. var声明被提升,初始值为 undefined
  3. let声明被记录,但未初始化(在TDZ中不可调用)
  4. 在let初始化前访问 → ReferenceError

常见面试题解析

多级嵌套作用域

var x = 10;
function foo() {
    console.log(x);
}
function bar() {
    var x = 20;
    foo();
}
bar(); // 输出什么?

上述代码输出结果为:10:

  1. foo函数定义在全局作用域。
  2. 因此foo的词法作用域链:foo作用域 → 全局作用域。
  3. foo在定义时就确定了作用域链,与调用位置无关。
  4. foo中访问x时,在自身作用域没找到,到全局作用域找到x=10

闭包与循环

function createFunctions() {
    var result = [];
    for (var i = 0; i < 3; i++) {
        result[i] = function() {
            return i;
        };
    }
    return result;
}

var funcs = createFunctions();
console.log(funcs[0]()); // 3
console.log(funcs[1]()); // 3  
console.log(funcs[2]()); // 3

详细解析过程与解决方案,可以查看这篇文章:JavaScript内存管理揭秘:变量究竟存在哪里

复杂的嵌套作用域

var a = 1;
function test() {
    var a = 2;    
    function innerTest() {
        var a = 3;        
        return function() {
            console.log(a);
            console.log(this.a);
        };
    }    
    var obj = {
        a: 4,
        getFunc: innerTest()
    };   
    return obj.getFunc;
}
var func = test();
func();

上述代码的输出结果是:3 1 :

  1. funcinnerTest 返回的匿名函数
  2. 匿名函数定义在 innerTest 内部,所以它的词法作用域链:
    • 匿名函数作用域 → innerTest 作用域(a=3) → test 作用域(a=2) → 全局(a=1)
  3. console.log(a):在自身作用域没找到,到innerTest找到a=3
  4. console.log(this.a)this指向全局 window 对象,输出全局a=1

思考题

如果JavaScript采用动态作用域而不是词法作用域,会有什么影响?闭包还能工作吗?

结语

JavaScript的词法作用域机制既是其强大之处,也是初学者容易困惑的地方。深入理解这一机制,不仅能帮助你写出更好的代码,还能在面试中游刃有余地解答相关题目。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!