函数的定义位置决定了它的作用范围:JavaScript 词法作用域
了解 JavaScript 如何通过词法环境解析变量名,为何查找时调用位置无关紧要,以及这一机制如何体现在 React 处理函数中。
来源: 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规范使用词法环境来建模作用域。每个词法环境都会记录两样内容:
- 该环境中绑定的标识符
作为一种思维模型:
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 中陈旧闭包现象的根源。