【每日一面】类型系统与类型转换
JavaScript 的类型转换全都落在 ToPrimitive、ToString、ToNumber、ToBoolean 四个抽象操作上,把加号与宽松相等(==)的规则串起来,[] == ![]、{} + [] 这类题就不再是靠背的。
基础问答
问:JavaScript 有哪几种数据类型?按什么维度分类?
答: ECMAScript 标准将数据类型分为两大类,共 8 种:
基本类型(Primitive Types): 值直接存储在栈内存中
undefined、null、boolean、number、string、symbol(ES6)、bigint(ES2020)
引用类型(Reference Types): 值存在堆内存,栈存引用地址
object(含普通对象、数组、函数、Date、RegExp、Map、Set 等所有非基本类型)
核心区分维度是存储方式:基本类型按值访问,引用类型按引用访问。typeof null === 'object' 是 JavaScript 一个历史遗留 bug,null 本质上是一个基本类型。
扩展延伸
概念铺垫:类型系统的存在意义
JavaScript 是动态弱类型语言,变量在运行时才确定类型,且允许隐式类型转换。这与静态类型语言(TypeScript/Java)形成比较强烈的对比。
let x = 42; // number
x = 'hello'; // string —— 动态类型允许随时改变类型
x = { a: 1 }; // object
console.log('5' - 3); // 2(隐式转 number)
console.log('5' + 3); // '53'(隐式转 string)这种灵活性是双刃剑,开发者使用时上手快,但隐式转换规则极其复杂,是过往面试必考的大坑。
typeof 与 instanceof
| 操作 | 用途 | 局限 |
|---|---|---|
typeof | 返回基本类型的字符串标识 | typeof null === 'object';无法区分对象的具体子类型(数组、Date 都返回 ‘object’) |
instanceof | 检测构造函数的 prototype 是否在原型链上 | 可跨 iframe 失效;基本类型无法直接用(除非自动解包) |
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof 42 // 'number'
typeof 'str' // 'string'
typeof Symbol() // 'symbol'
typeof 123n // 'bigint'
typeof null // 'object' ← 经典 bug
typeof {} // 'object'
typeof [] // 'object'
typeof new Date() // 'object'
[] instanceof Array // true
[] instanceof Object // true(数组也是对象)隐式类型转换
JavaScript 的类型转换分为 4 种抽象操作:ToPrimitive、ToString、ToNumber、ToBoolean。
flowchart LR
A["任意值"] --> B{"运算场景"}
B -->|""+" 字符串拼接优先"| C["调用 ToPrimitive<br/>hint: default"]
B -->|"比较运算符 =="| D["调用 ToPrimitive<br/>hint: number"]
B -->|"算术运算符 - * /"| E["ToNumber"]
B -->|"条件判断 if/&&/||"| F["ToBoolean"]
C --> G["string/number"]
D --> H["number"]
E --> I["number"]
F --> J["boolean"]
+ 运算符:字符串优先
只要一侧是字符串,+ 就执行字符串拼接;否则执行数值加法。
console.log(1 + 2); // 3
console.log('1' + 2); // '12'
console.log(1 + '2'); // '12'
console.log(1 + 2 + '3'); // '33'(先左结合:1+2=3,再 3+'3'='33')
console.log('1' + 2 + 3); // '123'(先左结合:'1'+2='12',再 '12'+3='123')
console.log(+'42'); // 42(一元+触发 ToNumber)== 宽松比较,复杂的隐式转换规则
flowchart TD
A["x == y"] --> B{"x 和 y 类型相同?"}
B -->|是| C["严格比较 ==="]
B -->|否| D{"null/undefined<br/>参与?"}
D -->|是| E["null == undefined → true<br/>null/undefined 与其他值 → false"]
D -->|否| F{"string ⭤ number?"}
F -->|是| G["ToNumber(string) 再比较"]
F -->|否| H{"boolean 参与?"}
H -->|是| I["ToNumber(boolean)<br/>1 或 0,再递归比较"]
H -->|否| J{"object ⭤ string/number/symbol?"}
J -->|是| K["ToPrimitive(object)<br/>再递归比较"]
J -->|否| L["false"]
经典问题:
console.log([] == ![]); // true ← 经典中的经典
// 分析:![] → false(引用类型转布尔为 true,取反为 false)
// 然后 [] == false
// false 触发 ToNumber 转为 0
// [] 触发 ToPrimitive 转为 ''(空字符串),再 ToNumber 转为 0
// 0 == 0 → true
console.log([1, 2] == '1,2'); // true(ToPrimitive 后 '1,2')
console.log([1, 2] == [1, 2]); // false(引用类型比较地址)
console.log(null == undefined); // true
console.log(null === undefined);// false
console.log('' == false); // true(''→0, false→0)
console.log('' == 0); // true
console.log(' ' == 0); // true(' '→0)真/假值
以下值在布尔上下文中为 falsy,其余为 truthy:
false, 0, -0, 0n, '', null, undefined, NaNif ([]) // true(空数组是 truthy)
if ({}) // true
if ('false')// true(非空字符串)
if (new Boolean(false)) // true(对象永远是 truthy)显式类型转换
| 转换目标 | 推荐方式 | 说明 |
|---|---|---|
| String | String(val) 或 val.toString() | null 和 undefined 只能用 String(),.toString() 会抛错 |
| Number | Number(val) 或 +val | 解析规则严格,Number('') 返回 0 |
| Integer | parseInt(val, radix) | 默认十进制,parseInt('08') 在旧环境可能被识别为八进制 |
| Float | parseFloat(val) | 解析到第一个非数字字符 |
| Boolean | !!val | 简洁高效 |
| 数字限定 | Math.floor/ceil/round | 取整需求 |
// String 转换对比
String(null) // 'null'
String(undefined) // 'undefined'
String([1, 2, 3]) // '1,2,3'
String({}) // '[object Object]'
// Number 转换对比
Number('') // 0 ← 容易踩坑
Number(' 123 ') // 123
Number('123abc') // NaN
Number(undefined) // NaN
Number(null) // 0
// parseInt 注意
parseInt('123abc') // 123
parseInt(' 0xFF') // 255(0x 前缀触发十六进制,需留意)
parseInt('08') // 8(ES5+ 已修正)
parseInt('08', 10) // 8(推荐始终传 radix)回顾一下深浅拷贝对比
| 方式 | 语法 | 拷贝深度 | 循环引用 | 函数/undefined/Symbol | Date/RegExp |
|---|---|---|---|---|---|
= 赋值 | b = a | 不拷贝 | — | — | — |
| 扩展运算符 | {...a} | 浅拷贝 | — | 正常拷贝引用 | 正常拷贝引用 |
Object.assign | Object.assign({}, a) | 浅拷贝 | — | 同上 | 同上 |
JSON.parse(JSON.stringify) | — | 深拷贝 | ❌ | ❌ | ❌ |
| 手写递归 | deepClone(a) | 深拷贝 | ✅ | ✅ | ✅ |
面试追问
追问1:typeof null 为什么是 'object'?这个 bug 能修吗?
这是 JavaScript 诞生时的设计缺陷。第一版 JavaScript 用类型标签(type tag)来存储数据类型:对象类型标签是 0,而 null 的机器码表示是空指针(0x00),因此 typeof 错误地将 null 识别为 'object'。
// 在 V8 引擎层面,typeof 的实现大致是:
// 如果 type_tag === 0,返回 'object'
// null 的二进制正好全部为 0...这个 bug 在 ES 提案阶段曾尝试修复(typeof null === 'null'),但已有大量线上代码依赖 typeof null === 'object' 的判断逻辑,修复会破坏兼容性,最终被拒绝。社区替代方案是 val === null ? 'null' : typeof val。
追问2:'1' + 2 + 3 和 1 + 2 + '3' 结果分别是什么?解释运算过程。
'1' + 2 + 3 → '123'
1 + 2 + '3' → '33'运算过程(+ 是左结合):
-
'1' + 2:有 string 参与 → 字符串拼接 →'12' -
'12' + 3:继续拼接 →'123' -
1 + 2:两边都是 number → 数值相加 →3 -
3 + '3':有 string 参与 → 字符串拼接 →'33'
追问3:以下代码输出什么?
console.log(true + false);
console.log([] + {});
console.log({} + []);
console.log(1 + - + + + 1);true + false // 1(ToNumber(true)=1, ToNumber(false)=0, 1+0=1)
[] + {} // '[object Object]'([]→'', {}→'[object Object]')
{} + [] // 0(⚠ 这行在浏览器中结果是 0)
// 原因:行首 {} 被解析为代码块(空语句块),实际表达式是 +[] → 0
// 若加括号:({} + []) → '[object Object]'
1 + - + + + 1 // 0
// 一元 + 和 - 从右向左结合:+1=1, ++1=1, ++1=1, -1=-1, 1+(-1)=0追问4:实现一个 isEqual 函数,深比较两个任意类型值是否相等(考虑 NaN、null、对象属性、日期、正则、循环引用)。
function isEqual(a, b, cache = new WeakSet()) {
// 处理 NaN(NaN !== NaN)
if (a !== a && b !== b) return true;
// 处理基本类型和 null/undefined
if (a === b) return true;
if (a === null || b === null || typeof a !== typeof b) return false;
// 处理循环引用
if (cache.has(a) || cache.has(b)) return a === b;
cache.add(a);
cache.add(b);
// 处理 Date
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// 处理 RegExp
if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source && a.flags === b.flags;
}
// 处理 Map
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) return false;
for (const [k, v] of a) {
if (!b.has(k) || !isEqual(v, b.get(k), cache)) return false;
}
return true;
}
// 处理 Set
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) return false;
for (const v of a) {
if (!b.has(v)) return false;
}
return true;
}
// 处理数组和对象
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!isEqual(a[key], b[key], cache)) return false;
}
return true;
}
// 测试
const obj1 = { a: 1, b: { c: NaN } };
const obj2 = { a: 1, b: { c: NaN } };
console.log(isEqual(obj1, obj2)); // true
console.log(isEqual(NaN, NaN)); // true
console.log(isEqual(+0, -0)); // true(=== 返回 true)追问5:你在实际开发中踩过哪些隐式类型转换的坑?如何通过工具或编码规范避免?
常见踩坑场景:
// 1. 表单输入值拼接而非求和
const age = '18'; // input.value 返回 string
console.log(age + 1); // '181' 而非 19
// 2. if 条件对 0 的误判
function processCount(count) {
if (count) { // count=0 时不会执行
// ...
}
}
// 修复:if (count !== null && count !== undefined)
// 3. 对象作为 Map 的 key
const key = { id: 1 };
const map = { [key]: 'value' }; // key 被 toString() 转为 '[object Object]'
// 修复:用 Map 数据结构替代
// 4. Array.indexOf 无法找到 NaN
[NaN].indexOf(NaN); // -1(indexOf 使用严格相等)
// 修复:使用 Array.prototype.includes
[NaN].includes(NaN); // true避免策略:
-
ESLint 规则:开启
eqeqeq(强制===/!==)、no-implicit-coercion -
TypeScript:编译期类型检查,杜绝隐式转换
-
Number 转换:表单数值用
Number(val)或+val而非parseInt -
判空习惯:显式判断
=== null || === undefined而非if (!val) -
Deep equality:迭代遍历使用
Object.values/keys而非for...in(防原型链污染)