首页 / 文章

函数的定义位置决定了它的作用范围:JavaScript 词法作用域

了解 JavaScript 如何通过词法环境解析变量名,为何查找时调用位置无关紧要,以及这一机制如何体现在 React 处理函数中。

发布者:REACTAPP.TOP阅读约 11 分钟2586 词

来源: How JavaScript Finds Variables: Scope, Lexical Environment & Scope Chain

每当 JavaScript 计算像 user 这样的标识符时,引擎必须确定它对应的是哪个绑定。最常见的错误是误以为被调用函数能够看到其调用函数的变量,这种错误在面试和实际生产问题中都会出现。本指南将从作用域讲到词法环境,再到标识符解析,并重点阐述作用域链与调用栈之间的区别,帮助你预判任何嵌套函数相关的问题,并清晰地理解 React 中的处理程序。

问题:嵌套函数如何找到外部变量?

从三层嵌套结构开始:

const appName = "HealthApp";

function dashboard() {
  const user = "Jashan";
  function showProfile() {
    const status = "Active";
    console.log(appName);
    console.log(user);
    console.log(status);
  }
  showProfile();
}

dashboard();

showProfile() 只声明了 status,但三个 console.log 调用都成功了。每个标识符都是在不同的地方被找到的:

status  → showProfile()
user    → dashboard()
appName → global

解释这一点需要简短地介绍几个相关概念:

Scope
   ↓
Lexical Scope
   ↓
Lexical Environment
   ↓
Scope Chain
   ↓
Identifier Resolution

作用域:名称可被使用的范围

作用域是指标识符可以被引用的代码区域:它回答了“我从哪里可以使用这个名称?”的问题。

function dashboard() {
  const user = "Jashan";
  console.log(user); // ✅
}

dashboard();
console.log(user); // ❌ ReferenceError

user 属于 dashboard() 函数;在其外部使用会引发 ReferenceError。以树状结构表示为:

GLOBAL
│
└── dashboard()
      └── user

由此得出规则:

Inside dashboard  → accessible ✅
Outside dashboard → inaccessible ❌

为何需要隔离机制

这两个函数各自都声明了一个 data 变量:

function loadPatients() {
  const data = ["Patient A"];
}
function loadAppointments() {
  const data = ["Appointment A"];
}

由于它们各自处于不同的函数作用域中,因此永远不会发生冲突。这种隔离机制使得代码更具可重用性且更易于调试。

四种作用域类型

JavaScript 具有四种主要的作用域类型:

Scope
├── Global Scope
├── Function Scope
├── Block Scope
└── Module Scope

全局作用域

在传统的(非模块化)脚本中,顶层声明属于全局作用域,可从任何地方访问:

var appName = "HealthApp";

function showAppName() {
  console.log(appName);
}

showAppName() 能找到 appName,因为其查找路径终止于全局环境。需要注意的一点是:顶层 var 会成为全局对象(浏览器中为 window)的属性,而顶层 let 和 const 则不会。应尽量减少全局状态,因为它会导致代码之间耦合过度并增加测试难度。

函数作用域

在函数内部声明的任何内容都属于该函数的局部作用域:

function calculateTotal() {
  const price = 100;
  const tax = 20;
  return price + tax;
}
console.log(price); // ❌

price 和 tax 是局部变量,因此最终的日志输出会出错。

var 不考虑代码块

var 的作用域仅限于最近的函数,而非最近的一对大括号:

function example() {
  if (true) {
    var message = "Hello";
  }
  console.log(message); // ✅ Hello
}

if 语句块中不包含 message,因此在该语句块之后依然可以正常读取。

使用 let 和 const 的作用域

let 和 const 的作用域仅限于其声明所在的语句块:

if (true) {
  const message = "Payment successful";
  let status = "completed";
}

console.log(message); // ❌
console.log(status);  // ❌

两次日志记录都会失败。循环的行为也是一样的,for (let ...) 循环的每次迭代都会为 i 创建新的绑定:

for (let i = 0; i < 3; i++) {
  console.log(i);
}
console.log(i); // ❌

请注意,对象字面量的大括号用于描述一个值而非一个语句块,因此不会创建任何作用域。

模块作用域

在 ES 模块中,顶层声明仅对当前文件可见:

// auth.js
const token = "secret";
export function getUser() {}

其他模块无法访问 token:

// dashboard.js
console.log(token); // ❌

只有模块明确导出的内容才能被其他地方导入:

import { getUser } from "./auth.js";

这是 React、Next.js、Vite 以及现代 Node.js 项目中的常见作用域。

词法作用域:由源代码中的位置决定

JavaScript 采用词法作用域:函数可以访问源代码中所有嵌套在其内部的作用域中的变量。这种结构在代码被解析时就已经确定,即在任何代码执行之前。

const name = "Global";

function outer() {
  const name = "Outer";
  function inner() {
    console.log(name);
  }
  inner();
}

outer();

程序的输出为:

Outer

在源代码中,inner() 位于 outer() 内部,因此 outer 中的 name 是最接近的那个变量:

GLOBAL
│
└── outer()
      ├── name = "Outer"
      │
      └── inner()

对 name 的查找会从 inner 开始向外搜索,最终在 outer 处停止:

inner
  ↓
not found locally
  ↓
outer
  ↓
name = "Outer" ✅

词法作用域是由函数的定义位置决定的,而非其调用位置。

面试陷阱:定义与调用

现在将inner()从outer()中移出,同时保持outer()继续调用它:

const name = "Global";
function outer() {
  const name = "Outer";
  inner();
}
function inner() {
  console.log(name);
}
outer();

常见的猜测是:

Outer

实际输出为:

Global

inner()是在顶层、与outer()相邻的位置定义的,而非在其内部。

GLOBAL
├── name = "Global"
├── outer()
└── inner()

因此它的查找路径会直接指向全局环境:

inner
  ↓
Global

它不会经过outer:

inner
  ↓
outer ❌

outer()调用inner()这一行为并不会改变什么。调用位置决定了调用栈的结构,而定义位置则决定了词法作用域。

词法环境:作用域背后的机制

ECMAScript规范使用词法环境来建模作用域。每个词法环境都会记录两样内容:

  1. 该环境中绑定的标识符
  • 指向其所在环境的链接
  • 作为一种思维模型:

    Lexical Environment
    │
    ├── Environment Record
    └── Outer Environment Reference
    

    最新的规范版本将外部链接作为[[OuterEnv]]字段包含在环境记录中;该模型本身并未改变。

    环境记录保存着各种绑定关系

    以一个包含两个局部常量的函数为例:

    function dashboard() {
      const user = "Jashan";
      const role = "Admin";
    }
    

    从概念上讲,它的环境记录负责将名称与对应的值关联起来:

    dashboard Environment
    user → "Jashan"
    role → "Admin"
    

    这只是一个模型,并非代码可以直接访问的对象。

    外部引用用于连接各个环境

    每个环境都指向在词法上位于其外部的那个环境:

    const appName = "HealthApp";
    
    function dashboard() {
      const user = "Jashan";
    }
    

    对于dashboard()函数来说,其结构如下所示:

    dashboard Environment
    │
    ├── user = "Jashan"
    └── Outer
          ↓
    Global Environment
    

    该指针使得可以在局部作用域之外进行查找。它在函数创建时根据其定义位置被设置,因此调用位置并不重要。

    追踪完整链式结构

    回到最初的示例,将三个日志信息合并起来:

    const appName = "HealthApp";
    
    function dashboard() {
      const user = "Jashan";
      function showProfile() {
        const status = "Active";
        console.log(appName, user, status);
      }
      showProfile();
    }
    
    dashboard();
    

    当 showProfile() 被调用时,其各个环境以如下方式相互关联:

    SHOWPROFILE ENVIRONMENT
    │
    ├── status = "Active"
    └── Outer
          ↓
    DASHBOARD ENVIRONMENT
    │
    ├── user = "Jashan"
    └── Outer
          ↓
    GLOBAL ENVIRONMENT
    │
    ├── appName = "HealthApp"
    └── Outer
          ↓
         null
    

    全局环境的外部引用为 null,从而终止了整个链式结构。

    作用域链与标识符解析

    作用域链是引擎用来查找标识符的路径:首先检查当前环境,然后沿着外部引用继续查找,直到找到该名称或链式结构结束。

    Current Environment
            ↓
    Outer Environment
            ↓
    Outer Environment
            ↓
    Global
            ↓
    null
    

    沿着这条路径查找绑定关系被称为标识符解析。以这三次查找为例:

    console.log(status);
    console.log(user);
    console.log(appName);
    

    解析 status

    在本地环境中立即找到:

    showProfile
         ↓
    status found ✅
    

    解析 user

    本地未找到,在上一层目录中找到:

    showProfile ❌
         ↓
    dashboard
         ↓
    user found ✅
    

    解析 appName

    在两个函数中均未找到,在全局环境中找到:

    showProfile ❌
         ↓
    dashboard ❌
         ↓
    Global
         ↓
    appName found ✅
    

    在所有情况下,搜索都会在首次匹配处停止。如需深入了解查找算法本身,请参阅JavaScript的作用域链究竟是如何解析变量的。

    解析失败时

    考虑一个尝试读取未被声明的名称的函数:

    function showProfile() {
      console.log(age);
    }
    

    以及对其的调用:

    showProfile();
    

    如果 age 不存在于任何可访问的环境中,搜索就会终止:

    showProfile
         ↓
    Global
         ↓
    null
    

    引擎无法解析该名称,从而抛出错误:

    ReferenceError: age is not defined
    

    这种情况不同于存储了undefined值的变量,或是尚未初始化的let绑定(即临时死区)。这三种状态很容易被混淆:

    undefined
    uninitialized
    undeclared
    

    涵盖大多数情况的三条规则

    查找仅向外部进行

    Inner
      ↓
    Outer
      ↓
    Global
    

    内部代码可以读取外部变量。外部代码无法进入嵌套作用域来读取仅在其中声明的变量。

    最近的绑定优先

    当多个层级存在相同名称时,会使用最接近的那个:

    const user = "Global User";
    
    function dashboard() {
      const user = "Dashboard User";
      function showProfile() {
        console.log(user);
      }
      showProfile();
    }
    

    输出结果:

    Dashboard User
    

    搜索在dashboard处结束,即向上查找一步:

    showProfile ❌
         ↓
    dashboard ✅
         ↓
    STOP
    

    全局变量user从未被引用。像这样内部声明遮蔽了外部声明的现象被称为遮蔽作用。

    嵌套函数中不会复制任何内容

    有一种看似合理但实际上错误的观点认为,内部函数会接收到外部变量的副本:

    showProfile
    ├── copied user
    └── copied appName
    

    正确的理解是存在一系列引用关系:

    showProfile
         ↓
    dashboard
         ↓
    Global
    

    通过引用访问意味着内部函数始终能看到外部变量当前的值,这正是闭包能够正常工作的原因。

    作用域链与调用栈

    再来看一个容易产生误解的例子:

    const name = "Global";
    
    function outer() {
      const name = "Outer";
      inner();
    }
    function inner() {
      console.log(name);
    }
    
    outer();
    

    当 inner() 执行时,调用栈的状态如下:

    ┌──────────────────┐
    │    inner FEC     │ ← Current
    ├──────────────────┤
    │    outer FEC     │
    ├──────────────────┤
    │    Global EC     │
    └──────────────────┘
    

    outer() 显然在调用栈上,因为它调用了 inner()。然而 inner() 仍然能够输出:

    Global
    

    这是因为变量查找是沿着作用域链进行的,而 inner 的作用域链直接指向全局环境。这两种结构用于回答不同类型的问题:

    • 调用栈用于追踪当前处于活动状态的执行上下文;作用域链则负责解析标识符。
    • 调用栈的顺序取决于函数之间的调用关系;作用域链则由源代码的嵌套结构决定。
    • 调用栈遵循后进先出原则;作用域链则依据外部环境的链接关系排列。
    • 调用栈回答“当前正在运行什么?”;作用域链则回答“这个标识符存在于何处?”

    执行上下文与词法环境

    执行上下文

    执行上下文保存了代码运行时所需的状态。主要有两种类型:

    Global Execution Context
    Function Execution Context
    

    词法环境

    词法环境保存着标识符的绑定信息以及与其外部环境的链接。执行上下文会引用其所在的环境,大致如下所示:

    Execution Context
          │
          └── environment information
                   ↓
           Lexical Environment
                   │
                   ├── bindings
                   └── outer environment
    

    因此,这两者并非同一概念:

    Execution Context ≠ Lexical Environment
    

    执行上下文解释了代码是如何运行的;词法环境则解释了名称是如何被查找的。

    React组件中的词法作用域

    React代码始终依赖于词法作用域。以在组件内部定义的事件处理函数为例:

    function UserDashboard({ userId }: { userId: string }) {
      const role = "Admin";
    
      function handleProfileClick() {
        console.log(userId);
        console.log(role);
      }
    
      return (
        <button onClick={handleProfileClick}>
          View Profile
        </button>
      );
    }
    

    handleProfileClick()并未声明这些名称:

    userId
    role
    

    但由于它是在UserDashboard内部编写的,所以仍然可以使用这些名称:

    handleProfileClick
            ↓
    UserDashboard
            ├── userId
            └── role
    

    同样的机制也适用于props、state、回调函数、定时器、Promise以及hooks。每次渲染都会创建一个包含该次渲染所对应值的新环境,而在该环境中定义的处理函数会绑定到这些具体的值上。这就是闭包的原理,也是“过时闭包”的成因——即早期渲染产生的回调函数会持续读取旧的状态值。

    常见误解

    “查找遵循调用者”

    并非如此。关键在于定义位置:

    Where function is called ❌
    Where function is defined ✅
    

    “调用栈与作用域链是相同的”

    它们的功能不同:

    Call Stack  → execution
    Scope Chain → identifier lookup
    

    “外部代码可以读取内部变量”

    查找是单向的:

    Inner → Outer → Global ✅
    Outer → Inner ❌
    

    “var会受作用域块影响”

    并非如此;只有let和const会受影响:

    var       → function-scoped
    let/const → block-scoped
    

    “词法环境是一个普通对象”

    它是规范中的概念;解释器可自由实现,且你的代码无法访问它。

    需牢记的心智模型

    这一主题可概括为一系列问题:

    Scope
       ↓
    Where can I use this variable?
       ↓
    Lexical Scope
       ↓
    Where was this function defined?
    Lexical Environment
       ↓
    What variables exist here?
    Outer Environment
       ↓
    Where do I look next?
    Scope Chain
       ↓
    Current → Outer → Global
    Identifier Resolution
       ↓
    Find the matching variable
    

    核心要点

    • 作用域决定了名称的使用范围;JavaScript 具有全局、函数、块级和模块作用域,var 的作用域限定在函数内,而 let/const 的作用域限定在块级中。
    • 作用域是词法作用域:函数的外部环境在其定义处就已确定,因此从其他地方调用该函数也不会改变它所能访问的内容。
    • 标识符解析会从当前作用域开始向外查找,遇到第一个匹配项即停止(发生遮蔽现象),如果遍历到末尾则会抛出 ReferenceError 错误。
    • 内部函数持有对外部环境的引用而非副本,这正是闭包以及 React 中陈旧闭包现象的根源。