详细解释 JavaScript 的值类型与引用类型的区别,以及它们在内存管理上的表现。

咳咳,各位观众老爷们,晚上好! 今天咱们来聊聊JavaScript里那些“值”和“引用”的事儿。 别看就两个词,这里面的门道儿可多了去了。 搞清楚了,以后写代码bug少一半,升职加薪指日可待!

开场白:都是变量,待遇咋不一样呢?

在JavaScript的世界里,变量就像一个个小盒子,用来存放各种各样的东西。 这些东西,我们称之为“值”。 但是呢,同样是变量,它们存放“值”的方式却大相径庭。 这就引出了我们今天的主题:值类型(Value Types)和引用类型(Reference Types)。

想象一下,你手里有两张纸条。 一张纸条上写着数字“10”,另一张纸条上写着一个地址“XXX小区YYY栋ZZZ单元”。 这两种纸条,代表了两种不同的存储方式。

  • 值类型: 就像写着数字“10”的纸条,你直接把这个“10”复制一份,放到另一个盒子里。 两个盒子里的“10”是独立的,互不影响。
  • 引用类型: 就像写着地址的纸条,你只是把这个地址复制一份,放到另一个盒子里。 两个盒子里的地址指向的是同一个地方。 你跑到这个地址对应的房子里,把墙刷成红色,两个盒子里地址指向的房子都会变成红色。

第一幕:值类型(Value Types)—— 复制粘贴的快乐

值类型,也叫基本类型(Primitive Types)。 在JavaScript里,它们包括:

  • Number (数字)
  • String (字符串)
  • Boolean (布尔值:truefalse)
  • Null (空值)
  • Undefined (未定义)
  • Symbol (ES6新增,符号)
  • BigInt (ES2020新增,大整数)

这些类型的值,都是直接存储在变量中。 当你把一个值类型变量赋值给另一个变量时,实际上是把这个值完整地复制了一份。

let num1 = 10;
let num2 = num1; // 把 num1 的值复制给 num2

console.log(num1); // 输出: 10
console.log(num2); // 输出: 10

num2 = 20; // 修改 num2 的值

console.log(num1); // 输出: 10  (num1 的值没有改变)
console.log(num2); // 输出: 20

在这个例子中,num1num2 初始值都是 10,但是修改 num2 的值,num1 并没有受到影响。 这就是值类型的特点:各自独立,互不干扰。

再来一个字符串的例子:

let str1 = "hello";
let str2 = str1;

console.log(str1); // 输出: hello
console.log(str2); // 输出: hello

str2 = "world";

console.log(str1); // 输出: hello
console.log(str2); // 输出: world

同样,修改 str2 的值,str1 也没有改变。

值类型在内存中的表现:

值类型的数据存储在栈(Stack)内存中。 栈内存的特点是:

  • 空间小: 通常比较小,适合存储体积小、生命周期短的数据。
  • 速度快: 存取速度非常快。
  • 后进先出(LIFO): 像叠盘子一样,后放的盘子先取走。

当声明一个值类型变量时,JavaScript会在栈内存中分配一块空间来存储这个值。 当把一个值类型变量赋值给另一个变量时,JavaScript会在栈内存中复制一份新的值,并分配给新的变量。

可以用表格来概括一下:

特性 值类型
存储位置 栈(Stack)内存
赋值方式 复制值
独立性 变量之间互不影响
适用场景 存储体积小、生命周期短的数据,例如数字、字符串等

第二幕:引用类型(Reference Types)—— 牵一发而动全身

引用类型,在JavaScript里主要包括:

  • Object (对象)
  • Array (数组)
  • Function (函数)

(其实还有 Date、RegExp 等,它们本质上也是对象。)

引用类型的值,并不是直接存储在变量中,而是存储在堆(Heap)内存中。 变量中存储的是指向堆内存中数据的“指针”或“引用”。

let obj1 = { name: "Alice", age: 30 };
let obj2 = obj1; // obj2 指向 obj1 指向的同一个对象

console.log(obj1.name); // 输出: Alice
console.log(obj2.name); // 输出: Alice

obj2.name = "Bob"; // 修改 obj2 指向的对象的 name 属性

console.log(obj1.name); // 输出: Bob  (obj1 的 name 属性也被修改了)
console.log(obj2.name); // 输出: Bob

在这个例子中,obj1obj2 指向的是同一个对象。 修改 obj2name 属性,obj1name 属性也跟着改变了。 这就是引用类型的特点:共享数据,牵一发而动全身。

再来一个数组的例子:

let arr1 = [1, 2, 3];
let arr2 = arr1;

console.log(arr1[0]); // 输出: 1
console.log(arr2[0]); // 输出: 1

arr2[0] = 10;

console.log(arr1[0]); // 输出: 10
console.log(arr2[0]); // 输出: 10

同样,修改 arr2 中的元素,arr1 也受到了影响。

引用类型在内存中的表现:

引用类型的数据存储在堆(Heap)内存中。 堆内存的特点是:

  • 空间大: 可以存储大量的数据。
  • 速度慢: 存取速度相对较慢。
  • 无序: 数据存储是无序的,不像栈内存那样有严格的顺序。

当声明一个引用类型变量时,JavaScript会在堆内存中分配一块空间来存储这个对象或数组。 变量中存储的是指向这块堆内存空间的“指针”或“引用”。 当把一个引用类型变量赋值给另一个变量时,JavaScript只是复制了这个“指针”或“引用”,而不是复制整个对象或数组。

可以用表格来概括一下:

特性 引用类型
存储位置 堆(Heap)内存
赋值方式 复制引用(指针)
独立性 变量之间共享数据,修改一个会影响其他变量
适用场景 存储体积大、生命周期长的数据,例如对象、数组等

第三幕:深拷贝与浅拷贝—— 如何打破共享的魔咒?

有时候,我们并不希望引用类型变量之间共享数据,而是希望创建一个完全独立的副本。 这时候,就需要用到“深拷贝”(Deep Copy)和“浅拷贝”(Shallow Copy)。

  • 浅拷贝: 创建一个新的对象或数组,然后将原始对象或数组的属性或元素复制到新的对象或数组中。 如果属性或元素是值类型,则直接复制值; 如果属性或元素是引用类型,则复制引用(指针)。 也就是说,浅拷贝只复制了第一层,如果对象或数组中嵌套了更深层次的引用类型,那么这些引用仍然指向原始对象或数组中的数据。

  • 深拷贝: 创建一个新的对象或数组,然后递归地复制原始对象或数组的所有属性或元素,包括嵌套的引用类型。 也就是说,深拷贝会创建一个完全独立的副本,原始对象或数组和新的对象或数组之间没有任何关联。

浅拷贝的实现方式:

  1. Object.assign()

    let obj1 = { name: "Alice", address: { city: "Beijing" } };
    let obj2 = Object.assign({}, obj1); // 浅拷贝
    
    obj2.name = "Bob";
    obj2.address.city = "Shanghai";
    
    console.log(obj1.name); // 输出: Alice
    console.log(obj1.address.city); // 输出: Shanghai  (被修改了!)
    
    console.log(obj2.name); // 输出: Bob
    console.log(obj2.address.city); // 输出: Shanghai

    Object.assign() 只能拷贝第一层,深层的对象仍然是引用。

  2. 展开运算符(Spread Operator):

    let arr1 = [1, 2, { name: "Alice" }];
    let arr2 = [...arr1]; // 浅拷贝
    
    arr2[0] = 10;
    arr2[2].name = "Bob";
    
    console.log(arr1[0]); // 输出: 1
    console.log(arr1[2].name); // 输出: Bob (被修改了!)
    
    console.log(arr2[0]); // 输出: 10
    console.log(arr2[2].name); // 输出: Bob

    展开运算符也只能拷贝第一层。

深拷贝的实现方式:

  1. JSON.parse(JSON.stringify(object))

    let obj1 = { name: "Alice", address: { city: "Beijing" } };
    let obj2 = JSON.parse(JSON.stringify(obj1)); // 深拷贝
    
    obj2.name = "Bob";
    obj2.address.city = "Shanghai";
    
    console.log(obj1.name); // 输出: Alice
    console.log(obj1.address.city); // 输出: Beijing (没有被修改)
    
    console.log(obj2.name); // 输出: Bob
    console.log(obj2.address.city); // 输出: Shanghai

    这种方式简单粗暴,但有一些限制:

    • 不能拷贝函数(函数会被忽略)。
    • 不能拷贝 undefined(会变成 null)。
    • 不能拷贝循环引用的对象(会报错)。
    • 不能拷贝 Date 对象(会变成字符串)。
    • 不能拷贝 RegExp 对象(会变成空对象)。
  2. 递归实现:

    function deepCopy(obj) {
        if (typeof obj !== 'object' || obj === null) {
            return obj; // 如果不是对象或数组,直接返回
        }
    
        let newObj = Array.isArray(obj) ? [] : {}; // 创建一个新的对象或数组
    
        for (let key in obj) {
            if (obj.hasOwnProperty(key)) {
                newObj[key] = deepCopy(obj[key]); // 递归拷贝
            }
        }
    
        return newObj;
    }
    
    let obj1 = { name: "Alice", address: { city: "Beijing" }, arr: [1, 2, 3] };
    let obj2 = deepCopy(obj1);
    
    obj2.name = "Bob";
    obj2.address.city = "Shanghai";
    obj2.arr[0] = 10;
    
    console.log(obj1.name); // 输出: Alice
    console.log(obj1.address.city); // 输出: Beijing
    console.log(obj1.arr[0]); // 输出: 1
    
    console.log(obj2.name); // 输出: Bob
    console.log(obj2.address.city); // 输出: Shanghai
    console.log(obj2.arr[0]); // 输出: 10

    这种方式可以处理各种复杂情况,但代码相对复杂。

  3. 使用 Lodash 等库:

    Lodash 提供了 _.cloneDeep() 方法,可以方便地进行深拷贝。

第四幕:内存管理——谁来打扫战场?

JavaScript 具有自动垃圾回收机制(Garbage Collection,GC),也就是说,你不需要手动分配和释放内存。 当一个对象不再被引用时,垃圾回收器会自动回收它所占用的内存。

垃圾回收的两种主要算法:

  1. 标记清除(Mark and Sweep):

    • 垃圾回收器会从根对象(例如全局对象)开始,遍历所有可以访问到的对象,并标记它们为“可达”。
    • 然后,垃圾回收器会清除所有没有被标记为“可达”的对象,释放它们所占用的内存。
    • 最后,垃圾回收器会对堆内存进行整理,消除内存碎片。

    这种算法的缺点是会产生内存碎片,影响性能。

  2. 引用计数(Reference Counting):

    • 每个对象都有一个引用计数器,记录有多少个地方引用了它。
    • 当一个对象被引用时,引用计数器加 1; 当一个对象的引用被移除时,引用计数器减 1。
    • 当一个对象的引用计数器为 0 时,垃圾回收器会立即回收它所占用的内存。

    这种算法的缺点是无法处理循环引用的对象,会导致内存泄漏。

现代 JavaScript 引擎(例如 V8)通常会采用标记清除算法,并进行一些优化,以提高垃圾回收的效率。

总结:

理解值类型和引用类型的区别,以及它们在内存管理上的表现,对于编写高效、可靠的 JavaScript 代码至关重要。

特性 值类型 引用类型
存储位置 栈(Stack)内存 堆(Heap)内存
赋值方式 复制值 复制引用(指针)
独立性 变量之间互不影响 变量之间共享数据,修改一个会影响其他变量
深拷贝/浅拷贝 无需考虑 需要考虑是否需要深拷贝
适用场景 存储体积小、生命周期短的数据,例如数字、字符串等 存储体积大、生命周期长的数据,例如对象、数组等

记住,值类型就像独立的个体,而引用类型就像共享的资源。 选择合适的类型,并合理地使用深拷贝和浅拷贝,可以让你更好地控制数据的行为,避免出现意想不到的 bug。

好了,今天的讲座就到这里。 希望大家有所收获! 散会!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注