阅读视图

发现新文章,点击刷新页面。

深入理解 JavaScript 词法作用域链:从代码到底层实现机制

一、引言:一个令人困惑的示例

先来看一段看似简单却容易出错的 JavaScript 代码:

// 全局环境
var myName = '极客时间';
let myAgent = 10;
let test = 1;

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

function foo(){
  var myName = '极客邦';
  bar();
}

foo(); // 输出什么?

直觉上,很多人会认为输出应该是 '极客邦',因为 bar() 是在 foo() 内部调用的。但实际上,这段代码输出的是 '极客时间'

为什么会出现这样的结果?这就引出了 JavaScript 中一个核心概念——词法作用域链

二、什么是词法作用域?

词法作用域(Lexical Scope)指的是:变量的可见性由函数在源代码中的声明位置决定,而不是函数被调用的位置

换句话说,解析变量名的"查找路径"(即作用域链)在代码的编译/解析阶段就已经确定好了,与运行时调用栈的顺序无关。这就是为什么 bar() 函数始终访问的是全局的 myName,因为它在源码中就是在全局作用域声明的。

三、更复杂的示例:混合作用域类型

让我们看一个更复杂的例子,包含 varlet 和块级作用域:

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();

这段代码展示了:

  • var 的函数级作用域
  • let 的块级作用域
  • 不同位置声明的变量如何相互影响

关键点在于:bar 在源码中声明的位置决定了它能访问的外层词法环境。即使 bar()foo 里的某个块中被调用,它也无法看到 foo 的局部变量(除非 bar 是在 foo 内部声明的)。

四、JavaScript 引擎的内部机制

要真正理解作用域链,我们需要深入到 JavaScript 引擎(如 V8)的实现层面。

执行上下文的组成

每个执行上下文(Execution Context)包含三个核心部分:

  1. Variable Environment(变量环境) - 存储 varfunction 声明
  2. Lexical Environment(词法环境) - 存储 let / const / class 声明
  3. ThisBinding(this 绑定) 及可执行代码

现代 JavaScript 引擎中,变量环境和词法环境是两套独立但协同工作的系统,它们各自维护环境记录(Environment Record),并共享相同的外层指针(outer),构成"并行的作用域链结构"。

编译阶段 vs 执行阶段

JavaScript 函数的执行分为两个关键阶段:

1. 编译阶段(Compilation)

在这个阶段,引擎会:

创建 Variable Environment:

  • 登记 var 声明(初始化为 undefined
  • 登记函数声明(初始化为对应函数对象)

创建 Lexical Environment:

  • 登记 let / const / class 声明,但保持在 TDZ(暂时性死区)
  • 为块级作用域创建独立的词法环境

建立 outer 链接:

  • 确定当前环境的外层环境引用
  • 这个链接基于代码的静态结构,而非运行时调用

2. 执行阶段(Execution)

代码真正开始执行时:

  1. 访问变量时,查找顺序为:

    • 先查 Lexical Environment(块级作用域 + let/const)
    • 找不到则查 Variable Environment(var/function)
    • 再沿着 outer 指针向外层环境查找,直到全局
  2. 环境记录中的值会被不断更新(赋值、初始化等)

执行上下文的内部结构

从实现角度看,执行上下文可以表示为:

Execution Context = {
  EnvironmentRecord: {
    Variable Environment,
    Lexical Environment,
    outer // 指向外层词法环境的引用
  },
  code  // 可执行代码
}

不同类型的声明有不同的处理策略:

  • var:在编译阶段被初始化为 undefined
  • function:在编译阶段被绑定为函数对象
  • let/const:在词法环境中登记,但直到执行到声明语句才正式初始化

五、回到示例:为什么是全局的 myName?

现在我们可以完整解释开头的例子了:

  1. bar 在全局作用域声明,因此 bar.[[Environment]] 指向全局词法环境
  2. bar 执行并访问 myName 时,查找路径是:
    • bar 的局部环境(没有找到)
    • 沿着 [[Environment]] 到全局环境
    • 找到 myName = '极客时间'
  3. barfoo 内部调用的事实不改变[[Environment]] 引用

这就是词法作用域(静态作用域)与动态作用域的核心区别。

六、闭包(closure)是如何“借用”词法作用域的

简单版结论:闭包是函数和其声明时关联的词法环境的组合

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()); // '极客邦'

分析:

  • getName / setNamefoo 内声明,因此它们的 [[Environment]] 指向 foo 的词法环境。
  • foo 返回 innerBar 后,foo 的执行上下文弹出调用栈,但 foo 的词法环境并未被回收,因为 innerBar 中的函数仍然通过闭包引用该环境(环境是“可达”的)。这就是闭包保持自由变量存活的机制。

GC(垃圾回收)角度

  • 只有当 foo 的词法环境不再被任何可达对象(如返回的函数对象)引用时,才会被回收。
  • 因此 bar(上例返回的对象)持有对那块环境的引用,导致 myNametest1 等变量继续存活。

七、常见面试/调试陷阱

  1. 函数在哪里声明,在哪里决定它的外部环境:无论何时调用,外部环境由声明位置决定。
  2. 调用栈 vs 环境:调用栈控制运行顺序和执行上下文的创建/销毁;环境控制变量解析路径,二者不同步。环境包括变量环境和词法环境。
  3. varlet/const 的差别var 是函数级(或全局)绑定且会被提前初始化为 undefinedlet/const 是块级绑定且存在 TDZ。
  4. 闭包不等于内存泄漏:闭包让外层环境继续可达,因此不会被 GC;需要手动断开引用(如把返回对象设为 null)来释放内存。

八、实践建议(写更容易理解、调试的代码)

  • 尽量用 let/const 而不是 var,避免意外提升带来的迷惑。
  • 函数如果需要访问周围变量,尽量把它在恰当的词法位置声明,这样阅读代码时能直观得知依赖关系。
  • 对长期持有闭包引用的场景(如事件回调、定时器、长生命周期对象),显式释放引用或把需要缓存的数据放到显式的对象上,以便管理其生命周期。

九、小结(一句话回顾)

词法作用域链在编译阶段就决定了变量解析路径;闭包则是函数与其声明时词法环境的绑定,正是它使得某些局部变量在函数返回后仍然存活。

❌