要理解 JS 的很多行为(闭包、变量查找、var 与 let 的差异、模块封装),都绕不开一个底层机制——词法作用域。它是 JavaScript 作用域模型的基石,也是面试里区分「背题」和「真懂」的分水岭。
本文从「是什么」「为什么这么设计」「常见例子」「和 this 的区别」四个角度,把词法作用域讲清楚。
一、词法作用域是什么
1. 一句话定义
词法作用域(Lexical Scope),也叫静态作用域(Static Scope):变量能被访问的范围,由代码书写时的位置决定,在编译(解析)阶段就已经确定,与函数在哪里被调用无关。
「词法」二字来自编译原理——作用域由源码的词法结构决定,也就是你在哪个函数、哪个代码块里写了这段代码,而不是运行时谁调用了它。
2. 作用域链与变量查找
JS 解析时,会为每个函数/代码块创建作用域,并按嵌套关系形成一条作用域链。查找变量时:
- 先找当前作用域;
- 找不到就沿作用域链去外层作用域找;
- 一直找到全局作用域,还找不到就报
ReferenceError。
关键点:这条链在代码写出来的时候就固定了,运行时不会因为调用位置发生变化。
3. 与动态作用域对比
| 维度 | 词法(静态)作用域 | 动态作用域 |
|---|---|---|
| 由什么决定 | 代码书写位置 | 函数调用位置/调用栈 |
| 确定时机 | 编译期 | 运行期 |
| JS 是否采用 | ✅ 是 | ❌ 否 |
| 典型语言 | JS、C、Java | Bash、早期 Lisp |
理解了这张表,就理解了下面那个经典陷阱。
二、为什么 JS 要这么设计
1. 可预测、可读
词法作用域让「一个变量来自哪里」只看代码结构就能确定,不用追踪运行时调用栈。
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo(); // 调用位置在 bar 内部
}
bar(); // 输出 1,而不是 2
foo 定义在全局,它看到的是全局的 value。即使它在 bar 里被调用,也不会去用 bar 里的 value。如果 JS 是动态作用域,这里就会输出 2——那样代码的行为会强烈依赖「谁调用了它」,非常难以推理。
2. 静态分析 / 编译优化
因为作用域在编译期就定了,引擎可以:
- 提前确定变量的存储位置(是局部变量还是闭包变量);
- 做内联、逃逸分析、死代码消除等优化;
- 让编辑器、TypeScript、ESLint 在不运行代码的情况下就能做跳转、补全、类型检查。
动态作用域下,一个变量指向哪里要运行时才知道,这些优化几乎无法进行。
3. 闭包的基础
闭包的本质就是「函数记住并访问它定义时所在的作用域」。词法作用域是闭包能够成立的前提——函数返回后,它的外层作用域仍然能通过作用域链被访问到。
function outer() {
var message = 'hello';
function inner() {
console.log(message); // inner 记住了定义时的外层作用域
}
return inner;
}
const fn = outer();
fn(); // hello
4. 减少命名冲突
有了作用域嵌套,内层可以定义同名变量而不污染外层,配合模块化就能做封装。
三、JS 中常见的词法作用域例子
1. 三种作用域
var globalVar = 'global'; // 全局作用域
function demo() {
var funcVar = 'function'; // 函数作用域
if (true) {
let blockVar = 'block'; // 块级作用域(ES6)
console.log(blockVar); // block
}
console.log(funcVar); // function
}
2. var 是函数作用域,let/const 是块级作用域
if (true) {
var a = 1;
let b = 2;
}
console.log(a); // 1,泄漏到外层
console.log(b); // ReferenceError,b 只属于 if 块
3. 经典 for 循环陷阱
// var:所有回调共享同一个 i
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 3 3 3
}
// let:每次迭代创建新的块级绑定
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0 1 2
}
let 的表现正是因为块级作用域是按书写结构确定的——每次循环体都是一个新的词法作用域。
4. 闭包与私有变量
function createCounter() {
let count = 0;
return {
increment() { return ++count; },
get() { return count; }
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.get()); // 2
count 不属于返回对象,但因为闭包持有 createCounter 的作用域,它既不会被回收,也无法被外部直接篡改——这就是模块化私有变量的原理。
5. 变量提升也是词法解析的结果
console.log(x); // undefined
var x = 1;
console.log(y); // ReferenceError(暂时性死区)
let y = 1;
var 的声明在解析阶段被提升到所属函数/全局作用域顶部,let/const 也有提升但处于「暂时性死区」,本质都是编译期的词法分析在起作用。
四、普通函数的 this 和词法作用域有什么不同
这是最容易混淆的地方。核心区别:
词法作用域管的是「变量怎么找」,
this是一套独立的特殊绑定,普通函数的this由调用方式在运行时决定。
1. 普通函数:this 是动态绑定
function show() {
console.log(this.name);
}
const obj = { name: 'obj', show };
const other = { name: 'other' };
obj.show(); // obj —— 隐式绑定,指向调用它的对象
show.call(other); // other —— 显式绑定
const fn = obj.show;
fn(); // undefined/global —— 默认绑定,丢失了 obj
new show(); // 指向新创建的实例
this 的指向完全取决于调用点(call site),同一份函数代码,换个调用方式结果就变。这叫动态绑定。
2. 箭头函数:this 是词法绑定
箭头函数没有自己的 this,它把 this 当作外层词法作用域里的一个普通变量来解析——沿作用域链找到最近的非箭头函数的 this。
const obj = {
name: 'obj',
regular() {
console.log(this.name);
},
arrow: () => {
console.log(this.name); // 这里的 this 来自模块/全局,不是 obj
}
};
obj.regular(); // obj
obj.arrow(); // undefined(非严格全局下是 window.name)
所以箭头函数一旦定义,this 就固定了,不受调用方式影响——这正是借用了词法作用域的解析机制,术语上叫词法 this(Lexical this)。
3. 对比表
| 维度 | 词法作用域(变量查找) | 普通函数的 this | 箭头函数的 this |
|---|---|---|---|
| 决定时机 | 编译期 | 运行时 | 编译期(跟随外层) |
| 由什么决定 | 代码书写位置 | 调用方式(call site) | 定义时所在外层作用域 |
| 是否可变 | 否 | 每次调用可变 | 否 |
| 绑定方式 | 词法/静态 | 动态 | 词法/静态 |
| 常见规则 | 作用域链逐层查找 | 默认/隐式/显式/new | 沿作用域链找外层 this |
4. 一个记忆窍诀
- 问「这个变量是谁的?」→ 看代码写在哪个作用域里 → 词法作用域。
- 问「这个
this是谁?」→ 看普通函数怎么被调用 → 动态绑定;箭头函数则看它定义在哪 → 词法 this。
千万别把两者混为一谈:JS 的作用域永远是词法的,只有普通函数的 this 是动态的。 所谓「动态作用域」是另一个概念,JS 并不支持。
总结
- 词法作用域:变量的可见范围由代码书写位置决定,编译期固定,靠作用域链查找,与调用位置无关。
- 为什么这么设计:可预测、可静态分析优化、是闭包和模块封装的基础、减少命名冲突。
- 常见体现:
var/let作用域差异、for 循环闭包、私有变量、变量提升。 - 与
this的区别:作用域解决「变量从哪来」;普通函数的this是运行时按调用方式动态绑定,箭头函数没有自己的this,采用词法 this,跟随外层作用域——这才是和词法作用域最接近的一处。