Published on 2025-02-07

在这篇文章中,我们深入探讨了 JavaScript(和 TypeScript)中变量和常量的本质区别。通过分析对象引用的共享特性,以及常量只是对绑定关系的保证,我们讲解了如何利用这种特性避免副作用和提高代码的响应性,尤其是在前端框架如 SolidJS/React 中的实际应用。文章还通过实际的代码示例,展示了如何在状态管理和条件渲染中充分利用这一机制。


为什么我们叫的变量,其实大多数时候是常量?

在日常开发中,我们经常看到这样的代码:

const obj = { count: 0 };
obj.count += 1; // 对象的属性可以修改
// obj = { count: 1 }; // ❌ 报错:不能改变 obj 的绑定

乍一看,obj 是一个“常量”,但其内部的数据却可以修改,但有时却也不能修改。这究竟意味着什么?为什么我们叫的“变量”,大多数情况下其实是常量? 理解这一点对我们如何管理数据、避免副作用以及设计响应式 UI 都有很大帮助。


为什么要创建常量而不是变量?

在很多编程语言中,我们习惯用“变量”这个词来描述一个值。然而在 JavaScript(以及 TypeScript)中,使用 const 声明的变量实际上只是绑定关系不变,而不是值完全不可变(几乎在所有的语言中都是这样)。也就是说:

  • 基本类型(如数字、字符串、布尔值):值不可修改(因为它们本身不可变)。
  • 引用类型(如对象、数组、函数):变量名与引用的内存地址(栈上的指针)固定不变,但堆中的数据可以被修改。

举个简单的例子:

const arr = [1, 2, 3];
arr.push(4);  // 正常:数组的内容发生了变化
// arr = [1, 2, 3, 4]; // ❌ 报错:不能重新赋值一个新的数组引用

这里我们看到,arr 的引用是常量,但数组内部的数据却可以动态更新。


常量只是“绑定关系的确定”

使用 const 声明变量时,实际上保证的是变量名与内存地址之间的绑定关系不变。具体说明如下:

  • 绑定不变:一旦绑定了一个对象或数组的内存地址,就无法再将该变量指向其他地址。
  • 数据可变:绑定后,该内存地址上的数据是可以修改的。即使对象或数组内部的值发生改变,变量名依然指向同一块内存。

这也是为什么我们常说“常量只是绑定关系的确定”,而不是“数据完全不可变”。这种设计能够帮助我们在代码中更清晰地表达意图,同时也让我们在必要时可以利用数据共享的特性。


3. 对象引用共享带来的影响

由于变量只是保存了对数据的引用,所以如果多个变量指向同一个对象或数组,对其中一个变量进行修改,其他所有引用该对象的地方都会看到变化。例如:

const obj1 = { name: "Alice" };
const obj2 = obj1;  // obj2 与 obj1 共享同一引用
obj2.name = "Bob";

console.log(obj1.name); // 输出 "Bob"

在上面的例子中,obj1obj2 实际上指向同一块堆内存。当我们通过 obj2 修改了 name 属性后,obj1 也会反映出这一变化。这种引用共享特性既是 JavaScript 高效操作对象的原因,也是我们在设计程序时需要注意避免副作用的重要原因。理解了这个概念之后,对应用的理解也会大大加深。


列表渲染与状态管理

在现代前端开发中,尤其是使用响应式框架(如 SolidJS)时,我们经常需要根据对象或数组数据动态渲染列表。利用常量的引用共享特性,我们可以让数据变化自动驱动 UI 更新。

示例:使用 SolidJS 渲染动态列表

下面的示例展示了如何使用 SolidJS 的 createSignalFor 组件来渲染一个列表。这里我们依然强调:即使变量(例如 items)是常量,其内部数据的变化也能驱动页面更新。

// ListComponent.tsx
import { createSignal, For } from "solid-js";

export default function ListComponent() {
  // 使用 createSignal 声明状态(实际上内部维护了对数据的引用)
  const [items, setItems] = createSignal([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" }
  ]);

  const addItem = () => {
    // 这里采用不可变更新的方式,不过也可以直接修改数组后触发更新
    setItems([...items(), { id: items().length + 1, name: "New Item" }]);
  };

  return (
    <>
      <button onClick={addItem}>Add Item</button>
      <For each={items()}>
        {(item) => (
          <div>
            {item.id}: {item.name}
          </div>
        )}
      </For>
    </>
  );
}

在这个例子中,我们通过修改 items 数组的内容来触发 UI 更新。尽管 items 这个信号的绑定(引用)不会改变,但其内部的数据一旦更新,所有依赖该数据的地方(例如列表渲染)都会自动得到最新状态。


闭包与状态共享

闭包是 JavaScript 中的重要特性,而它们捕获的往往是变量的引用而非值。这就意味着,即使在闭包外部修改了对象,闭包内部也能访问到最新的数据。

示例:闭包捕获对象引用

function createCounter() {
  // 使用 const 声明 counter,绑定不变,但其内部 value 可以修改
  const counter = { value: 0 };

  // 返回两个函数,一个用于修改,一个用于读取
  function increment() {
    counter.value += 1;
  }

  function log() {
    console.log("当前计数:", counter.value);
  }

  return { increment, log };
}

const counterObj = createCounter();
counterObj.log(); // 当前计数: 0

// 修改对象内部数据
counterObj.increment();
counterObj.log(); // 当前计数: 1

在上面的代码中,闭包捕获了 counter 对象的引用。无论在闭包外部对 counter 的内部属性如何修改,闭包内部调用 log 时都会获取最新的值。这使得我们可以设计出无需重新绑定就能响应状态变化的代码结构,非常适合状态管理和响应式编程。


总结

理解 JavaScript(及 TypeScript)中 const 的本质——绑定关系的确定,对于编写高质量的代码至关重要:

  • 变量与常量的本质const 只是保证绑定关系不变,引用类型内部的数据是可变的。
  • 数据共享与副作用:多个变量共享同一引用时,对象的修改会在所有引用中反映出来,需谨慎管理,避免意外副作用。
  • 响应式 UI 与闭包:在现代前端开发中,利用对象引用的共享特性,可以实现数据变化自动驱动 UI 更新;闭包捕获的是引用,能确保函数总是访问到最新的数据状态。

通过掌握这一原理,我们不仅能避免在不同函数中无意间修改同一对象带来的问题,还能更好地利用闭包特性实现灵活而高效的状态管理。例如,在条件渲染场景中,只需修改对象数据,闭包中捕获的函数便会对最新数据作出响应,而不必额外管理每个函数的绑定关系。

希望这篇博客能帮助你更深入地理解 JavaScript 中常量的精髓,并在实际开发中写出更健壮、更高效的代码!


相关阅读:

Happy coding!

built byHannus, a former statistician