数据类型判断看似基础,但真出 bug 时能让人查一天。之前接手一个老项目,前端表单提交一直 500,后端同学骂了一个礼拜------最后定位到:我方接口把 null 判成「空对象」直接跳过校验,把 "" 空字符串和 0 判成「没填」丢回前端。根因就是没分清「假值」和「空值」、没分清「值类型」和「引用类型」、typeof null === 'object' 这个 20 年前的 BUG 还不知道。
真正搞懂数据类型,要从两份清单 + 一张存储图 + 4 种判断方式开始。
一、先记住两份清单:JS 一共几种数据类型?
JS 一共 8 种原始类型 + 1 种引用类型(不算提案里还在改的 Tuple/Record)。不要再说「6 种」了------那是 ES5 时代的答案。
1. 原始类型(Primitive,7 + 1 = 8 种)
| 类型 | 说明 | 典型值 |
|---|---|---|
number |
数字(含整数、浮点、NaN、Infinity) |
1, 3.14, NaN, Infinity |
string |
字符串(不可变) | 'abc', "", 模板 |
boolean |
布尔 | true, false |
undefined |
「声明了但没赋值」的默认值 | let a; → a === undefined |
null |
「故意空值」,typeof 是 BUG 返回 'object' | null |
symbol(ES6) |
唯一不可变,常做对象私有 key | Symbol('id') |
bigint(ES2020) |
大整数(>2^53),后缀 n |
9007199254740993n |
2. 引用类型(Reference,1 大类)
所有「不是原始类型」的都是 object 的派生:
typescript
object
┌────────────┼────────────┐
│ │ │
普通对象 数组 函数(typeof 返回 'function',
{}/new Object []/new Array 但本质是可调用对象)
│ │ │
还有:Date、RegExp、Error、Map、Set、Promise、ArrayBuffer
(这些都是 new 出来的内置对象实例)
💡关键差异 :原始类型按值赋值 / 按值比较 ;引用类型按引用(内存地址)赋值 / 比较 。这条规则决定了「为什么改一个对象另一个也变」「为什么
[] === []是 false」------后面存储图会展开。
3. 一句话口诀
🎯**「8 个原始 + 1 个 object,函数也是对象但 typeof 单独给面子返回 'function'」**。所有类型判断题都从这句推。
二、栈 vs 堆:值类型和引用类型到底差在哪
JS 引擎存变量时分两块内存:
- 栈(Stack):存原始类型的值本身 + 引用类型的「地址指针」。速度快、空间小、自动回收。
- 堆(Heap):存引用类型真正的数据本体。空间大、要 GC 管理。
存储图(务必画在草稿纸上)
ini
栈内存(Stack) 堆内存(Heap)
┌───────────────────────────┐ ┌─────────────────────────┐
│ let a = 10 │ │ │
│ ┌─────┐ │ │ │
│ │ 10 │ ← 原始类型直接存值 │ │ │
│ └─────┘ │ │ │
│ │ │ │
│ let b = a → 复制了一份值 │ │ │
│ ┌─────┐ │ │ │
│ │ 10 │ ← 改 b 不影响 a │ │ │
│ └─────┘ │ │ │
│ │ │ │
│ let obj1 = { name: '张三' } │ │
│ ┌─────────┐ │ │ ┌────────────────────┐ │
│ │ 0x1F ↗──┼───────────────────────→ │ { name: '张三' } │ │
│ └─────────┘ ← 只存地址指针 │ │ └────────────────────┘ │
│ │ │ ▲ │
│ let obj2 = obj1 │ │ │ │
│ ┌─────────┐ │ │ 两个指针指向同一块堆内存│
│ │ 0x1F ↗──┼───────────────────────→ 改 obj2.name = '李四' │
│ └─────────┘ │ │ → obj1.name 也变 '李四'│
└───────────────────────────┘ └─────────────────────────┘
代码验证:
js
// 原始类型:按值
let a = 10
let b = a
b = 20
console.log(a) // 10 ← 不受影响
// 引用类型:按引用
let obj1 = { name: '张三' }
let obj2 = obj1
obj2.name = '李四'
console.log(obj1.name) // '李四' ← 被牵连改了
// 引用比较:比的是地址
console.log([] === []) // false ← 两块不同的堆内存
console.log({} === {}) // false
console.log([1,2] === [1,2]) // false
let arr1 = [1,2], arr2 = arr1
console.log(arr1 === arr2) // true ← 同一块堆内存
⚠️这就是深拷贝 vs 浅拷贝之所以存在的根因------引用类型赋值只是复制了「门牌号」,真要彻底独立必须深拷贝。下面专门开一章手写。
三、深拷贝:从浅拷贝到手写生产级版本
引用类型按地址赋值是根源,但实际业务里我们经常需要「两个完全独立的对象」------比如把表单初值拷一份当编辑副本、把接口返回数据拷一份做本地缓存、给函数传参时不希望原对象被改。这时候就得上深拷贝。
1. 先看浅拷贝:只复制第一层,嵌套对象还是共享
三种浅拷贝方式
js
// 方式 1:Object.assign( target, ...sources )
const a = { name: '张三', info: { age: 18 } }
const b = Object.assign({}, a)
b.name = '李四'
b.info.age = 99
console.log(a.name) // '张三' ← 第一层独立了
console.log(a.info.age) // 99 ← 第二层还是共享,被改了!
// 方式 2:展开运算符 { ...obj } / [ ...arr ](写法更现代,行为同上)
const c = { ...a }
c.info.age = 100
console.log(a.info.age) // 100 ← 还是共享
// 方式 3:数组专用 slice / concat / Array.from
const arr1 = [ { v: 1 }, [2, 3] ]
const arr2 = arr1.slice()
arr2[0].v = 99
console.log(arr1[0].v) // 99 ← 元素对象还是共享
记忆点:浅拷贝 = 第一层基本类型独立,第二层及更深的引用类型还是同一块堆内存。
2. JSON 方案:能解决 80% 场景,但有 6 个坑
js
const obj = { name: '张三', age: 18, hobbies: ['游泳', '看书'] }
const copy = JSON.parse(JSON.stringify(obj))
copy.hobbies.push('打游戏')
console.log(obj.hobbies) // ['游泳', '看书'] ← 深层独立了 ✅
6 个致命缺陷(生产代码必踩):
js
const src = {
fn: () => {}, // ① 函数 → 丢失
undef: undefined, // ② undefined → 丢失
sym: Symbol('id'), // ③ Symbol → 丢失
big: 10n, // ④ BigInt → 直接抛 TypeError(JSON 不支持)
date: new Date(), // ⑤ Date → 变成字符串 "2026-08-23T..."
reg: /abc/g, // ⑥ RegExp → 变成空对象 {}
nan: NaN, // ⑦ NaN → 变成 null
inf: Infinity // ⑧ Infinity → 变成 null
}
const copy = JSON.parse(JSON.stringify(src))
// 还有:循环引用直接抛错 "Converting circular structure to JSON"
适用场景 :纯数据对象(无函数、无 Date、无 RegExp、无循环引用)才能用。表单数据通常满足,但接口返回的数据经常有 Date 字段,要小心。
3. 手写递归深拷贝(基础版,处理 90% 场景)
js
function deepClone(source, hash = new WeakMap()) {
// ① 原始类型、函数、null 直接返回(函数一般不需要深拷贝,按引用传即可)
if (source === null || typeof source !== 'object') return source
// ② 处理循环引用:先查缓存,避免无限递归
if (hash.has(source)) return hash.get(source)
// ③ 处理 Date
if (source instanceof Date) return new Date(source)
// ④ 处理 RegExp
if (source instanceof RegExp) return new RegExp(source)
// ⑤ 处理 Map
if (source instanceof Map) {
const map = new Map()
hash.set(source, map)
source.forEach((v, k) => map.set(deepClone(k, hash), deepClone(v, hash)))
return map
}
// ⑥ 处理 Set
if (source instanceof Set) {
const set = new Set()
hash.set(source, set)
source.forEach(v => set.add(deepClone(v, hash)))
return set
}
// ⑦ 处理数组 / 普通对象:用 source.constructor 拿到对应构造函数
// 这样继承自 Array 还是普通 Object 都能正确创建
const target = new source.constructor()
hash.set(source, target) // 关键:先存缓存,再递归,否则循环引用会爆栈
// ⑧ 遍历自有 key(包括 Symbol key)
Reflect.ownKeys(source).forEach(key => {
target[key] = deepClone(source[key], hash)
})
return target
}
逐行解释:
WeakMap缓存:解决「对象 A 引用了 A 自己」导致死循环爆栈。用 WeakMap 不用 Map,是因为深拷贝完成后缓存能随 GC 自动回收,不占内存。new source.constructor():保证拷出来的还是原类型------[].constructor是Array,({}).constructor是Object,不会把数组拷成对象。Reflect.ownKeys:比Object.keys多遍历 Symbol 字段和不可枚举字段(按需决定要不要不可枚举,这里全拿)。
4. 自测:覆盖 8 种场景
js
// 1. 基础对象
const obj = { a: 1, b: 'str', c: true, d: null, e: undefined }
const c1 = deepClone(obj)
c1.a = 999
console.log(obj.a) // 1 ✅
// 2. 嵌套对象 + 数组
const obj2 = { list: [{ v: 1 }], info: { name: '张三' } }
const c2 = deepClone(obj2)
c2.list[0].v = 99
console.log(obj2.list[0].v) // 1 ✅
// 3. Date
const obj3 = { date: new Date('2026-08-23') }
const c3 = deepClone(obj3)
console.log(c3.date instanceof Date, c3.date.getTime() === obj3.date.getTime()) // true true ✅
// 4. RegExp
const obj4 = { reg: /abc/g }
const c4 = deepClone(obj4)
console.log(c4.reg instanceof RegExp, c4.reg.source === obj4.reg.source) // true true ✅
// 5. Map
const obj5 = { map: new Map([['key', { v: 1 }]]) }
const c5 = deepClone(obj5)
c5.map.get('key').v = 99
console.log(obj5.map.get('key').v) // 1 ✅
// 6. Set
const obj6 = { set: new Set([{ v: 1 }]) }
const c6 = deepClone(obj6)
const arr6 = [...c6.set]
arr6[0].v = 99
console.log([...obj6.set][0].v) // 1 ✅
// 7. 循环引用
const obj7 = { name: '循环' }
obj7.self = obj7 // 自己引用自己
const c7 = deepClone(obj7)
console.log(c7.self === c7) // true ✅,没爆栈
// 8. Symbol key
const sym = Symbol('id')
const obj8 = { [sym]: '私有', name: '普通' }
const c8 = deepClone(obj8)
console.log(c8[sym]) // '私有' ✅
5. 现代 API:structuredClone(2022+,浏览器和 Node 17+ 原生支持)
js
const obj = { date: new Date(), reg: /abc/g, map: new Map(), list: [{ v: 1 }] }
const copy = structuredClone(obj)
copy.list[0].v = 99
console.log(obj.list[0].v) // 1 ✅
优势:原生支持 Date、RegExp、Map、Set、ArrayBuffer、Blob、循环引用,比手写版完整。
限制:
- 不支持函数 (直接抛
DataCloneError) - 不支持 Symbol key(会丢)
- 不支持 Class 实例(会变成普通对象)
- 不支持 DOM 节点(部分浏览器支持)
选型建议:
| 场景 | 推荐 |
|---|---|
| 纯数据对象(表单、配置、JSON 接口数据) | JSON.parse(JSON.stringify()) 够用,最快 |
| 含 Date/RegExp/Map/Set 但无函数、无 Symbol | structuredClone(obj) |
| 含函数(要保留) / 含 Class 实例 / 兼容老浏览器 | 手写 deepClone |
| 极致性能(大对象频繁拷贝) | 引入 lodash _.cloneDeep,做了大量优化 |
6. 一张对比表
| 方案 | 第一层独立 | 深层独立 | Date/RegExp | Map/Set | 循环引用 | 函数 | Symbol key | 性能 |
|---|---|---|---|---|---|---|---|---|
Object.assign / {...obj} |
✅ | ❌ | ✅ | ✅ | ❌(共享) | ✅(按引用) | ✅ | 最快 |
JSON.parse(JSON.stringify()) |
✅ | ✅ | ❌(变字符串) | ❌(报错或丢) | ❌(抛错) | ❌(丢) | ❌(丢) | 快 |
structuredClone |
✅ | ✅ | ✅ | ✅ | ✅ | ❌(抛错) | ❌(丢) | 中 |
手写 deepClone |
✅ | ✅ | ✅ | ✅ | ✅ | ✅(按引用) | ✅ | 慢 |
lodash _.cloneDeep |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 中 |
🎯口诀:纯数据用 JSON,含 Date 用 structuredClone,含函数或老浏览器用手写或 lodash。
四、4 种类型判断方式:各自能判什么、有什么坑
这是日常排查 bug 用得最多的一块。没有一种方式能判所有类型,必须搭配使用。
1. typeof:只能判原始类型 + function
js
typeof 10 // 'number'
typeof 'abc' // 'string'
typeof true // 'boolean'
typeof undefined // 'undefined'
typeof Symbol() // 'symbol'
typeof 10n // 'bigint'
typeof function(){} // 'function' ← 唯一能精确判的引用类型
typeof null // 'object' ← 🔥 20 年前的 BUG,到现在没修,怕老项目炸
typeof [] // 'object' ← 数组也判成 object
typeof {} // 'object'
typeof new Date() // 'object'
适用场景 :判断变量是否存在 / 是不是 undefined,或判断是不是函数。
js
// ✅ 判断变量有没有声明过(注意 typeof 不会对未声明变量报错)
if (typeof someVar !== 'undefined') { /* 安全使用 */ }
// ✅ 判断是不是函数(回调函数场景常用)
if (typeof cb === 'function') cb()
💡为什么
typeof null === 'object'?JS 早期版本用低位标识类型(000 代表对象),而 null 在内存里是空指针,全 0,被误判成 object。这个 BUG 存进了规范,改不了了,怕破坏老项目。
2. instanceof:判断引用类型的具体类型(沿原型链找)
js
[] instanceof Array // true
[] instanceof Object // true ← 数组也是对象
new Date() instanceof Date // true
function(){} instanceof Function // true
'abc' instanceof String // false ← 原始类型不能 instanceof
10 instanceof Number // false
原理 :沿对象的 __proto__ 链往上找,看有没有等于 Ctor.prototype 的节点(不熟的回去看 04 原型链)。
坑 1:跨 iframe / 跨 Window 失效 。每个 window 有自己的 Array 构造函数,[] 在 iframe A 里 instanceof Array 是 true,传到父页面就成了 false。所以 ECMAScript 后来补了 Array.isArray。
坑 2:原始类型判不了 (除了 new Number(1) instanceof Number 这种包装对象,业务里几乎不写)。
坑 3:能往上找父类 ------[] instanceof Object 也是 true,因为 Array.prototype 最终链到 Object.prototype。所以 instanceof 不能精确判「就是数组不是别的」。
3. Object.prototype.toString.call():万能判断法
js
Object.prototype.toString.call(10) // '[object Number]'
Object.prototype.toString.call('abc') // '[object String]'
Object.prototype.toString.call(null) // '[object Null]' ← 唯一能精确判 null 的
Object.prototype.toString.call(undefined) // '[object Undefined]'
Object.prototype.toString.call([]) // '[object Array]'
Object.prototype.toString.call(new Date()) // '[object Date]'
Object.prototype.toString.call(/abc/) // '[object RegExp]'
Object.prototype.toString.call(Symbol()) // '[object Symbol]'
Object.prototype.toString.call(10n) // '[object BigInt]'
Object.prototype.toString.call(function(){}) // '[object Function]'
原理 :toString 是 Object.prototype 上的方法,默认返回 '[object Type]'。基本类型和内置对象都重写了 Symbol.toStringTag,所以能拿到精确类型。
为什么非要 .call()? 因为很多对象自己重写了 toString(比如数组的 toString 会把元素拼成字符串),直接 obj.toString() 拿不到 [object XXX]。借用 Object.prototype.toString.call(obj) 才能强制走 Object 那一版。
适用场景 :需要拿到「精确类型字符串」时,几乎不会出错。这是判断类型最稳的方式。
4. Array.isArray() 和其它专用判断
js
Array.isArray([]) // true
Array.isArray({}) // false
Array.isArray('abc') // false
Array.isArray(new Array(3)) // true
为什么不直接用 instanceof? 因为跨 iframe 不会失效------Array.isArray 内部认 [[Class]] 内部槽,不依赖原型链。
其它专用判断:
js
Number.isNaN(NaN) // true ← 比全局 isNaN 安全(isNaN('abc') 也是 true)
Number.isFinite(10) // true ← 比 isFinite 严格(isFinite('10') 也是 true)
Number.isInteger(10) // true
Number.isInteger(10.5) // false
5. 一张能力对比表(背下来)
| 方式 | 能判原始类型? | 能判 null? | 能判具体引用类型? | 跨 iframe 安全? | 字符串结果 |
|---|---|---|---|---|---|
typeof |
✅(除了 null) | ❌(返回 'object') | ❌(只有 function 行) | ✅ | 'number' 等 |
instanceof |
❌ | ❌ | ✅(精确) | ❌ | boolean |
Object.prototype.toString.call |
✅ | ✅ | ✅ | ✅ | '[object Type]' |
Array.isArray |
❌ | ❌ | 只判 Array | ✅ | boolean |
🎯口诀:判有没有用 typeof,判具体引用类型用 instanceof / isArray,要稳就用 toString.call。
五、手写一个完整的 getType 工具函数(生产级)
日常业务里经常要拿到「小写类型名」做分发判断。直接封装一个:
js
function getType(value) {
// toString.call 返回 '[object Type]',取中间的 Type 并转小写
return Object.prototype.toString.call(value)
.slice(8, -1)
.toLowerCase()
}
// 自测
getType(10) // 'number'
getType('abc') // 'string'
getType(true) // 'boolean'
getType(undefined) // 'undefined'
getType(null) // 'null' ← 关键:typeof 判不了的,这里能判
getType(Symbol()) // 'symbol'
getType(10n) // 'bigint'
getType([]) // 'array'
getType({}) // 'object'
getType(new Date()) // 'date'
getType(/abc/) // 'regexp'
getType(new Map()) // 'map'
getType(new Set()) // 'set'
getType(function(){}) // 'function'
getType(new Error()) // 'error'
getType(Promise.resolve()) // 'promise'
原理拆解:
'[object Array]'.slice(8, -1)→'Array'(去掉前 8 字符[object和最后 1 字符]).toLowerCase()→'array'
实际开源库(vue / lodash)里判类型就是这思路。
六、手写 instanceof(和原型链联动)
js
function myInstanceof(obj, Ctor) {
// 原始类型直接 false
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return false
}
let proto = Object.getPrototypeOf(obj)
while (proto !== null) {
if (proto === Ctor.prototype) return true
proto = Object.getPrototypeOf(proto)
}
return false
}
// 自测
myInstanceof([], Array) // true
myInstanceof([], Object) // true
myInstanceof(10, Number) // false ← 原始类型判不了
myInstanceof(new Number(10), Number) // true ← 包装对象能判
为什么 obj instanceof Ctor 不能判原始类型? 因为原始类型没有 __proto__。要判包装对象,必须 new Number(10) 包一层。
七、6 个最常见的类型坑(我全踩过)
坑 1:typeof null === 'object'
js
function process(data) {
if (typeof data === 'object') {
// 这里会漏过 null,下一行就崩
data.forEach(...) // ❌ Cannot read property 'forEach' of null
}
}
// ❌ 上面的写法 null 进来了
process(null) // 崩
✅修正:要么先排空,要么直接判「非 null 的对象」。
js
function isObject(v) {
return typeof v === 'object' && v !== null
}
坑 2:NaN !== NaN
js
NaN === NaN // false ← 唯一一个不等于自己的值
typeof NaN // 'number' ← 也是 number!
// ❌ 老代码里这样判 NaN
function isNaNOld(v) { return v !== v } // 能用但反人类
// ✅ 用 Number.isNaN
Number.isNaN(NaN) // true
Number.isNaN('abc') // false ← 严格判断(不会把字符串当 NaN)
isNaN('abc') // true ← 全局 isNaN 会先 Number('abc') = NaN,再判断
坑 3:假值(falsy)≠ 空值
7 个假值要背下来:
js
if (v) { ... }
// 以下都是假值,进不了 if:
// false, 0, -0, 0n, '', "", ``, null, undefined, NaN
业务里最大的坑:
js
// ❌ 把 0 和 '' 当成「没填」
if (form.age) {
// 用户填了 0 岁的婴儿 → 进不来
}
if (form.nickname) {
// 用户故意填空字符串清空昵称 → 进不来
}
// ✅ 按场景用精确判空
if (form.age !== '' && form.age !== null && form.age !== undefined) { ... }
// 或者
if (form.age != null) { ... } // 只排除 null 和 undefined
坑 4:[] == false 是 true(相等运算符的暗坑)
js
[] == false // true
[] == 0 // true
[] == '' // true
{} == '[object Object]' // 老引擎上 true,新版严格模式会语法错误
null == undefined // true
null === undefined // false
NaN == NaN // false
规则 :== 会做类型转换,规则极其复杂(ToPrimitive → ToNumber → 再比)。所以业务代码一律用 === ,只在判 null/undefined 同时出现时用 == null(这是唯一推荐用 == 的场景)。
js
// ✅ 推荐:判断「非 null 且非 undefined」
if (v != null) { ... }
坑 5:Symbol() 不能 new,但能当 key
js
new Symbol() // ❌ TypeError
Symbol() // ✅
const id = Symbol('id')
const obj = { [id]: '私有字段', name: '张三' }
Object.keys(obj) // ['name'] ← Symbol 不会被遍历出来
Object.getOwnPropertySymbols(obj) // [Symbol(id)] ← 要这样取
坑点:JSON.stringify 也会丢掉 Symbol 字段------接口传参时容易「怎么字段没了」。
坑 6:0.1 + 0.2 !== 0.3
js
0.1 + 0.2 === 0.3 // false
0.1 + 0.2 // 0.30000000000000004
原因:浮点数 IEEE 754 表示,部分小数无法精确存储。
js
// ✅ 修正:用容差
function isEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON // ≈ 2.22e-16
}
isEqual(0.1 + 0.2, 0.3) // true
// ✅ 涉及金额:用分(整数)或用 toFixed 保留指定位
const yuan = (priceInFen / 100).toFixed(2) // 字符串
八、一张速查表收尾
数据类型清单
| 类别 | 类型 | typeof 返回 | 典型 |
|---|---|---|---|
| 原始 | number | 'number' |
1, NaN, Infinity |
| 原始 | string | 'string' |
'abc', "" |
| 原始 | boolean | 'boolean' |
true, false |
| 原始 | undefined | 'undefined' |
let a; → a |
| 原始 | null | 'object' ⚠️ BUG |
null |
| 原始 | symbol | 'symbol' |
Symbol() |
| 原始 | bigint | 'bigint' |
10n |
| 引用 | object | 'object' |
{}, [], new Date() |
| 引用 | function | 'function' |
function(){}, 箭头函数 |
类型判断方式对照
| 场景 | 推荐方式 | 例子 |
|---|---|---|
| 判变量有没有声明 | typeof |
typeof x !== 'undefined' |
| 判 undefined / null | == null 或精确判 |
v == null(同判两者) |
| 判是不是函数 | typeof |
typeof cb === 'function' |
| 判是不是数组 | Array.isArray |
Array.isArray([]) |
| 判具体引用类型(Date/RegExp/...) | Object.prototype.toString.call |
getType(v) === 'date' |
| 判是不是某类的实例 | instanceof |
v instanceof Error |
| 判 NaN | Number.isNaN |
Number.isNaN(v) |
| 判安全整数 | Number.isSafeInteger |
Number.isSafeInteger(v)(±2^53-1) |
| 判浮点数相等 | 容差比较 | Math.abs(a-b) < Number.EPSILON |
| 浅拷贝(第一层独立) | Object.assign / {...obj} |
Object.assign({}, obj) |
| 深拷贝(含 Date/Map/Set) | structuredClone |
structuredClone(obj) |
| 深拷贝(含函数/Symbol/老浏览器) | 手写 deepClone / lodash |
见第三章代码 |
九、最后说一句
数据类型看着基础,但**90% 的「为什么字段没了」「为什么改一个另一个也变」「为什么接口 500」**根因都在这。原型链、闭包、this 三大难题也得先跨过类型判断这道坎。
类型判断不是炫技,是写代码的第一道防线------一个 typeof null === 'object' 的认知缺位,就能让 if (data) 后面所有代码全军覆没。
💡下一步:把 getType + myInstanceof + deepClone + 类型判断 4 件套 + 假值清单,全部 commit 到
js-basics-practice仓库。下次遇到「这个值为什么是 NaN / 为什么是空」「为什么改 obj2 牵连 obj1」,先在脑子里画一遍「栈存值 vs 堆存引用」那张图,再决定用浅拷贝还是深拷贝。