跳到主要内容

作用域与作用域链

问题

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 对比

特性varletconst
作用域函数/全局块级块级
重复声明✅ 允许❌ 报错❌ 报错
重新赋值✅ 允许✅ 允许❌ 报错
变量提升✅ 提升存在 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 的区别?

答案

特性varletconst
作用域函数作用域块级作用域块级作用域
变量提升值为 undefinedTDZTDZ
重复声明允许不允许不允许
重新赋值允许允许不允许
全局挂载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?

答案

三者的核心区别在于作用域范围变量提升行为可变性

特性varletconst
作用域函数作用域 / 全局作用域块级作用域块级作用域
变量提升提升并初始化为 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

  1. 意图清晰:告诉阅读代码的人"这个绑定不会变",降低心智负担
  2. 避免意外修改:防止在大型代码库中不小心重新赋值
  3. 引擎优化提示:虽然现代引擎已经很智能,但 const 为引擎提供了额外的不可变性信息,理论上有助于优化
  4. 最佳实践:默认使用 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

  1. 捕获编程错误var 的变量提升经常导致难以发现的 bug(访问到 undefined 而不报错),TDZ 让这类问题在开发阶段就暴露
  2. 语义一致性const 声明的变量不应该在赋值前被访问,TDZ 保证了这一点
  3. typeof 的安全性:迫使开发者先声明再使用,而不是依赖 typeof 的"安全"检查

Q8: 解释以下代码的输出并说明原因

for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}

答案

输出结果是:每隔 1 秒打印一次 5,共打印 5 次(5, 5, 5, 5, 5)。

原因分析

  1. var 声明的 i函数作用域(或全局作用域),整个循环共享同一个 i
  2. setTimeout 的回调函数通过闭包引用了外部的 i
  3. 当循环结束时,i 的值已经变成 5
  4. 之后回调依次执行,读取的都是同一个 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 创建新函数时固定了参数值
面试延伸

这道题的核心考点是闭包 + 作用域。面试官可能追问:

  • letfor 循环中的特殊行为是什么?(每次迭代创建新的词法环境)
  • 如果把 setTimeout 改成同步操作,结果有什么不同?
  • 这几种解决方案的性能差异?(let 最优,由引擎原生支持)

相关链接