《javascript进阶》- 类型系统深度解析(原始类型 vs 引用类型)

161 阅读9分钟

JavaScript 是一种动态类型语言,这意味着变量在声明时不需要指定类型,类型会在运行时自动确定。JavaScript 的类型系统可以分为两大类:原始类型(Primitive Types)和引用类型(Reference Types) 。理解这两种类型的差异对于编写高效、可靠的 JavaScript 代码至关重要。本文将深入探讨原始类型与引用类型的区别、使用场景以及注意事项,并通过代码示例帮助读者更好地理解这些概念。

1. 原始类型(Primitive Types)

原始类型是 JavaScript 中最基本的数据类型,它们是不可变的(immutable),即一旦创建,其值就不能被修改。JavaScript 中的原始类型包括以下六种:

  • Undefined: 表示未定义的值。
  • Null: 表示空值或不存在的对象。
  • Boolean: 表示逻辑值,truefalse
  • Number: 表示数字,包括整数和浮点数。
  • String: 表示文本数据。
  • Symbol (ES6 引入): 表示唯一的、不可变的值,通常用作对象属性的键。

1.1 原始类型的特性

1.1.1 不可变性

原始类型的值是不可变的。这意味着一旦一个原始类型的值被创建,它就不能被修改。例如:

let str = "hello";
str[0] = "H"; // 尝试修改字符串的第一个字符
console.log(str); // 输出: "hello" (字符串未被修改)

在上面的例子中,尽管我们尝试修改字符串 str 的第一个字符,但字符串本身并没有被修改。这是因为字符串是原始类型,其值是不可变的。

1.1.2 值的比较

原始类型的值是通过值比较的。这意味着当比较两个原始类型的值时,JavaScript 会比较它们的实际值。

let a = 42;
let b = 42;
console.log(a === b); // 输出: true

在这个例子中,ab 都是数字 42,因此 a === b 返回 true

1.2 原始类型的使用注意事项

1.2.1 类型转换

JavaScript 是一种弱类型语言,它在某些情况下会自动进行类型转换。例如:

let num = 42;
let str = "42";
console.log(num == str); // 输出: true (自动类型转换)
console.log(num === str); // 输出: false (严格比较)

在上面的例子中,== 操作符会进行类型转换,因此 num == str 返回 true。而 === 操作符不会进行类型转换,因此 num === str 返回 false

1.2.2 原始类型的包装对象

虽然原始类型是不可变的,但 JavaScript 提供了对应的包装对象(如 String, Number, Boolean 等),这些对象可以包含原始类型的值,并提供了一些方法。

let str = "hello";
let strObj = new String("hello");

console.log(typeof str); // 输出: "string"
console.log(typeof strObj); // 输出: "object"

console.log(str.toUpperCase()); // 输出: "HELLO"
console.log(strObj.toUpperCase()); // 输出: "HELLO"

在这个例子中,str 是一个原始类型的字符串,而 strObj 是一个 String 对象。尽管它们的行为在某些情况下相似,但它们的类型不同。

2. 引用类型(Reference Types)

引用类型是 JavaScript 中更复杂的数据类型,它们通常是对象或对象的实例。引用类型的值是可变的(mutable),并且是通过引用来存储和访问的。常见的引用类型包括:

  • Object: 通用的对象类型。
  • Array: 数组类型。
  • Function: 函数类型。
  • Date: 日期类型。
  • RegExp: 正则表达式类型。
  • 自定义对象: 用户定义的对象类型。

2.1 引用类型的特性

2.1.1 可变性

引用类型的值是可变的。这意味着我们可以修改对象、数组等引用类型的属性和元素。

let obj = { name: "Alice" };
obj.name = "Bob"; // 修改对象的属性
console.log(obj); // 输出: { name: "Bob" }

在这个例子中,我们修改了 obj 对象的 name 属性,对象本身被修改了。

2.1.2 引用比较

引用类型的值是通过引用比较的。这意味着当比较两个引用类型的值时,JavaScript 会比较它们的内存地址,而不是它们的实际内容。

let obj1 = { name: "Alice" };
let obj2 = { name: "Alice" };
console.log(obj1 === obj2); // 输出: false

在这个例子中,尽管 obj1obj2 的内容相同,但它们是两个不同的对象,因此 obj1 === obj2 返回 false

2.2 引用类型的使用注意事项

2.2.1 浅拷贝与深拷贝

由于引用类型的值是通过引用存储的,因此在复制对象或数组时需要注意浅拷贝和深拷贝的区别。

let arr1 = [1, 2, 3];
let arr2 = arr1; // 浅拷贝
arr2[0] = 99;
console.log(arr1); // 输出: [99, 2, 3]

在这个例子中,arr2arr1 的浅拷贝,修改 arr2 会影响 arr1,因为两个变量存储的其实是同一个数组的地址。如果需要深拷贝,可以使用 JSON.parse(JSON.stringify(arr1)) 或其他深拷贝方法。

2.2.2 内存管理

引用类型的值存储在堆内存中,而变量存储的是对堆内存中对象的引用。因此,当不再需要某个对象时,应该将其引用置为 null,以便垃圾回收器可以回收内存。

let obj = { name: "Alice" };
obj = null; // 解除引用,允许垃圾回收

3. 原始类型与引用类型的差异总结

特性原始类型引用类型
可变性不可变可变
存储方式存储在栈内存中存储在堆内存中,变量存储引用
比较方式值比较引用比较
复制方式值复制引用复制(浅拷贝)
内存管理无需特别管理需要解除引用以便垃圾回收

4. 代码示例

4.1 原始类型的不可变性

let num = 42;
let num2 = num; // 值复制
num2 = 100;
console.log(num); // 输出: 42 (原始值未被修改)

4.2 引用类型的可变性

let obj = { name: "Alice" };
let obj2 = obj; // 引用复制
obj2.name = "Bob";
console.log(obj); // 输出: { name: "Bob" } (原始对象被修改)

4.3 浅拷贝与深拷贝

let arr1 = [1, 2, 3];
let arr2 = arr1.slice(); // 浅拷贝
arr2[0] = 99;
console.log(arr1); // 输出: [1, 2, 3] (原始数组未被修改)

let arr3 = [{ name: "Alice" }];
let arr4 = JSON.parse(JSON.stringify(arr3)); // 深拷贝
arr4[0].name = "Bob";
console.log(arr3); // 输出: [{ name: "Alice" }] (原始数组未被修改)

6. 总结

JavaScript 的类型系统分为原始类型和引用类型,它们在存储方式、可变性、比较方式等方面有显著差异。理解这些差异有助于编写更高效、更可靠的代码。在实际开发中,需要注意类型转换、浅拷贝与深拷贝、内存管理等问题,以避免常见的陷阱和错误。通过本文的介绍和代码示例,希望读者能够更好地掌握 JavaScript 的类型系统,并在实际项目中灵活运用。

7. 扩展(深拷贝)

在 JavaScript 中,深拷贝(Deep Copy)是指创建一个新对象,并递归地复制原对象的所有属性及其嵌套属性,使得新对象与原对象完全独立,修改新对象不会影响原对象。JSON.parse(JSON.stringify(obj)) 是一种常见的深拷贝方法,但它有一些局限性。


1. JSON.parse(JSON.stringify(obj)) 的局限

JSON.parse(JSON.stringify(obj)) 是一种简单且常用的深拷贝方法,但它并不适用于所有场景。以下是它的主要限制:

1.1 不支持函数、undefinedSymbol

JSON.stringify 会忽略函数、undefinedSymbol 类型的属性,因此在深拷贝时会丢失这些值。

let obj = {
  name: "Alice",
  age: undefined,
  greet: function() { console.log("Hello!"); },
  id: Symbol("id")
};

let copiedObj = JSON.parse(JSON.stringify(obj));
console.log(copiedObj); // 输出: { name: "Alice" }

1.2 不支持循环引用

如果对象中存在循环引用(即对象属性间接或直接引用了自身),JSON.stringify 会抛出错误。

let obj = { name: "Alice" };
obj.self = obj; // 循环引用

try {
  let copiedObj = JSON.parse(JSON.stringify(obj));
} catch (error) {
  console.log(error); // 抛出 TypeError: Converting circular structure to JSON
}

1.3 不支持特殊对象类型

JSON.stringify 无法正确处理一些特殊对象类型,如 DateRegExpMapSet 等。这些对象会被转换为字符串或其他形式,导致信息丢失。

let obj = {
  date: new Date(),
  regex: /abc/g,
  map: new Map([["key", "value"]])
};

let copiedObj = JSON.parse(JSON.stringify(obj));
console.log(copiedObj);
// 输出:
// {
//   date: "2023-10-01T12:00:00.000Z", // Date 对象被转换为字符串
//   regex: {},                        // RegExp 对象被转换为空对象
//   map: {}                            // Map 对象被转换为空对象
// }

1.4 性能问题

JSON.parse(JSON.stringify(obj)) 需要将对象序列化为字符串,然后再解析为对象。对于大型对象或嵌套层级较深的对象,这种操作可能会比较耗时。


2. 自己实现一个深拷贝方法

为了克服 JSON.parse(JSON.stringify(obj)) 的局限性,我们可以自己实现一个深拷贝方法。以下是一个支持多种数据类型、循环引用和特殊对象的深拷贝实现。

2.1 实现代码

function deepClone(obj, cache = new WeakMap()) {
  // 如果是原始类型或 null,直接返回
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // 如果对象是 Date 或 RegExp 类型,直接创建新实例
  if (obj instanceof Date) {
    return new Date(obj);
  }
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }

  // 如果对象是 Map 类型
  if (obj instanceof Map) {
    let clonedMap = new Map();
    obj.forEach((value, key) => {
      clonedMap.set(key, deepClone(value, cache));
    });
    return clonedMap;
  }

  // 如果对象是 Set 类型
  if (obj instanceof Set) {
    let clonedSet = new Set();
    obj.forEach((value) => {
      clonedSet.add(deepClone(value, cache));
    });
    return clonedSet;
  }

  // 如果对象是数组或普通对象
  if (cache.has(obj)) {
    return cache.get(obj); // 解决循环引用问题
  }

  let clonedObj = Array.isArray(obj) ? [] : {};
  cache.set(obj, clonedObj); // 缓存当前对象,避免循环引用

  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      clonedObj[key] = deepClone(obj[key], cache); // 递归拷贝属性
    }
  }

  // 处理 Symbol 类型的属性
  let symbolKeys = Object.getOwnPropertySymbols(obj);
  for (let symKey of symbolKeys) {
    clonedObj[symKey] = deepClone(obj[symKey], cache);
  }

  return clonedObj;
}

2.2 代码解析

  1. 原始类型和 null
    • 如果 obj 是原始类型(如 numberstringboolean)或 null,直接返回 obj
  1. 特殊对象类型
    • 对于 DateRegExp 对象,直接创建新的实例。
    • 对于 MapSet 对象,递归地拷贝其内容。
  1. 循环引用
    • 使用 WeakMap 缓存已经拷贝过的对象,避免循环引用导致的无限递归。
  1. 数组和普通对象
    • 如果是数组,创建一个新数组;如果是普通对象,创建一个新对象。
    • 递归地拷贝所有属性(包括 Symbol 类型的属性)。

3. 总结

  • JSON.parse(JSON.stringify(obj)) 是一种简单的深拷贝方法,但它不支持函数、undefinedSymbol、循环引用和特殊对象类型。
  • 自己实现一个深拷贝方法可以解决这些问题,支持更多数据类型和复杂场景。
  • 在实际开发中,如果需要深拷贝复杂对象,建议使用自己实现的深拷贝方法或第三方库(如 lodash_.cloneDeep)。