1. 不只是背面试题:为什么数据类型值得认真整理
做前端开发的,JavaScript 数据类型这关绕不过去。不管是刚入行还是写了几年,你迟早会发现,很多诡异的 bug 根本不是逻辑写错了,而是数据类型在背后搞鬼。我见过太多人栽在typeof null === 'object'这种经典大坑上,也见过面试时被问到0.1 + 0.2 !== 0.3直接卡壳的候选人。说实话,这些知识点如果只是碎片化地刷题,今天记住了明天就忘,根本没法内化成自己的东西。
这份 js 数据类型笔记,我不会像教科书那样把每个类型简单罗列一遍,而是换个角度——从类型系统演进的逻辑出发,搞明白每个类型为什么存在、解决了什么问题、在什么场景下最容易出 bug。同时把类型判断、类型转换、深浅拷贝这些高频实战点串起来,配合我在实际开发中踩过的坑一起讲,适合刚入门想打好基础的新人,也适合写了几年想系统梳理一遍的开发者。
JavaScript 的类型体系在 ES6 之后发生了不小的变化,Symbol 和 BigInt 的加入让原来的"五基本类型加一引用类型"的说法彻底过时了。我在整理这份笔记的时候,最大的感触是:很多东西不是不知道,而是没有把"它们为什么会这样设计"想明白。比如你理解了 Number 类型为什么采用 IEEE 754 浮点标准,自然就明白浮点精度问题根本不是"bug",而是二进制存储的必然结果。理解了原始类型和引用类型在内存中的存储差异,深浅拷贝的概念就不用死记了。所以这份笔记的核心思路是:先建立底层认知框架,再去记各种 API 和特性,效率和效果完全不同。
全文的技术基线是 ES2020+,示例代码我会标注运行环境,方便你直接复制验证。我尽量少用"概念翻译成概念"的讲法,多举真实开发中会遇到的例子,让你看完能直接用。
2. 数据类型总览:先建立一张完整的地图
2.1 新旧两代分类法的区别
老一代的分类法很简单:基本类型(Undefined、Null、Boolean、String、Number)加引用类型(Object)。ES6 加入 Symbol 后,ES2020 又加入 BigInt,这个体系就变成了七种基本类型加上 Object 这一种引用类型。
我建议你从"栈和堆"的角度去理解这个分类。基本类型的值直接存在栈内存里,赋值的时候是值拷贝,互不影响。引用类型的值存在堆内存里,栈里只存一个指向堆内存的地址,赋值的时候拷贝的是指针,两个变量指向同一个对象。这个差异是后面所有"类型坑"的总根源。
整理成表就是:
| 类型大类 | 具体类型 | typeof 结果 | 存储位置 | 赋值行为 |
|---|---|---|---|---|
| 基本类型 | Undefined | "undefined" | 栈 | 值拷贝 |
| 基本类型 | Null | "object"(历史遗留 bug) | 栈 | 值拷贝 |
| 基本类型 | Boolean | "boolean" | 栈 | 值拷贝 |
| 基本类型 | String | "string" | 栈 | 值拷贝 |
| 基本类型 | Number | "number" | 栈 | 值拷贝 |
| 基本类型 | Symbol | "symbol" | 栈 | 值拷贝 |
| 基本类型 | BigInt | "bigint" | 栈 | 值拷贝 |
| 引用类型 | Object | "object" / "function" | 堆 | 引用拷贝 |
有个细节值得注意:Function 在被 typeof 检查时返回的是 "function",而不是 "object"。这是 typeof 唯一的"例外"处理,因为当时的设计者认为函数在语言里地位特殊,是一个"可调用对象"。有经验的老开发会利用这个特性来判断一个变量是不是函数,这个后面会细讲。
2.2 从 ECMAScript 规范理解"类型标签"
ECMAScript 规范内部其实用了一套"类型标签"体系来描述值的类型。规范里定义了 Language Types 和 Specification Types,前者就是我们开发者能感知到的这些类型,后者是引擎内部使用的。你不需要背规范,但理解一件事就够了:
引擎在执行代码时,会时刻追踪每个值的内部类型标签。这也就是 typeof 运算符能工作的原理——它本质上就是读取这个内部标签。
但 typeof null 为什么会返回 "object"?这是第一版 JavaScript 里的一个设计失误。当时把 null 的内部类型标签设定为 0,而 0 恰好是 object 类型标签的值。后来为了兼容早期网站,这个 bug 一直没有修复。你猜这个 bug 在规范里被"官方认证"有多久?答案是二十多年,到现在都没改。所以你在任何版本、任何环境下执行typeof null,得到的永远是 "object"。
注意:判断一个值是否为 null,不能依赖 typeof。正确写法是
value === null。这个看起来像废话,但在实际代码审查中我见过太多次用typeof value === 'object' && !value来判断 null 的绕路写法,其实没必要,直接全等比较最清晰。
3. 基本类型逐个拆解:每一个都有你没想到的细节
3.1 Undefined 和 Null:两个"空"的区别
Undefined 表示"声明了变量但还没赋值",Null 表示"有意的空值"或者说"空对象引用"。
面试里最经典的题是:为什么null == undefined返回 true,而null === undefined返回 false?
答案其实藏在规范的字面里。相等运算符==在比较 null 和 undefined 时,规范明确规定返回 true,因为语言设计者认为"确实没有值"和"定义了但没赋值"在实际语义上足够接近,值得被宽松相等地对待。而严格相等===要求类型和值都一致,null 的类型标签和 undefined 完全不同,自然不相等。
实际开发中我建议一个团队统一风格:凡是"应该主动显式置空"的变量用 null,凡是"尚未初始化"的变量就用 undefined 自然表现。不要手动给变量赋 undefined,那会模糊掉语义。比如接口返回的数据,字段不存在就是 undefined,字段被后端明确置为 null 就表示"我知道这个字段,但是空值",这两个在数据处理时的策略可以完全不一样。
3.2 Number:IEEE 754 浮点与三个"偏门值"
Number 类型覆盖了整数和浮点数,但底层的存储标准只有一个:IEEE 754 双精度浮点格式(64 位)。意味着所谓"整数"在 JavaScript 里本质上也是浮点数。你写const a = 1,它在内存里其实是 1.0。
这个设计带来三个最常见的衍生话题。
第一个是0.1 + 0.2 !== 0.3。十进制小数转成二进制小数,很多是无限循环的,比如 0.1 转成二进制就无限循环。双精度浮点只有 52 位有效位,必然要截断,于是 0.1 和 0.2 在内存里的值都不是精确的,相加之后误差累计,结果就成了0.30000000000000004。
日常开发怎么处理浮点?我的经验是:金额计算绝对不要交给 JavaScript 浮点数直接运算。要么把单位换成"分"用整数算,要么引入一个十进制计算库(比如业内常用的 number-precision 或者 decimal.js 这类方案)。后端传金额一般也是字符串或分单位整数,前端再根据业务转成展示格式,这样最稳。
第二个是Number.MAX_SAFE_INTEGER,值是9007199254740991,也就是2^53 - 1。超出这个范围的整数,双精度浮点就放不下了,会出现精度丢失。后端下发的 ID 如果超过了这个值,前端直接用 Number 类型处理就会出问题,截断成奇怪的值。这时候要么后端直接给字符串 ID,要么前端用 BigInt 接。
第三个是NaN。它不是一种"类型",而是一个 Number 类型的值,代表"不是一个数值"。最坑的是NaN !== NaN,永恒为 true。所以判断一个值是不是 NaN,不能直接比较,要用Number.isNaN()(ES6 引入,不会做类型转换)或者传统的isNaN()(会先尝试把值转成数字再判断,行为容易误判)。
额外提一句:
Number.isNaN('abc')返回 false,但isNaN('abc')返回 true。因为后者先把字符串转数字,转出来 NaN,就判断为是。我建议新代码里只用 Number.isNaN,摒弃全局的 isNaN,语义才准确。
3.3 String:不可变性与编码问题
String 是原始类型,但很多人误以为字符串是可变的,因为str += 'x'看起来像是修改了原字符串。实际上这个操作是创建了一个新的字符串对象,然后把变量指向它。原始字符串本身从未变过。
字符串还有一个隐藏考点:它本身是"类数组"的,有 length 属性和下标访问能力。但你不能通过str[0] = 'a'去修改它的某个字符,因为字符串不可变。这个操作在非严格模式下会静默失败,在严格模式下直接报错。
编码上需要注意 Unicode 的代理对问题。像 emoji 这种码点超过\uFFFF的字符(比如 😀 的码点是U+1F600),会被 UTF-16 编码成两个代理项(surrogate pair)。你用一个字符串存 emoji,它的.length会返回 2。这在做字数限制、截断、翻转字符串等操作时都会踩坑。
ES6 引入了Array.from和字符串的for...of迭代,它们能正确识别代理对。比如:
const emoji = '👋'; console.log(emoji.length); // 2 console.log([...emoji].length); // 1实际开发中如果要做"按用户感知字符数统计",一定要用后者的方式。
3.4 Symbol:不只是"唯一标识符"
Symbol 是 ES6 新增的原始类型,主要用途是生成唯一的标识符。每次调用Symbol()都会创建全新的值,即使传入相同的描述字符串,两个 Symbol 也绝不相等。
它有五个高频应用场景:
第一,作为对象属性名,避免命名冲突。尤其在协作开发中,两个模块都想用同一个 key 挂载状态时,用 Symbol 可以根除冲突。
第二,内置的 Symbol.iterator、Symbol.toStringTag 等是引擎约定的特殊协议接口。比如全局的Symbol.toStringTag,你可以在类上定义它来改变 Object.prototype.toString 的行为。
class MyArray { get [Symbol.toStringTag]() { return 'MyCustomArray'; } } const instance = new MyArray(); console.log(Object.prototype.toString.call(instance)); // "[object MyCustomArray]"第三,实现"私有"属性的一种方案。因为 Symbol 属性默认不被for...in和Object.keys()遍历到,能在一定程度上隐藏内部状态。注意这不是真正的私有,因为通过Object.getOwnPropertySymbols()还是可以取出来。
第四,消除魔术字符串。业务代码里大量散落的字符串常量很容易拼错,换成 Symbol 常量后,写错直接报错而不是静默不匹配。
第五,定义对象 / 类的迭代协议时可以配合Symbol.iterator自定义 for...of 行为。比如给一个普通对象实现可迭代,让它可以被解构或用展开运算符处理。
实战提示:Symbol 不是只能作为对象 key 存在,它本质上是一种值,可以被存储在变量、数组、Set、Map 里。但如果你用 JSON.stringify 序列化一个含 Symbol 属性的对象,这些属性会被直接忽略,这是一个很隐蔽的坑——简单说,带 Symbol 属性的对象不适合直接走 JSON 序列化。
3.5 BigInt:大整数的新出路
ES2020 加入的 BigInt 解决了 Number 安全整数范围不足的问题,可以表示任意大的整数。字面量就是在整数后面加个 n:
const big = 9007199254740993n; console.log(big + 2n); // 9007199254740995n使用时有几个硬性限制:
第一,BigInt 和 Number 不能直接混合运算,1n + 2会抛 TypeError。必须显式转换,但注意大数转成 Number 可能丢失精度,所以转换的逻辑要放在业务层面提前判断。
第二,BigInt 不支持 Math 对象的方法。Math.max(1n, 2n)直接报错。也没有Math.sqrt(4n)这种用法。想做大数的数学运算,要么自己实现,要么分段处理。
第三,JSON.stringify 序列化 BigInt 直接抛异常。你后端接口如果返回大整数,建议直接用字符串传输,前端也不要轻易把字符串转回 BigInt 去参与运算,除非有确切的业务需要。
第四,比较运算不受限制,1n === 1返回 false(类型不同),1n == 1返回 true(宽松相等下规范允许),1n < 2返回 true。这在写条件判断时要心里有数,避免出现类型混淆。
4. 引用类型:Object 体系的深度认知
4.1 对象、数组、函数的本质
引用类型的基类是 Object,Array、Function、Date、RegExp、Map、Set 等都是它下面的具体形态。它们在内存中用同一个模型:堆中的某一块区域存放内容,栈中的变量只是"遥控器"(指针)。
我习惯用"遥控器"这个类比来解释引用类型:你手握一个遥控器,遥控器指向一台电视。把遥控器复制一份给朋友,你们两个按遥控器上的按钮,操控的是同一台电视。这就是引用赋值。
const objA = { name: 'Alice' }; const objB = objA; objB.name = 'Bob'; console.log(objA.name); // "Bob"基础类型则更像是"把电视频道写在纸条上",复制纸条就是复制内容本身,改纸条内容不影响另一张。
这个差异催生了深浅拷贝的核心问题:希望复制出一份"完全独立"的对象,不能简单等号赋值,因为那只是又复制了一把遥控器。需要把堆里的内容也复制一份,这就是"拷贝"。而"深拷贝"连嵌套的子对象也一并复制,不共享任何内部引用。
4.2 "装箱"与"拆箱":原始类型为什么有方法
有个问题很经典:原始类型明明没有属性和方法,为什么'hello'.toUpperCase()能用?
因为 JavaScript 做了一件叫"自动装箱"的事。引擎在访问原始类型的属性和方法时,会临时创建对应的包装对象——String 对象、Number 对象、Boolean 对象——调用完方法后这个临时对象立刻被丢弃。
const str = 'hello'; str.length; // 引擎临时做了一次 new String(str)偶尔你会踩到拆箱相关的坑。比如new Boolean(false)这个包装对象,它的布尔值是 true(因为对象永远是真值)。所以在阻止判断里写if (new Boolean(false))结果永远是成立。
const flag = new Boolean(false); if (flag) { console.log('这段代码会执行,因为 flag 是一个对象'); }实际开发中我坚决建议:永远不要用 new 的方式创建 Boolean、Number、String 包装对象,直接用字面量写法。需要类型转换时用 Boolean(value)、Number(value)、String(value) 这种函数调用方式,语义清晰且不会引入对象真值问题。
4.3 Map 和 Set:比你想的更常用
Map 和 Set 是 ES6 引入的两种引用类型。很多从 Java 转过来的开发者会觉得似曾相识,但这里有几个 JS 特有的讲究:
Map 的 key 可以是任意类型,包括对象、函数、Symbol、NaN(特殊处理,NaN 会被视为同一个 key)。这点比 Object 强很多,Object 的 key 只能是字符串或 Symbol,数字 key 也会被自动转成字符串。
const map = new Map(); map.set({ id: 1 }, 'alice'); map.set([1, 2], 'arrayKey'); map.set(NaN, 'bibi'); // NaN 可以被访问到 console.log(map.get(NaN)); // "bibi"Map 还保持了"插入顺序",遍历时按添加顺序输出。Object 虽然在现代引擎里也基本按插入顺序遍历字符串 key,但整数类 key 会被单独排序,行为不完全一致。
Set 的底层实现基本可以理解为"只有 key 没有 value 的 Map"。它自动保证元素完全不重复,但要注意:对象放进 Set 里,两个结构相同但引用不同的对象会被视为两个不同的元素。
const set = new Set(); set.add({ name: 'Alice' }); set.add({ name: 'Alice' }); console.log(set.size); // 2,因为这是两个不同的对象引用想按"结构相同就视为重复"去重,需要先把对象转成某种可比较的串(比如 JSON.stringify),但这套方案在循环引用时会出问题。所以实际开发里,能用基本类型去重就用基本类型,涉及对象去重要先想清楚需求。
5. 类型判断的黄金法则:六大方法,各有适用场景
5.1 typeof 的正确姿势
typeof 是最快的类型判断工具。它的准确覆盖面是:
typeof undefined // "undefined" typeof true // "boolean" typeof 'str' // "string" typeof 123 // "number" typeof 123n // "bigint" typeof Symbol() // "symbol" typeof function(){} // "function"但它在 null、数组、普通对象这三个身上全部失效。typeof null返回 "object",数组返回 "object",普通对象也返回 "object"。根本原因是这三个值的内部类型标签本质上都是 object,typeof 只区分到这一层。
所以 typeof 适合在知道变量只能是 "非 object 基本类型" 或者 "明确可接受 function 是函数" 的场景下快速判断。比如判断一个可选参数是不是函数回调、判断一个变量是否为字符串类型。
5.2 instanceof 的适用边界
instanceof 检查的是"构造函数"与实例之间的关系,沿着原型链一路查找。
const arr = []; console.log(arr instanceof Array); // true console.log(arr instanceof Object); // true,原型链上确实有 Object.prototype有两个重要的失灵场景:
第一,跨上下文(比如 iframe)或跨 realm 后,Array 构造函数不是一个引用,arr instanceof Array可能为 false。现代环境下跨 iframe 的场景少了很多,但在一些插件化、嵌套浏览器的架构中仍然存在。
第二,原始类型用 instanceof 几乎全部失效。123 instanceof Number返回 false,因为没有经过装箱过程。除非你显式 new Number(123),但这属于反面教材用法。
5.3 Object.prototype.toString.call:全类型终极方案
目前最可靠、覆盖最全的判断方案还是:
Object.prototype.toString.call(value)它的返回值是"[object Type]"的格式。几乎所有内置类型都能被正确识别:
Object.prototype.toString.call(null) // "[object Null]" Object.prototype.toString.call(undefined) // "[object Undefined]" Object.prototype.toString.call([]) // "[object Array]" Object.prototype.toString.call({}) // "[object Object]" Object.prototype.toString.call(new Date()) // "[object Date]" Object.prototype.toString.call(new Map()) // "[object Map]" Object.prototype.toString.call(() => {}) // "[object Function]"为什么不用 value.toString()?因为很多对象自己重写了 toString 方法,比如数组会输出元素拼成的串,Date 输出日期串,直接调用被重写的版本就识别不了类型了。而 Object.prototype.toString 是"源头版本",配合 call 方法强制指定 this,绕开所有重写。
布局一个小技巧:可以直接封装一个通用函数
getType(value),截取 "object" 之后的那段作为类型名,然后和常量比较。这种方法在调试、打日志、写公共工具库时极其好用,比堆一坨 typeof 加 instanceof 的判定干净得多。
5.4 Array.isArray 和 Number.isNaN 等专用函数
ES6 以后,语言层面针对特定类型补了不少专用判断函数:
| 函数 | 用途 | 与老方法的差异 |
|---|---|---|
| Array.isArray(value) | 判断数组 | 跨 realm 依然可靠 |
| Number.isNaN(value) | 判断 NaN | 不做类型转换 |
| Number.isInteger(value) | 判断整数 | 只对 Number 有效 |
| BigInt 相关判断 | 无内置专用函数 | 用 typeof 即可 |
| Symbol 判断 | 无专用函数 | typeof 即可 |
Array.isArray 是唯一一个对"数组类型"的专用精确判断,优于 typeof 和 instanceof,因为它内部不依赖原型链比较,所以在跨 realm 场景下也可靠。
5.5 通用类型判断函数实战封装
我把上面这些方案整合成一个常用工具函数,供团队内部使用:
function getType(value) { return Object.prototype.toString.call(value).slice(8, -1); } function isNull(value) { return value === null; } function isPlainObject(value) { if (value === null || typeof value !== 'object') return false; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } function isNaNValue(value) { return typeof value === 'number' && Number.isNaN(value); } function isArrayLike(value) { return value !== null && typeof value !== 'function' && typeof value.length === 'number' && value.length >= 0 && value.length <= Number.MAX_SAFE_INTEGER; }这些函数看起来简单,但每个都有各自的设计考量。比如 isPlainObject 是用来区分"普通字面量对象"和"Date、Map 等内置对象"的,在做深拷贝或者判断参数类型时非常关键。isArrayLike 则可以用来识别类数组对象(比如 arguments、DOM 的 NodeList 这类),但不强制要求它一定可以迭代。
6. 类型转换:隐式转换的陷阱与显式转换的姿势
6.1 强制转换和隐式转换的规则
JavaScript 的类型转换什么时候发生,很容易让人头晕。强制转换就是显式调用 String()、Number()、Boolean() 等。隐式转换则是语言在运算符求值时"自作主张"做的。
关键的隐式转换场景可以分为三类:
第一,字符串连接。只要加号两边有一个是字符串,另一边就会强制转字符串。
console.log('1' + 2); // "12" console.log(1 + '2'); // "12" console.log(1 + 2 + '3'); // "33",因为先执行 1 + 2 得到 3,再字符串拼接第二,数学运算。减号、乘号、除号两边都会被强制转成 Number。字符串转不成功就得到 NaN。
console.log('5' - 2); // 3 console.log('abc' - 1); // NaN console.log(true + 1); // 2,true 转成 1第三,相等比较。宽松相等==会做一系列复杂转换。有一个口诀比较实用:"数字相比,倾向转数;字符串和对象比,对象转原始值"。比如:
console.log('1' == 1); // true,字符串转数字 console.log([] == 0); // true,数组转原始值为空字符串'',再转数字得到0 console.log([1] == 1); // true,数组转原始值为"1",再转数字得到1 console.log([1, 2] == '1,2'); // true越是写复杂业务,越建议在代码里全面使用===和显式转换,禁止依赖隐式转换。团队规范里甚至可以加一条 lint 规则,强制在比较时使用全等,然后在有明确意图时手动转化。
6.2 对象转原始值的 ToPrimitive 流程
对象(包括数组、函数)在参与类型转换时,会走一条 ToPrimitive 流程,规则可以简单记成:
- 先检查有没有 Symbol.toPrimitive 方法,有就用;
- 否则尝试调用 valueOf(),如果返回的不是原始值,进入下一步;
- 再尝试调用 toString(),如果返回的还不是原始值,抛出 TypeError(只能出现在极少数自定义对象上)。
数组的 valueOf 返回数组本身(不是原始值),所以会走进 toString:转成元素用逗号拼接的字符串。对象的默认 toString 返回"[object Object]"。
这个流程解释了一大堆"诡异"结果:
const obj = {}; obj + '' // "[object Object]" [] + {} // "[object Object]",数组 toString 得到"",对象 toString 得到"[object Object]" {} + [] // 0,这里的 {} 被解释成了代码块而不是对象字面量其中{} + []在大多数浏览器输出 0 这个结果,实际原因是 {} 被当成语句块,然后执行+[],空数组转数字得到 0。这种"表达式看起来一致但结果完全不同"的情况,是 JS 历史包袱里最迷惑的一类。
实操建议:如果想要对象转字符串,直接显式
String(obj)或JSON.stringify(obj)。想要对象转数字,直接Number(obj),不要依赖隐式触发,因为对象的 valueOf/toString 行为在自定义类里很容易被改得面目全非。
6.3 显式转换的规范姿势
显式转换看起来简单,但有几个"坑"一定要知道。
第一,Boolean('false') 是 true。因为任何非空字符串转布尔都是 true。
const str = 'false'; if (Boolean(str)) { // 会进入这里,str 是非空字符串 }第二,Number(null) 是 0,Number(undefined) 是 NaN。这两个结果完全不同,容易疏忽。如果后端返回的可空字段没提前处理,前端 Number(row.value) 可能会意外得到 0 或 NaN,进而影响计算逻辑。
第三,parseInt 和 Number 不是一回事。parseInt('12px') 返回 12,Number('12px') 返回 NaN。parseInt('0x10') 返回 16(十六进制),Number('0x10') 也返回 16。parseInt 遇到第一个非数字字符就停下来,Number 则要求整体是合法数字。
第四,String(Symbol()) 是可以的(返回 "Symbol(desc)"),但显式拼接 Symbol 会抛错:'' + Symbol()报 TypeError。所以 Symbol 参与字符串拼接时必须走 String() 或其他显式方案。
7. 深浅拷贝实战:从原理到一行代码的深拷贝
7.1 为什么浅拷贝不够用
浅拷贝只复制最外层,嵌套对象仍然是共享引用。常见的浅拷贝方式有:
const origin = { name: 'Alice', address: { city: 'Hangzhou' } }; // 方式一:展开运算符 const copy1 = { ...origin }; // 方式二:Object.assign const copy2 = Object.assign({}, origin); // 方式三:遍历属性赋值 const copy3 = {}; for (const k in origin) { copy3[k] = origin[k]; }这三种都只复制了一层。你修改 copy1.address.city,origin.address.city 也跟着变。对于一层的简单对象,浅拷贝完全够用,而且性能远好于深拷贝。但如果对象嵌套层级可能很深,就需要深拷贝。
7.2 深拷贝的三大方案对比
方案一:JSON.parse(JSON.stringify(value))。这可能是用得最多的"一行深拷贝"。优点是实现极简单;缺点是一大堆类型直接失效,比如 undefined、Function、Symbol、RegExp、Date(Date 会被转成 ISO 字符串,不是 Date)、Map、Set、BigInt,以及循环引用(直接抛错)。
const origin = { a: 1, b: undefined, c: new Date() }; const copy = JSON.parse(JSON.stringify(origin)); console.log(copy); // { a: 1, c: "2024-01-...T...Z" }方案二:手写递归遍历。优点是你能完全控制每个类型的处理方式,缺点是需要处理的边界情况非常多(数组、Date、RegExp、Map、Set、Symbol key、循环引用、特殊对象等),写起来不短,而且要不断维护。
方案三:使用社区成熟库(比如深拷贝领域常用的 lodash.cloneDeep 这类方案)。这是我在生产环境里最推荐的方案。库作者已经把绝大多数边界情况都处理过了,一致性远好于自己随手写的递归函数。虽然要引入依赖,但稳定性带来的收益远大于包体积那点代价。
7.3 手写深拷贝的完整思路
即便用了库,理解手写实现思路也很重要,因为它能帮你建立对"对象结构"的深刻认知。下面是一个简化但相对完整的手写深拷贝:
function deepClone(value, map = new WeakMap()) { // 基础类型直接返回 if (value === null || typeof value !== 'object') { return value; } // 处理 Date if (value instanceof Date) { return new Date(value.getTime()); } // 处理 RegExp if (value instanceof RegExp) { return new RegExp(value.source, value.flags); } // 处理 Map if (value instanceof Map) { const result = new Map(); map.set(value, result); value.forEach((item, key) => { result.set(deepClone(key, map), deepClone(item, map)); }); return result; } // 处理 Set if (value instanceof Set) { const result = new Set(); map.set(value, result); value.forEach((item) => { result.add(deepClone(item, map)); }); return result; } // 循环引用检测:如果已经拷贝过该对象,直接返回之前的结果 if (map.has(value)) { return map.get(value); } // 普通对象和数组 const result = Array.isArray(value) ? [] : {}; map.set(value, result); for (const key of Object.keys(value)) { result[key] = deepClone(value[key], map); } // Symbol 类型的 key 也要拷贝 const symbolKeys = Object.getOwnPropertySymbols(value); for (const symKey of symbolKeys) { result[symKey] = deepClone(value[symKey], map); } return result; }这个版本没有处理类型化数组(TypedArray)、内置类实例等更冷门的边界,但已经覆盖了日常开发 95% 以上的场景。WeakMap 在这里是关键:它用来记录"已经被拷贝过的对象",既处理了循环引用,又不干扰垃圾回收,比用普通 Map 更合适。
实战经验:不要在生产代码里为了炫技自己写深拷贝用在核心业务路径上。我自己早年踩过一个坑,写深拷贝时没处理 Map 的 key 是对象的情况,结果线上拷贝出来的 Map 查询不到数据,排查了半天。从此以后业务代码一律用成熟方案,手写版本只出现在学习笔记和面试准备里。
8. 常见排查速查:类型相关的经典 Bug 与对策
8.1 判断数组时踩到 false 的坑
跨 iframe 取到的数组,用array instanceof Array会得到 false,因为 iframe 里的数组构造函数和主窗口的 Array 构造函数不是同一个。改用 Array.isArray 就没有这个问题。
另一个相似的场景是 NodeList、arguments 等类数组。它们不是 Array 实例,NodeList instanceof Array是 false。需要遍历时可以Array.from(nodeList)或[...nodeList]转成真数组,转完就能用 map、filter。
8.2 相等判断意外为 true
这个通常发生在从输入框、路由参数、localStorage 取数据时。因为这些来源数据一般是字符串,直接和数字用 == 比较很危险。
// 前端从 input 拿到的值 const age = '18'; if (age == 18) { // 成立 } if (age === 18) { // 不成立 }建议统一在边界处做一次显式转换,比如Number(age)并校验是否为有限数,不要在业务逻辑里散落 ==。
8.3 对象属性存在但读取 undefined
这类问题可能是属性名拼写错误、属性是 Symbol key、或者属性来自原型链而不是自身。排查顺序:先用 Object.prototype.hasOwnProperty.call 检查自身属性,再用 Reflect.ownKeys 列出所有 key(包含 Symbol),确认是不是被 Symbol 藏起来了。
8.4 JSON 序列化丢失数据
这类问题通常发生在接口上报、localStorage 存储之前。如果对象里有 undefined、函数、Symbol、BigInt、循环引用,JSON.stringify 的行为分别是不输出、不输出、不输出、直接抛异常、直接抛异常。
对策就是在序列化前做一次清洗,或者从源头保证写入对象的数据都是可序列化类型。
8.5 0.1 + 0.2 计算显示异常
这类问题的表现是金额计算后出现一串小数尾巴。正面资料里讲过多遍了,核心对策是金额用分单位整数,其他浮点计算如果不需要特别高精度,用 toFixed 做展示即可;需要精确计算时引库,不自己造轮子。
8.6 类型判断速查总表
我整理了一张日常工作高频使用的速查表,建议存下来:
| 想判断的值 | 推荐写法 | 不推荐写法 |
|---|---|---|
| null | value === null | typeof value === 'object' && !value |
| undefined | value === undefined | 不使用 typeof 做全等对比 |
| NaN | Number.isNaN(value) | isNaN(value) 或 value === NaN |
| 数组 | Array.isArray(value) | instanceof Array |
| 普通对象 | Object.prototype.toString.call(value) === '[object Object]' | typeof === 'object' |
| 函数 | typeof value === 'function' | instanceof Function |
| Symbol | typeof value === 'symbol' | value instanceof Symbol(会报错) |
| BigInt | typeof value === 'bigint' | value instanceof BigInt(会报错) |
9. 这份笔记对应的一个完整示例:写一个类型安全的工具函数
到这里,前面所有知识点可以串成一个实际可用的工具集。假设你要给团队写一个"数据规范化"的工具函数,要求从接口拿到不同类型的值时,能返回一个保证类型正确的字符串。
function normalizeToString(value) { if (value === null) { return ''; } if (value === undefined) { return ''; } if (typeof value === 'string') { return value; } if (typeof value === 'number') { if (!Number.isFinite(value)) { return ''; } return String(value); } if (typeof value === 'bigint') { return value.toString(); } if (typeof value === 'boolean' || typeof value === 'symbol') { return value.toString(); } if (Array.isArray(value)) { return value.map(normalizeToString).join(','); } if (Object.prototype.toString.call(value) === '[object Date]') { return value.toISOString(); } // 兜底情况 try { return JSON.stringify(value); } catch (e) { return ''; } }这个函数的思路:先处理基础类型的各类特殊情况,再处理数组和 Date 这两个高频引用类型,最后兜底用 JSON.stringify。BigInt 没直接放进 JSON.stringify 的原因是它会在序列化时抛异常,所以先行转成字符串。
实际调用时,输入什么类型都能得到一个稳定的字符串结果,不会再出现把对象转成 "[object Object]" 然后塞进接口的尴尬局面。这就是类型判断、类型转换、边界处理三项能力合在一起的价值。
我在实际开发里维护过类似的基础工具,最大的体会是:类型处理不是"键盘上多敲几个条件"那么简单,而是需要你真正理解每个类型的边界行为。就拿 undefined 和 null 这两个"空"来说,如果不在工具函数入口统一处理,后面每一步都要重复防御,代码会膨胀得很快。把所有类型处理的脏活累活集中到一个函数里,业务代码才能保持干净。
这份 js 数据类型笔记整理到这里,核心的思路就是一句话:先搞懂类型系统底层逻辑,再记 API 和特性,最后用实战和排查把知识焊死在脑子里。你把 typeof、instanceof、Object.prototype.toString 这三层工具搞清楚,把 null、undefined、NaN、浮点精度、深浅拷贝、隐式转换这几类高频坑都过一遍之后,基本不会再被 JavaScript 的类型问题绊住脚。后面如果有机会,我还会把运行机制、原型链、事件循环这几个主题一个个整理出来,和这份笔记串成一套完整的前端底层知识体系。