作用域与作用域链
问题
JavaScript 中作用域是什么?作用域链如何工作?var、let、const 有什么区别?
答案
作用域是程序中定义变量的区域,决定了变量的可访问性和生命周期。JavaScript 采用词法作用域(静态作用域),作用域在代码书写时就已确定。
作用域类型
全局作用域
// 全局作用域中定义的变量
var globalVar = '全局变量';
let globalLet = '全局 let';
const globalConst = '全局 const';
function foo(): void {
console.log(globalVar); // 可以访问
}
// 在浏览器中,var 会挂载到 window
console.log(window.globalVar); // '全局变量'
console.log(window.globalLet); // undefined - let 不挂载
函数作用域
function outerFunction(): void {
var functionScope = '函数作用域';
function innerFunction(): void {
console.log(functionScope); // 可以访问外层函数变量
}
innerFunction();
}
// console.log(functionScope); // ❌ ReferenceError
块级作用域
// ES6 的 let 和 const 引入块级作用域
{
let blockLet = '块级 let';
const blockConst = '块级 const';
var blockVar = '块级 var'; // var 无块级作用域
}
// console.log(blockLet); // ❌ ReferenceError
// console.log(blockConst); // ❌ ReferenceError
console.log(blockVar); // ✅ '块级 var'
// if/for 等语句也创建块级作用域
if (true) {
let x = 1;
}
// console.log(x); // ❌ ReferenceError
for (let i = 0; i < 3; i++) {
// i 只在循环内有效
}
// console.log(i); // ❌ ReferenceError
作用域链
当访问一个变量时,JavaScript 引擎会沿着作用域链向上查找:
const globalVar = 'global';
function outer(): void {
const outerVar = 'outer';
function inner(): void {
const innerVar = 'inner';
console.log(innerVar); // 1. 当前作用域找到
console.log(outerVar); // 2. 外层作用域找到
console.log(globalVar); // 3. 全局作用域找到
}
inner();
}
outer();
词法作用域 vs 动态作用域
const value = 1;
function foo(): number {
console.log(value);
return value;
}
function bar(): void {
const value = 2;
foo(); // 输出 1,不是 2
}
bar();
// JavaScript 使用词法作用域:foo 定义时 value=1
// 如果是动态作用域:foo 调用时 value=2
var、let、const 对比
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数/全局 | 块级 | 块级 |
| 重复声明 | ✅ 允许 | ❌ 报错 | ❌ 报错 |
| 重新赋值 | ✅ 允许 | ✅ 允许 | ❌ 报错 |
| 变量提升 | ✅ 提升 | 存在 TDZ | 存在 TDZ |
| 挂载 window | ✅ 全局时 | ❌ 不会 | ❌ 不会 |
变量提升
// var 变量提升
console.log(a); // undefined
var a = 1;
// 相当于:
// var a;
// console.log(a);
// a = 1;
// 函数声明也会提升
foo(); // 正常执行
function foo(): void {
console.log('foo');
}
// 函数表达式不会提升声明内容
bar(); // ❌ TypeError: bar is not a function
var bar = function(): void {
console.log('bar');
};
暂时性死区(TDZ)
// let/const 存在暂时性死区
console.log(x); // ❌ ReferenceError: Cannot access 'x' before initialization
let x = 1;
// TDZ 的范围:从块开始到变量声明之间
{
// TDZ 开始
console.log(y); // ❌ ReferenceError
// TDZ 结束
let y = 2;
}
// typeof 也不安全
console.log(typeof undeclared); // 'undefined'
console.log(typeof letVar); // ❌ ReferenceError
let letVar = 1;
重复声明
// var 可以重复声明
var a = 1;
var a = 2; // ✅ 不报错
// let/const 不允许重复声明
let b = 1;
// let b = 2; // ❌ SyntaxError
// 不同作用域可以重复
let c = 1;
{
let c = 2; // ✅ 不同作用域,是新变量
}
const 的本质
// const 是常量引用,不是常量值
const obj = { name: 'Alice' };
obj.name = 'Bob'; // ✅ 可以修改属性
// obj = {}; // ❌ TypeError: Assignment to constant variable
const arr = [1, 2, 3];
arr.push(4); // ✅ 可以修改数组
// arr = []; // ❌ 不能重新赋值
// 如果需要不可变对象,使用 Object.freeze
const frozen = Object.freeze({ name: 'Alice' });
frozen.name = 'Bob'; // 静默失败(严格模式报错)
console.log(frozen.name); // 'Alice'
经典问题:循环中的闭包
// ❌ var 的问题
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 3, 3, 3
}, 100);
}
// ✅ 解决方案 1: 使用 let
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 0, 1, 2
}, 100);
}
// ✅ 解决方案 2: IIFE
for (var i = 0; i < 3; i++) {
((j) => {
setTimeout(() => {
console.log(j); // 0, 1, 2
}, 100);
})(i);
}
// ✅ 解决方案 3: setTimeout 第三个参数
for (var i = 0; i < 3; i++) {
setTimeout((j) => {
console.log(j); // 0, 1, 2
}, 100, i);
}
let 在循环中的特殊行为
// let 在 for 循环中每次迭代创建新的绑定
for (let i = 0; i < 3; i++) {
// 每次迭代都是新的 i
}
// 等价于:
{
let i = 0;
{
let _i = i;
setTimeout(() => console.log(_i), 100);
}
i++;
{
let _i = i;
setTimeout(() => console.log(_i), 100);
}
// ...
}
执行上下文与作用域
// 执行上下文创建阶段
function example(a: number): number {
var b = 2;
let c = 3;
function inner(): void {}
return a + b + c;
}
// 创建阶段:
// VariableEnvironment: { b: undefined, arguments: {...} }
// LexicalEnvironment: { c: <uninitialized>, inner: function }
// 执行阶段:
// VariableEnvironment: { b: 2, arguments: {...} }
// LexicalEnvironment: { c: 3, inner: function }
常见面试问题
Q1: var、let、const 的区别?
答案:
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 值为 undefined | TDZ | TDZ |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 重新赋值 | 允许 | 允许 | 不允许 |
| 全局挂载 | window | 不挂载 | 不挂载 |
最佳实践:默认使用 const,需要重新赋值时用 let,避免使用 var。
Q2: 什么是暂时性死区(TDZ)?
答案:
TDZ 是指变量在作用域内已声明但未初始化的区间。在 TDZ 内访问变量会抛出 ReferenceError。
{
// TDZ 开始
console.log(x); // ❌ ReferenceError
let x = 1; // TDZ 结束
}
Q3: 以下代码输出什么?
var a = 1;
function test(): void {
console.log(a);
var a = 2;
}
test();
答案:输出 undefined。
分析:函数内的 var a 被提升,但赋值在 console.log 之后执行。
Q4: 什么是词法作用域?
答案:
词法作用域也叫静态作用域,指作用域在代码书写时确定,而不是在运行时确定。
const x = 10;
function foo(): void {
console.log(x);
}
function bar(): void {
const x = 20;
foo(); // 输出 10,使用定义时的作用域
}
Q5: 如何创建私有变量?
答案:
// 方法 1: 闭包
function createCounter(): { increment: () => void; getCount: () => number } {
let count = 0; // 私有变量
return {
increment(): void { count++; },
getCount(): number { return count; }
};
}
// 方法 2: Symbol
const _count = Symbol('count');
class Counter {
[_count] = 0;
increment(): void { this[_count]++; }
getCount(): number { return this[_count]; }
}
// 方法 3: ES2022 私有字段
class Counter2 {
#count = 0; // 私有字段
increment(): void { this.#count++; }
getCount(): number { return this.#count; }
}
Q6: let/const/var 的区别?为什么推荐使用 const?
答案:
三者的核心区别在于作用域范围、变量提升行为和可变性:
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 / 全局作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 提升并初始化为 undefined | 提升但不初始化(TDZ) | 提升但不初始化(TDZ) |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 重新赋值 | 允许 | 允许 | 不允许 |
| 全局时挂载 window | 是 | 否 | 否 |
// 1. 作用域差异
function scopeDemo(): void {
if (true) {
var a = 1; // 函数作用域,if 外可访问
let b = 2; // 块级作用域,if 外不可访问
const c = 3; // 块级作用域,if 外不可访问
}
console.log(a); // 1
// console.log(b); // ❌ ReferenceError
// console.log(c); // ❌ ReferenceError
}
// 2. const 不可重新赋值,但引用类型的内容可修改
const obj = { name: 'Alice' };
obj.name = 'Bob'; // ✅ 修改属性没问题
// obj = { name: 'Charlie' }; // ❌ TypeError: Assignment to constant variable
const arr = [1, 2, 3];
arr.push(4); // ✅ 修改数组内容没问题
// arr = [5, 6]; // ❌ 不能重新赋值
// 3. 如果需要完全不可变,使用 Object.freeze
const frozen = Object.freeze({ name: 'Alice', hobbies: ['reading'] });
frozen.name = 'Bob'; // 静默失败(严格模式下报错)
frozen.hobbies.push('coding'); // ⚠️ 注意:Object.freeze 是浅冻结,嵌套对象仍可修改
为什么推荐使用 const:
- 意图清晰:告诉阅读代码的人"这个绑定不会变",降低心智负担
- 避免意外修改:防止在大型代码库中不小心重新赋值
- 引擎优化提示:虽然现代引擎已经很智能,但
const为引擎提供了额外的不可变性信息,理论上有助于优化 - 最佳实践:默认使用
const,只有确实需要重新赋值时才用let,完全避免var
Q7: 什么是暂时性死区(TDZ)?为什么需要 TDZ?
答案:
暂时性死区(Temporal Dead Zone,TDZ) 是指从代码块开始到 let/const 声明语句之间的区域。在这个区域内访问变量会抛出 ReferenceError。
// TDZ 示例
{
// ---- TDZ 开始 ----
// console.log(x); // ❌ ReferenceError: Cannot access 'x' before initialization
// typeof x; // ❌ ReferenceError(即使 typeof 对未声明的变量返回 'undefined')
// ---- TDZ 结束 ----
let x = 10;
console.log(x); // ✅ 10
}
// 对比:typeof 对未声明变量是安全的,但对 TDZ 中的变量不安全
console.log(typeof undeclaredVar); // 'undefined'(不报错)
// console.log(typeof tdzVar); // ❌ ReferenceError
// let tdzVar = 1;
TDZ 的一些易踩坑场景:
// 场景 1:函数参数默认值中的 TDZ
// function foo(a = b, b = 1): void {} // ❌ ReferenceError: b 在 TDZ 中
function bar(a = 1, b = a): void { // ✅ a 已经初始化
console.log(a, b); // 1, 1
}
// 场景 2:class 中的 TDZ
// const instance = new MyClass(); // ❌ ReferenceError(class 声明也有 TDZ)
// class MyClass {}
// 场景 3:switch 语句共享块级作用域
function switchDemo(action: string): void {
switch (action) {
case 'a':
let x = 1; // x 的作用域是整个 switch 块
break;
case 'b':
// let x = 2; // ❌ SyntaxError: 重复声明
break;
}
}
为什么需要 TDZ:
- 捕获编程错误:
var的变量提升经常导致难以发现的 bug(访问到undefined而不报错),TDZ 让这类问题在开发阶段就暴露 - 语义一致性:
const声明的变量不应该在赋值前被访问,TDZ 保证了这一点 - 与
typeof的安全性:迫使开发者先声明再使用,而不是依赖typeof的"安全"检查
Q8: 解释以下代码的输出并说明原因
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
答案:
输出结果是:每隔 1 秒打印一次 5,共打印 5 次(5, 5, 5, 5, 5)。
原因分析:
var声明的i是函数作用域(或全局作用域),整个循环共享同一个isetTimeout的回调函数通过闭包引用了外部的i- 当循环结束时,
i的值已经变成5 - 之后回调依次执行,读取的都是同一个
i,所以都输出5
// ✅ 解决方案 1: 使用 let(推荐,最简洁)
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i); // 0, 1, 2, 3, 4
}, i * 1000);
}
// 原理:let 在每次循环迭代时创建一个新的块级作用域绑定
// 每个回调闭包捕获的是各自迭代的 i
// ✅ 解决方案 2: IIFE(立即执行函数表达式)
for (var i = 0; i < 5; i++) {
((j: number) => {
setTimeout(() => {
console.log(j); // 0, 1, 2, 3, 4
}, j * 1000);
})(i);
}
// 原理:IIFE 创建新的函数作用域,参数 j 拷贝了当前 i 的值
// ✅ 解决方案 3: setTimeout 第三个参数
for (var i = 0; i < 5; i++) {
setTimeout((j: number) => {
console.log(j); // 0, 1, 2, 3, 4
}, i * 1000, i);
}
// 原理:setTimeout 的额外参数会作为回调函数的实参传入,传参时值已确定
// ✅ 解决方案 4: 使用 bind
for (var i = 0; i < 5; i++) {
setTimeout(((j: number) => {
console.log(j);
}).bind(null, i), i * 1000);
}
// 原理:bind 创建新函数时固定了参数值
面试延伸
这道题的核心考点是闭包 + 作用域。面试官可能追问:
let在for循环中的特殊行为是什么?(每次迭代创建新的词法环境)- 如果把
setTimeout改成同步操作,结果有什么不同? - 这几种解决方案的性能差异?(
let最优,由引擎原生支持)