整理一下 JavaScript 中值类型和引用类型的区别,以及由此引出的深拷贝问题。从变量赋值的行为差异,到 typeof 的边界,再到变量计算中的隐式转换——这几个概念串起来,构成了对 JS 类型系统的理解基线。

值类型与引用类型

值类型(Primitive Types)

值类型的数据存在栈(Stack)里,变量直接保存值本身。赋值时是复制一份,之后互不影响:

let a = 100;
let b = a; // 把 a 的值复制一份给 b
a = 200;
console.log(b); // 100 —— 已经"分家"了

这个行为也适用于函数传参——传值类型时,函数内部对参数的修改不会影响外部:

function addOne(n) {
  n = n + 1;
  console.log(n); // 101
}
let x = 100;
addOne(x);
console.log(x); // 100 —— 不受影响
类型示例说明
undefinedlet a;声明但未赋值
stringconst s = 'abc';字符串,不可变
numberconst n = 100;整数和浮点统一为 IEEE 754 双精度
booleanconst b = true;布尔
symbolconst s = Symbol('s');ES6,创建唯一标识,常用于对象私有属性
bigintconst n = 100n;ES2020,处理超大整数(Number.MAX_SAFE_INTEGER 以上的范围)

为什么需要 bigintnumber 类型的安全整数范围是 -(2^53 - 1)2^53 - 1,超出这个范围会出现精度丢失:

console.log(9007199254740992 === 9007199254740993); // true —— 精度丢失
console.log(9007199254740992n === 9007199254740993n); // false —— bigint 精确

引用类型(Reference Types)

引用类型的数据存在堆(Heap)里,变量只存一个指向堆内存的指针。赋值时复制的是指针,两个变量指向同一块内存:

let a = { age: 20 };
let b = a; // 复制指针,a 和 b 指向同一块堆内存
b.age = 21;
console.log(a.age); // 21 —— 一改全改

函数传参同理:

function updateAge(person) {
  person.age = 30;
}
const user = { age: 20 };
updateAge(user);
console.log(user.age); // 30 —— 传的是引用,函数内修改会反映到外部

常见的引用类型:ObjectArrayFunctionMapSetWeakMapWeakSetDateRegExp 等,本质都是对象。

typeof null === 'object' 是 JavaScript 最早版本的历史遗留 bug(二进制前三位为 0 的对象标记被 null 的表示方式误判),至今未修复。null 实际上是基本类型,语义上表示”空对象指针”。

值类型(栈):                    引用类型(堆):
┌──────┐                        ┌──────────┐
│ a=100 │                        │ a ──→ {age:20} │
│ b=100 │  各自独立               │ b ──→ (同上)  │ 共享同一块
└──────┘                        └──────────┘

typeof 的边界

typeof 能判断所有值类型和函数,但引用类型无法细分:

// 值类型
typeof undefined; // 'undefined'
typeof 'abc'; // 'string'
typeof 100; // 'number'
typeof true; // 'boolean'
typeof Symbol(); // 'symbol'
typeof 100n; // 'bigint'
 
// 函数
typeof console.log; // 'function'
typeof function () {}; // 'function'
typeof class {}; // 'function' —— class 本质是函数
 
// 引用类型——不可再细分
typeof null; // 'object'  ← 历史 bug
typeof ['a', 'b']; // 'object'
typeof { x: 100 }; // 'object'
typeof new Map(); // 'object'
typeof new Date(); // 'object'

判断数组可以用 Array.isArray()(推荐,跨 iframe 可靠)或 instanceof Array。更全面的类型判断见 判断数据类型的四种方法

typeof 还有一个冷门用法:typeof undeclaredVar 不会抛 ReferenceError,返回 'undefined'。这可以用在检查全局变量是否存在而不用 window.xxx 的场景,但现代代码中很少用到。

浅拷贝与深拷贝

浅拷贝只复制第一层

浅拷贝(展开运算符、Object.assignsliceconcat)只复制对象的第一层属性,嵌套引用仍然共享:

const obj1 = { name: 'zhangsan', info: { city: 'beijing', zip: '100000' } };
 
// 展开运算符 —— 浅拷贝
const obj2 = { ...obj1 };
obj2.info.city = 'shanghai';
console.log(obj1.info.city); // 'shanghai' —— 内层对象还是共享的!
 
// Object.assign 也一样
const obj3 = Object.assign({}, obj1);
 
// 数组的浅拷贝
const arr1 = [{ id: 1 }, { id: 2 }];
const arr2 = [...arr1];
arr2[0].id = 999;
console.log(arr1[0].id); // 999 —— 元素是对象,浅拷贝只复制引用

这在 React 状态管理中是一个常见的 bug 来源:你创建了新对象引用,但嵌套的对象没有断开,导致 React 认为状态没变而跳过重新渲染。

深拷贝:递归复制

function deepClone(obj) {
  if (typeof obj !== 'object' || obj == null) return obj;
 
  let result = obj instanceof Array ? [] : {};
 
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      result[key] = deepClone(obj[key]);
    }
  }
 
  return result;
}
 
const obj1 = {
  name: 'zhangsan',
  info: { city: 'beijing', zip: '100000' },
  hobbies: ['reading', 'coding'],
};
const obj2 = deepClone(obj1);
obj2.info.city = 'shanghai';
obj2.hobbies.push('gaming');
 
console.log(obj1.info.city); // 'beijing'
console.log(obj1.hobbies); // ['reading', 'coding']

深拷贝的边界情况

上面是教学版本,生产环境需要额外处理:

边界问题处理方向
循环引用a.self = a 导致递归栈溢出维护一个 WeakMap 记录已拷贝对象
Date变为空对象 {}特殊判断,返回 new Date(obj)
RegExp变为空对象 {}特殊判断,返回 new RegExp(obj)
Map / Set数据丢失遍历后重建
Function通常不需要深拷贝直接复用引用
Symbol 作为 keyfor...in 无法遍历配合 Object.getOwnPropertySymbols
原型链拷贝后的对象原型丢失设置 result.__proto__ 或使用 Object.create

生产环境的三个常用方案:

// 1. lodash.cloneDeep —— 最稳健,处理了所有边界情况
import cloneDeep from 'lodash/cloneDeep';
const copy = cloneDeep(original);
 
// 2. structuredClone —— 原生 API,Node 17+ / 现代浏览器
// 不支持:Function、Symbol、DOM 节点、Error 对象的 stack
const copy = structuredClone(original);
 
// 3. JSON 序列化 —— 简单但多坑
// 丢失:undefined、Function、Symbol
// 变形:Date → 字符串、NaN/Infinity → null
// 报错:循环引用
const copy = JSON.parse(JSON.stringify(original));

选型建议:简单对象且不关心上述丢失的类型 → structuredClone;复杂场景 → lodash.cloneDeep;能控制数据结构且追求零依赖 → 手写递归 + WeakMap 处理循环引用。

变量计算与类型转换

字符串拼接

+ 运算符在任意操作数为字符串时触发拼接,非字符串操作数会被隐式转换:

100 + 10; // 110 —— 纯数字加法
100 + '10'; // '10010' —— 遇字符串转拼接
true + '10'; // 'true10'
undefined + '10'; // 'undefined10'
null + '10'; // 'null10'
({}) + '10'; // '[object Object]10'

后端返回的数字 ID 参与字符串拼接时需要显式处理:'' + String(id) 或模板字符串 `${id}`

=====

==(两等)会尝试类型转换让两边相等,规则复杂且出人意料:

100 == '100'; // true —— 字符串转数字
0 == ''; // true —— 空字符串转 0
0 == false; // true —— false 转 0
false == ''; // true —— 都转成 0
null == undefined; // true —— 规范特例
' \t\r\n ' == 0; // true —— 空白字符串也转 0

实践中:除了 == null 之外一律用 ===

const obj = { x: 100 };
if (obj.a == null) {
} // 等价于 obj.a === null || obj.a === undefined

== null 好用是因为它同时涵盖 nullundefined,不会误匹配 false0''。这是规范中少数设计得干净的特性之一。

逻辑运算中的隐式转换

&&|| 返回的不是布尔值,而是第一个决定结果的操作数的值(短路求值):

console.log(0 && 'hello'); // 0 —— 第一个操作数 falsely,直接返回它
console.log(1 && 'hello'); // 'hello' —— 第一个 truly,返回第二个
console.log('' || 'default'); // 'default' —— 第一个 falsely,返回第二个
console.log('value' || 'default'); // 'value' —— 第一个 truly,直接返回它
 
// 利用这个特性做默认值(在 ?? 出现之前很常见)
const name = userInput || 'Anonymous';
 
// ?? (nullish coalescing) 比 || 更精确:只在 null/undefined 时取默认值
const count = inputCount ?? 0; // inputCount 为 0 时不会取默认值

truly 与 falsely

if 语句不要求布尔值——它把任何值都判定为 truly 或 falsely。以下 6 个是 JavaScript 中仅有的 falsely 值:

!!0 === false;
!!NaN === false;
!!'' === false;
!!null === false;
!!undefined === false;
!!false === false;

其余一切值都是 truly,包括空对象 {}、空数组 []、字符串 'false'、负数 -1new Boolean(false)(包装对象)。

注意 if ([]) 为 true,但 [] == false 也为 true——前者走 truly/falsely 判断,后者走 == 的类型转换游戏。两套规则分开理解。

相关笔记