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

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

先来看一段看似简单却容易出错的 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,因为它在源码中就是在全局作用域声明的。

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

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

javascript 复制代码
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(变量环境) - 存储 var 与 function 声明
  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. 环境记录中的值会被不断更新(赋值、初始化等)

执行上下文的内部结构

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

javascript 复制代码
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. bar 在 foo 内部调用的事实不改变 其 [[Environment]] 引用

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

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

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

js 复制代码
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 / setName 在 foo 内声明,因此它们的 [[Environment]] 指向 foo 的词法环境。
  • 当 foo 返回 innerBar 后,foo 的执行上下文弹出调用栈,但 foo 的词法环境并未被回收,因为 innerBar 中的函数仍然通过闭包引用该环境(环境是"可达"的)。这就是闭包保持自由变量存活的机制。

GC(垃圾回收)角度

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

七、常见面试/调试陷阱

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

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

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

九、小结(一句话回顾)

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

相关推荐
子兮曰4 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰4 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万4 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝4 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋4 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁4 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
李少兄4 天前
JavaScript 隐式全局变量解析
javascript
汉堡大王95274 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大4 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端
计算机魔术师4 天前
Meta Muse agent 接入 Shopify 的 Shop Pay 实现代理式购物
前端