为什么 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的过程如下:
- 检查level3的环境记录 → 没有c
- 通过Outer引用检查level2的环境记录 → 没有c
- 通过Outer引用检查level1的环境记录 → 没有c
- 通过Outer引用检查全局环境记录 → 找到c = "global c"
- 如果一直找到最外层都没找到: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 → 全局
│ │ └─
│ └─
└─
作用域链的关键特性
- 静态性(词法作用域):作用域链在函数定义时就已经确定,而不是在调用时确定的。
- 链式结构:像链条一样一环扣一环,从当前作用域指向外层作用域。
- 单向性:只能从内层作用域访问外层作用域的变量,不能反向访问。
- 与执行上下文相关:每次函数调用都会创建新的执行上下文,但作用域链基于函数定义位置确定。
闭包与作用域链的持久化
闭包的本质就是:函数记住了它被创建时的词法环境:
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的实现原理:
- 在编译阶段,
let/const声明的变量被记录在词法环境中 - 在变量声明之前访问会抛出错误(暂时性死区)
- 每个
{}代码块都会创建一个新的词法环境
块级作用域的嵌套结构
// 多层块级作用域
{
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结束
}
上述实际执行过程(简化):
- 进入块级作用域,创建词法环境
- var声明被提升,初始值为 undefined
- let声明被记录,但未初始化(在TDZ中不可调用)
- 在let初始化前访问 → ReferenceError
常见面试题解析
多级嵌套作用域
var x = 10;
function foo() {
console.log(x);
}
function bar() {
var x = 20;
foo();
}
bar(); // 输出什么?
上述代码输出结果为:10:
- foo函数定义在全局作用域。
- 因此foo的词法作用域链:foo作用域 → 全局作用域。
- foo在定义时就确定了作用域链,与调用位置无关。
- 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 :
func是innerTest返回的匿名函数- 匿名函数定义在
innerTest内部,所以它的词法作用域链:- 匿名函数作用域 →
innerTest作用域(a=3) →test作用域(a=2) → 全局(a=1)
- 匿名函数作用域 →
console.log(a):在自身作用域没找到,到innerTest找到a=3console.log(this.a):this指向全局window对象,输出全局a=1
思考题
如果JavaScript采用动态作用域而不是词法作用域,会有什么影响?闭包还能工作吗?
结语
JavaScript的词法作用域机制既是其强大之处,也是初学者容易困惑的地方。深入理解这一机制,不仅能帮助你写出更好的代码,还能在面试中游刃有余地解答相关题目。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!