JavaScript值和引用详解:从栈堆内存到面试实战
引言
在JavaScript开发中,理解值和引用的区别是掌握语言核心的关键。本文将深入探讨JavaScript中的简单值和复杂值,以及它们在内存中的存储机制。
1. 数据类型分类
1.1 简单值(原始类型)
JavaScript中的简单值包括:
javascript
// 基本数据类型
let num = 42; // Number
let str = "Hello"; // String
let bool = true; // Boolean
let undef = undefined; // Undefined
let nul = null; // Null (特殊的原始值)
let sym = Symbol('id'); // Symbol (ES6)
let big = 123n; // BigInt (ES2020)
1.2 复杂值(引用类型)
javascript
// 引用数据类型
let obj = { name: "张三", age: 25 }; // Object
let arr = [1, 2, 3, 4, 5]; // Array
let func = function() { return "函数"; }; // Function
let date = new Date(); // Date
let reg = /pattern/g; // RegExp
2. 内存存储机制
2.1 null和undefined的特殊性
在深入了解内存存储之前,我们需要特别理解null和undefined这两个特殊值:
2.1.1 null的历史包袱
typeof null的"著名bug":
javascript
console.log(typeof null); // "object" ❌ 这是JavaScript的历史bug
console.log(typeof undefined); // "undefined" ✅ 正确的
// null实际上是原始值,不是对象
console.log(null instanceof Object); // false
console.log(null === null); // true
console.log(Object.prototype.toString.call(null)); // "[object Null]"
历史原因解释:
在JavaScript的最初实现中(1995年),Brendan Eich在设计类型检测时采用了一个基于标签位的系统:
- 对象的类型标签是000
- null在底层被表示为0x00(全零)
- typeof操作符检查类型标签,发现是000就认为是对象
这个设计缺陷一直保留至今,因为修复它会破坏大量现有代码。
javascript
// 演示null的特殊性
let obj = {};
let nullValue = null;
console.log(typeof obj); // "object" ✅
console.log(typeof nullValue); // "object" ❌ 历史bug
// 正确检测null的方法
console.log(nullValue === null); // true ✅
console.log(Object.is(nullValue, null)); // true ✅
console.log(String(nullValue)); // "null" ✅
2.1.2 undefined的后来补充
undefined的设计初衷:
undefined是后来添加的概念,用来表示"未定义"的状态:
javascript
// undefined的各种出现场景
let a; // 声明但未赋值
console.log(a); // undefined
function func(param) {
console.log(param); // 未传参时为undefined
}
func(); // undefined
let obj = { name: "张三" };
console.log(obj.age); // 访问不存在的属性:undefined
function noReturn() {
// 没有return语句
}
console.log(noReturn()); // undefined
// 数组的稀疏元素
let arr = [1, , , 4];
console.log(arr[1]); // undefined
console.log(arr[2]); // undefined
null vs undefined的设计哲学:
javascript
// null:有意的空值,表示"无对象值"
let user = null; // 明确表示用户对象为空
// undefined:系统层面的"未定义"
let config; // 配置还未初始化
// 比较行为
console.log(null == undefined); // true (抽象相等)
console.log(null === undefined); // false (严格相等)
// 类型转换差异
console.log(Number(null)); // 0
console.log(Number(undefined)); // NaN
console.log(String(null)); // "null"
console.log(String(undefined)); // "undefined"
console.log(Boolean(null)); // false
console.log(Boolean(undefined)); // false
实际开发建议:
javascript
// 推荐的使用方式
let user = null; // 主动设置为空
let config = undefined; // 或者不设置,让其保持undefined
// 检测方法
function checkValue(value) {
if (value === null) {
console.log("值被主动设置为null");
} else if (value === undefined) {
console.log("值未定义或未初始化");
} else if (value == null) { // 同时检测null和undefined
console.log("值为空(null或undefined)");
}
}
// 安全的属性访问
let user = { profile: null };
console.log(user.profile?.name); // undefined (可选链)
console.log(user.settings?.theme); // undefined
2.2 为什么需要了解栈和堆?
理解栈和堆的存储机制,能帮我们解释以下现象:
javascript
// 为什么这样会发生?
let num1 = 10;
let num2 = num1;
num1 = 20;
console.log(num2); // 10,为什么num2没有变?
// 为什么这样会发生?
let obj1 = { name: "张三" };
let obj2 = obj1;
obj1.name = "李四";
console.log(obj2.name); // "李四",为什么obj2也变了?
答案就在于它们的存储方式不同!
2.3 栈内存(Stack)- 简单值的家
栈的工作原理:先进后出(LIFO - Last In First Out)
想象栈就像一个弹夹,子弹只能从顶部装入和取出:
javascript
// 让我们看看这些变量是如何进栈的
let a = 10; // 第一个进栈
let b = "hello"; // 第二个进栈
let c = true; // 第三个进栈
let d = null; // 第四个进栈
let e = undefined; // 最后进栈
// 栈内存的实际存储(从底部到顶部)
┌─────────────┐ ← 栈顶(最后进入)
│ e: undefined│ 第5个进栈
├─────────────┤
│ d: null │ 第4个进栈
├─────────────┤
│ c: true │ 第3个进栈
├─────────────┤
│ b: "hello" │ 第2个进栈
├─────────────┤
│ a: 10 │ 第1个进栈
└─────────────┘ ← 栈底(最先进入)
// 当函数执行完毕,变量会按相反顺序出栈
// 出栈顺序:e → d → c → b → a
为什么简单值的赋值不会相互影响?
javascript
let original = 100;
let copy = original; // 这里发生了什么?
// 栈内存中的实际情况:
┌─────────────┐
│ copy: 100 │ ← 完全独立的内存空间
├─────────────┤
│original: 100│ ← 另一个独立的内存空间
└─────────────┘
original = 200; // 只修改original的内存空间
// 修改后的栈内存:
┌─────────────┐
│ copy: 100 │ ← 不受影响,仍然是100
├─────────────┤
│original: 200│ ← 被修改了
└─────────────┘
2.4 堆内存(Heap)- 复杂值的家
为什么复杂值不能直接存在栈中?
javascript
// 想象一下,如果对象直接存在栈中会怎样?
let hugeObject = {
users: [/* 假设有10000个用户 */],
settings: {/* 复杂的配置对象 */},
cache: {/* 大量缓存数据 */}
// ... 更多数据
};
// 栈的空间有限,无法存储这么大的对象!
// 而且栈需要快速的压入和弹出操作
堆栈配合的工作方式:
javascript
let person = { name: "张三", age: 25 };
let student = { grade: "A", school: "清华" };
// 实际的内存分布:
栈内存(存储变量名和地址) 堆内存(存储实际数据)
┌─────────────────┐ ┌──────────────────────┐
│student: 0x002 │───────→│0x002: { │
├─────────────────┤ │ grade: "A", │
│person: 0x001 │───────→│ school: "清华" │
└─────────────────┘ │} │
├──────────────────────┤
│0x001: { │
│ name: "张三", │
│ age: 25 │
│} │
└──────────────────────┘
现在我们来解释开头的谜题:
javascript
// 谜题1:为什么简单值互不影响?
let num1 = 10; // 栈中分配空间存储10
let num2 = num1; // 栈中分配新空间,复制10的值
num1 = 20; // 只修改num1的栈空间
// 栈内存状态:
┌─────────────┐
│ num2: 10 │ ← 独立空间,不受影响
├─────────────┤
│ num1: 20 │ ← 被修改
└─────────────┘
// 谜题2:为什么复杂值会相互影响?
let obj1 = { name: "张三" }; // 栈中存储地址0x001,堆中存储实际对象
let obj2 = obj1; // 栈中存储相同的地址0x001
obj1.name = "李四"; // 通过地址0x001修改堆中的对象
// 内存状态:
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│obj2: 0x001 │────→│0x001: { │
├─────────────┤ ┌─→│ name: "李四" │ ← 被修改
│obj1: 0x001 │──┘ │} │
└─────────────┘ └─────────────────┘
// 两个变量指向同一个堆内存地址,所以obj2.name也变成了"李四"
3. 基于内存机制理解访问方式
现在我们已经理解了栈和堆的工作原理,让我们深入分析不同的访问方式:
3.1 简单值访问:值拷贝的本质
javascript
// 每次赋值都是完整的值复制
let a = 10;
let b = a; // 在栈中为b分配新空间,复制a的值
a = 20; // 修改a的栈空间,不影响b
console.log(a); // 20
console.log(b); // 10 (不受影响)
// 内存变化过程:
// 步骤1: let a = 10
┌─────────────┐
│ a: 10 │
└─────────────┘
// 步骤2: let b = a
┌─────────────┐
│ b: 10 │ ← 新分配的栈空间,复制了a的值
├─────────────┤
│ a: 10 │
└─────────────┘
// 步骤3: a = 20
┌─────────────┐
│ b: 10 │ ← 不受影响
├─────────────┤
│ a: 20 │ ← 只修改a的栈空间
└─────────────┘
null和undefined的值拷贝:
javascript
// null和undefined也是值拷贝
let value1 = null;
let value2 = value1; // 复制null值到新的栈空间
value1 = "changed"; // 只修改value1的栈空间
console.log(value1); // "changed"
console.log(value2); // null (不受影响)
// undefined的赋值演示
let x; // x在栈中被分配空间,值为undefined
let y = x; // y在栈中被分配新空间,复制undefined
x = 100; // 只修改x的栈空间
console.log(x); // 100
console.log(y); // undefined (不受影响)
3.2 复杂值访问:引用拷贝的真相
javascript
// 引用拷贝:拷贝的是堆内存的地址
let obj1 = { name: "张三" }; // obj1存储堆地址
let obj2 = obj1; // obj2复制相同的堆地址
obj1.name = "李四"; // 通过地址修改堆中的对象
console.log(obj1.name); // "李四"
console.log(obj2.name); // "李四" (受影响,因为指向同一个对象)
// 详细的内存变化过程:
// 步骤1: let obj1 = { name: "张三" }
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│obj1: 0x001 │────→│0x001: { │
└─────────────┘ │ name: "张三" │
│} │
└─────────────────┘
// 步骤2: let obj2 = obj1
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│obj2: 0x001 │────→│0x001: { │
├─────────────┤ ┌─→│ name: "张三" │
│obj1: 0x001 │──┘ │} │
└─────────────┘ └─────────────────┘
// 步骤3: obj1.name = "李四"
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│obj2: 0x001 │────→│0x001: { │
├─────────────┤ ┌─→│ name: "李四" │ ← 堆中对象被修改
│obj1: 0x001 │──┘ │} │
└─────────────┘ └─────────────────┘
// obj1和obj2都指向同一个堆地址,所以都能看到修改
函数参数传递的内存机制:
javascript
// 简单值传递
function changeNumber(num) {
num = 999; // 修改的是参数num的栈空间
return num;
}
let original = 10;
let result = changeNumber(original); // 传递original的值的副本
// 内存状态:
┌─────────────┐
│result: 999 │ ← 函数返回值
├─────────────┤
│original: 10 │ ← 原值不变
└─────────────┘
console.log(original); // 10 (不变)
console.log(result); // 999
// 复杂值传递
function changeObject(obj) {
obj.count = 999; // 通过传递的地址修改堆中的对象
return obj; // 返回相同的地址
}
let myObj = { count: 10 };
let result2 = changeObject(myObj); // 传递myObj存储的地址
// 内存状态:
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│result2:0x001│────→│0x001: { │
├─────────────┤ ┌─→│ count: 999 │ ← 被修改
│myObj: 0x001 │──┘ │} │
└─────────────┘ └─────────────────┘
console.log(myObj.count); // 999 (被修改)
console.log(result2.count); // 999 (指向同一对象)
4. 基于内存机制理解比较方式
理解了栈和堆的存储机制后,我们就能深刻理解为什么比较会有不同的结果:
4.1 简单值比较:比较栈中的实际值
javascript
// 值比较:直接比较栈内存中存储的值
let a = 10;
let b = 10;
console.log(a === b); // true,因为栈中都存储着相同的值10
let str1 = "hello";
let str2 = "hello";
console.log(str1 === str2); // true,栈中都存储着相同的字符串
// 内存中的实际情况:
┌─────────────┐
│ str2:"hello"│ ← 值相同
├─────────────┤
│ str1:"hello"│ ← 值相同
├─────────────┤
│ b: 10 │ ← 值相同
├─────────────┤
│ a: 10 │ ← 值相同
└─────────────┘
// 比较时直接比较栈中的值,所以结果为true
null和undefined的比较特殊性:
javascript
// 严格比较:比较值和类型
console.log(null === null); // true,值和类型都相同
console.log(undefined === undefined); // true,值和类型都相同
console.log(null === undefined); // false,类型不同
// 抽象比较:有特殊的转换规则
console.log(null == undefined); // true,ES规范规定它们抽象相等
// 内存中的存储:
┌─────────────┐
│var4:undefined│ ← 类型:undefined
├─────────────┤
│var3:undefined│ ← 类型:undefined
├─────────────┤
│var2: null │ ← 类型:object(历史bug)
├─────────────┤
│var1: null │ ← 类型:object(历史bug)
└─────────────┘
// 特殊值的比较
console.log(NaN === NaN); // false (NaN是唯一不等于自身的值)
console.log(Object.is(NaN, NaN)); // true (推荐的比较方式)
console.log(+0 === -0); // true (IEEE 754标准认为相等)
console.log(Object.is(+0, -0)); // false (Object.is能区分)
4.2 复杂值比较:比较栈中的地址
javascript
// 引用比较:比较的是栈中存储的堆内存地址
let obj1 = { name: "张三" };
let obj2 = { name: "张三" }; // 内容相同但是不同对象
let obj3 = obj1; // 指向同一个对象
console.log(obj1 === obj2); // false (不同的堆内存地址)
console.log(obj1 === obj3); // true (相同的堆内存地址)
// 内存中的实际情况:
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│obj3: 0x001 │────→│0x001: { │
├─────────────┤ ┌─→│ name: "张三" │
│obj2: 0x002 │──┼─→│} │
├─────────────┤ │ ├─────────────────┤
│obj1: 0x001 │──┘ │0x002: { │
└─────────────┘ │ name: "张三" │
│} │
└─────────────────┘
// 比较过程:
// obj1 === obj2: 比较 0x001 === 0x002,结果false
// obj1 === obj3: 比较 0x001 === 0x001,结果true
如何进行内容比较?
javascript
// 简单的内容比较(有局限性)
function simpleEqual(obj1, obj2) {
return JSON.stringify(obj1) === JSON.stringify(obj2);
}
let person1 = { name: "张三", age: 25 };
let person2 = { name: "张三", age: 25 };
console.log(simpleEqual(person1, person2)); // true
// 但这种方法有问题:
let obj1 = { a: 1, b: 2 };
let obj2 = { b: 2, a: 1 }; // 属性顺序不同
console.log(simpleEqual(obj1, obj2)); // 可能是false(取决于属性顺序)
// 更可靠的深度比较函数
function deepEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (typeof a !== 'object') return false;
let keysA = Object.keys(a);
let keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (let key of keysA) {
if (!keysB.includes(key)) return false;
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
console.log(deepEqual(obj1, obj2)); // true,正确的内容比较
5. 基于内存机制理解动态属性
栈和堆的不同存储方式,也解释了为什么简单值无法添加属性,而复杂值可以:
5.1 简单值无法添加属性的内存原因
javascript
let num = 42;
num.property = "test";
console.log(num.property); // undefined
// 为什么会这样?让我们看看内存层面发生了什么:
// 步骤1: let num = 42
┌─────────────┐
│ num: 42 │ ← 栈中存储原始值
└─────────────┘
// 步骤2: num.property = "test"
// JavaScript引擎尝试:
// 1. 临时将42装箱为Number对象
// 2. 在临时对象上设置property
// 3. 临时对象被销毁
// 4. num仍然是栈中的原始值42
// 步骤3: console.log(num.property)
// JavaScript引擎再次:
// 1. 临时将42装箱为一个新的Number对象
// 2. 查找property属性
// 3. 新对象没有property属性,返回undefined
字符串的只读特性:
javascript
let str = "hello";
str.length = 10; // 尝试修改length属性
console.log(str.length); // 5 (仍然是原来的长度)
// 内存解释:
┌─────────────┐
│ str:"hello" │ ← 栈中的字符串是不可变的
└─────────────┘
// length是字符串的内置只读属性,无法修改
null和undefined访问属性的错误:
javascript
let nullValue = null;
let undefinedValue = undefined;
try {
nullValue.property = "test"; // TypeError!
} catch (e) {
console.log("null无法添加属性:", e.message);
// 原因:null在栈中就是null,无法装箱为对象
}
try {
undefinedValue.property = "test"; // TypeError!
} catch (e) {
console.log("undefined无法添加属性:", e.message);
// 原因:undefined在栈中就是undefined,无法装箱为对象
}
// 但数字和字符串可以临时装箱访问方法:
console.log((42).toString()); // "42" ← 临时装箱为Number对象
console.log("hello".toUpperCase()); // "HELLO" ← 临时装箱为String对象
// 装箱过程的内存示意:
// (42).toString() 执行时:
// 1. 创建临时Number对象
// 2. 调用toString方法
// 3. 返回结果
// 4. 销毁临时对象
// 5. 原始值42保持不变
5.2 复杂值可以动态添加属性的内存原理
javascript
let obj = {}; // 在堆中创建空对象
obj.name = "张三"; // 在堆对象中添加属性
obj.sayHello = function() { // 在堆对象中添加方法
return `你好,我是${this.name}`;
};
// 内存变化过程:
// 步骤1: let obj = {}
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│ obj: 0x001 │────→│0x001: { │
└─────────────┘ │} │
└─────────────────┘
// 步骤2: obj.name = "张三"
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│ obj: 0x001 │────→│0x001: { │
└─────────────┘ │ name: "张三" │
│} │
└─────────────────┘
// 步骤3: obj.sayHello = function...
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│ obj: 0x001 │────→│0x001: { │
└─────────────┘ │ name: "张三", │
│ sayHello: fn │
│} │
└─────────────────┘
console.log(obj.name); // "张三" ← 从堆中读取
console.log(obj.sayHello()); // "你好,我是张三" ← 执行堆中的函数
数组也是对象,同样可以添加属性:
javascript
let arr = [1, 2, 3];
arr.customProperty = "自定义属性";
arr.customMethod = function() {
return this.length;
};
// 内存结构:
栈内存 堆内存
┌─────────────┐ ┌─────────────────────┐
│ arr: 0x001 │────→│0x001: { │
└─────────────┘ │ 0: 1, │ ← 数组索引
│ 1: 2, │
│ 2: 3, │
│ length: 3, │ ← 内置属性
│ customProperty: │ ← 自定义属性
│ "自定义属性", │
│ customMethod: fn │ ← 自定义方法
│} │
└─────────────────────┘
console.log(arr.customProperty); // "自定义属性"
console.log(arr.customMethod()); // 3
console.log(arr[0]); // 1 ← 正常的数组访问
为什么复杂值可以动态添加属性?
- 堆内存的可扩展性:堆内存空间相对灵活,可以动态分配和扩展
- 对象的哈希表结构:JavaScript对象本质上是属性名到值的映射(哈希表)
- 引用访问:通过栈中的地址,可以直接修改堆中的对象结构
javascript
// 对象属性的本质:哈希表
let person = {};
// 每次添加属性,就是在堆中的哈希表添加键值对
person["name"] = "张三"; // 等价于 person.name = "张三"
person["age"] = 25; // 等价于 person.age = 25
person[Symbol("id")] = 123; // 甚至可以用Symbol作为键
// 堆中的结构类似于:
// {
// "name" -> "张三",
// "age" -> 25,
// Symbol(id) -> 123
// }
6. 基于内存机制深入理解变量赋值
通过前面对栈和堆内存的详细了解,我们现在可以更深入地理解各种赋值操作:
6.1 简单值赋值:栈中的值复制
javascript
// 值拷贝的完整过程
let original = 100;
let copy = original;
original = 200;
// 内存变化的完整过程:
// 步骤1: let original = 100
┌─────────────┐
│original: 100│ ← 在栈中分配空间存储100
└─────────────┘
// 步骤2: let copy = original
┌─────────────┐
│ copy: 100 │ ← 新分配栈空间,复制original的值
├─────────────┤
│original: 100│
└─────────────┘
// 步骤3: original = 200
┌─────────────┐
│ copy: 100 │ ← 不受影响,仍然是独立的值
├─────────────┤
│original: 200│ ← 修改original的栈空间
└─────────────┘
console.log(original); // 200
console.log(copy); // 100
函数参数传递中的值拷贝:
javascript
function changeValue(val) {
val = 999; // 修改参数val的栈空间
return val;
}
let num = 10;
let result = changeValue(num);
// 函数调用的内存过程:
// 调用前:
┌─────────────┐
│ num: 10 │
└─────────────┘
// 调用changeValue(num)时:
// 1. 为参数val分配新的栈空间
// 2. 复制num的值到val的栈空间
┌─────────────┐
│ val: 10 │ ← 函数内部的参数空间
├─────────────┤
│ num: 10 │ ← 外部变量空间
└─────────────┘
// 3. 执行val = 999
┌─────────────┐
│ val: 999 │ ← 只修改函数内部的参数
├─────────────┤
│ num: 10 │ ← 外部变量不受影响
└─────────────┘
// 4. 函数返回,val的栈空间被释放
┌─────────────┐
│result: 999 │ ← 返回值被存储到新变量
├─────────────┤
│ num: 10 │ ← 原值保持不变
└─────────────┘
console.log(num); // 10 (原值不变)
console.log(result); // 999
6.2 复杂值赋值:栈中的地址复制
javascript
// 引用拷贝的完整过程
let originalObj = { count: 1 };
let copyObj = originalObj;
originalObj.count = 2;
// 内存变化的详细过程:
// 步骤1: let originalObj = { count: 1 }
栈内存 堆内存
┌──────────────────┐ ┌─────────────────┐
│originalObj: 0x001│────→│0x001: { │
└──────────────────┘ │ count: 1 │
│} │
└─────────────────┘
// 步骤2: let copyObj = originalObj
栈内存 堆内存
┌──────────────────┐ ┌─────────────────┐
│copyObj: 0x001 │────→│0x001: { │
├──────────────────┤ ┌─→│ count: 1 │
│originalObj: 0x001│──┘ │} │
└──────────────────┘ └─────────────────┘
// 注意:复制的是地址0x001,不是对象本身
// 步骤3: originalObj.count = 2
栈内存 堆内存
┌──────────────────┐ ┌─────────────────┐
│copyObj: 0x001 │────→│0x001: { │
├──────────────────┤ ┌─→│ count: 2 │ ← 堆中对象被修改
│originalObj: 0x001│──┘ │} │
└──────────────────┘ └─────────────────┘
// 两个变量都指向同一个堆地址,所以都看到了修改
console.log(originalObj.count); // 2
console.log(copyObj.count); // 2 (受影响)
函数参数传递中的引用拷贝:
javascript
function changeObject(obj) {
obj.count = 999; // 通过地址修改堆中的对象
return obj; // 返回相同的地址
}
let myObj = { count: 10 };
let result = changeObject(myObj);
// 函数调用的内存过程:
// 调用前:
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│myObj: 0x001 │────→│0x001: { │
└─────────────┘ │ count: 10 │
│} │
└─────────────────┘
// 调用changeObject(myObj)时:
// 1. 为参数obj分配栈空间
// 2. 复制myObj的地址到obj的栈空间
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│obj: 0x001 │────→│0x001: { │
├─────────────┤ ┌─→│ count: 10 │
│myObj: 0x001 │──┘ │} │
└─────────────┘ └─────────────────┘
// 3. 执行obj.count = 999
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│obj: 0x001 │────→│0x001: { │
├─────────────┤ ┌─→│ count: 999 │ ← 堆中对象被修改
│myObj: 0x001 │──┘ │} │
└─────────────┘ └─────────────────┘
// 4. 函数返回,obj的栈空间被释放,但返回地址
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│result:0x001 │────→│0x001: { │
├─────────────┤ ┌─→│ count: 999 │
│myObj: 0x001 │──┘ │} │
└─────────────┘ └─────────────────┘
console.log(myObj.count); // 999 (原对象被修改)
console.log(result.count); // 999 (指向同一对象)
console.log(myObj === result); // true (相同的地址)
6.3 深拷贝 vs 浅拷贝的内存机制
浅拷贝:只复制第一层的地址
javascript
let original = {
name: "张三",
hobbies: ["游戏", "电影"]
};
let shallowCopy = { ...original };
// 浅拷贝的内存结构:
栈内存 堆内存
┌──────────────────┐ ┌─────────────────────┐
│shallowCopy: 0x002│────→│0x002: { │
├──────────────────┤ │ name: "张三", │
│original: 0x001 │──┐ │ hobbies: 0x003 ────┼─┐
└──────────────────┘ │ │} │ │
│ ├─────────────────────┤ │
└─→│0x001: { │ │
│ name: "张三", │ │
│ hobbies: 0x003 ────┼─┘
│} │
├─────────────────────┤
│0x003: ["游戏","电影"] │ ← 共享的数组
└─────────────────────┘
shallowCopy.name = "李四"; // 修改浅拷贝对象的name
shallowCopy.hobbies.push("读书"); // 修改共享的数组
console.log(original.name); // "张三" (不受影响)
console.log(original.hobbies); // ["游戏", "电影", "读书"] (受影响)
console.log(shallowCopy.name); // "李四"
console.log(shallowCopy.hobbies); // ["游戏", "电影", "读书"]
深拷贝:递归复制所有层级
javascript
function deepClone(obj) {
// 处理原始值
if (obj === null || typeof obj !== "object") {
return obj;
}
// 处理日期
if (obj instanceof Date) {
return new Date(obj.getTime());
}
// 处理数组
if (obj instanceof Array) {
return obj.map(item => deepClone(item));
}
// 处理对象
if (typeof obj === "object") {
const clonedObj = {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]); // 递归复制
}
}
return clonedObj;
}
}
let deepCopy = deepClone(original);
// 深拷贝的内存结构:
栈内存 堆内存
┌──────────────────┐ ┌─────────────────────┐
│deepCopy: 0x004 │────→│0x004: { │
├──────────────────┤ │ name: "张三", │
│original: 0x001 │──┐ │ hobbies: 0x005 ────┼─┐
└──────────────────┘ │ │} │ │
│ ├─────────────────────┤ │
└─→│0x001: { │ │
│ name: "张三", │ │
│ hobbies: 0x003 ────┼─┼─┐
│} │ │ │
├─────────────────────┤ │ │
│0x005: ["游戏","电影"] │←┘ │ ← 独立的数组副本
├─────────────────────┤ │
│0x003: ["游戏","电影"] │←──┘ ← 原始数组
└─────────────────────┘
deepCopy.hobbies.push("旅行");
console.log(original.hobbies); // ["游戏", "电影"] (不受影响)
console.log(deepCopy.hobbies); // ["游戏", "电影", "旅行"]
这样的解释让读者能够清楚地看到每种操作背后的内存机制,理解为什么会产生不同的结果。
7. 经典面试题
面试题:分析下面代码的输出结果
javascript
// 题目
function test() {
let a = 1;
let b = { value: 1 };
let c = [1, 2, 3];
function changeValues(x, y, z) {
x = 10;
y.value = 10;
z.push(4);
z = [5, 6, 7];
z.push(8);
}
changeValues(a, b, c);
console.log("a:", a);
console.log("b:", b);
console.log("c:", c);
}
test();
分析过程:
-
初始状态:
css栈内存 堆内存 ┌─────────────┐ ┌──────────────────┐ │a: 1 │ │0x001: {value: 1} │ │b: 0x001 │────→├──────────────────┤ │c: 0x002 │────→│0x002: [1, 2, 3] │ └─────────────┘ └──────────────────┘
-
函数调用 changeValues(a, b, c):
x = 10
:创建新的局部变量x,不影响外部的ay.value = 10
:通过引用修改堆内存中的对象z.push(4)
:通过引用修改堆内存中的数组z = [5, 6, 7]
:z指向新的数组对象z.push(8)
:向新数组添加元素,不影响原数组
-
执行后内存状态:
css栈内存 堆内存 ┌─────────────┐ ┌─────────────────────┐ │a: 1 │ │0x001: {value: 10} │ │b: 0x001 │────→├─────────────────────┤ │c: 0x002 │────→│0x002: [1, 2, 3, 4] │ └─────────────┘ ├─────────────────────┤ │0x003: [5, 6, 7, 8] │ ← 局部变量z指向的新数组 └─────────────────────┘
答案:
yaml
a: 1
b: { value: 10 }
c: [1, 2, 3, 4]
进阶面试题
javascript
// 更复杂的题目
let obj1 = {
name: "原始对象",
nested: { count: 1 }
};
let obj2 = obj1;
let obj3 = { ...obj1 };
let obj4 = JSON.parse(JSON.stringify(obj1));
obj1.name = "修改后的对象";
obj1.nested.count = 999;
console.log("obj1:", obj1);
console.log("obj2:", obj2);
console.log("obj3:", obj3);
console.log("obj4:", obj4);
// 请分析输出结果
答案:
javascript
obj1: { name: "修改后的对象", nested: { count: 999 } }
obj2: { name: "修改后的对象", nested: { count: 999 } } // 引用拷贝
obj3: { name: "原始对象", nested: { count: 999 } } // 浅拷贝
obj4: { name: "原始对象", nested: { count: 1 } } // 深拷贝
null和undefined的终极面试题
javascript
// 经典陷阱题
function mysteryFunction() {
console.log("1.", typeof null);
console.log("2.", null == undefined);
console.log("3.", null === undefined);
console.log("4.", !null);
console.log("5.", !undefined);
console.log("6.", null + 1);
console.log("7.", undefined + 1);
console.log("8.", String(null));
console.log("9.", String(undefined));
let obj = { a: null, b: undefined };
console.log("10.", JSON.stringify(obj));
let arr = [null, undefined, , null];
console.log("11.", arr.length);
console.log("12.", arr[2]);
console.log("13.", JSON.stringify(arr));
}
mysteryFunction();
参考答案:
javascript
1. object // typeof null的历史bug
2. true // 抽象相等,null和undefined被认为相等
3. false // 严格相等,类型不同
4. true // null是falsy值
5. true // undefined是falsy值
6. 1 // null转换为0
7. NaN // undefined转换为NaN
8. null // null的字符串表示
9. undefined // undefined的字符串表示
10. {"a":null} // JSON.stringify会省略undefined属性
11. 4 // 数组长度包括稀疏元素
12. undefined // 稀疏数组的空位
13. [null,null,null,null] // JSON.stringify将undefined和稀疏位都转为null
8. 总结
关键要点
- 简单值:存储在栈内存,赋值时进行值拷贝
- 复杂值:存储在堆内存,栈中存储引用地址,赋值时进行引用拷贝
- 栈内存:先进后出,存储简单值和引用地址
- 堆内存:存储复杂值的实际数据
- 比较方式:简单值比较值,复杂值比较引用地址
- null特殊性:虽然typeof返回"object",但它是原始值
- undefined设计:表示未定义状态,与null在语义上有区别
实际开发建议
- 理解引用拷贝,避免意外修改原对象
- 合理使用深拷贝和浅拷贝
- 注意函数参数传递的特性
- 使用不可变数据结构减少副作用
- 正确处理null和undefined :
- 使用
value === null
检测null - 使用
value === undefined
检测undefined - 使用
value == null
同时检测两者 - 避免依赖
typeof null
的结果
- 使用
- JSON序列化注意事项 :
- undefined属性会被忽略
- null会被保留
- 函数会被忽略
掌握这些概念对于编写高质量的JavaScript代码至关重要,也是面试中的常考点。