JavaScript 数据类型完全指南

前言

写了这么多年 JavaScript,回头再看,我发现绝大多数"莫名其妙"的 bug,根源都在数据类型这一层。0.1 + 0.2 !== 0.3typeof null === 'object'[] == ![] 居然是 true、两个长得一模一样的对象比较却不相等......这些行为单看每一个都像是语言的设计失误,但当你真正翻开 ECMA-262 规范,把转换算法一条一条捋下来之后会发现:它们全都是机械规则的必然产物,没有一处是随机的。

这篇文章我做了一次彻底的梳理。从七大原始类型的底层表示讲起,覆盖引用类型的完整家族、内存模型、六种类型检测手段的适用边界、规范级的类型转换算法链条,最后落到 JSON 序列化、深拷贝、空值判断这些每天都要面对的工程问题上。篇幅很长,但每一节都对应实际开发中真实会踩的坑,建议收藏后慢慢消化。


一、类型系统总览:两条主线

按照 ECMA-262 规范的划分,JavaScript 中的值(value)分为两大类:

原始类型(Primitive Type) ,共 7 种:NumberStringBooleanNullUndefinedSymbolBigInt。它们不是对象,没有方法,不可变,按值操作。

引用类型(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_INTEGERMAX_VALUE:前者是"每个整数都精确"的边界,后者是"不变成 Infinity"的边界,两者差了天文数字级别。

这个坑在生产环境中最常见的触发场景是后端返回的 64 位长整型 ID (比如雪花算法生成的 19 位数字)。前端 JSON.parse 的一瞬间,末几位就已经悄悄变了,而且不会有任何报错。防坑方案:

  1. 让后端把长整型 ID 以字符串形式返回(最稳妥,业界主流做法);
  2. 前端用 json-bigint 这类支持 BigInt 的 JSON 解析库;
  3. 需要运算时用 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 类型本身只有 truefalse 两个值,真正重要的是**真值(truthy)/ 假值(falsy)**体系------它决定了 ifwhile!&&||、三元运算符的行为。

假值是封闭集合,有且仅有 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 调包装构造函数的原因之一。

顺带澄清一个高频困惑:[] == falsetrue,但 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.stringifyfor...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. 立即销毁包装对象

六种原始类型有对应的包装构造器:StringNumberBooleanSymbolBigInt(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,其 为 nullobj.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 之源:

变异方法(改原数组,通常返回非数组值或自身)pushpopshiftunshiftsplicesortreversefillcopyWithin

非变异方法(返回新值,原数组不动)sliceconcatmapfilterflatflatMapjoin(返回字符串)、reduce/reduceRight(返回累积值),以及查找类 findfindIndexincludesindexOflastIndexOfsomeeveryatentries/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.fromfor...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 给出的根治方案:它没有自己的 thisargumentssupernew.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 失败时抛的就是它)。

区分错误的类型用 instanceoferr.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 序列化、结构简单固定、要配合解构和展开 → ObjectJSON.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 种):Int8ArrayUint8ArrayUint8ClampedArrayInt16ArrayUint16ArrayInt32ArrayUint32ArrayFloat32ArrayFloat64ArrayBigInt64ArrayBigUint64Array。其中 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-PolicyCross-Origin-Embedder-Policy 跨域隔离响应头。

日常接触这套 API 的入口:fetchresponse.arrayBuffer()FileReader.readAsArrayBuffercanvas.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 永远找不到------只能用 includesfindIndex(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'。完整流程:

  1. 若对象定义了 Symbol.toPrimitive 方法,调用它并返回结果,流程结束(最高优先级,hint 作为参数传入);
  2. 否则按 hint 决定内置方法的调用顺序:
    • hint = 'string'(String()、模板字符串、alert、对象作属性键):先 toString(),失败再 valueOf()
    • hint = 'number'(Number()、一元 +/-、算术运算)或 'default'(+ 二元、== 比较):先 valueOf(),失败再 toString()
  3. "失败"的定义是返回值不是原始值;两个方法都失败 → 抛 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) 的判断顺序,逐条列出:

  1. 类型相同 → 按 === 的规则比较(结束);
  2. null 与 undefined → 互相宽松相等,返回 true;且它们不与任何其他值宽松相等(null == 0 是 false 的规范依据);
  3. number 与 bigint → 按数学值比较;
  4. 一方是 number、另一方是 string → string 转 number 再比;
  5. 一方是 boolean → boolean 转 number(true→1,false→0)再递归;
  6. 一方是 object、另一方是原始值(number/string/bigint/symbol)→ object 做 ToPrimitive 再递归;
  7. symbol 与其他任何类型 → false;
  8. 其余组合(如 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 < 00 < 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✓)

以及六条最值得带走的实践原则:

  1. 相等比较默认 === ;判空用 == null??;NaN 用 Number.isNaN;同值语义用 Object.is;数组找 NaN 用 includes 而非 indexOf
  2. 类型检测分层选择 :原始类型和函数用 typeof,数组用 Array.isArray,内置对象用 Object.prototype.toString.call,自定义类用 instanceof,未声明变量探测用 typeof
  3. 数字的边界要设防 :金额用整数分或 decimal.js;大整数 ID 用字符串或 BigInt;浮点比较用 EPSILON;永远给 parseInt 传基数;sort 数字数组永远传比较函数。
  4. 键值集合选对工具:对象键、需要 size、防原型污染 → Map/Set;对象关联的私有数据、自动失效缓存 → WeakMap;纯静态结构、要序列化 → 普通对象。
  5. 隐式转换的高发区列黑名单+ 的拼接优先、falsy 里的 0/'' 与 truthy 里的 []/{}、属性键 ToString、'10' < '9' 的字典序、JSON 对特殊类型的静默处理------每一处都对应本文的一条规则,出 bug 时按规则链推导即可定位。
  6. 不可变性要显式管理:const 只锁绑定,freeze 只是浅层,Date/数组方法/对象引用都可能是可变的------在状态管理、缓存、并发场景里,明确知道"谁在什么时候能改这块数据"。

JavaScript 的类型系统表面上宽松随意,骨子里却是一套严丝合缝的机械规则。所谓"玄学",只是规则链条太长而大多数人只记住了结果。把 ToPrimitive 的三个 hint、宽松相等的八步决策、四种相等算法的差异真正内化之后,你会发现这门语言的每一个行为都有据可查、有法可推------这种掌控感,正是从"会写 JS"到"精通 JS"之间那道门槛的本质。

相关推荐
Seoyoneh1 小时前
Agentic Workflow编排架构:云客服从“被动响应”迈向“主动执行”的技术实现
java·开发语言·架构
Tangyuewei2 小时前
Java 写 Agent:模型只是组件
java·开发语言
小玮看世界2 小时前
[Python]从合并区间到传感器融合区:合并区间在传感器区域融合的实际落地
开发语言·python
繁星蓝雨3 小时前
C++的设计与演进大纲(如何从C语言一步步成长为C++)
c语言·开发语言·c++·c++历史·c++演进
Evand J3 小时前
【MATLAB例程,图像降噪滤波9】自适应维纳滑动窗口滤波(Wiener)图像降噪、方法对比,有中文注释
开发语言·图像处理·计算机视觉·matlab·滑动窗口·滤波·降噪
杜大哥4 小时前
python程序:如何查看电脑【电池电量的剩余百分比】 和 【是否插入连接着充电器】?
开发语言·python
wuyk5554 小时前
Python网络爬虫入门到实战 第01章:爬虫到底是什么?原理、流程、合法性、风险全解析(零基础必看)
开发语言·爬虫·python
yurenpai(27届找实习中)4 小时前
Java 10 的 var 为什么不能随便用?从订单汇总看懂类型推断与泛型陷阱
java·开发语言·java18
shmily麻瓜小菜鸡4 小时前
JS/TS 易踩坑知识点 — 模块化与工程化类
开发语言·javascript·ecmascript