从
0.1+0.2 !== 0.3到null不是对象,那些面试官爱问但你似懂非懂的底层细节
开场:一个被面试题暴击的下午
想象一下,你坐在面试官对面,自信满满。面试官问:"JavaScript 有几种数据类型?"你脱口而出:"六种!"面试官微微一笑:"那是 ES6 以前。"接着他问:"0.1 + 0.2 等于多少?"你心想这不是侮辱我吗,脱口而出:"0.3。"面试官又笑了。那一刻你意识到,JS 的基础类型看似平平无奇,实则处处是坑。今天我们就把这八种类型的老底掀开,看看它们到底在内存里捣什么鬼。
第一幕:八张身份证——JS 到底认识几种类型?
ECMA-262 规范就像 JS 的户口本,上面白纸黑字写着:八种。ES6 以前只有六种,后来新增了两个"刺头"——Symbol 和 BigInt。具体名单如下:
**原始类型(Primitive Types)**就像是复印机印出来的独立文件:
Number:数值Boolean:布尔值String:字符串null:空引用undefined:未初始化Symbol:唯一标识(ES6 新增)BigInt:大整数(ES6 新增)
**复杂类型(Complex Type)**就像是共享文档:
Object:对象、数组、函数都归它管
但这里埋着 JS 历史上最著名的 Bug:
console.log(typeof null); // "object"
这不是设计,这是遗产。当年 JS 引擎把 null 的标记位做成了和对象一样的 000,结果一传就是二十多年,修不了了。知道有八种只是开始,可怕的是它们住在内存的不同楼层——有的住快捷酒店(栈),有的住大别墅(堆)。
第二幕:复印机 vs 共享文档——原始类型与引用类型的内存分家
你复印了一份文件,在复印件上涂鸦,原件毫发无伤。但如果你打开一个共享文档改一个字,所有拿到链接的人,屏幕上的内容都会同步变。这就是原始类型和引用类型最核心的区别。
看看下面这段代码:
let a = null;
let b = a;
b = 2;
console.log(a, b); // null, 2
a 和 b 各自在栈内存里有一块独立的小格子。b = 2 只是把 b 格子里原来的 null 擦掉,写上 2,a 那边完全不受影响。这就是拷贝式赋值——像复印机,各玩各的。
但对象就完全是另一回事了:
let obj1 = { name: "谢鲁立" };
let obj2 = obj1;
obj2.company = "快手";
console.log(obj1); // { name: "谢鲁立", company: "快手" }
为什么改了 obj2,obj1 也跟着变了?因为它们在栈里存的根本不是对象本身,而是对象在堆内存里的地址。
┌─────────────────────────────────────────────────────────────┐
│ 栈 内 存(Stack) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 变量 a │ null │
│ 变量 b │ 2 ← 修改 b 只是给 b 换了张新便签 │
│ │
│ 变量 obj1 │ 0x1001 ───────────────────┐ │
│ 变量 obj2 │ 0x1001 ───────────────────┤ │
│ │ │
└───────────────────────────────────────────┼─────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 堆 内 存(Heap) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 地址 0x1001 │ { name: "谢鲁立", company: "快手" } │
│ │
└─────────────────────────────────────────────────────────────┘
函数执行时,JS 引擎会把执行上下文推入调用栈。栈这玩意儿的特点是快、稳定、空间小,而且函数执行完直接出栈,指针偏移一下就能切换上下文,非常适合存固定大小的原始类型和对象地址。
代码开始执行
│
▼
┌─────────────────┐
│ 全局执行上下文 │ ──→ 压入调用栈
│ (Global EC) │
└─────────────────┘
│
▼
┌─────────────────┐
│ 调用栈(Stack) │
├─────────────────┤
│ 全局上下文 │ ◀── 栈底
│ ───────────── │
│ 变量环境 │ a = null
│ 词法环境 │ obj1 = 0x1001
└─────────────────┘
│
▼
函数执行完毕
│
▼
出栈(Pop)──→ 指针偏移,上下文快速切换
所以当你写下面这行代码时,不要觉得自己在强迫症发作:
let largeObject = {
data: new Array(100000000).fill("hgh")
};
largeObject = null; // 手动断开引用,告诉垃圾回收器:这仓库我不要了
你这是在手动给内存松绑。
第三幕:null 是对象?undefined 是未定义?——一对卧底的真假身份
前面说了 typeof null === 'object' 是个官方 Bug,那 null 和 undefined 到底有啥区别?一句话:null 是"我故意把这里腾空",undefined 是"这东西还没出生"。
undefined 在代码里有四种经典出场方式:
let a;
console.log(a); // undefined —— 声明了变量,但没赋值
let obj = {};
console.log(obj.property); // undefined —— 访问不存在的属性
function noReturn() {}
noReturn(); // undefined —— 函数没有 return
let arr = [1, 2, 3];
console.log(arr[5]); // undefined —— 访问越界的数组索引
换句话说,undefined 是 JS 引擎发给你的"空白支票"——表示"此处应该有值,但我还没想好放什么"。而 null 是你自己签的"腾空确认书"——表示"这里原来有东西,现在被我清空了"。
变量声明
│
▼
┌──────────────┐
│ undefined │ ← "这东西存在,但我还没想好放什么"
└──────────────┘
│
▼
显式赋值
│
┌───┴───┐
▼ ▼
┌──────┐ ┌──────┐
│ 值 │ │ null │ ← "我故意把这里腾空,为了释放内存"
└──────┘ └──────┘
面试官最爱问的送命题来了:null == undefined 是 true,但 null === undefined 是 false。因为双等号会做类型转换,而三等号不会。这哥俩就像一对双胞胎,长得像,但户口本不一样。
第四幕:当数字背叛了你——0.1+0.2 与 BigInt 的救世
小学数学老师告诉我 0.1 + 0.2 = 0.3,但 JS 给我的答案是:
let a = 0.1;
let b = 0.2;
console.log(a + b); // 0.30000000000000004
为什么?因为 JS 统一使用 IEEE 754 双精度浮点数来存储所有数字。0.1 转成二进制是 0.0001100110011...,一个无限循环小数。64 位的存储空间装不下无限循环,只能截断,截断就丢了精度,丢了精度再转回十进制,就给你整出了这么个小尾巴。
十进制 0.1
│
▼
二进制转换
│
▼
┌──────────────────────┐
│ 0.0001100110011... │ ← 无限循环,存不下
│ (无限循环小数) │
└──────────────────────┘
│
▼
截断存储(64 位)
│
▼
精度丢失
│
▼
0.1 + 0.2 ≠ 0.3
如果你要算钱,千万别用原生 Number 直接加减,要么转成整数算,要么用专门的库。但要是你非要处理一个连科学计数法都装不下的巨数,ES6 给你准备了 BigInt:
let num1 = 999999999999999999999999999999999999999999999999999999999999999n;
let num2 = 123456789098765433467324577654789008733233456899003466788924243n;
console.log(num1 + num2, typeof num1); // "bigint"
console.log(num1 + 1n); // BigInt 只能和 BigInt 运算
注意末尾那个 n,它就是 BigInt 的身份证。BigInt 和 Number 不能混算,强行混用会直接报错。BigInt 的出现不是为了替代 Number,而是为了填补那个"大到连 Infinity 都嫌小"的空白地带。
第五幕:Symbol——给属性上把无法复制的锁
假设你写了一个工具库,想偷偷给用户的对象塞一个内部标记,但又怕用户不小心用同名属性把你覆盖了。怎么办?Symbol 就是来解决这种" naming conflict "焦虑的。
console.log(Symbol('张志恒') === Symbol('张志恒')); // false
console.log(typeof Symbol('张志恒')); // "symbol"
即使标签一模一样,每次调用 Symbol() 都会生成一个绝对唯一的标识符。
let obj = {
[Symbol()]: 'value',
prop: "2"
};
Symbol('A')
│
▼
┌────────┐ ┌────────┐
│ UUID 1 │ ≠ │ UUID 2 │ ← 即使标签相同,内核也不同
└────────┘ └────────┘
│ │
▼ ▼
Symbol('A') Symbol('A')
更妙的是,Symbol 属性默认不会被 for...in、Object.keys()、JSON.stringify() 遍历到。你可以把它当作"半私有属性"来用——不是真私有,但足够低调,不轻易被外界打扰。如果你需要全局共享同一个 Symbol,可以用 Symbol.for('key') 在全局注册表里查找或创建,但大部分场景下,匿名 Symbol() 就是你最安全的命名空间隔离器。
结尾:类型是语法,内存才是真相
很多教程只教你怎么用 typeof,却不告诉你变量在内存里到底长什么样。但懂了栈和堆,你就突然明白为什么数据会"意外变化";懂了 null 和 undefined 的分工,你就不会在清空变量时手抖;懂了 0.1 + 0.2 的委屈,你就不会再被面试官的微笑吓到。
JS 的八种类型,表面上是一套语法规则,骨子里是一场关于内存地址的博弈。下次有人再问你 JS 有几种类型,你可以淡定地说——八种,外加一个永远的 Bug。