前言
写了这么多年 JavaScript,回头再看,我发现绝大多数"莫名其妙"的 bug,根源都在数据类型这一层。0.1 + 0.2 !== 0.3、typeof null === 'object'、[] == ![] 居然是 true、两个长得一模一样的对象比较却不相等......这些行为单看每一个都像是语言的设计失误,但当你真正翻开 ECMA-262 规范,把转换算法一条一条捋下来之后会发现:它们全都是机械规则的必然产物,没有一处是随机的。
这篇文章我做了一次彻底的梳理。从七大原始类型的底层表示讲起,覆盖引用类型的完整家族、内存模型、六种类型检测手段的适用边界、规范级的类型转换算法链条,最后落到 JSON 序列化、深拷贝、空值判断这些每天都要面对的工程问题上。篇幅很长,但每一节都对应实际开发中真实会踩的坑,建议收藏后慢慢消化。
一、类型系统总览:两条主线
按照 ECMA-262 规范的划分,JavaScript 中的值(value)分为两大类:
原始类型(Primitive Type) ,共 7 种:Number、String、Boolean、Null、Undefined、Symbol、BigInt。它们不是对象,没有方法,不可变,按值操作。
引用类型(Reference Type),也就是对象(Object)。规范的原文定义非常朴素------"An Object is a collection of properties"(对象是属性的集合)。数组、函数、日期、正则、Map、Set、Error,本质上全是对象,只是各自带有不同的内部属性(internal slots)和行为特征。
要真正理解这套划分,得先搞清楚两类值在内存中的存放方式,这是后面所有行为差异的总根源。
1.1 栈与堆:一切差异的起点
JS 引擎(以 V8 为例)管理变量时会区分两种存储位置:
- 原始类型的值直接存放在栈内存中。变量名对应栈里的一块空间,值就在那里,读写都是直接操作。
- 引用类型的实际数据存放在堆内存中 。栈里存的不是对象本身,而是一个指向堆地址的引用(指针)。
为什么这样设计?因为原始值大小固定、体积小巧(一个 Number 就是 64 位),适合放栈上快速存取;对象大小不定、可能非常庞大、生命周期也难以预测,放堆上由垃圾回收器统一管理更合理。
这个存储模型直接解释了两类最经典的行为差异:
js
// 原始类型:赋值即拷贝值
let a = 10;
let b = a;
b = 20;
console.log(a); // 10,互不影响
// 引用类型:赋值即拷贝地址
let obj1 = { name: 'Tom' };
let obj2 = obj1;
obj2.name = 'Jerry';
console.log(obj1.name); // 'Jerry' ------ 两个变量指向同一块堆内存
对象比较也是同理,比的是地址而非内容:
js
{} === {}; // false,两个独立的堆地址
[1, 2] === [1, 2]; // false,内容相同但地址不同
let x = {}, y = x;
x === y; // true,同一个地址
1.2 澄清一个流传甚广的说法:"对象是按引用传递的"
严格来说,JavaScript 中所有函数参数传递都是按值传递(call by value)。只不过对象传递的"值"是地址的副本。这个区别在下面的例子里体现得淋漓尽致:
js
function change(obj) {
obj.name = 'Jerry'; // ① 修改属性:影响外部
obj = { name: 'Bob' }; // ② 重新赋值:不影响外部
}
let person = { name: 'Tom' };
change(person);
console.log(person.name); // 'Jerry',而不是 'Bob'
如果是真正的"引用传递"(像 C++ 的引用参数),第 ② 步重新赋值应该会让外部的 person 也指向新对象。但实际不会------因为 obj 只是拿到了地址的一份拷贝,重新赋值改变的只是这份拷贝的指向,外部变量手里的原地址纹丝不动。这种模式在学术上有个专门的名字叫 call by sharing(共享传递)。
理解了这一点,你就明白了为什么在函数里能改对象属性(操作同一块堆内存),却不能通过给参数赋新值来"替换"外部的对象。
二、七大原始类型逐个拆解
2.1 Number:双精度浮点数的甜蜜与陷阱
JavaScript 没有整数类型。所有数字------无论写成 42 还是 3.14------统一采用 IEEE 754 双精度 64 位浮点格式存储。这 64 位的分配是:1 位符号位、11 位指数位、52 位尾数位。
精度丢失:不是 bug,是二进制浮点的宿命
js
0.1 + 0.2 === 0.3; // false
0.1 + 0.2; // 0.30000000000000004
原因很简单:0.1 和 0.2 换成二进制都是无限循环小数(就像十进制里的 1/3 = 0.333...),存进 52 位尾数时必然被截断,两个"已经不准的数"相加,结果自然不等于精确的 0.3。这不是 JS 独有的问题------Java、Python、C#、Go,凡是用 IEEE 754 双精度浮点的语言全都一样。
工程上的应对方式按场景分三种:
js
// 方式一:科学计算等允许微小误差的场景,用误差范围比较
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true
// 方式二:金额计算,永远用最小货币单位(整数)
// 19.99 元存成 1999 分,展示时再除以 100
const priceInCents = 1999;
// 方式三:需要任意精度小数时,上库
// decimal.js / big.js / dinero.js(专做货币)
Number.EPSILON 是 1 与大于 1 的最小浮点数之间的差,约等于 2.22e-16,它是浮点数体系里"最小可感知精度"的度量衡,浮点比较的标准做法就是拿差值跟它比。
安全整数范围:大数悄悄失真的隐患
由于尾数只有 52 位(加隐含位共 53 位有效),JS 能精确表示的整数上限是 2⁵³ − 1:
js
Number.MAX_SAFE_INTEGER; // 9007199254740991
Number.MIN_SAFE_INTEGER; // -9007199254740991
Number.MAX_VALUE; // 约 1.798e+308 ------ 这是"不溢出"的上限,不是"精确"的上限!
// 超出安全范围后,精度丢失是无声无息的:
9007199254740992 === 9007199254740993; // true!
注意区分 MAX_SAFE_INTEGER 和 MAX_VALUE:前者是"每个整数都精确"的边界,后者是"不变成 Infinity"的边界,两者差了天文数字级别。
这个坑在生产环境中最常见的触发场景是后端返回的 64 位长整型 ID (比如雪花算法生成的 19 位数字)。前端 JSON.parse 的一瞬间,末几位就已经悄悄变了,而且不会有任何报错。防坑方案:
- 让后端把长整型 ID 以字符串形式返回(最稳妥,业界主流做法);
- 前端用
json-bigint这类支持 BigInt 的 JSON 解析库; - 需要运算时用 BigInt 接住。
三个特殊值
NaN(Not a Number) :数学上无效的运算结果。它最著名的特性是不等于自身------这是 IEEE 754 明文规定的,专门用来标识"无效值":
js
NaN === NaN; // false
NaN !== NaN; // true
// 因此判断 NaN 必须用专用方法,且两个方法行为不同:
isNaN('abc'); // true ------ 先做 Number() 转换再判断,'abc' → NaN
isNaN('123'); // false ------ '123' → 123,不是 NaN
Number.isNaN('abc'); // false ✅ 不做任何转换,参数必须本身就是 NaN
Number.isNaN(NaN); // true ✅
全局 isNaN 的"先转换再判断"让它名不副实------它对 'abc'、undefined、{} 都返回 true,但这些值根本不是 NaN。永远用 Number.isNaN 。老代码里还有一种判断技巧 x !== x(只有 NaN 不等于自身),能用,但可读性差,不推荐新代码使用。
Infinity 与 -Infinity:数值溢出或除以零的产物。JS 对除以零的态度很特别------不抛异常,直接给你无穷大:
js
1 / 0; // Infinity
-1 / 0; // -Infinity
Infinity + 1; // Infinity
Infinity * 0; // NaN ------ 数学上未定义的运算
Infinity > Number.MAX_VALUE; // true
−0(负零):IEEE 754 保留了正负两个零。绝大多数场景它们无法区分,但有两处会露出马脚:
js
0 === -0; // true
Object.is(0, -0); // false ------ Object.is 能分辨
1 / 0; // Infinity
1 / -0; // -Infinity ------ 倒数运算暴露了符号
−0 通常由数学运算产生,比如 Math.round(-0.2)、-1 * 0。除非你在写数学库、物理引擎或图形代码,日常开发基本感知不到它,但要知道它存在------Object.is 与 === 的两处行为差异之一就是它(另一处是 NaN)。
parseInt 与 parseFloat:宽容解析
与 Number() 的"整体严格解析"不同,parseInt 从字符串头部逐字符解析,遇到非法字符就停下返回已解析部分:
js
Number('12px'); // NaN ------ 整串必须合法
parseInt('12px'); // 12 ------ 解析到 'p' 停下
parseInt('px12'); // NaN ------ 第一个字符就非法
parseInt(' 8 '); // 8 ------ 忽略前导空白
parseFloat('3.14abc'); // 3.14
parseInt 有一条必须刻进肌肉记忆的规则:永远显式传第二个参数(基数):
js
parseInt('010'); // 10 ------ 现代引擎默认十进制
parseInt('0x1f'); // 31 ------ 但仍会识别十六进制前缀!
parseInt('010', 10); // 10 ✅ 显式声明,杜绝一切歧义
ES5 之前的老浏览器会把 '010' 当八进制解析成 8,这个历史包袱虽然已经过去,但 0x 前缀识别等行为依然存在,显式传基数是唯一万无一失的做法。
2.2 String:不可变的 UTF-16 序列
字符串是 UTF-16 编码的字符序列,最核心的特性是不可变性(immutable):字符串一旦创建,其中任何一个字符都无法被修改,所有"修改类"方法返回的都是新字符串。
js
let str = 'hello';
str[0] = 'H'; // 静默失败,不报错也不生效
str.toUpperCase(); // 返回新串 'HELLO',str 本身没变
str = 'world'; // 唯一改变 str 的方式:整体重新赋值
不可变性带来两个工程含义:一是字符串可以安全共享(引擎内部会对字面量做驻留优化);二是在循环里大量拼接字符串时,每次拼接都产生新对象,性能敏感场景应该先收集到数组再 join------不过现代引擎的 ropes 优化已经让这个问题不那么尖锐了。
UTF-16 与代理对:length 骗了你的那些年
UTF-16 的基本单位是 16 位(2 字节)的码元(code unit) ,但 Unicode 字符集早已超过 65536 个字符。码点大于 U+FFFF 的字符------绝大多数 emoji、大量生僻汉字、数学符号------在 UTF-16 中要用两个码元(称为代理对,surrogate pair)来表示。JS 字符串的很多老 API 是按码元工作的,于是出现了这些怪现象:
js
'😀'.length; // 2!emoji 占了两个码元
'😀a'.slice(0, 1); // 半个乱码字符
// ES6 提供了按码点工作的现代 API:
[...'😀a'].length; // 2 ✅ 迭代器按码点切分
'😀'.codePointAt(0); // 128512 ✅ 完整码点
'😀'.charCodeAt(0); // 55357 ❌ 只是前半个代理单元
String.fromCodePoint(128512); // '😀' ✅
// 正则同理,加 u 标志启用 Unicode 模式:
/^.$/.test('😀'); // false
/^.$/u.test('😀'); // true ✅
处理用户昵称、评论内容等可能含 emoji 的字符串时,截断逻辑必须用 [...str] 或 Intl.Segmenter(按用户感知字符切分,能正确处理组合 emoji 如 👨👩👧),直接 slice 按长度截断很可能把一个 emoji 切成两半乱码。
模板字符串与标签模板
ES6 的模板字符串(反引号)支持多行、插值和转义,已是现代 JS 的默认选择:
js
const name = 'World';
const s = `Hello, ${name}!
第二行,表达式插值:${1 + 2},对象插值:${JSON.stringify({a: 1})}`;
更深一层的机制是标签模板(Tagged Template)------在模板前放一个函数,函数会收到拆分后的字符串片段和插值:
js
function highlight(strings, ...values) {
return strings.reduce((acc, str, i) =>
acc + str + (i < values.length ? `<mark>${values[i]}</mark>` : ''), '');
}
highlight`用户 ${'Tom'} 买了 ${3} 件商品`;
// '用户 <mark>Tom</mark> 买了 <mark>3</mark> 件商品'
字符串片段数组还挂着一个 raw 属性保存未转义的原文。标签模板是很多知名库的底层机制:styled-components 的 CSS-in-JS、graphql-tag 的查询构造、sql-template-strings 的防注入查询,全都构建在这个语法之上。
2.3 Boolean:两个值,八条假值规则
Boolean 类型本身只有 true 和 false 两个值,真正重要的是**真值(truthy)/ 假值(falsy)**体系------它决定了 if、while、!、&&、||、三元运算符的行为。
假值是封闭集合,有且仅有 8 个:
js
false, 0, -0, 0n, '', null, undefined, NaN
其余一切都是真值,包括这些极易误判的:
js
Boolean([]); // true!空数组是真值
Boolean({}); // true!空对象是真值
Boolean('0'); // true!非空字符串(哪怕内容是 0)
Boolean('false'); // true!
Boolean(' '); // true!空格串非空
Boolean(new Boolean(false)); // true!包装对象是对象,对象是真值
最后一条尤其阴险:new Boolean(false) 创建的是一个"内部值为 false 的对象",而对象永远是 truthy。这也是所有风格指南都禁止用 new 调包装构造函数的原因之一。
顺带澄清一个高频困惑:[] == false 为 true,但 if ([]) 会执行------两者并不矛盾。前者走的是宽松相等算法(两边都转成数字 0),后者走的是 ToBoolean(空数组是真值)。"等于 false"和"是假值"是两个不同的概念。
逻辑运算符的短路与返回值
&& 和 || 返回的不是布尔值,而是参与运算的原始值之一,这个特性在实战中大量使用:
js
// || 返回第一个真值,全是假值则返回最后一个
const name = input || 'anonymous'; // 经典的默认值写法
null || undefined || 0 || 'fallback'; // 'fallback'
// && 返回第一个假值,全是真值则返回最后一个
user && user.profile && user.profile.age; // 安全访问链(ES6 时代的写法)
'a' && 'b'; // 'b'
0 && anything; // 0,短路,后面根本不执行
// ES2020 的现代替代品:
const city = user?.address?.city; // 可选链:任一环节为 null/undefined 即短路
const port = config.port ?? 8080; // 空值合并:只在 null/undefined 时取默认值
config.timeout ??= 3000; // 逻辑空赋值
?? 与 || 的选择是重点:如果 0、''、false 在业务上是合法值(超时时间、输入框内容、开关默认态),必须用 ?? 。用 || 的话,用户把超时设为 0 会被静默替换成默认值,这类 bug 非常隐蔽。
另外注意语法限制:?? 不能与 ||/&& 直接混写(a || b ?? c 是 SyntaxError),必须加括号明确优先级------这是规范有意为之,防止开发者误判结合顺序。
2.4 Undefined:系统层面的"缺失"
undefined 表示"没有值",且这种"没有"通常是系统性的、非开发者主动为之的。以下场景产生 undefined:
js
let a; // 声明未赋值
function f() {}
f(); // 无 return 或 return 后无值
const obj = {};
obj.nonexistent; // 访问不存在的属性
function g(x) {}
g(); // 实参缺位,形参为 undefined
void 0; // void 运算符对任何表达式都返回 undefined
ES2020 的可选链让"可能不存在"的链式访问变得安全:
js
// 老写法:层层防御
const city = user && user.address && user.address.city;
// 新写法:
const city = user?.address?.city; // 任一环为 null/undefined 直接短路返回 undefined
const first = arr?.[0]; // 索引访问形式
const result = obj.method?.(); // 方法调用形式:方法不存在则不抛错,返回 undefined
可选链短路的是整条链 ------a?.b.c.d 中若 a 为 null,后面的 .c.d 全部不再求值,不会抛 TypeError。
一个冷知识:undefined 作为标识符在全局作用域中是只读的,但在局部作用域里可以被遮蔽(shadowing),因为它本质上只是一个普通的全局属性而非关键字:
js
(function() {
let undefined = 'oops'; // 合法!局部变量遮蔽了全局 undefined
console.log(undefined); // 'oops'
})();
这就是老代码中 void 0 和 (function(window, undefined) {...})(window) 这些写法的由来------它们在防御 undefined 被篡改。现代严格模式代码和 ES Module 中这个风险几乎不存在(IIFE 参数遮蔽那套是 ES5 时代的产物),读遗留代码时认识即可。
2.5 Null:开发者主动表达的"空"
null 表示一个有意的空值------语义上是"这里本应有一个对象,但现在没有"。它和 undefined 的分工是社区约定俗成的:
- undefined:系统层面的缺失------没赋值、没传参、没返回、属性不存在。
- null:开发者或 API 主动表达的空------查无此记录、显式清空引用、"空对象"占位。
js
const user = db.findUser(id); // 库的惯例:找不到返回 null 而非 undefined
// 这样能区分"没查到"(null)和"调用出错了"(undefined)
let cache = loadHugeData();
cache = null; // 主动断开引用。现代引擎中通常没必要手动做,
// 但长生命周期的闭包/全局变量持有大对象时仍有意义
typeof null === 'object':一段被写进规范的事故
这个 bug 的来龙去脉值得讲清楚。JS 最初实现时,值在底层由"类型标签 + 数据"构成,对象的类型标签是 000。而 null 在底层表示为全零地址(C 语言的 NULL 指针),它的类型标签位恰好也被读成了 000,于是 typeof 把它误判为对象。
这个 bug 在语言诞生第一年就被发现了。2013 年曾有一份正式的修复提案(typeof null → 'null'),但因为互联网上已有海量代码依赖现有行为(比如用 typeof x === 'object' 做对象判断的逻辑会被破坏),提案最终被否决,这个误判被明文写进 ECMA-262 规范成为永久行为。
实践中判断 null 的正确方式:
js
x === null; // 直接严格比较 ✅
typeof x === 'object' && x !== null; // 判断"真正的对象"(排除 null)
Object.prototype.toString.call(null); // '[object Null]' ✅
还要记住宽松相等中的一个特例:null 只与 undefined 宽松相等,不与任何其他值相等:
js
null == undefined; // true ------ 规范明文规定的一对
null === undefined; // false ------ 类型不同
null == 0; // false!很多人以为 true,实际上 null 不参与数值转换
null == false; // false!
2.6 Symbol:绝对唯一的标识符
ES6 引入 Symbol 的动机非常纯粹:提供一种全局唯一 的值,主要用作对象属性键,从根本上杜绝属性名冲突。典型场景是框架/库需要在用户的对象上挂内部状态,又不想污染用户可见的属性、不想被 JSON.stringify 或 for...in 暴露出来。
js
const s1 = Symbol('desc');
const s2 = Symbol('desc');
s1 === s2; // false!描述字符串只是调试用的标签,不影响唯一性
const KEY = Symbol('internal');
const obj = {
[KEY]: 'secret', // 计算属性名语法挂 Symbol 键
visible: 'normal',
};
Object.keys(obj); // ['visible'] ------ Symbol 键对常规遍历隐身
JSON.stringify(obj); // '{"visible":"normal"}' ------ 序列化也无视它
for (const k in obj) {} // 同样枚举不到
// 但注意:Symbol 键不是真私有,专用 API 能拿到
Object.getOwnPropertySymbols(obj); // [Symbol(internal)]
Reflect.ownKeys(obj); // 字符串键 + Symbol 键全量
所以 Symbol 键的准确定位是"默认隐藏 "而非"加密私有"。真正的私有字段是 ES2022 的 # 语法,那是另一套机制。
Symbol.for 与全局符号注册表
普通的 Symbol() 每次都创建全新值,跨模块无法共享。如果需要一个进程内全局唯一的 Symbol(比如多个 bundle 要约定同一个键),用注册表 API:
js
Symbol.for('app.token') === Symbol.for('app.token'); // true ------ 注册表缓存
Symbol.for('x') === Symbol('x'); // false ------ 注册表 vs 独立创建
Symbol.keyFor(Symbol.for('app.token')); // 'app.token' ------ 反查描述
知名 Symbol:介入语言内部行为的钩子
规范定义了一批 Well-Known Symbols,它们是 JS 元编程的核心接口------自定义对象实现这些 Symbol 方法,就能改变语言内置操作对自己的行为:
js
const special = {
// 控制所有类型转换(优先级高于 valueOf/toString)
[Symbol.toPrimitive](hint) {
if (hint === 'number') return 42;
if (hint === 'string') return '四十二';
return 'default';
},
// 控制 Object.prototype.toString 的输出
[Symbol.toStringTag]: 'MyType',
// 让对象可被 for...of / 扩展运算符遍历
*[Symbol.iterator]() { yield 1; yield 2; },
};
+special; // 42
`${special}`; // '四十二'
[...special]; // [1, 2]
Object.prototype.toString.call(special); // '[object MyType]'
其他常用的还有 Symbol.hasInstance(自定义 instanceof)、Symbol.replace/match/split/search(自定义字符串方法行为,字符串的 replace 内部其实是调用正则对象上的 Symbol.replace)、Symbol.species(控制派生构造器)。日常业务代码很少直接定义它们,但阅读框架源码时随处可见------Vue 3 的响应式系统用 Symbol 做内部标记(__v_isRef 早期版本)、Redux 的 action 类型、各种库的"防误用"哨兵值,都是 Symbol 的典型应用。
2.7 BigInt:任意精度整数
ES2020 加入 BigInt,解决 Number 无法精确表示大整数的问题(尤其是前面说的后端 64 位 ID 场景)。
js
const a = 9007199254740993n; // 字面量,n 结尾
const b = BigInt('9007199254740993'); // 构造函数,推荐传字符串
const c = BigInt(9007199254740993); // ⚠️ 传 Number 时精度在传入前已丢失!得到 ...992n
第三种写法的教训:Number 字面量在源码解析阶段就已经失真了,BigInt 无力回天。超过安全范围的整数一律以字符串形式进入 BigInt。
BigInt 的使用限制必须烂熟于心:
js
1n + 2; // TypeError!BigInt 与 Number 严禁混合运算
1n + BigInt(2); // 3n ✅ 显式转换
Number(1n) + 2; // 3 ✅ 反方向显式转换
BigInt(1.5); // RangeError!不能带小数
// 比较运算是放宽的(数学值比较):
1n == 1; // true
1n === 1; // false(类型不同)
1n < 2; // true
2n > 1; // true
// 除法是整数除法,向零取整:
5n / 2n; // 2n,不是 2.5n
7n / 2n; // 3n
-7n / 2n; // -3n(向零取整,不是向下)
// 位运算完全支持,这正是 BigInt 的重要用途:
0xffffffffffffffffn & 0xf0f0n; // 大位掩码运算无压力
其他注意点:JSON.stringify(10n) 直接抛 TypeError(需要自定义 replacer 或 toJSON);localStorage 同理存不了;Math 对象的所有方法都不接受 BigInt;BigInt 运算比普通 Number 慢(软件模拟任意精度),不要拿它做高频循环计算。
典型应用场景:后端长整型 ID、密码学与大数运算、高精度金融整数计算、超过 32 位的位操作。
三、原始类型的"影子":包装对象
字符串没有方法,为什么 'abc'.toUpperCase() 能调用?答案是**包装对象(Wrapper Object)**机制。
当你在原始值上访问属性或调用方法时,引擎在幕后执行三步:
js
let str = 'hello';
str.length; // 5
// 幕后发生的事:
// 1. 创建临时包装对象 new String('hello')
// 2. 在包装对象上读取 length,得到 5
// 3. 立即销毁包装对象
六种原始类型有对应的包装构造器:String、Number、Boolean、Symbol、BigInt(Null 和 Undefined 没有------这就是 null.toString() 直接抛 TypeError 的原因)。
理解包装机制就能解释这个经典迷惑行为:
js
let s = 'hello';
s.custom = 'test'; // 给临时包装对象加了属性,随即包装对象被销毁
console.log(s.custom); // undefined!下一次访问创建的是全新的包装对象
给原始值挂属性永远无效------每次属性访问操作的都是一个转瞬即逝的新对象。
包装构造器绝对不能加 new 调用:
js
typeof new String('a'); // 'object'!你创建的是对象,不是字符串
typeof String('a'); // 'string' ✅ 不加 new 时是普通转换函数
const flag = new Boolean(false);
if (flag) {
console.log('执行了'); // 真的会执行!对象永远是 truthy,哪怕内部值是 false
}
flag.valueOf(); // false ------ 想拿到真实值还得显式调用
new Boolean(false) 是 truthy 这件事曾制造过无数事故,ESLint 的 no-new-wrappers 规则就是专门封杀这类代码的。记住:要转换用 String(x) / Number(x) / Boolean(x) 函数形式,永远不要 new。
四、引用类型:Object 家族全景
对象是属性(property)的集合。属性键只能是 String 或 Symbol(用其他原始值作键会被隐式 ToString),值可以是任何类型。下面把家族成员逐一过一遍。
4.1 普通对象 Object
js
const obj = {
name: 'Tom', // 数据属性
['computed' + 'Key']: 1, // ES6 计算属性名
greet() { return `Hi ${this.name}`; }, // 方法简写
get info() { return this.name; }, // 访问器属性 getter
set info(v) { this.name = v; }, // setter
__proto__: someProto, // 字面量中指定原型(不推荐,用 Object.create)
};
属性描述符:对象元行为的控制层
每个属性背后都有一组属性描述符(Property Descriptor),控制着属性的元行为:
js
Object.defineProperty(obj, 'id', {
value: 1001,
writable: false, // 不可重新赋值
enumerable: false, // 不可枚举:for...in / Object.keys / JSON.stringify 都看不到
configurable: false, // 不可删除、不可再修改描述符
});
Object.getOwnPropertyDescriptor(obj, 'id'); // 读取描述符
两种属性各有四个描述符字段:数据属性 是 value / writable / enumerable / configurable;访问器属性 (getter/setter)是 get / set / enumerable / configurable。这套机制是 Object.freeze 的实现基础,也是 Vue 2 响应式系统的核心(用 getter/setter 劫持读写)------理解描述符是读懂那个时代框架源码的钥匙。
js
Object.freeze(obj); // 冻结:不能增、删、改属性,不能改描述符(浅冻结!)
Object.seal(obj); // 密封:不能增删,但现有值可改
Object.preventExtensions(obj); // 只防扩展:不能加新属性
Object.isFrozen(obj) / Object.isSealed(obj) / Object.isExtensible(obj);
freeze 是浅冻结 ------嵌套对象不受影响。需要深度只读要么递归冻结,要么用 Immer(以不可变方式操作,产出冻结结果)、TypeScript readonly(编译期约束)。
遍历方式大对比
对象的遍历 API 多达六种,行为差异必须分清:
| 方式 | 范围 | 包含不可枚举 | 包含 Symbol | 包含原型链 |
|---|---|---|---|---|
for...in |
字符串键 | ❌ | ❌ | ✅(唯一会走原型链的) |
Object.keys |
自身 | ❌ | ❌ | ❌ |
Object.values |
自身 | ❌ | ❌ | ❌ |
Object.entries |
自身 | ❌ | ❌ | ❌ |
Object.getOwnPropertyNames |
自身 | ✅ | ❌ | ❌ |
Object.getOwnPropertySymbols |
自身 | --- | ✅(仅 Symbol) | ❌ |
Reflect.ownKeys |
自身 | ✅ | ✅ | ❌(全量) |
for...in 会遍历原型链上的可枚举属性,所以配 hasOwnProperty(或现代的 Object.hasOwn(obj, k),ES2022)过滤自身属性是经典写法。ES2015 起遍历顺序被规范固定:整数键升序 → 字符串键按插入序 → Symbol 键按插入序,早年"顺序不保证"的说法已经过时。
js
Object.hasOwn(obj, 'key'); // ES2022,替代 obj.hasOwnProperty('key')
// 后者在 Object.create(null) 创建的对象上会抛错
原型链本身是对象模型的核心:每个对象有内部槽 ,属性查找沿链向上,终点是 Object.prototype,其 为 null。obj.toString 之所以能调用,就是查到了链上的 Object.prototype.toString。
4.2 Array:带着 length 的特殊对象
数组本质是键为"数组索引"(0 到 2³²−2 的规范化数字字符串)的特殊对象,外加一个自动同步的 length 属性。
js
Array.isArray([1]); // true ✅ 唯一可靠的数组判断
typeof [1]; // 'object' ------ typeof 分不清
[1] instanceof Array; // true,但跨 iframe 失效!
instanceof Array 的失效场景:iframe、Worker、Node 的 vm 模块各自拥有独立的 Array 构造器,A 环境的数组到 B 环境 instanceof 就是 false。Array.isArray 内部走 Object.prototype.toString 检测内部标签,跨环境免疫。判断数组永远用它。
变异与非变异方法:必须背下来的分类
数组方法按"是否修改原数组"分两类,混用是 bug 之源:
变异方法(改原数组,通常返回非数组值或自身) :push、pop、shift、unshift、splice、sort、reverse、fill、copyWithin。
非变异方法(返回新值,原数组不动) :slice、concat、map、filter、flat、flatMap、join(返回字符串)、reduce/reduceRight(返回累积值),以及查找类 find、findIndex、includes、indexOf、lastIndexOf、some、every、at、entries/keys/values。
在 React/Vue 这类依赖不可变数据做变更检测的框架里,误用变异方法是"视图不更新"或"意外更新"的头号原因:
js
// ❌ 变异写法:state 数组被原地修改,浅比较认为没变化
this.setState({ list: this.state.list.sort() });
// ✅ 不可变写法
this.setState({ list: [...this.state.list].sort((a, b) => a - b) });
sort 的默认行为是个陷阱
js
[10, 9, 1].sort(); // [1, 10, 9]!默认转字符串按字典序比较
// '10' < '9' 因为首字符 '1' < '9'
[10, 9, 1].sort((a, b) => a - b); // [1, 9, 10] ✅ 数字必须传比较函数
['b', 'a'].sort(); // ['a', 'b'] 字符串默认排没问题
[{v:2},{v:1}].sort((a,b) => a.v - b.v); // 对象数组自定义比较
另外注意 sort 的稳定性在 ES2019 才被规范强制要求(现代引擎都稳定了),比较函数返回 NaN 的行为是未定义的------(a, b) => a - b 对含 NaN 的数组会产生不可预期的结果。
稀疏数组:JS 独有的"空洞"
通过 new Array(n)、超长下标赋值或字面量中连续逗号产生的空洞(hole),是"不存在的属性"而非"值为 undefined":
js
const sparse = [1, , 3]; // 索引 1 是空洞
sparse.length; // 3
1 in sparse; // false ------ 属性根本不存在
sparse[1]; // undefined ------ 读取不存在的索引返回 undefined,具迷惑性
// 大部分迭代方法跳过空洞:
sparse.forEach(x => console.log(x)); // 只打印 1、3
sparse.map(x => x * 2); // [2, <1 empty>, 6] 空洞被保留
// 但普通 for 循环把空洞当 undefined:
for (let i = 0; i < sparse.length; i++) console.log(sparse[i]); // 1, undefined, 3
扩展运算符、Array.from、for...of 会把空洞物化成 undefined。现代代码建议彻底避免稀疏数组------new Array(10).fill(0) 才是创建定长数组的正确姿势。
类数组与可迭代对象
arguments、DOM 的 NodeList、字符串都"像数组"但不是数组。转换手段:
js
Array.from(nodeList); // ✅ 类数组/可迭代 → 数组,还支持 map 函数第二参数
[...nodeList]; // ✅ 可迭代对象(有 Symbol.iterator)可用扩展运算符
Array.prototype.slice.call(arguments); // ES5 老写法,了解即可
区别在于:Array.from 接受类数组(只要有 length),扩展运算符要求可迭代。arguments 两者都满足,{length: 2, 0: 'a', 1: 'b'} 这种纯类数组只能 Array.from。另外,箭头函数没有自己的 arguments,剩余参数 ...args 是现代替代品(且它天生就是真数组)。
4.3 Function:一等公民对象
函数首先是对象,其次才是可调用实体------它可以被赋值、传递、返回,也可以挂属性:
js
function add(a, b) { return a + b; }
add.length; // 2 ------ 首个默认参数之前的形参个数
add.name; // 'add'
add.custom = 'x'; // 函数也是对象,能挂属性
const bound = add.bind(null, 1);
bound.length; // 1 ------ bind 会扣掉预置参数
bound(2); // 3
this:调用时决定,而非定义时
普通函数的 this 由调用方式决定,四条绑定规则优先级从高到低:
js
// ① new 绑定:this 是新创建的对象
function P() { this.x = 1; }
new P().x; // 1
// ② 显式绑定:call / apply / bind 强行指定
fn.call(ctx, a, b); // 逐个传参
fn.apply(ctx, [a, b]); // 数组传参
const bound = fn.bind(ctx, a); // 预置 this 和部分参数,返回新函数
// ③ 隐式绑定:谁调用指向谁
obj.method(); // this === obj
const m = obj.method;
m(); // 隐式丢失!this 变 undefined(严格模式)或 globalThis
// ④ 默认绑定:独立调用
fn(); // 严格模式 this === undefined;非严格模式 === globalThis
隐式丢失 是回调场景的头号事故:把 obj.method 作为回调传出去后,obj. 这个调用语境没了,this 就丢了。解决方案是 bind、包装箭头函数,或在构造函数/class 里预先绑定。
箭头函数 是 ES6 给出的根治方案:它没有自己的 this、arguments、super、new.target,其 this 在定义时从外层词法作用域继承,且 call/apply/bind 无法改变(第一个参数被静默忽略):
js
const obj = {
name: 'obj',
regular() { return this.name; }, // 调用决定:obj.regular() → 'obj'
arrow: () => this?.name, // 定义决定:this 是模块作用域 → undefined
delayed() {
setTimeout(() => console.log(this.name), 100); // ✅ 箭头函数保住 this === obj
setTimeout(function() { console.log(this.name); }, 100); // ❌ this 丢失
},
};
由此推出两条实践规则:对象方法、需要动态 this 的场景用普通函数/方法简写;回调、定时器、数组遍历内部需要外层 this 时用箭头函数 。class 的构造函数和原型方法都有 this 语义,唯独不能把它们写成箭头函数字段还指望 this 是实例------箭头类字段的 this 固定指向构造时的实例,这反而常用于"自动绑定"技巧(handleClick = () => {...} 在 React class 组件里的用途)。
函数的其他类型学特性:函数是 Function.prototype 的实例,同时又是可调用对象(拥有 内部方法);typeof fn === 'function' 是规范特批的(函数在类型系统里是对象的子类型但 typeof 单独返回 'function');构造函数与普通函数只是使用方式的差别,任何函数都能被 new(箭头函数、生成器函数、async 函数除外------它们没有 ,new 会抛 TypeError)。
4.4 Date:缺陷累累却无法回避
Date 对象内部存储一个整数:从 UTC 时间 1970-01-01T00:00:00.000Z (Unix 纪元)起算的毫秒数,有效范围 ±8.64×10¹⁵ 毫秒(约正负 27.4 万年),超出即为 Invalid Date。
js
Date.now(); // 当前时间戳,不创建对象,性能敏感处用它
new Date().getTime(); // 等价,但多一次对象分配
new Date().valueOf(); // 同上
+new Date(); // 同上,隐式转换写法
构造与解析的坑
js
new Date(2026, 8, 9); // ⚠️ 月份从 0 开始!这是 2026 年 9 月 9 日
new Date(2026, 8); // 日缺省为 1
new Date(2026, 8, 9, 12, 30, 0, 0); // 完整参数:年月日时分秒毫秒
// 数字参数 vs 字符串参数的时区差异(大坑):
new Date(2026, 8, 9); // 按【本地时区】解析
new Date('2026-09-09'); // 按【UTC】解析!ISO 日期格式(无时间部分)规范规定按 UTC
new Date('2026-09-09T00:00:00'); // 无时区后缀的日期时间 → 按本地
new Date('2026-09-09T00:00:00Z'); // 明确 UTC
new Date('2026/09/09'); // 非 ISO 格式 → 按本地,且格式合法性因引擎而异
new Date('9-9-2026'); // 老浏览器可能 Invalid Date
同一天的日期,new Date('2026-09-09') 在东八区取 getDate() 得 9(UTC 零点 = 本地早上八点),但在西五区就得 8(UTC 零点还是前一天晚上七点)。日期字符串解析的时区歧义是国际化业务 bug 的重灾区。 工程铁律:传输和存储统一用 UTC(ISO 8601 字符串或时间戳),只在展示层做本地化;复杂业务直接上 day.js / date-fns / Luxon,别手搓。
其他必知行为
js
// 无效日期:
const bad = new Date('nonsense');
bad.getTime(); // NaN
String(bad); // 'Invalid Date'
Number.isNaN(d.getTime()); // 判断有效性的惯用法
// Date 是可变对象:
const d = new Date();
d.setFullYear(2000); // 原地修改!
// 存入 Redux/Vuex 等不可变数据流时要先拷贝:new Date(old) 或 +old 重建
// getter/setter 的 UTC 变体:
d.getHours(); // 本地时区小时
d.getUTCHours(); // UTC 小时
// toString/toISOString:
d.toISOString(); // '2026-09-09T14:03:00.000Z' 永远是 UTC,机器间传输标准格式
d.toJSON(); // 内部就是调 toISOString ------ JSON.stringify 日期变字符串的原因
Date 与数字、字符串的隐式转换差异也源自 hint 机制(下一节详述):+d 得时间戳(number hint 走 valueOf),d + '' 得可读字符串(default hint 对 Date 特殊处理走 toString)。
4.5 RegExp:正则表达式对象
两种创建方式的行为差异需要留意:
js
const re1 = /ab+c/gi; // 字面量:ES5 起每次求值都新建对象(更早的规范会复用)
const re2 = new RegExp('ab+c', 'gi'); // 构造器:运行时动态构建,模式来自变量时用
new RegExp(re1); // ES6 起可以拷贝正则(含 flags)
new RegExp(re1, 'i'); // 还能改 flags
re2.source; // 'ab+c'
re2.flags; // 'gi'(ES6)
全局正则的 lastIndex 状态陷阱
带 g(或 y)标志的正则是有状态的 ------test/exec 会把匹配位置记录在 lastIndex 上,下次从那里继续找:
js
const re = /a/g;
re.test('abc'); // true,lastIndex 变 1
re.test('abc'); // true,从索引 1 找到,lastIndex 变 2
re.test('abc'); // false!从索引 2 开始没有 'a' 了,lastIndex 归 0
re.test('abc'); // true,循环往复......
同一个正则对象在循环或多次调用中 test 结果忽真忽假,根源就在这。对策:每次使用前重置 re.lastIndex = 0;或在函数内部新建正则;或去掉 g 标志(不需要全局时)。
字符串方法 'str'.replace(pattern, ...) 等实际调用的是正则对象的 Symbol.replace 等方法,字符串方法只是转发者------这个设计允许自定义"类正则对象"完全接管字符串操作,是规范级的扩展点。
常用标志速记:g 全局、i 忽略大小写、m 多行(^$ 匹配每行)、s dotAll(. 匹配换行,ES2018)、u Unicode 模式(正确处理码点大于 U+FFFF 的字符)、y 粘性匹配(必须从 lastIndex 处开始匹配)、d 捕获组索引(ES2022)。
4.6 Error 家族:错误也是对象
Error 常被类型讨论遗漏,但它确实是引用类型家族的重要成员:
js
const err = new Error('出错了');
err.message; // '出错了'
err.name; // 'Error'
err.stack; // 调用栈字符串(非标准但所有引擎都实现)
err.cause; // ES2022:错误链
new Error('外层', { cause: innerErr });
// instanceof 完整可用:
err instanceof Error; // true
new TypeError('x') instanceof Error; // true ------ 子类
内置子类各司其职:TypeError(类型不符,如对 null 取属性)、ReferenceError(访问未声明变量)、SyntaxError(解析失败,JSON.parse 坏数据最常见)、RangeError(数值越界,如数组长度非法、无限递归爆栈)、URIError(编解码失败)、EvalError(历史遗留,基本不再抛出)。ES2021 的原生聚合错误 AggregateError 用于包装多个错误(Promise.any 失败时抛的就是它)。
区分错误的类型用 instanceof 或 err.name(跨 realm 场景后者更稳)。自定义错误类的标准写法是继承 Error 并设置 name------注意直接 class MyError extends Error 在编译到 ES5 时会丢失原型链(Babel 需要专门处理),这是 TypeScript 项目的一个经典坑。
4.7 Map 与 Set:为键值集合正名
ES6 之前用普通对象模拟字典有一堆先天缺陷:键会被强制转字符串、原型链上的属性会"泄漏"进来(obj['toString'] 竟然有值)、无法直接获知大小、早年遍历顺序无保证、频繁增删性能不佳。Map 和 Set 就是针对性补强。
Map
js
const map = new Map();
// 任意类型作键,这是与 Object 的本质区别:
const objKey = { id: 1 };
map.set(objKey, '对象键');
map.set(function(){}, '函数键');
map.set(NaN, 'NaN 也能当键');
map.set(true, '布尔键');
map.get(objKey); // '对象键' ------ 必须是同一个引用,长得一样的另一个 {} 不行
map.get(NaN); // 'NaN 也能当键' ✅ Map 内部用 SameValueZero 算法,NaN 等于 NaN
map.size; // 4
map.has(objKey); // true
map.delete(objKey);
map.clear();
// 初始化:接受 [key, value] 二元组的可迭代对象
new Map([['a', 1], ['b', 2]]);
new Map(Object.entries(obj)); // 普通对象 → Map 的便捷通道
Map vs Object 选型清单:
- 键不是字符串/Symbol(对象、函数、数字要严格区分 1 和 '1')→ 必须 Map (Object 的键
1和'1'是同一个); - 需要
size、需要严格插入序遍历、需要高频增删 → Map; - 需要 JSON 序列化、结构简单固定、要配合解构和展开 → Object (
JSON.stringify(map)得'{}',Map 必须手动转换); - 安全视角:Object 会被原型链污染(
Object.create(null)可缓解),Map 天然免疫------map.get('constructor')返回 undefined 除非你显式 set 过。
Set
js
const set = new Set([1, 2, 2, 3, 3]);
set.size; // 3 ------ 构造即去重
set.add(4).add(5); // 链式调用,add 返回 Set 本身
// 去重惯用法:
const unique = [...new Set([1, 1, 2])]; // [1, 2]
// 判重规则同样是 SameValueZero:
new Set([NaN, NaN]).size; // 1 ------ NaN 视为同值
new Set([{}, {}]).size; // 2 ------ 对象按引用判重!内容相同也是两个
// 集合运算(ES2025 新增原生方法,此前需手写或用库):
a.union(b); // 并集
a.intersection(b); // 交集
a.difference(b); // 差集(a 有 b 没有)
a.symmetricDifference(b); // 对称差集
a.isSubsetOf(b); // 子集判断
Map/Set 都是可迭代对象,支持 for...of、扩展运算符、解构、forEach,且规范保证按插入顺序遍历 。它们的 forEach 回调签名略有不同:Set 是 (value, value, set)(前两个参数相同,历史设计),Map 是 (value, key, map)(值在前,与直觉相反,注意)。
4.8 WeakMap 与 WeakSet:为垃圾回收而生
Weak 版本的核心机制:键(WeakSet 则是值)必须是对象(ES2023 起也可以是 Symbol),且持有的是弱引用------不计入垃圾回收的可达性判断。
js
let key = { id: 1 };
const wm = new WeakMap();
wm.set(key, '关联数据');
key = null; // 外部唯一的强引用断开
// 下一次 GC 时 {id: 1} 被回收,WeakMap 中对应条目自动消失,内存不泄漏
对比普通 Map:即使 key = null,Map 内部的强引用依然拽着对象不让 GC 回收,条目永远留在内存里。
Weak 版本的能力被刻意阉割:没有 size、不能遍历(无 forEach/keys/values/entries)、不能 clear、键值不能是原始值。原因很本质------条目随时可能被 GC 悄悄回收,任何"快照式"的枚举都无法保证一致性,索性不提供。
三大实战场景:
js
// 场景一:DOM 节点关联数据,节点从文档移除后被 GC 时数据自动释放
const nodeMeta = new WeakMap();
nodeMeta.set(el, { clickCount: 0, boundHandlers: [] });
// jQuery 的 .data() 内部就是类似机制
// 场景二:ES2022 #私有字段出现之前的"真私有属性"标准方案
const _stack = new WeakMap();
class Tracker {
constructor() { _stack.set(this, []); } // 外部完全无法访问
push(v) { _stack.get(this).push(v); }
}
// 现在可以直接用 class Tracker { #stack = []; },但 WeakMap 方案
// 在需要"跨类共享私有状态"或"给不可修改的第三方对象挂数据"时仍不可替代
// 场景三:带自动失效的缓存(memoization)
const cache = new WeakMap();
function expensiveCompute(obj) {
if (cache.has(obj)) return cache.get(obj);
const result = /* 昂贵计算 */ obj;
cache.set(obj, result);
return result;
}
// obj 被回收后缓存自动清空,无需手动管理缓存上限
WeakSet 的典型用途是记录"已处理过的对象"(防循环引用重复遍历、标记已初始化实例),不阻止对象回收。
4.9 二进制家族:ArrayBuffer、TypedArray、DataView
处理文件上传下载、WebSocket 二进制帧、Canvas 像素、WebAssembly 内存、加密运算时,这套 API 是绕不开的基础设施。三者分工:
- ArrayBuffer :一段固定长度的原始二进制内存。它本身不可读写,只能作为"地基"。
- TypedArray :ArrayBuffer 的类型化视图,按特定数值格式解释内存,API 长得像数组。
- DataView :低级视图,允许在任意字节偏移处读写任意类型,并显式控制字节序(大端/小端)。
js
const buffer = new ArrayBuffer(16); // 申请 16 字节,初始全 0
buffer.byteLength; // 16
const u8 = new Uint8Array(buffer); // 16 个 8 位无符号整数的视图
u8[0] = 255;
u8[1] = 300; // 溢出取模 → 44(300 % 256),静默!
// 同一块内存可以套多个视图,各按各的类型解读:
const i32 = new Int32Array(buffer); // 4 个 32 位有符号整数
i32[0]; // 按 32 位小端整数解读前 4 个字节
// 视图可以只覆盖 buffer 的一部分:
new Uint8Array(buffer, 4, 8); // 从字节偏移 4 开始,长度 8
完整的 TypedArray 家族(11 种):Int8Array、Uint8Array、Uint8ClampedArray、Int16Array、Uint16Array、Int32Array、Uint32Array、Float32Array、Float64Array、BigInt64Array、BigUint64Array。其中 Uint8ClampedArray 的溢出行为特殊------不是取模而是钳制 到 0~255 边界(300 → 255,-1 → 0),这正是 Canvas ImageData 像素数据采用它的原因(颜色值天然需要钳制语义)。
TypedArray 与普通数组的行为差异:长度创建时固定不可变;元素只能是该视图类型的数字(赋 'a' 变 NaN 或 0);delete arr[0] 不会留空洞而是置 0;map/filter 返回的仍是同类型 TypedArray 而非普通数组。
DataView 的价值在解析混合结构的二进制协议(比如一个文件头前两字节是 uint16、接着四字节是 float32):
js
const dv = new DataView(buffer);
dv.setUint16(0, 258, false); // false = 大端序(网络字节序);true = 小端
dv.getFloat32(2, true); // 小端读 float
SharedArrayBuffer 是它的多线程变体------允许多个 Web Worker 共享同一块内存,配合 Atomics 对象(Atomics.add/wait/notify 等)做同步,是浏览器端 ffmpeg.wasm、重度并行计算的地基。因 Spectre 侧信道漏洞,启用它需要页面配置 Cross-Origin-Opener-Policy 和 Cross-Origin-Embedder-Policy 跨域隔离响应头。
日常接触这套 API 的入口:fetch 的 response.arrayBuffer()、FileReader.readAsArrayBuffer、canvas.getContext('2d').getImageData()(返回 Uint8ClampedArray)、Node.js 的 Buffer(Uint8Array 的子类)。
4.10 Proxy:可定制的"第十三种对象"
ES6 的 Proxy 虽然不常作为"数据类型"讨论,但它确实创造了一类新对象------行为可以被完全拦截的对象,值得在这里点名:
js
const proxy = new Proxy(target, {
get(t, prop, receiver) { /* 拦截读取 */ },
set(t, prop, value, receiver) { /* 拦截写入 */ },
has(t, prop) { /* 拦截 in 运算符 */ },
deleteProperty(t, prop) { /* 拦截 delete */ },
// 共 13 种 trap
});
Vue 3 的响应式系统整个构建在 Proxy 之上(替代 Vue 2 的 defineProperty,从而支持新增属性、数组索引、Map/Set 的响应式)。typeof proxy 返回的是 target 的类型------Proxy 在类型系统里是透明的。
五、类型检测:六种武器的能力边界
没有任何一种检测方法是万能的,工程上必须按目标类型选择正确的工具。逐一分析各方案的能力与短板。
5.1 typeof:原始类型的快筛器
js
typeof 42; // 'number'
typeof 's'; // 'string'
typeof true; // 'boolean'
typeof undefined; // 'undefined'
typeof Symbol(); // 'symbol'
typeof 10n; // 'bigint'
typeof function(){}; // 'function'
typeof {}; // 'object'
typeof []; // 'object' ← 分不清
typeof null; // 'object' ← 史前 bug
typeof new Date(); // 'object' ← 分不清
typeof /re/; // 'object'(个别古董浏览器返回 'function')
能力边界一句话概括:6 种原始类型(null 除外)和函数能精确识别;其余一切对象一律 'object',无法细分;null 被误判为 object。
它有一项独门绝技------对未声明变量求值不抛错:
js
typeof undeclaredVariable; // 'undefined',安全
undeclaredVariable; // ReferenceError!
这让 typeof x === 'undefined' 成为特性检测的标准写法:
js
if (typeof window !== 'undefined' && typeof window.crypto !== 'undefined') {
// 安全地使用 Web Crypto API
}
(补充:现代代码检测未声明变量也可以用 globalThis + in 运算符,或 try/catch,但 typeof 仍是最简洁的。)
5.2 instanceof:沿原型链追溯
a instanceof B 的语义:沿 a 的 链向上查找,B.prototype 是否出现在链上。
js
[] instanceof Array; // true
[] instanceof Object; // true ------ 链继续向上是 Object.prototype
new Date() instanceof Date; // true
new Date() instanceof Object; // true
function f(){}
f instanceof Function; // true
f instanceof Object; // true ------ 函数也是对象
// 对原始值一律 false:
1 instanceof Number; // false!原始值没有原型链可查
new Number(1) instanceof Number; // true(但没人这么写)
两大短板:
短板一:跨全局环境(realm)失效。 iframe、Worker、Node 的 vm 各有独立的内置构造器:
js
// iframe 中的数组:
iframeWindow.Array === Array; // false,两个不同的构造器
arrFromIframe instanceof Array; // false!但 Array.isArray 是 true ✅
短板二:结果可被伪造。 原型链可改,检测逻辑本身也可被 Symbol.hasInstance 重写:
js
const fake = {};
Object.setPrototypeOf(fake, Array.prototype);
fake instanceof Array; // true,但它连 length 都没有
class Even {
static [Symbol.hasInstance](x) { return Number(x) % 2 === 0; }
}
2 instanceof Even; // true!instanceof 语义被完全改写
结论:instanceof 适合检测你自己定义的类(同一 realm 内可靠),检测内置类型时永远优先专用方案。
5.3 Object.prototype.toString:万能检测器
原理:Object.prototype.toString 的实现会读取对象的内部标签(ES5 时代是 ,ES6 起由 Symbol.toStringTag 决定),输出 [object Xxx]。由于数组、日期等重写了各自的 toString,必须用 call 强行让目标值以 Object.prototype 的版本执行:
js
const rawType = x => Object.prototype.toString.call(x).slice(8, -1);
rawType(1); // 'Number'
rawType('s'); // 'String'
rawType(true); // 'Boolean'
rawType(null); // 'Null' ✅ 修正 typeof 误判
rawType(undefined); // 'Undefined'
rawType([]); // 'Array' ✅
rawType(new Date()); // 'Date' ✅
rawType(/re/); // 'RegExp' ✅
rawType(new Error()); // 'Error'
rawType(new Map()); // 'Map'
rawType(new Set()); // 'Set'
rawType(new WeakMap()); // 'WeakMap'
rawType(Symbol()); // 'Symbol'
rawType(10n); // 'BigInt'
rawType(function(){}); // 'Function'
rawType(Math); // 'Math'
rawType(JSON); // 'JSON'
rawType(new ArrayBuffer(8)); // 'ArrayBuffer'
rawType(new Uint8Array(1)); // 'Uint8Array'
rawType(Promise.resolve()); // 'Promise'
这是唯一一套逻辑覆盖所有内置类型 的方案,lodash 的 baseGetTag、各类工具库的 type 函数底层都是它。
诚实声明它的两个局限:① ES6 起自定义对象可用 Symbol.toStringTag 伪造标签({[Symbol.toStringTag]: 'Array'} 会被测成 'Array'),检测不可信输入时要有心理预期;② 无法区分"Array 实例"与"手动改了 toStringTag 的骗子"------但实践中这已足够可靠。
5.4 专用检测函数:精确打击
js
Array.isArray(x); // 数组:唯一正解,跨 realm 可靠
Number.isNaN(x); // NaN:不做隐式转换
Number.isFinite(x); // 有限数字:排除 Infinity/-Infinity/NaN,不转换
Number.isInteger(x); // 整数(1.0 也算,因为 JS 无 float/int 之分)
Number.isSafeInteger(x); // 安全整数:±2^53-1 范围内
Number.isSafeInteger(x) && typeof x === 'number'; // 严格校验数字输入的组合拳
Object.is(a, b); // 同值相等(SameValue 算法)
Object.is 与 === 只有两处差异,恰好各修正了 === 的一个"痛点":
js
Object.is(NaN, NaN); // true ✅(=== 为 false)
Object.is(0, -0); // false ✅(=== 为 true)
Object.is(1, 1); // true ------ 其余行为与 === 完全一致
Object.is('a', 'a'); // true
它对应规范中的 SameValue 算法------Object.defineProperty 判断值是否变化、React 的 useState 判断是否需要重渲染(bailout 优化),内部用的都是 Object.is。这就是为什么 setState(NaN) 连续两次相同不会触发渲染,而 setState({}) 每次都触发(引用不同)。
还有第四种相等算法 SameValueZero ,用在 Map 的键、Set 的成员、Array.prototype.includes 中:NaN 等于 NaN(同 SameValue),但 +0 等于 −0(同 ===)。它没有直接暴露的全局函数,但影响着一个著名的行为差异:
js
[NaN].includes(NaN); // true ✅ SameValueZero
[NaN].indexOf(NaN); // -1 ❌ indexOf 用严格相等,NaN !== NaN
在数组里找 NaN,indexOf 永远找不到------只能用 includes 或 findIndex(Number.isNaN)。
5.5 constructor 检测:了解即可
js
(1).constructor === Number; // true
[].constructor === Array; // true
's'.constructor === String; // true ------ 借包装对象机制工作
缺陷太多:null/undefined 没有 constructor,直接访问抛 TypeError;prototype 可被改写导致 constructor 指错;跨 realm 同样失效;Symbol.hasInstance 类的伪造对它无效但原型篡改对它有效。知道存在即可,生产代码不用。
5.6 检测策略速查表
| 检测目标 | 首选方案 | 备注 |
|---|---|---|
| number/string/boolean/symbol/bigint/function | typeof |
快且准 |
| undefined | x === undefined |
未声明变量用 typeof x === 'undefined' |
| null | x === null |
|
| null 或 undefined | x == null |
== 唯一推荐的用法 |
| 数组 | Array.isArray |
别用 instanceof |
| NaN | Number.isNaN |
别用全局 isNaN |
| 有限数字 | Number.isFinite |
|
| Date/RegExp/Map/Error 等内置对象 | Object.prototype.toString.call |
或专用判断 |
| 自定义类实例 | instanceof |
同 realm 内可靠 |
| 值的同一性(含 NaN/±0 语义) | Object.is |
一个可以直接抄进工具库的通用实现:
js
function getType(value) {
if (value === null) return 'null';
const primitive = typeof value;
if (primitive !== 'object') return primitive;
// number/string/boolean/undefined/symbol/bigint/function 到此已返回
return Object.prototype.toString.call(value)
.slice(8, -1)
.toLowerCase(); // 'array' / 'date' / 'map' / 'regexp' ...
}
六、类型转换:规范级的完整规则链
隐式转换是 JS 被黑得最惨的部分。但翻开规范你会发现它不是"玄学",而是一套完全确定的算法:三个抽象操作 ToPrimitive、ToNumber、ToString(外加 ToBoolean),加上几条运算符的组合规则。把这套规则捋顺,任何转换结果都能机械推导出来。
6.1 ToPrimitive:对象 → 原始值
当一个对象出现在需要原始值的上下文(算术运算、字符串拼接、属性键)时,引擎执行 ToPrimitive(input, hint),hint 有三种:'string'、'number'、'default'。完整流程:
- 若对象定义了
Symbol.toPrimitive方法,调用它并返回结果,流程结束(最高优先级,hint 作为参数传入); - 否则按 hint 决定内置方法的调用顺序:
- hint =
'string'(String()、模板字符串、alert、对象作属性键):先toString(),失败再valueOf(); - hint =
'number'(Number()、一元 +/-、算术运算)或'default'(+ 二元、== 比较):先valueOf(),失败再toString();
- hint =
- "失败"的定义是返回值不是原始值;两个方法都失败 → 抛 TypeError。
用实验验证这套顺序:
js
const obj = {
valueOf() { return 10; },
toString() { return 'str'; },
};
obj + 1; // 11 ------ default hint → 先 valueOf → 10 + 1
obj * 2; // 20 ------ number hint → valueOf
obj - 1; // 9
`${obj}`; // 'str' ------ string hint → 先 toString
String(obj); // 'str'
Number(obj); // 10 ------ number hint → valueOf
['x'][obj]; // 属性键走 string hint,实际访问 obj['str']
// 两个方法都不返回原始值 → 报错
const evil = { valueOf: () => ({}), toString: () => ({}) };
evil + 1; // TypeError: Cannot convert object to primitive value
数组 的 valueOf 返回自身(不是原始值),所以永远落到 toString,而数组的 toString 就是 join(','):
js
[1, 2].toString(); // '1,2'
[].toString(); // ''
[1, 2] + [3, 4]; // '1,2' + '3,4' → '1,23,4'(字符串拼接)
[1] + 1; // '1' + 1 → '11'
[] + []; // '' + '' → ''
[] + {}; // '' + '[object Object]' → '[object Object]'
[null] + undefined; // '' + 'undefined' → 'undefined'
Date 是 default hint 的特例------它的 Symbol.toPrimitive 实现在 hint 为 default 时走 string 分支(历史行为,规范保留):
js
const d = new Date();
+d; // 1757... ------ number hint → valueOf → 时间戳数字
d + ''; // 'Wed Sep 09 2026...' ------ default hint 对 Date → toString
d + 1; // '...2026 GMT...1' ------ 拼接字符串而非时间戳加一!
那道著名"玄学题"的完整推导:{} + \[\]
js
{} + []; // 在语句位置:0
[] + {}; // '[object Object]'
为什么顺序换一下结果就天差地别?关键在解析上下文 :JS 引擎在语句起始位置看到 {,把它解析为代码块 而非对象字面量。于是 {} + [] 实际执行的是空代码块加表达式 +[]------一元加号作用于空数组:+[] → +'' → 0。
而 [] + {} 中 [] 在语句开头是表达式,+ 就是二元运算符:'' + '[object Object]'。
更微妙的是,同样的代码在不同环境结果不同------Chrome DevTools 控制台会把行首 {} 按表达式解析(得到 '[object Object]'),Node REPL 又有自己的行为。所以这道题的"标准答案"依赖于运行环境和上下文,理解解析机制远比背结果重要。这也是"JS 面试题"里水分最大的一类------真实代码中没人会在语句位置这么写。
6.2 ToNumber:原始值 → 数字
规范定义的转换表:
| 输入 | 结果 |
|---|---|
undefined |
NaN |
null |
0 |
true / false |
1 / 0 |
合法数字串 '123'、' 42 '、'1e3'、'0x1f'、'0b101'、'0o17'、'1_000'❌(仅字面量支持下划线) |
对应数值(前后空白被忽略) |
含非法字符 '12px'、'abc' |
NaN |
''、' '(空/纯空白) |
0 |
| Symbol | TypeError(不可转换,连隐式都不行) |
| BigInt | 整数照常转;带小数抛 RangeError |
对照几组高频例子:
js
Number(''); // 0
Number(' '); // 0
Number(null); // 0
Number(undefined); // NaN
Number(true); // 1
Number([]); // 0 ------ 先 ToPrimitive → '',再 Number('') → 0
Number([1]); // 1 ------ '1' → 1
Number([1, 2]); // NaN ------ '1,2' 非法
Number({}); // NaN ------ '[object Object]' 非法
一元 + 就是 ToNumber 的语法糖:+x 完全等价于 Number(x)(对对象先 ToPrimitive)。+''、+null、+[] 都是 0,+'12px' 是 NaN。
6.3 ToString:原始值 → 字符串
| 输入 | 结果 |
|---|---|
null |
'null' |
undefined |
'undefined' |
true / false |
'true' / 'false' |
| 数字 | 规范算法输出;极大/极小转科学计数法;-0 → '0' |
| Symbol | TypeError(但模板字符串和 String() 例外允许) |
js
String(-0); // '0' ------ 负零的符号在字符串化时丢失
String(1e21); // '1e+21'
String(0.0000001); // '1e-7'
(0.1 + 0.2).toString(); // '0.30000000000000004' ------ 忠实输出,不美化
// Symbol 的三重标准,规范有意为之:
String(Symbol('a')); // 'Symbol(a)' ✅ 显式转换放行(方便调试)
`${Symbol('a')}`; // 'Symbol(a)' ✅ 模板字符串放行
Symbol('a') + ''; // TypeError ❌ 隐式 + 运算禁止
Symbol('a').toString(); // 'Symbol(a)' ✅ 方法调用放行
对象的 ToString 走 ToPrimitive(string hint),即前面讲的 toString/valueOf 流程。
6.4 ToBoolean:没有中间商
ToBoolean 是三者中最简单的------没有方法调用,没有转换链,就是查假值表 :8 个假值(false, 0, -0, 0n, '', null, undefined, NaN)→ false,其余一切 → true。
触发位置:if/while/for 的条件、!、&&/|| 的操作数判断、三元条件、Boolean() 显式调用、do...while。
6.5 == 宽松相等:完整决策流程
规范中 IsLooselyEqual(x, y) 的判断顺序,逐条列出:
- 类型相同 → 按
===的规则比较(结束); - null 与 undefined → 互相宽松相等,返回 true;且它们不与任何其他值宽松相等(null == 0 是 false 的规范依据);
- number 与 bigint → 按数学值比较;
- 一方是 number、另一方是 string → string 转 number 再比;
- 一方是 boolean → boolean 转 number(true→1,false→0)再递归;
- 一方是 object、另一方是原始值(number/string/bigint/symbol)→ object 做 ToPrimitive 再递归;
- symbol 与其他任何类型 → false;
- 其余组合(如 bigint 与 string)→ false。
用这套流程机械推导所有"经典怪题":
js
// 题一:null == 0
// 规则2命中前半句吗?0 不是 undefined → null 不与 0 宽松相等
// 继续往下:number vs null 没有转换规则 → false ✅
null == 0; // false
null == false; // false(同理,规则5要求先转 boolean,但 null 一侧无规则跟进)
undefined == 0; // false
undefined == false; // false
// 题二:'' == 0
// 规则4:'' → Number('') → 0,0 == 0 → true
'' == 0; // true
// 题三:'' == false
// 规则5:false → 0;变成 '' == 0;规则4:'' → 0;0 == 0 → true
'' == false; // true
// 题四:[] == false
// 规则5:false → 0;[] == 0;规则6:[] → ToPrimitive → '';'' == 0;→ true
[] == false; // true
// 题五:[] == 
// 第一步:! 优先级远高于 ==,先算 ![];[] 是 truthy → ![] === false
// 表达式变成 [] == false,即题四 → true
[] == ![]; // true
// 题六:'0' == false
// false → 0;'0' → 0;true
'0' == false; // true
// 题七:NaN 和一切
NaN == NaN; // false ------ 规则1走 ===,NaN 不等于任何值包括自身
NaN == 0; // false
// 题八:对象 vs 对象
[] == []; // false ------ 类型相同走 ===,比引用
{} == {}; // false
每一道题都能沿着规则链走到底,没有一步需要"背"。[] == ![] 为 true 的全部秘密在于运算符优先级 (一元 ! 先执行)加上空数组是 truthy 这两条知识,与 == 本身关系不大。
6.6 ===、Object.is 与四种相等算法总结
把散落的线索收拢,JS 规范中实际存在四种相等算法:
| 算法 | 暴露方式 | NaN vs NaN | +0 vs -0 | 类型不同 |
|---|---|---|---|---|
| 宽松相等 IsLooselyEqual | == |
false | true | 尝试转换后比较 |
| 严格相等 IsStrictlyEqual | ===、switch、indexOf |
false | true | 直接 false |
| 同值 SameValue | Object.is |
true | false | 直接 false |
| 同值零 SameValueZero | Map/Set 键、includes、Set 成员 | true | true | 直接 false |
switch 语句用的是严格相等,没有隐式转换:
js
switch (1) {
case '1': console.log('不会执行'); break; // 1 === '1' 为 false
}
switch (undefined) {
case null: console.log('也不会'); break; // switch 中 undefined ≠ null!
}
工程铁律三条:默认 ===;判 NaN 用 Number.isNaN;需要"NaN 视为相等、±0 视为不同"的语义(如实现 React 式的 bailout 比较)用 Object.is。ESLint eqeqeq 常开,可配置 "smart" 或 ["error", "always", {"null": "ignore"}] 放行 == null。
6.7 其他隐式转换高发区盘点
js
// ① 属性键:一律 ToString
obj[{}] = 1; // 实际是 obj['[object Object]'] = 1
obj[[1, 2]] = 1; // 实际是 obj['1,2'] = 1
obj[null] = 1; // obj['null']
// 两个不同的 {} 作键会互相覆盖------都是 '[object Object]'
// ② + 运算符:拼接优先规则
// ToPrimitive 后任一方是字符串 → 拼接;否则 → 加法
'5' + 3; // '53' ------ 有字符串,拼接
'5' - 3; // 2 ------ 减法没有字符串语义,只能 ToNumber
'5' * 2; // 10
5 + '3'; // '53'
1 + 2 + '3'; // '33' ------ 从左到右:(1+2)+'3'
'1' + 2 + 3; // '123' ------ ('1'+2)+3
// ③ 关系运算符 < > <= >=
// 两边 ToPrimitive 后【都是字符串】→ 按 UTF-16 码元字典序;否则 → ToNumber
'10' < '9'; // true!字符串比较,'1' < '9'
'10' < 9; // false,转数字
'a' < 'b'; // true,字典序
'B' < 'a'; // true,大写码元在前(不是"字母序"!)
'2' > '10'; // true,字符串比较
null > 0; // false(null 不触发转换规则比较...实际是 ToNumber(null)=0,0>0 为 false)
null >= 0; // true!(0 >= 0)------ > 和 >= 不是简单的取反关系
undefined > 0; // false,NaN 参与的一切比较都是 false
undefined < 0; // false
// ④ 解构与参数默认值:只在 === undefined 时触发默认值
const { a = 1 } = { a: undefined }; // a = 1
const { b = 1 } = { b: null }; // b = null!null 不触发默认值
// ⑤ 模板字符串:隐式 ToString(Symbol 例外放行)
`${null}`; // 'null'
`${[1,2]}`; // '1,2'
`${{a:1}}`; // '[object Object]'
null >= 0 为 true 而 null > 0 为 false 这类"不对称"现象,根源是 >= 被规范定义为"不满足 < 即为 true"(x >= y ≡ !(x < y) 再处理 undefined),而 null < 0 是 0 < 0 为 false,取反即 true。又是一条可以机械推导的规则。
七、类型相关的工程专题
7.1 const 与不可变性的真相
js
const obj = { a: 1 };
obj.a = 2; // ✅ 合法!const 锁的是绑定(栈上的地址),不是堆上的内容
obj = {}; // ❌ TypeError: Assignment to constant variable
const arr = [1];
arr.push(2); // ✅ 同理
arr = []; // ❌
const 的准确语义:变量名与值的绑定关系不可改变。对原始类型,绑定不可变等价于值不可变;对引用类型,地址不可变但对象内容随便改。
需要值层面的不可变性时,工具箱按强度排列:Object.freeze(运行时浅冻结,注意嵌套)→ 递归 freeze 工具函数 → Immer(以可变语法写不可变更新,产出自动冻结)→ TypeScript readonly / Readonly<T>(编译期约束,运行时无效)。
7.2 JSON 序列化的类型盲区
JSON.stringify / JSON.parse 能表示的类型极其有限(对象、数组、字符串、数字、布尔、null),其余类型的处理行为必须心里有数:
js
// 直接消失(返回 undefined 或属性被剔除):
JSON.stringify(undefined); // undefined(返回值本身是 undefined,不是字符串)
JSON.stringify(function(){}); // undefined
JSON.stringify(Symbol()); // undefined
JSON.stringify({ a: undefined, b: () => {} }); // '{}' ------ 属性被静默剔除
// 变成 null:
JSON.stringify(NaN); // 'null'!
JSON.stringify(Infinity); // 'null'
JSON.stringify(-Infinity); // 'null'
// 数组中的特殊规则(位置保留,值变 null):
JSON.stringify([undefined, () => {}, 1]); // '[null,null,1]'
JSON.stringify([NaN, Infinity]); // '[null,null]'
// 抛异常:
JSON.stringify(10n); // TypeError: Do not know how to serialize a BigInt
// 有 toJSON 方法的类型走自定义序列化:
JSON.stringify(new Date()); // '"2026-09-09T14:03:00.000Z"' ------ Date.toJSON = toISOString
JSON.stringify(new Map()); // '{}' ------ Map 没有 toJSON,条目不是自身属性,全丢
JSON.stringify(new Set([1,2])); // '{}' ------ 同上
JSON.stringify(/re/); // '{}' ------ RegExp 序列化成空对象,且 parse 回来不是正则
JSON.stringify(new Error('x')); // '{}' ------ message/stack 不可枚举,全丢!
// 循环引用直接抛错:
const c = {}; c.self = c;
JSON.stringify(c); // TypeError: Converting circular structure to JSON
需要序列化特殊类型时的标准方案------replacer 函数:
js
JSON.stringify(data, (key, value) =>
typeof value === 'bigint' ? value.toString() + 'n' :
value instanceof Map ? Object.fromEntries(value) :
value instanceof Set ? [...value] :
value
);
对称地,JSON.parse 也有 reviver 参数做反序列化还原;解析失败抛 SyntaxError,处理外部数据必须 try/catch。另外 JSON.parse('{"__proto__": {...}}') 产生的对象其 __proto__ 只是普通属性(不会污染原型),但把它 Object.assign 合并进目标对象时就有原型污染风险------安全敏感的合并逻辑要用 Map 或过滤 __proto__ 键。
7.3 深拷贝:三种方案的完整对比
方案一:JSON 往返 ------JSON.parse(JSON.stringify(x))。简单粗暴,但丢失清单触目惊心:undefined、函数、Symbol 直接消失;Date 变字符串;RegExp/Error 变空对象;Map/Set 变空对象;NaN/Infinity 变 null;BigInt 抛错;循环引用抛错。只适合纯数据(POJO + 数组 + 基本类型)的场景。
方案二:structuredClone(现代标准答案)------浏览器全平台和 Node 17+ 原生支持:
js
const clone = structuredClone(original);
正确处理:循环引用、Date、RegExp、Map、Set、ArrayBuffer、TypedArray、Blob、File、ImageData 等。不能 处理:函数(抛 DataCloneError)、Symbol(抛错)、DOM 节点(抛错)、类实例(能拷贝属性但原型链丢失,克隆出来是普通对象)、属性的 getter 会被求值成静态值。Error 对象在新版规范中可以克隆(含 message/name/stack,cause 递归克隆)。
方案三:库 ------lodash 的 _.cloneDeep 覆盖面最广(支持类实例原型、Buffer 等),代价是包体积。
浅拷贝的日常工具也顺便对齐:扩展运算符 {...obj} / [...arr]、Object.assign({}, obj)、Array.prototype.slice()------都只拷贝一层,嵌套对象共享引用。
7.4 空值判断的语义光谱
把"值是否为空"的各种判断按语义精确度排开,这是业务代码里出现频率最高的判断,选错一个就是线上 bug:
js
// ① 最严格:只认 null
x === null
// ② 空值合并语义:null 或 undefined(?? 的判断标准)
x == null // ✅ 简洁且语义精确,ESLint eqeqeq 默认放行的唯一宽松比较
x === null || x === undefined
// ③ 假值判断:范围扩大到 0/''/NaN/false/0n
!x // ⚠️ 会误伤合法的 0、空串、false
// ④ 业务级"空":还要考虑空集合
!x?.length // 空数组/空字符串/null/undefined 一网打尽
Object.keys(x ?? {}).length === 0 // 空对象
选型原则:默认值场景用 ??(保护 0/''/false 等合法假值);确实想过滤所有假值才用 ||;判"没传值"用 == null;判"空集合"用显式的 length/keys 检查。
7.5 类型相关的运行时防御清单
最后一组工程习惯,把本文的知识点落成代码防线:
js
// 处理外部输入(接口响应、URL 参数、postMessage)永远先校验类型:
if (typeof id !== 'string' || id.length === 0) throw new TypeError('id 非法');
if (!Array.isArray(list)) list = [];
// 数值入参防 NaN 污染(NaN 会传染一切算术运算且无声无息):
const n = Number.isFinite(input) ? input : 0;
// 大整数 ID 从源头当字符串处理,全程不做 Number 转换
// 日期传输用 ISO 字符串(toISOString),比较用时间戳(getTime)
if (dateA.getTime() < dateB.getTime()) {...}
// 对象合并防原型污染:过滤危险键
const DANGEROUS = ['__proto__', 'constructor', 'prototype'];
// 判断"纯对象"的可靠写法:
function isPlainObject(x) {
return Object.prototype.toString.call(x) === '[object Object]'
&& (x.constructor === undefined || x.constructor === Object);
}
再往上走一层就是 TypeScript 的世界------编译期的类型系统是运行时防御的超集,但即便全面转向 TS,本文的所有运行时知识依然必要:TS 的类型在编译后完全消失,边界处(any、外部 JSON、类型断言)的运行时校验一个都不能少;而且 TS 的 typeof 类型守卫、判别联合、satisfies 等特性,本身就是对这套运行时类型系统的编译期镜像。
八、总结
把全文的骨架收拢成一张图:
JavaScript 值
├── 原始类型(7 种;栈存储;按值复制;不可变)
│ ├── Number ------ IEEE 754 双精度;NaN/±Infinity/−0;安全整数 ±(2⁵³−1)
│ ├── String ------ UTF-16 码元序列;不可变;代理对与码点 API
│ ├── Boolean ------ 8 个假值,其余全真;&&/|| 短路返回原值
│ ├── Undefined ------ 系统性缺失;typeof 可安全探测未声明变量
│ ├── Null ------ 有意的空;typeof 误判为 object;只与 undefined 宽松相等
│ ├── Symbol ------ 全局唯一;默认隐藏的键;知名 Symbol 元编程钩子
│ └── BigInt ------ 任意精度整数;禁与 Number 混算;JSON 不友好
├── 引用类型(堆存储;按地址复制;可变;全是 Object)
│ ├── Object ------ 属性描述符;freeze/seal;六种遍历;原型链
│ ├── Array ------ 变异 vs 非变异方法;sort 默认字典序;稀疏数组
│ ├── Function ------ this 四规则;箭头函数词法 this;闭包捕获引用
│ ├── Date ------ UTC 毫秒时间戳;月份从 0 起;解析时区歧义;可变对象
│ ├── RegExp ------ g 标志的 lastIndex 状态;字符串方法的 Symbol 转发
│ ├── Error ------ 六大家族 + AggregateError;name/message/stack/cause
│ ├── Map / Set ------ 任意键;SameValueZero 判重;插入序遍历;ES2025 集合运算
│ ├── WeakMap / WeakSet ------ 弱引用;无 size 无遍历;私有数据与自动失效缓存
│ ├── TypedArray / ArrayBuffer / DataView ------ 二进制三件套;字节序;Clamped 钳制
│ └── Proxy ------ 13 种 trap;Vue 3 响应式之基
└── 四种相等算法
├── == 宽松相等(8 步转换决策树)
├── === 严格相等(switch/indexOf)
├── Object.is 同值(NaN✓ ±0✗)
└── SameValueZero(Map/Set/includes:NaN✓ ±0✓)
以及六条最值得带走的实践原则:
- 相等比较默认
===;判空用== null或??;NaN 用Number.isNaN;同值语义用Object.is;数组找 NaN 用includes而非indexOf。 - 类型检测分层选择 :原始类型和函数用
typeof,数组用Array.isArray,内置对象用Object.prototype.toString.call,自定义类用instanceof,未声明变量探测用typeof。 - 数字的边界要设防 :金额用整数分或 decimal.js;大整数 ID 用字符串或 BigInt;浮点比较用 EPSILON;永远给
parseInt传基数;sort数字数组永远传比较函数。 - 键值集合选对工具:对象键、需要 size、防原型污染 → Map/Set;对象关联的私有数据、自动失效缓存 → WeakMap;纯静态结构、要序列化 → 普通对象。
- 隐式转换的高发区列黑名单 :
+的拼接优先、falsy 里的0/''与 truthy 里的[]/{}、属性键 ToString、'10' < '9'的字典序、JSON 对特殊类型的静默处理------每一处都对应本文的一条规则,出 bug 时按规则链推导即可定位。 - 不可变性要显式管理:const 只锁绑定,freeze 只是浅层,Date/数组方法/对象引用都可能是可变的------在状态管理、缓存、并发场景里,明确知道"谁在什么时候能改这块数据"。
JavaScript 的类型系统表面上宽松随意,骨子里却是一套严丝合缝的机械规则。所谓"玄学",只是规则链条太长而大多数人只记住了结果。把 ToPrimitive 的三个 hint、宽松相等的八步决策、四种相等算法的差异真正内化之后,你会发现这门语言的每一个行为都有据可查、有法可推------这种掌控感,正是从"会写 JS"到"精通 JS"之间那道门槛的本质。