typeof null === 'object',0.1 + 0.2不等于0.3,'1' + 2得到'12'而'1' - 2得到-1。如果你看到这几行代码没有觉得哪里不对劲,那么这篇关于JS数据类型的笔记,应该能在不少场景帮上忙。
我自己整理JS数据类型笔记的时候,最初的出发点其实特别简单:前端面试几乎必问,日常代码里因为类型问题踩的坑又实在太多。比如说后端返回一个字符串类型的数字,前端直接拿来相加,结果发现是字符串拼接;又比如用==判断两个值相等,结果因为隐式转换得出完全相反的结论。这些问题的根源,都出在“对JS类型系统没有一个清晰、完整的认识”上。
这篇笔记会从8种数据类型的基本盘开始讲,然后逐个拆解typeof的坑、隐式转换的规则、深浅拷贝的实现思路,最后附上一份可以直接用的排查速查表。不管是刚入门前端的同学,还是写了两三年业务代码想回头补基础的人,读完都能有收获。如果你是奔着“临时查一下某个类型转换规则”来的,直接翻到第6章的速查表即可。
1. 先在JS的世界里认个门:8种数据类型总览
1.1 怎么理解这些类型:原始类型与引用类型
JS的数据类型在最新的ECMAScript标准里一共有8种:undefined、null、boolean、number、string、symbol、bigint,外加一种object。前7种都是原始类型,最后一种是引用类型。
原始类型和引用类型最大的区别在于存储方式。原始类型的数据直接存在栈内存里,值是“复制”的;引用类型的数据存的是地址,值是“指向”的。这么说有点抽象,看两行代码就明白:
let a = 10; let b = a; b = 20; console.log(a); // 10,a没变 let obj1 = { name: '张三' }; let obj2 = obj1; obj2.name = '李四'; console.log(obj1.name); // '李四',obj1也跟着变了这就是为什么很多刚接触JS的同学会发现“两个对象明明内容一样,为什么===是false”,因为比较引用类型时,JS比的是内存地址,不是内容。这个概念贯穿整个数据类型体系,后面讲深浅拷贝时又会遇到它。
1.2 面试必问的typeof坑,一次说清楚
typeof是最常用的类型判断运算符,但它的行为不是那么“直觉”。比如:
typeof null; // 'object' typeof undefined; // 'undefined' typeof function() {}; // 'function' typeof Symbol(); // 'symbol' typeof 10n; // 'bigint' typeof NaN; // 'number'typeof null === 'object'是JS一个著名的历史遗留bug,远古时代的实现里null的二进制标签和对象相同,后来为了兼容不能修。明白这一点之后,判断null就绝不能只用typeof,要配合Object.prototype.toString.call()或=== null来处理。
还有一个日常很容易踩的点:typeof一个未声明的变量不会报错,返回'undefined':
typeof notExist; // 'undefined'利用这个特性,可以在浏览器环境里安全地判断某个全局API是否存在,比如检查window.xxx存不存在。理解了typeof的这几个行为,才算过了第一关。
2. 七种原始类型逐个拆,搞清楚底层规则
2.1undefined和null:两个“空”有什么区别
很多初学者会把undefined和null混为一谈,但它们语义不同。undefined表示“变量声明了但还没赋值”,或者“对象的属性不存在”;null表示“变量存在,但人为让它指向一个空值”。
举个例子:
let x; console.log(x); // undefined let obj = { name: '张三' }; console.log(obj.age); // undefined,属性不存在 let y = null; // 主动置空 console.log(y); // null console.log(null == undefined); // true,注意是== console.log(null === undefined); // falsenull == undefined为true,这是隐式转换规则里专门为它们俩开的一个“后门”,后面讲==时再说细节。平时写代码,我的建议是:声明一个暂时不知道值的变量时用undefined(或者干脆不初始化),需要释放引用、重置状态的时候用null。团队协作时,如果能把这两个的语义统一,代码的可读性会有明显提升。
2.2number的精度硬伤和BigInt的救场
number类型在JS里只有一种,不区分整数和浮点数,底层都是IEEE 754双精度浮点数。这个设计带来的问题是著名的0.1 + 0.2 !== 0.3:
console.log(0.1 + 0.2); // 0.30000000000000004 console.log(0.1 + 0.2 === 0.3); // false原因在于二进制无法精确表示0.1和0.2,存储时已经产生了舍入误差,相加之后误差被放大了。解决方式不是去改底层,而是避开浮点数比较的雷区。我的习惯是:金额相关的计算,尽量用“分”为单位存整数;非要浮点运算时,用toFixed做展示,用“差值小于一个极小值”的思路来比较:
function isEqual(a, b) { return Math.abs(a - b) < 1e-10; } console.log(isEqual(0.1 + 0.2, 0.3)); // true另一个和number相关的坑是安全整数范围。JS的Number.MAX_SAFE_INTEGER是9007199254740991,超过这个范围再加减整数,结果就可能不精确了。如果业务场景涉及超大数字,比如雪花ID、订单号,直接存number就会丢精度。这时候要用BigInt:
const bigNum = 9007199254740993n; console.log(bigNum + 1n); // 9007199254740994n注意BigInt和普通number不能直接参与算术运算,会抛异常,必须显式转换。后端接口返回超大数字时,很多公司直接用字符串传输,也是为了避免精度丢失。
2.3NaN到底是个什么存在
NaN虽然名字叫 “Not a Number”,但typeof NaN的结果是'number',挺反直觉的。它表示“一个不合法、不可表示的数值”,一般出现在把非数字字符串转数字的失败场景里:
console.log(Number('abc')); // NaN console.log(parseInt('abc')); // NaN console.log(0 / 0); // NaNNaN有一个特别不友好的属性:它不等于它自己。
console.log(NaN === NaN); // false console.log(NaN == NaN); // false所以要判断一个值是不是NaN,千万别直接和NaN比较。ES6之后有了Number.isNaN,用法清晰:
console.log(Number.isNaN(NaN)); // true console.log(Number.isNaN('abc')); // false,不会做类型转换 console.log(isNaN('abc')); // true,全局isNaN会先转成number再判断Number.isNaN和全局isNaN的差别,在于前者不会对参数做隐式类型转换,后者会。这也是一个很隐蔽、容易在代码评审里被指出的点。
3.string、boolean、symbol、bigint:各有各的脾气
3.1 字符串是不可变的,拼接要讲究效率
string类型存储的是Unicode字符序列,而且它是不可变的。也就是说,任何字符串方法(toUpperCase、slice、replace)都不会修改原字符串,而是返回一个新字符串:
let str = 'hello'; let upper = str.toUpperCase(); console.log(str); // 'hello' console.log(upper); // 'HELLO'这个特性在日常写业务时影响不大,但在循环里用+拼接大量字符串时,性能问题会很明显。推荐用数组的join或者ES6的模板字符串来组织:
const parts = ['用户', '张三', '登录成功']; console.log(parts.join(' ')); // '用户 张三 登录成功'另外,很多同学容易忽略字符串的比较规则。JS字符串比较是按Unicode码点一位一位比的,比如'a' > 'B'结果是true(因为'a'的码点97大于'B'的码点66),这一点在排序场景下需要注意。
3.2boolean的隐式转换:所有类型都有“真假”
boolean类型只有两个值,true和false,但JS里所有类型的值都能转成布尔值。转换规则记起来并不难:除了明确为“假”的那几个,其他都是“真”。
console.log(Boolean(0)); // false console.log(Boolean('')); // false console.log(Boolean(null)); // false console.log(Boolean(undefined)); // false console.log(Boolean(NaN)); // false console.log(Boolean(false)); // false console.log(Boolean('0')); // true,注意字符串'0'不是空 console.log(Boolean([])); // true,空数组也是真 console.log(Boolean({})); // true,空对象也是真这也就解释了为什么有些同学写if ([]) { ... }时,代码永远会走进分支。空数组是真值,不是说数组里有元素才是真。
3.3symbol:JS里的唯一标识
symbol是ES6新增的类型,它的核心特征是“唯一”。每次调用Symbol()得到的值都不一样,哪怕是传了相同的描述:
const s1 = Symbol('desc'); const s2 = Symbol('desc'); console.log(s1 === s2); // false它的主要应用场景有三个:一是作为对象的私有属性名,防止属性名冲突;二是定义对象的迭代器方法(Symbol.iterator);三是实现一些元编程能力,比如Symbol.toStringTag可以改变Object.prototype.toString返回的标签。
Symbol还有一个特征:它不能和字符串做隐式拼接,symbol + ''会直接报错。如果要转成字符串,得用String(symbol)或者symbol.toString()。
3.4bigint:超大整数的正确打开方式
前文提到过BigInt解决大数精度问题,这里再补充一下它的边界。BigInt的创建方式是数字后面加n或者调BigInt():
const a = 123n; const b = BigInt(456); console.log(a + b); // 579n它不能和number混用运算,比如1n + 1会直接抛TypeError。比较时1n === 1是false,但1n == 1是true,因为==会做隐式转换。在涉及到对象属性键时,BigInt和普通数字一样可以安全地作为键名,不会出现类型混淆问题。
4. 引用类型与对象:复杂数据的内存与操作细节
4.1 对象、数组、函数:都逃不过“地址传递”
引用类型里面我们接触最多的就是object、array和function。它们虽然使用场景不同,但在“赋值传递的是引用”这件事上行为完全一致。
数组也是一个对象,所以:
const arr1 = [1, 2, 3]; const arr2 = arr1; arr2.push(4); console.log(arr1.length); // 4函数也同样:把函数赋值给另一个变量,两个变量指向同一个函数对象。理解了这一点,就能解释为什么深拷贝这么麻烦:如果只是简单地把对象赋值给新变量,改的是同一份数据。
4.2 判断类型不能只靠typeof:一整套完整方案
既然typeof有坑,那么判断一个值具体是什么类型,业界常用的方案是Object.prototype.toString.call():
Object.prototype.toString.call([]); // '[object Array]' Object.prototype.toString.call({}); // '[object Object]' Object.prototype.toString.call(null); // '[object Null]' Object.prototype.toString.call(undefined); // '[object Undefined]' Object.prototype.toString.call(Symbol()); // '[object Symbol]'这个方式基本覆盖所有内置类型,而且不受typeof null那个历史bug影响。很多第三方库的类型判断工具,底层就是封装了这一招。
在数组判断上,直接用Array.isArray()最靠谱。它比instanceof好在哪儿呢?instanceof的检测依赖于原型链,遇到跨iframe的场景,数组实例的原型链可能连不到主窗口的Array.prototype,结果就误判了。Array.isArray则不受这个影响。
4.3 基本包装类型:为什么字符串会有方法
用'abc'.length能取到长度,'abc'.toUpperCase()能转换大写,但你有没有想过:字符串明明是原始类型,为什么能调用方法?
JS引擎在执行这个操作时,会临时把'abc'包装成一个字符串对象,调用方法后再销毁这个临时对象。这就是“基本包装类型”的概念,类似的还有Number和Boolean。
注意new String('abc')和'abc'不一样,前者是对象,后者是原始类型:
const primitive = 'abc'; const wrapped = new String('abc'); console.log(typeof primitive); // 'string' console.log(typeof wrapped); // 'object'实际操作中没必要特意用包装类型,按原始类型写就行。除非在Object.prototype.toString判断场景里,包装后的对象会被识别为对应标签,其它业务场景基本用不上。
5. 类型转换:那些年我们踩过的隐式转换坑
5.1 显式转换怎么写才稳妥
显式转换主要记住三组函数:Number()、String()、Boolean()。
Number()的规则比较多样,最容易出错的几个:
Number(''); // 0,注意空字符串转成0 Number(' '); // 0,空白字符串也是0 Number(null); // 0 Number(undefined); // NaN Number('12px'); // NaN,带单位的转不了和parseInt、parseFloat相比,Number('12px')返回NaN,而parseInt('12px')返回12。如果你需要解析“带有单位”的字符串,比如'12px'、'3.5rem',应该用parseInt/parseFloat;如果确认字符串是纯数字格式,用Number更严格。
String()的转换基本不会出问题:
String(123); // '123' String(true); // 'true' String(null); // 'null' String(undefined); // 'undefined' String(Symbol()); // 'Symbol()'Boolean()的规则前面已经说过了,只要记住那7个假值即可。
5.2==和===到底差在哪,为什么推荐后者
==在做比较时,如果两边类型不同,会先做隐式转换,转换规则相当绕;===则要求类型和值都相等,不转换。
举几个容易翻车的例子:
0 == ''; // true,都是falsy 0 == '0'; // true,字符串转数字 0 == false; // true,布尔转数字 '' == false; // true,都转成0 null == undefined; // true,特殊规则 null == 0; // false,null不会转成0 [] == false; // true,空数组转成空字符串,空字符串转成0看到没有,==的结果常常让人意外。我在代码评审里会建议团队默认全用===,只有明确要检查null或undefined时,才用value == null这种写法(它会同时匹配null和undefined)。这个写法很常见,而且语义清晰。
5.3 对象转原始值的规则:valueOf和toString的博弈
对象在做运算或比较时,会先被转成原始值。规则是:先找valueOf,如果返回的不是原始类型,再找toString。
const obj = { valueOf: () => 42, toString: () => 'forty-two' }; console.log(obj + 1); // 43,valueOf被优先调用默认情况下,普通对象的valueOf返回对象本身,所以会继续调toString,结果是'[object Object]'。这解释了为什么{} + []的结果是'[object Object]',而[] + {}也是'[object Object]'。
数组也有自己的toString,默认行为是join:
console.log([1, 2, 3] + 1); // '1,2,31',数组先转成'1,2,3',然后再拼接理解了这个博弈过程,再看隐式转换题就不会慌。
6. 深浅拷贝和不可变数据处理
6.1 浅拷贝有哪些现成手段
上面提到引用类型赋值传的是地址,所以希望“复制一份互不影响的新数据”时,就要做拷贝。浅拷贝只复制第一层,第二层及以下的引用关系仍然保留。
常用手段有三个:
// 方式一:展开运算符 const copy1 = { ...original }; // 方式二:Object.assign const copy2 = Object.assign({}, original); // 方式三:数组用slice或concat const arrCopy = originalArr.slice();浅拷贝最大的坑在于:如果原始对象有嵌套对象,修改拷贝后的嵌套对象,原始对象也会变。这在处理复杂表单数据时尤其容易踩。
6.2 深拷贝的正确打开方式
深拷贝要保证所有层级的对象都被递归复制。最简单的方案是JSON.parse(JSON.stringify(obj)),但它有局限性:
const obj = { name: '张三', age: undefined, sayHi: function() {}, birth: new Date('2020-01-01'), reg: /abc/g, [Symbol('id')]: 1, }; const copy = JSON.parse(JSON.stringify(obj)); // age、sayHi、Symbol都会丢失;birth变成字符串;reg变成空对象{}它还会在循环引用时直接抛异常:
const obj = { name: '张三' }; obj.self = obj; JSON.parse(JSON.stringify(obj)); // TypeError业务上如果数据结构简单、没有undefined/函数/循环引用,用JSON方案省事;如果数据复杂,可以用浏览器的原生structuredClone:
const copy = structuredClone(obj);它能处理循环引用、Date、Map、Set等内置类型,是目前浏览器环境里最省心的深拷贝方案。Node.js 环境也可以直接用。手写深拷贝可以作为面试题去研究,但在工程里优先用现成可靠的方案。
7. 实战排查:JS数据类型相关问题的定位思路
7.1 高频疑问与解决方案速查
下面这张表是我平时代码评审和排查问题时的“查错字典”,遇到类型相关的bug,先对照看一遍:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
0.1 + 0.2结果不对 | 浮点数存储误差 | 将金额换算成整数计算;比较时用差值容错 |
| 超大数字尾数全是0 | 超出安全整数范围 | 改用BigInt或后端返回字符串 |
typeof null返回object | 历史遗留bug | 用value === null判断 |
NaN !== NaN判断失效 | NaN不等于自身 | 用Number.isNaN(value) |
{}和[]比较/相加结果诡异 | 隐式转换调用toString | 避免用==,明确转成字符串 |
| 修改新对象导致原对象变化 | 只做了浅拷贝 | 根据数据结构选深拷贝方案 |
| 后端数字字段变成字符串拼接 | 类型未转换就做+ | 用Number()显式转换,或先检查类型 |
[1,2,3] + 1得到'1,2,31' | 数组先转字符串再拼接 | 利用join显式控制字符串格式 |
| 某全局API在低版本浏览器不存在 | typeof判断误写 | 用typeof window.xxx !== 'undefined'保护 |
7.2 几个新手的常见误区,我也都踩过
误区一:认为Number()能解析带单位的字符串。实际上Number('12px')直接返回NaN,想要提取数字就得用parseFloat。我曾经在处理样式相关的逻辑时没意识到这点,结果一整段操作都失效了,排查半天才发现是转换函数选错了。
误区二:用isNaN判断一个变量是否合法数字。isNaN会对参数隐式转数字,导致isNaN('123')返回false,看起来没问题,但isNaN('abc')返回true,你根本分不清是“变量本身是NaN”还是“变量转number之后是NaN”。需要严格判断时,一定用Number.isNaN。
误区三:认为空数组和空对象是假值。它们是真值,if ([])、if ({})都会进入分支。如果业务意图是“数组为空时不执行”,必须显式判断arr.length === 0,不能依赖真值判断。
误区四:在console.log里查看对象时,误以为打印的是那一刻的快照。控制台打印对象时,展示的是展开那一刻的属性值。如果代码在打印后修改了对象属性,再展开打印结果,看到的往往是修改后的值。调试时可以先console.log(JSON.parse(JSON.stringify(obj)))转成字符串打印,避免疑惑,或者直接打断点看实时的属性状态。
8. 整理笔记过程中最有价值的两个习惯
8.1 给每个“反直觉”的API配一个“最稳用法”
我在整理笔记时,给每个坑都记了一行“实战建议”。比如:
- 判断
null:value === null - 判断
NaN:Number.isNaN(value) - 判断数组:
Array.isArray(value) - 通用类型字符串:
Object.prototype.toString.call(value)
这样做的价值在于,笔记不是知识点的堆砌,而是一份“决策手册”。遇到具体场景,直接查最稳的写法,不需要每次都在脑子里面过一遍转换规则。
8.2 写一个简单的getType工具函数,一劳永逸
如果你经常要做类型判断,可以把Object.prototype.toString封装一下,做成一个通用的类型获取工具:
function getType(value) { const tag = Object.prototype.toString.call(value); return tag.slice(8, -1).toLowerCase(); } console.log(getType([])); // 'array' console.log(getType({})); // 'object' console.log(getType(null)); // 'null' console.log(getType(undefined)); // 'undefined' console.log(getType('abc')); // 'string' console.log(getType(10n)); // 'bigint' console.log(getType(Symbol())); // 'symbol'这个函数能规避typeof的所有坑,尤其适合在公共工具库里作为底层基础设施。我自己维护的项目里一直保留着类似实现,后续处理复杂类型时省了很多事。
最后再分享一个小技巧:整理任何语言的数据类型笔记,都别只抄文档,最好把“这个类型的坑在哪、实际项目里遇到什么场景要特别小心”给写进去。JS数据类型的知识点不算多,但每个点都能引出至少一个线上事故。把这些经验和代码绑在一起,笔记才真正有价值。以后遇到类型相关的问题,翻翻自己的笔记,往往比重新搜索来得更快更准。