MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript原始类型与引用类型的区别

2022-01-063.3k 阅读

一、JavaScript 数据类型概述

在 JavaScript 中,数据类型是编程的基础,它决定了数据的存储方式以及可对其执行的操作。JavaScript 主要分为两种数据类型:原始类型(Primitive Types)和引用类型(Reference Types)。理解这两种类型的区别对于编写高效、可靠的 JavaScript 代码至关重要。

JavaScript 中的原始类型包括:

  1. undefined:当一个变量被声明但未被赋值时,它的值就是 undefined。例如:
let a;
console.log(a); // 输出: undefined
  1. null:表示一个空值,它是 JavaScript 中表示“无”或“空”的特殊值。注意 null 是一个对象的占位符,从历史原因看,typeof null 会返回 'object',但实际上它是原始类型。
let b = null;
console.log(typeof b); // 输出: object
  1. boolean:只有两个值 truefalse,用于逻辑判断。例如:
let isDone = true;
let isFailed = false;
  1. number:用于表示数字,包括整数和浮点数。JavaScript 内部使用 64 位双精度格式来存储数字。例如:
let num1 = 10;
let num2 = 3.14;
  1. string:用于表示文本数据,是由零个或多个 16 位 Unicode 字符组成的序列。字符串可以用单引号(')、双引号(")或反引号( )来表示。例如:
let str1 = 'Hello';
let str2 = "World";
let str3 = `JavaScript`;
  1. symbol:ES6 引入的新原始类型,它表示唯一的、不可变的值。每个 symbol 值都是唯一的。例如:
let sym1 = Symbol('description');
let sym2 = Symbol('description');
console.log(sym1 === sym2); // 输出: false

引用类型主要指的是 Object(对象),包括普通对象、数组、函数等。对象是一种复合数据类型,可以包含多个键值对,这些键值对可以是不同的数据类型。例如:

let person = {
  name: 'John',
  age: 30,
  hobbies: ['reading', 'coding']
};

二、存储方式的区别

  1. 原始类型的存储 原始类型的值直接存储在栈内存(stack memory)中。栈是一种后进先出(LIFO, Last In First Out)的数据结构,它的特点是存取速度快。由于原始类型的值大小固定,所以直接存储在栈中可以提高效率。例如,当我们声明一个 number 类型的变量 let num = 5; 时,数值 5 就直接存储在栈内存中。
let num1 = 5;
let num2 = num1;
num2 = 10;
console.log(num1); // 输出: 5

在上述代码中,num1num2 虽然开始时值相同,但它们在栈内存中是独立存储的。当 num2 的值改变时,num1 的值不受影响。

  1. 引用类型的存储 引用类型的值存储在堆内存(heap memory)中,而在栈内存中存储的是指向堆内存中实际对象的引用地址。堆内存是一种用于动态分配内存的区域,它的空间比较大,但存取速度相对较慢。例如,当我们创建一个对象 let obj = { key: 'value' }; 时,对象 { key: 'value' } 存储在堆内存中,而在栈内存中存储的是指向该对象在堆内存中位置的引用地址。
let obj1 = { data: 'initial' };
let obj2 = obj1;
obj2.data = 'changed';
console.log(obj1.data); // 输出: changed

在这个例子中,obj1obj2 都指向堆内存中的同一个对象。当通过 obj2 修改对象的属性时,obj1 所指向的对象也会发生变化,因为它们实际上引用的是同一个对象。

三、复制行为的区别

  1. 原始类型的复制 当对原始类型进行复制操作时,会在栈内存中创建一个新的值,这个新值与原始值是完全独立的。例如:
let str1 = 'hello';
let str2 = str1;
str2 = 'world';
console.log(str1); // 输出: hello

这里,str2 初始时复制了 str1 的值 'hello',但当 str2 被重新赋值为 'world' 时,str1 的值不受影响,因为它们在栈内存中是两个不同的存储单元。

  1. 引用类型的复制 对于引用类型,复制操作实际上是复制了栈内存中的引用地址,而不是堆内存中的对象本身。这意味着两个变量将引用同一个对象,对其中一个变量所引用对象的修改会反映到另一个变量上。例如:
let arr1 = [1, 2, 3];
let arr2 = arr1;
arr2.push(4);
console.log(arr1); // 输出: [1, 2, 3, 4]

在上述代码中,arr2 复制了 arr1 的引用地址,它们都指向堆内存中的同一个数组对象。所以当通过 arr2 对数组进行修改(push 操作)时,arr1 所引用的数组也发生了变化。

四、比较方式的区别

  1. 原始类型的比较 原始类型的比较是值的比较。当使用 ===== 进行比较时,比较的是它们在栈内存中存储的值。例如:
let num1 = 10;
let num2 = 10;
console.log(num1 === num2); // 输出: true

这里 num1num2 的值相同,所以 === 比较结果为 true。需要注意的是,== 在比较时会进行类型转换,而 === 不会。例如:

let num3 = 10;
let str3 = '10';
console.log(num3 == str3); // 输出: true
console.log(num3 === str3); // 输出: false

num3 == str3 的比较中,JavaScript 会将 str3 转换为数字类型再进行比较,所以结果为 true;而 num3 === str3 由于类型不同,直接返回 false

  1. 引用类型的比较 引用类型的比较是引用地址的比较。同样使用 ===== 时,比较的是栈内存中存储的引用地址。例如:
let obj1 = { key: 'value' };
let obj2 = { key: 'value' };
console.log(obj1 === obj2); // 输出: false

虽然 obj1obj2 的内部结构和属性值都相同,但它们在堆内存中是不同的对象,栈内存中的引用地址也不同,所以 === 比较结果为 false。即使使用 == 进行比较,结果也为 false,因为引用类型的 == 比较同样是比较引用地址,除非进行自定义的比较逻辑(例如通过 Object.is() 方法,它在比较对象时会考虑对象的属性值等因素,但对于普通对象比较,默认还是引用地址比较)。

五、作为函数参数传递的区别

  1. 原始类型作为参数传递 当原始类型作为函数参数传递时,是值传递。这意味着函数内部对参数的修改不会影响到函数外部的变量。例如:
function changeNumber(num) {
  num = num + 1;
  return num;
}
let num = 5;
let newNum = changeNumber(num);
console.log(num); // 输出: 5
console.log(newNum); // 输出: 6

在这个例子中,num 的值被传递给 changeNumber 函数的参数 num,函数内部对参数 num 进行了修改,但这并不会影响到函数外部的 num 变量。

  1. 引用类型作为参数传递 引用类型作为函数参数传递时,是引用传递(本质上还是值传递,传递的是引用地址这个值)。函数内部对对象的修改会影响到函数外部的对象。例如:
function changeObject(obj) {
  obj.key = 'new value';
  return obj;
}
let obj = { key: 'old value' };
let newObj = changeObject(obj);
console.log(obj.key); // 输出: new value
console.log(newObj.key); // 输出: new value

在这个例子中,obj 的引用地址被传递给 changeObject 函数的参数 obj,函数内部通过这个引用地址对堆内存中的对象进行了修改,所以函数外部的 obj 也受到了影响。

六、在内存管理上的区别

  1. 原始类型的内存管理 原始类型的值在其作用域结束后,会自动从栈内存中移除。例如,在一个函数内部声明的原始类型变量,当函数执行结束后,该变量所占用的栈内存空间会被释放。例如:
function test() {
  let num = 10;
  // 函数执行结束,num 占用的栈内存空间被释放
}
test();
  1. 引用类型的内存管理 引用类型的对象存储在堆内存中,其内存的释放依赖于垃圾回收机制(Garbage Collection, GC)。当一个对象不再被任何变量引用时,垃圾回收机制会在适当的时候回收该对象所占用的堆内存空间。例如:
function createObject() {
  let obj = { data: 'initial' };
  return obj;
}
let newObj = createObject();
// 假设之后 newObj 不再被使用,垃圾回收机制会在某个时刻回收 obj 占用的堆内存
newObj = null;

在上述代码中,当 newObj 被赋值为 null 后,原来指向的对象不再被任何变量引用,垃圾回收机制会将其占用的堆内存回收。然而,垃圾回收的时机是不确定的,这取决于 JavaScript 引擎的实现和运行环境。

七、可变性的区别

  1. 原始类型的可变性 原始类型的值是不可变的。一旦创建,其值就不能被改变。例如,对于字符串类型:
let str = 'hello';
str = str + 'world';

在这段代码中,str 初始值为 'hello',当执行 str = str + 'world'; 时,并不是修改了原来 'hello' 字符串的值,而是创建了一个新的字符串 'helloworld',并将 str 指向这个新字符串。

  1. 引用类型的可变性 引用类型的对象通常是可变的。我们可以随时修改对象的属性值、添加或删除属性等。例如:
let obj = { key: 'value' };
obj.newKey = 'new value';
delete obj.key;

在这个例子中,我们通过直接操作对象 obj,添加了新属性 newKey 并删除了 key 属性,这体现了引用类型对象的可变性。但也有一些特殊情况,例如使用 Object.freeze() 方法可以将对象冻结,使其变为不可变。

let frozenObj = { data: 'initial' };
Object.freeze(frozenObj);
frozenObj.newData = 'new value';
console.log(frozenObj.newData); // 输出: undefined

这里,frozenObj 被冻结后,尝试添加新属性的操作不会生效,对象保持不可变状态。

八、类型检测的区别

  1. 原始类型的类型检测 可以使用 typeof 操作符来检测原始类型。例如:
let num = 10;
let str = 'hello';
let bool = true;
let undef;
let nul = null;
let sym = Symbol('test');

console.log(typeof num); // 输出: number
console.log(typeof str); // 输出: string
console.log(typeof bool); // 输出: boolean
console.log(typeof undef); // 输出: undefined
console.log(typeof nul); // 输出: object
console.log(typeof sym); // 输出: symbol

需要注意的是,typeof null 返回 'object' 是 JavaScript 早期的设计失误,实际 null 是原始类型。

  1. 引用类型的类型检测 对于引用类型,typeof 操作符对于普通对象返回 'object',对于函数返回 'function'。例如:
let obj = { key: 'value' };
function func() {}

console.log(typeof obj); // 输出: object
console.log(typeof func); // 输出: function

如果要更精确地检测引用类型,可以使用 instanceof 操作符。instanceof 用于检测一个对象是否是某个构造函数的实例。例如:

let arr = [1, 2, 3];
console.log(arr instanceof Array); // 输出: true

此外,Object.prototype.toString.call() 方法也可以用于准确检测引用类型。例如:

let obj = { key: 'value' };
let arr = [1, 2, 3];

console.log(Object.prototype.toString.call(obj)); // 输出: [object Object]
console.log(Object.prototype.toString.call(arr)); // 输出: [object Array]

这种方法可以准确区分不同类型的对象,避免 typeofinstanceof 的一些局限性。

九、在原型链上的区别

  1. 原始类型与原型链 原始类型本身没有原型链,但当对原始类型调用方法时,JavaScript 会自动创建一个临时的包装对象,这个包装对象是有原型链的。例如,对于字符串类型:
let str = 'hello';
console.log(str.length);

在这个例子中,str 是原始类型字符串,它本身没有 length 属性。但当调用 str.length 时,JavaScript 会创建一个临时的 String 包装对象,这个包装对象的原型链上有 length 属性,所以可以正常访问。包装对象的原型是 String.prototype

  1. 引用类型与原型链 引用类型的对象都有原型链。每个对象都有一个 __proto__ 属性,它指向该对象的原型对象。原型对象本身也是一个对象,它也有自己的原型,这样就形成了原型链。例如,对于自定义对象:
function Person(name) {
  this.name = name;
}
Person.prototype.sayHello = function() {
  console.log('Hello, my name is'+ this.name);
};
let john = new Person('John');
john.sayHello();

在这个例子中,johnPerson 构造函数创建的对象,它的 __proto__ 属性指向 Person.prototypePerson.prototype 上定义的 sayHello 方法可以被 john 对象访问,这就是通过原型链实现的。

十、在性能方面的区别

  1. 原始类型的性能 由于原始类型存储在栈内存中,存取速度快,对于简单的数据操作,使用原始类型通常性能较好。例如,在进行大量的数值计算时,使用 number 类型比使用对象来存储数值要高效得多。例如:
let sum = 0;
for (let i = 0; i < 1000000; i++) {
  sum += i;
}

这里使用原始类型 number 进行累加操作,栈内存的快速存取使得计算效率较高。

  1. 引用类型的性能 引用类型存储在堆内存中,虽然堆内存空间大,但存取速度相对较慢。当处理复杂的数据结构,如大型对象或数组时,频繁的读写操作可能会导致性能问题。例如,在一个包含大量元素的数组中查找元素:
let largeArray = Array.from({ length: 1000000 }, (_, i) => i);
let target = 500000;
let found = false;
for (let i = 0; i < largeArray.length; i++) {
  if (largeArray[i] === target) {
    found = true;
    break;
  }
}

在这个例子中,由于数组存储在堆内存,遍历查找元素时需要通过引用地址在堆内存中访问数据,相比原始类型在栈内存中的直接访问,性能会有所下降。此外,如果对象或数组的嵌套层次很深,访问深层属性也会增加性能开销。

综上所述,JavaScript 中原始类型和引用类型在存储方式、复制行为、比较方式、作为函数参数传递、内存管理、可变性、类型检测、原型链以及性能等方面都存在明显的区别。深入理解这些区别,有助于开发者编写更高效、更健壮的 JavaScript 代码,避免因类型相关问题导致的错误和性能瓶颈。在实际编程中,应根据具体需求合理选择使用原始类型和引用类型。