Skip to content

块级作用域:let 和 const 带来的精确控制

在传统的办公楼里,每个楼层都是一个独立的工作区域,但楼层内部可能有很多小隔间。在没有隔间的时代,整个楼层的员工都在一个开放空间里工作,任何人都能看到和接触到所有资料。而有了隔间之后,每个团队都有了自己的私密空间,资料的管理变得更加精确和安全。JavaScript 的块级作用域(Block Scope)就像这些隔间,它是 ES6 引入的特性,让变量的作用范围可以精确到任何一对花括号 {} 之内。

在 ES6 之前,JavaScript 只有全局作用域和函数作用域,这常常导致一些意外的行为和难以追踪的 bug。块级作用域的引入,配合 letconst 关键字,让变量的生命周期更加可控,代码更加安全。

什么是块级作用域

块级作用域是指由一对花括号 {} 包裹的代码区域所形成的作用域。在块级作用域内使用 letconst 声明的变量,只能在这个代码块内部访问,外部无法访问。

块的定义

在 JavaScript 中,以下情况会创建代码块:

  • if 语句
  • else 语句
  • for 循环
  • while 循环
  • switch 语句
  • try...catch 语句
  • 单独的代码块(用 {} 包裹的任意代码)
javascript
// if 语句块
if (true) {
  let blockVar = "I'm in a block";
  console.log(blockVar); // 可以访问
}
// console.log(blockVar); // ReferenceError - 块外无法访问

// for 循环块
for (let i = 0; i < 3; i++) {
  let loopVar = i * 2;
  console.log(loopVar); // 0, 2, 4
}
// console.log(i); // ReferenceError
// console.log(loopVar); // ReferenceError

// 独立代码块
{
  let isolatedVar = "Isolated";
  console.log(isolatedVar); // 可以访问
}
// console.log(isolatedVar); // ReferenceError

var vs let/const

理解块级作用域的关键在于对比 varletconst 的行为差异:

var: 无视块级作用域

javascript
if (true) {
  var x = 10;
}
console.log(x); // 10 - var 忽略块级作用域!

for (var i = 0; i < 3; i++) {
  var y = i;
}
console.log(i); // 3 - 循环外仍可访问
console.log(y); // 2 - 循环变量泄露到外部

var 声明的变量会被提升到最近的函数作用域(如果在函数内)或全局作用域(如果在函数外),完全无视代码块的边界。

letconst: 严格遵守块级作用域

javascript
if (true) {
  let x = 10;
  const y = 20;
}
console.log(x); // ReferenceError
console.log(y); // ReferenceError

for (let i = 0; i < 3; i++) {
  const doubled = i * 2;
}
console.log(i); // ReferenceError
console.log(doubled); // ReferenceError

使用 letconst 声明的变量严格限制在声明所在的代码块内,这提供了更精确的变量控制。

let 的特性

let 用于声明可以被重新赋值的变量,它有以下关键特性:

1. 块级作用域

这是 let 最重要的特性:

javascript
function testLet() {
  let outer = "outer";

  if (true) {
    let inner = "inner";
    console.log(outer); // "outer" - 可以访问外层变量
    console.log(inner); // "inner"
  }

  console.log(outer); // "outer"
  // console.log(inner); // ReferenceError - 无法访问内层变量
}

testLet();

2. 不能重复声明

在同一作用域内,let 不允许重复声明同名变量:

javascript
let name = "Alice";
// let name = "Bob"; // SyntaxError: Identifier 'name' has already been declared

// 但可以在不同的块中声明同名变量
{
  let name = "Charlie"; // 这是新的变量,遮蔽了外层的 name
  console.log(name); // "Charlie"
}
console.log(name); // "Alice"

相比之下,var 允许重复声明,这常常导致意外覆盖:

javascript
var count = 5;
var count = 10; // 不会报错,但会覆盖之前的值
console.log(count); // 10

3. 可以重新赋值

let 声明的变量可以被重新赋值:

javascript
let score = 0;
score = 10; // 可以重新赋值
score = 20;
console.log(score); // 20

4. 暂时性死区(Temporal Dead Zone, TDZ)

使用 let 声明的变量,在声明之前的区域被称为"暂时性死区",在这个区域访问变量会报错:

javascript
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 10;

这与 var 的提升行为完全不同:

javascript
console.log(y); // undefined - var 会被提升
var y = 10;

TDZ 的存在让代码更加安全,强制开发者在使用变量之前先声明。

const 的特性

const 用于声明常量,即声明后不能被重新赋值的变量。它具有 let 的所有特性,外加一些额外限制:

1. 必须在声明时初始化

javascript
const PI = 3.14159; // 正确

// const VALUE; // SyntaxError: Missing initializer in const declaration

2. 不能重新赋值

javascript
const MAX_SIZE = 100;
// MAX_SIZE = 200; // TypeError: Assignment to constant variable

这个限制让 const 非常适合声明不会改变的值,如配置项、常量等。

3. 对象和数组的特殊情况

虽然 const 不允许重新赋值,但如果值是对象或数组,对象的属性或数组的元素是可以修改的:

javascript
const user = {
  name: "Alice",
  age: 25,
};

// 可以修改属性
user.age = 26;
user.email = "[email protected]";
console.log(user); // { name: "Alice", age: 26, email: "[email protected]" }

// 但不能重新赋值整个对象
// user = { name: "Bob" }; // TypeError

const numbers = [1, 2, 3];
numbers.push(4); // 可以修改数组
numbers[0] = 10; // 可以修改元素
console.log(numbers); // [10, 2, 3, 4]

// 但不能重新赋值整个数组
// numbers = [5, 6, 7]; // TypeError

const 保证的是变量引用的不可变,而不是值的不可变。如果需要真正的不可变对象,可以使用 Object.freeze()

javascript
const config = Object.freeze({
  API_URL: "https://api.example.com",
  TIMEOUT: 5000,
});

config.API_URL = "https://other.com"; // 严格模式下报错,非严格模式下静默失败
console.log(config.API_URL); // "https://api.example.com" - 没有被修改

块级作用域的实际应用

1. 循环中的块级作用域

这是块级作用域最常见也是最实用的应用场景:

javascript
// 使用 var 的问题
for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log("var: " + i);
  }, 100);
}
// 输出: var: 3, var: 3, var: 3

// 使用 let 的解决方案
for (let i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log("let: " + i);
  }, 100);
}
// 输出: let: 0, let: 1, let: 2

每次循环迭代,let i 都会创建一个新的绑定,每个 setTimeout 回调都捕获到自己那次迭代的 i 值。

2. 条件判断中的临时变量

ifswitch 语句中,经常需要创建一些临时变量来简化逻辑:

javascript
function processUser(user) {
  if (user.age >= 18) {
    const welcomeMessage = `Welcome, adult user ${user.name}!`;
    const permissions = ["read", "write", "delete"];

    console.log(welcomeMessage);
    console.log("Permissions:", permissions);
  } else {
    const welcomeMessage = `Welcome, young user ${user.name}!`;
    const permissions = ["read"];

    console.log(welcomeMessage);
    console.log("Permissions:", permissions);
  }

  // welcomeMessage 和 permissions 在这里都不可访问
}

processUser({ name: "Alice", age: 25 });

块级作用域确保这些临时变量不会泄露到函数的其他部分,避免命名冲突。

3. 创建独立的作用域

有时我们需要在函数内部创建一个独立的作用域,用于执行一些临时计算:

javascript
function calculate(data) {
  let result;

  // 使用独立块处理复杂计算
  {
    const tempA = data.value * 2;
    const tempB = data.factor + 10;
    const tempC = tempA * tempB;
    result = tempC / 100;
  }

  // tempA, tempB, tempC 已经不可访问,避免污染外层作用域

  return result;
}

4. switch 语句中的作用域

switch 语句的每个 case 不会创建独立的块级作用域,需要手动添加花括号:

javascript
function handleAction(action) {
  switch (action) {
    case "create": {
      const message = "Creating new item...";
      console.log(message);
      break;
    }
    case "update": {
      const message = "Updating item..."; // 不会和上面的 message 冲突
      console.log(message);
      break;
    }
    case "delete": {
      const message = "Deleting item...";
      console.log(message);
      break;
    }
  }
}

如果不添加花括号,在同一个 case 中重复声明变量会报错:

javascript
switch (action) {
  case "create":
    const message = "Creating...";
    break;
  case "update":
    // const message = 'Updating...'; // SyntaxError: Identifier 'message' has already been declared
    break;
}

常见问题与陷阱

问题 1: 在声明前访问变量(TDZ)

javascript
function test() {
  console.log(value); // ReferenceError: Cannot access 'value' before initialization
  let value = 10;
}

解决方案:始终在使用变量之前声明它们。

问题 2: 在循环中创建函数但使用 var

javascript
const functions = [];

for (var i = 0; i < 3; i++) {
  functions.push(function () {
    return i;
  });
}

console.log(functions[0]()); // 3
console.log(functions[1]()); // 3
console.log(functions[2]()); // 3

解决方案:使用 let 替代 var

javascript
const functions = [];

for (let i = 0; i < 3; i++) {
  functions.push(function () {
    return i;
  });
}

console.log(functions[0]()); // 0
console.log(functions[1]()); // 1
console.log(functions[2]()); // 2

问题 3: 误用 const 声明会改变的值

javascript
const total = 0;

for (let i = 0; i < items.length; i++) {
  // total += items[i]; // TypeError: Assignment to constant variable
}

解决方案:对会改变的值使用 let

javascript
let total = 0;

for (let i = 0; i < items.length; i++) {
  total += items[i]; // 正确
}

问题 4: 在 const 对象中添加属性时的困惑

javascript
const config = {};
config.apiUrl = "https://api.example.com"; // 可以!

// 但不能重新赋值
// config = { apiUrl: "https://other.com" }; // TypeError

记住:const 保护的是引用,不是值本身。

块级作用域与闭包

块级作用域配合闭包可以创建强大的模式:

javascript
function createCounters() {
  const counters = [];

  for (let i = 0; i < 3; i++) {
    // 每个i都是独立的,形成闭包
    counters.push({
      increment() {
        i++;
        return i;
      },
      getValue() {
        return i;
      },
    });
  }

  return counters;
}

const myCounters = createCounters();
console.log(myCounters[0].increment()); // 1
console.log(myCounters[0].increment()); // 2
console.log(myCounters[1].getValue()); // 1 - 独立的计数器

每个计数器对象都捕获了自己那次循环迭代的 i 变量,形成独立的闭包。

最佳实践

1. 默认使用 const,需要时使用 let

javascript
// 推荐
const MAX_ATTEMPTS = 3;
const users = [];
let currentIndex = 0;

// 不推荐
var maxAttempts = 3;
var users = [];
var currentIndex = 0;

这样可以让代码意图更明确:const 表示不会重新赋值,let 表示会改变。

2. 尽可能缩小变量的作用域

javascript
// 不好 - 变量作用域过大
function processItems(items) {
  let result;
  let temp;

  for (let i = 0; i < items.length; i++) {
    temp = items[i] * 2;
    result = temp + 10;
    console.log(result);
  }
}

// 更好 - 变量作用域最小化
function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    const temp = items[i] * 2;
    const result = temp + 10;
    console.log(result);
  }
}

3. 在需要时使用块级作用域隔离代码

javascript
function complexCalculation(data) {
  // 第一阶段计算
  let stage1Result;
  {
    const input = data.values;
    const multiplier = 2.5;
    stage1Result = input.map((v) => v * multiplier);
  }

  // 第二阶段计算
  let stage2Result;
  {
    const threshold = 100;
    stage2Result = stage1Result.filter((v) => v > threshold);
  }

  return stage2Result;
}

4. 避免在全局作用域使用 var

javascript
// 不好
var globalData = {};

// 好
const globalData = {};

// 更好 - 使用模块或 IIFE 完全避免全局变量
(function () {
  const localData = {};
  // 使用 localData...
})();

块级作用域与性能

块级作用域通常不会对性能产生负面影响,现代 JavaScript 引擎对 letconst 的优化已经非常成熟。实际上,使用块级作用域可以帮助引擎更早地回收不需要的变量,潜在地提升性能。

javascript
function processLargeData() {
  {
    const hugeArray = new Array(1000000).fill(0);
    const processed = hugeArray.map((v) => v + 1);
    console.log("Processed:", processed.length);
  }
  // hugeArray 和 processed 超出作用域,可以被垃圾回收

  // 继续其他计算...
}

小结

块级作用域是 ES6 带来的重要特性,它让 JavaScript 的变量管理更加精确和安全。通过 letconst,我们可以:

  • 将变量限制在最小的必要范围内
  • 避免变量提升带来的困惑
  • 防止意外的变量覆盖和泄露
  • 编写更清晰、更易维护的代码

关键要点:

  • let 用于声明可变的块级作用域变量
  • const 用于声明不可重新赋值的块级作用域变量
  • 块级作用域由任何一对花括号 {} 创建
  • 暂时性死区(TDZ)确保变量在声明前不能被访问
  • const 保护的是引用,不是值本身

最佳实践是默认使用 const,只在需要重新赋值时使用 let,完全避免使用 var。这样的代码更加安全、可预测,也更符合现代 JavaScript 的开发规范。

在下一章中,我们将探讨函数作用域的特性,了解函数如何创建独立的作用域,以及如何利用函数作用域实现封装和模块化。