咳咳,各位观众老爷们,晚上好! 今天咱们来聊聊JavaScript里那些“值”和“引用”的事儿。 别看就两个词,这里面的门道儿可多了去了。 搞清楚了,以后写代码bug少一半,升职加薪指日可待!
开场白:都是变量,待遇咋不一样呢?
在JavaScript的世界里,变量就像一个个小盒子,用来存放各种各样的东西。 这些东西,我们称之为“值”。 但是呢,同样是变量,它们存放“值”的方式却大相径庭。 这就引出了我们今天的主题:值类型(Value Types)和引用类型(Reference Types)。
想象一下,你手里有两张纸条。 一张纸条上写着数字“10”,另一张纸条上写着一个地址“XXX小区YYY栋ZZZ单元”。 这两种纸条,代表了两种不同的存储方式。
- 值类型: 就像写着数字“10”的纸条,你直接把这个“10”复制一份,放到另一个盒子里。 两个盒子里的“10”是独立的,互不影响。
- 引用类型: 就像写着地址的纸条,你只是把这个地址复制一份,放到另一个盒子里。 两个盒子里的地址指向的是同一个地方。 你跑到这个地址对应的房子里,把墙刷成红色,两个盒子里地址指向的房子都会变成红色。
第一幕:值类型(Value Types)—— 复制粘贴的快乐
值类型,也叫基本类型(Primitive Types)。 在JavaScript里,它们包括:
Number
(数字)String
(字符串)Boolean
(布尔值:true
或false
)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
在这个例子中,num1
和 num2
初始值都是 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
在这个例子中,obj1
和 obj2
指向的是同一个对象。 修改 obj2
的 name
属性,obj1
的 name
属性也跟着改变了。 这就是引用类型的特点:共享数据,牵一发而动全身。
再来一个数组的例子:
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)。
-
浅拷贝: 创建一个新的对象或数组,然后将原始对象或数组的属性或元素复制到新的对象或数组中。 如果属性或元素是值类型,则直接复制值; 如果属性或元素是引用类型,则复制引用(指针)。 也就是说,浅拷贝只复制了第一层,如果对象或数组中嵌套了更深层次的引用类型,那么这些引用仍然指向原始对象或数组中的数据。
-
深拷贝: 创建一个新的对象或数组,然后递归地复制原始对象或数组的所有属性或元素,包括嵌套的引用类型。 也就是说,深拷贝会创建一个完全独立的副本,原始对象或数组和新的对象或数组之间没有任何关联。
浅拷贝的实现方式:
-
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()
只能拷贝第一层,深层的对象仍然是引用。 -
展开运算符(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
展开运算符也只能拷贝第一层。
深拷贝的实现方式:
-
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
对象(会变成空对象)。
-
递归实现:
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
这种方式可以处理各种复杂情况,但代码相对复杂。
-
使用 Lodash 等库:
Lodash 提供了
_.cloneDeep()
方法,可以方便地进行深拷贝。
第四幕:内存管理——谁来打扫战场?
JavaScript 具有自动垃圾回收机制(Garbage Collection,GC),也就是说,你不需要手动分配和释放内存。 当一个对象不再被引用时,垃圾回收器会自动回收它所占用的内存。
垃圾回收的两种主要算法:
-
标记清除(Mark and Sweep):
- 垃圾回收器会从根对象(例如全局对象)开始,遍历所有可以访问到的对象,并标记它们为“可达”。
- 然后,垃圾回收器会清除所有没有被标记为“可达”的对象,释放它们所占用的内存。
- 最后,垃圾回收器会对堆内存进行整理,消除内存碎片。
这种算法的缺点是会产生内存碎片,影响性能。
-
引用计数(Reference Counting):
- 每个对象都有一个引用计数器,记录有多少个地方引用了它。
- 当一个对象被引用时,引用计数器加 1; 当一个对象的引用被移除时,引用计数器减 1。
- 当一个对象的引用计数器为 0 时,垃圾回收器会立即回收它所占用的内存。
这种算法的缺点是无法处理循环引用的对象,会导致内存泄漏。
现代 JavaScript 引擎(例如 V8)通常会采用标记清除算法,并进行一些优化,以提高垃圾回收的效率。
总结:
理解值类型和引用类型的区别,以及它们在内存管理上的表现,对于编写高效、可靠的 JavaScript 代码至关重要。
特性 | 值类型 | 引用类型 |
---|---|---|
存储位置 | 栈(Stack)内存 | 堆(Heap)内存 |
赋值方式 | 复制值 | 复制引用(指针) |
独立性 | 变量之间互不影响 | 变量之间共享数据,修改一个会影响其他变量 |
深拷贝/浅拷贝 | 无需考虑 | 需要考虑是否需要深拷贝 |
适用场景 | 存储体积小、生命周期短的数据,例如数字、字符串等 | 存储体积大、生命周期长的数据,例如对象、数组等 |
记住,值类型就像独立的个体,而引用类型就像共享的资源。 选择合适的类型,并合理地使用深拷贝和浅拷贝,可以让你更好地控制数据的行为,避免出现意想不到的 bug。
好了,今天的讲座就到这里。 希望大家有所收获! 散会!