【每日一面】类型系统与类型转换

2026-09-15 19 min 6824 字 -- 次阅读
摘要

JavaScript 的类型转换全都落在 ToPrimitive、ToString、ToNumber、ToBoolean 四个抽象操作上,把加号与宽松相等(==)的规则串起来,[] == ![]、{} + [] 这类题就不再是靠背的。

基础问答

问:JavaScript 有哪几种数据类型?按什么维度分类?

答: ECMAScript 标准将数据类型分为两大类,共 8 种:

基本类型(Primitive Types): 值直接存储在栈内存中

  • undefinednullbooleannumberstringsymbol(ES6)、bigint(ES2020)

引用类型(Reference Types): 值存在堆内存,栈存引用地址

  • object(含普通对象、数组、函数、Date、RegExp、Map、Set 等所有非基本类型)

核心区分维度是存储方式:基本类型按值访问,引用类型按引用访问。typeof null === 'object' 是 JavaScript 一个历史遗留 bug,null 本质上是一个基本类型。

扩展延伸

概念铺垫:类型系统的存在意义

JavaScript 是动态弱类型语言,变量在运行时才确定类型,且允许隐式类型转换。这与静态类型语言(TypeScript/Java)形成比较强烈的对比。

javascript
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 失效;基本类型无法直接用(除非自动解包)
javascript
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 种抽象操作:ToPrimitiveToStringToNumberToBoolean

flowchart LR
    A["任意值"] --> B{"运算场景"}
    B -->|"&quot;+&quot; 字符串拼接优先"| 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"]

+ 运算符:字符串优先

只要一侧是字符串,+ 就执行字符串拼接;否则执行数值加法。

javascript
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"]

经典问题:

javascript
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

plaintext
false, 0, -0, 0n, '', null, undefined, NaN
javascript
if ([])     // true(空数组是 truthy)
if ({})     // true
if ('false')// true(非空字符串)
if (new Boolean(false)) // true(对象永远是 truthy)

显式类型转换

转换目标推荐方式说明
StringString(val)val.toString()nullundefined 只能用 String().toString() 会抛错
NumberNumber(val)+val解析规则严格,Number('') 返回 0
IntegerparseInt(val, radix)默认十进制,parseInt('08') 在旧环境可能被识别为八进制
FloatparseFloat(val)解析到第一个非数字字符
Boolean!!val简洁高效
数字限定Math.floor/ceil/round取整需求
javascript
// 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/SymbolDate/RegExp
= 赋值b = a不拷贝
扩展运算符{...a}浅拷贝正常拷贝引用正常拷贝引用
Object.assignObject.assign({}, a)浅拷贝同上同上
JSON.parse(JSON.stringify)深拷贝
手写递归deepClone(a)深拷贝

面试追问

追问1:typeof null 为什么是 'object'?这个 bug 能修吗?

这是 JavaScript 诞生时的设计缺陷。第一版 JavaScript 用类型标签(type tag)来存储数据类型:对象类型标签是 0,而 null 的机器码表示是空指针(0x00),因此 typeof 错误地将 null 识别为 'object'

javascript
// 在 V8 引擎层面,typeof 的实现大致是:
// 如果 type_tag === 0,返回 'object'
// null 的二进制正好全部为 0...

这个 bug 在 ES 提案阶段曾尝试修复(typeof null === 'null'),但已有大量线上代码依赖 typeof null === 'object' 的判断逻辑,修复会破坏兼容性,最终被拒绝。社区替代方案是 val === null ? 'null' : typeof val

追问2:'1' + 2 + 31 + 2 + '3' 结果分别是什么?解释运算过程。

plaintext
'1' + 2 + 3  →  '123'
1 + 2 + '3'  →  '33'

运算过程(+ 是左结合):

  • '1' + 2:有 string 参与 → 字符串拼接 → '12'

  • '12' + 3:继续拼接 → '123'

  • 1 + 2:两边都是 number → 数值相加 → 3

  • 3 + '3':有 string 参与 → 字符串拼接 → '33'

追问3:以下代码输出什么?

javascript
console.log(true + false);
console.log([] + {});
console.log({} + []);
console.log(1 + - + + + 1);
javascript
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、对象属性、日期、正则、循环引用)。

javascript
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:你在实际开发中踩过哪些隐式类型转换的坑?如何通过工具或编码规范避免?

常见踩坑场景:

javascript
// 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

避免策略:

  1. ESLint 规则:开启 eqeqeq(强制 ===/!==)、no-implicit-coercion

  2. TypeScript:编译期类型检查,杜绝隐式转换

  3. Number 转换:表单数值用 Number(val)+val 而非 parseInt

  4. 判空习惯:显式判断 === null || === undefined 而非 if (!val)

  5. Deep equality:迭代遍历使用 Object.values/keys 而非 for...in(防原型链污染)

评论