八:JavaScript 数据类型

一、为什么数据类型是必过的一道坎

上一篇我们讲了原型链和面向对象,那是 JavaScript 的“组织方式”。这一篇要讲的是 JavaScript 的“基本粒子”——数据类型。

你写的每一行 JavaScript 代码,都在和数据打交道。变量存的是数据,函数处理的是数据,表达式计算的是数据。如果你不清楚数据在底层是如何存储、如何传递、如何比较的,你就会在某个深夜对着一个 typeof null === 'object' 的结果陷入沉思,或者被 0.1 + 0.2 !== 0.3 逼疯。

本篇的目标,就是把这些让人困惑的问题一个一个拆开,从底层原理到实战技巧,彻底讲清楚。

二、两种截然不同的存储方式:栈与堆

JavaScript 的数据类型虽多,但从存储方式上只分两类,这条分界线比任何官方分类都重要。

基本类型:存在栈里,按值访问

基本类型的数据直接存储在栈内存中。当你把一个基本类型的值赋给一个变量时,这个变量的内存空间里存的就是值本身

let a = 10;
let b = a;     // 把 a 的值复制一份给 b
a = 20;
console.log(b); // 10 —— b 不受 a 影响,因为 b 存的是独立的副本

基本类型有七种:stringnumberbooleannullundefinedsymbolbigint

引用类型:存在堆里,按引用访问

对象(包括数组、函数)的数据存储在堆内存中。变量里存的不是对象本身,而是一个指向堆内存中该对象的地址(引用)

let objA = { name: '张三' };
let objB = objA;      // 把 objA 存的地址复制给 objB,两个变量指向同一个对象
objA.name = '李四';
console.log(objB.name); // '李四' —— objB 也变了!因为它俩指向同一个对象

这就是“引用类型”名字的由来——变量存储的是引用(地址),不是值本身。这解释了为什么修改 objA 会影响 objB,也解释了为什么两个内容相同的对象用 === 比较却返回 false(因为它们比较的是地址,不是内容)。

一张对比表

特性 基本类型 引用类型
存储位置 栈内存(值本身) 堆内存(变量存地址)
赋值行为 复制值,互不影响 复制地址,指向同一对象
比较行为 比较值 比较地址(引用)
可变性 不可变(修改会创建新值) 可变(可以直接修改属性)
包含哪些 string, number, boolean, null, undefined, symbol, bigint Object, Array, Function, Date, RegExp 等

三、typeof:类型检测的第一把尺子

typeof 是检测数据类型最常用的操作符。它返回一个表示类型的字符串。但 typeof 有几个著名的坑,必须逐个说清楚。

基本用法

typeof 'hello';      // 'string'
typeof 42;           // 'number'
typeof true;         // 'boolean'
typeof undefined;    // 'undefined'
typeof Symbol();     // 'symbol'
typeof 123n;         // 'bigint'
typeof {};           // 'object'
typeof [];           // 'object'
typeof null;         // 'object'  ← 最著名的坑
typeof function(){}; // 'function'

坑一:typeof null === 'object'

这是 JavaScript 诞生之初就存在的 bug,而且因为历史原因无法修复。在 JavaScript 的底层实现中,值的类型信息存储在二进制的前几位。null 的二进制表示恰好全是 0,而当时判断对象的逻辑是“如果前三位是 0,就是对象”。于是 null 被错误地归类为 object

如何正确判断 null

let value = null;
// 不能用 typeof,要直接用 === 比较
if (value === null) {
  console.log('这是 null');
}

坑二:typeof [] === 'object'

数组本质上也是对象,所以 typeof 无法区分数组和普通对象。要判断一个值是不是数组,用 Array.isArray()

Array.isArray([]);   // true
Array.isArray({});   // false

坑三:typeof NaN === 'number'

NaN 的意思是“不是数字”(Not a Number),但它的类型却是 number。这句话听起来矛盾,但逻辑是通顺的:NaN 是数字类型中的一个特殊值,表示“这个数值无法表示”(比如 0 除以 0 的结果)。

判断一个值是不是 NaN 需要用 Number.isNaN()

Number.isNaN(NaN);       // true
Number.isNaN('hello');   // false —— 全局的 isNaN() 会先把参数转成数字,不要用它

注意区分:全局函数 isNaN() 会先把参数转换成数字再判断,这导致 isNaN('hello') 返回 true(因为 'hello' 转数字是 NaN)。ES6 引入的 Number.isNaN() 不会做类型转换,更准确。

四、instanceof:沿着原型链向上查找

instanceof 用来判断一个对象是否是某个构造函数的实例。它的工作机制是:沿着对象的原型链向上查找,看是否能找到该构造函数的 prototype 属性。

[] instanceof Array;       // true —— [] 的原型链上有 Array.prototype
[] instanceof Object;      // true —— [] 的原型链上也有 Object.prototype
{} instanceof Object;      // true
new Date() instanceof Date; // true

function Foo() {}
let f = new Foo();
f instanceof Foo;          // true
f instanceof Object;       // true

instanceof 的局限

  • 不能判断基本类型42 instanceof Number 返回 false,因为 42 是基本类型,不是对象。只有 new Number(42) 这样的包装对象才能被 instanceof 识别。
  • 跨 iframe 或跨窗口失效:不同窗口有各自独立的全局执行环境,它们的 Array.prototype 是不同的对象。instanceof 无法识别来自另一个窗口的数组。对于这种场景,用 Array.isArray()

更精确的类型检测:Object.prototype.toString.call()

这是一个“终极”类型检测方法,可以精确区分所有内置类型:

Object.prototype.toString.call('hello');     // '[object String]'
Object.prototype.toString.call(42);          // '[object Number]'
Object.prototype.toString.call(true);        // '[object Boolean]'
Object.prototype.toString.call(null);        // '[object Null]'
Object.prototype.toString.call(undefined);   // '[object Undefined]'
Object.prototype.toString.call([]);          // '[object Array]'
Object.prototype.toString.call({});          // '[object Object]'
Object.prototype.toString.call(function(){});// '[object Function]'
Object.prototype.toString.call(new Date());  // '[object Date]'
Object.prototype.toString.call(/regex/);     // '[object RegExp]'
Object.prototype.toString.call(new Map());   // '[object Map]'
Object.prototype.toString.call(new Set());   // '[object Set]'
Object.prototype.toString.call(Symbol());    // '[object Symbol]'

这个方法内部调用了 [[Class]] 内部属性(规范中的叫法),返回一个格式为 [object Type] 的字符串。它比 typeof 精确,比 instanceof 适用范围广,是编写工具库时常用的类型判断手段。

五、类型转换:隐式转换的规则与陷阱

JavaScript 是弱类型语言——变量不绑定类型,不同类型之间可以自动转换。这种灵活性是双刃剑:写起来快,读起来容易踩坑。

转换为字符串

加号 + 只要有一侧是字符串,就会触发字符串拼接:

console.log('5' + 3);       // '53' —— 数字 3 被转为字符串
console.log('5' + true);    // '5true'
console.log('5' + null);    // '5null'
console.log('5' + undefined); // '5undefined'
console.log('5' + {});      // '5[object Object]'
console.log('5' + [1,2]);   // '51,2' —— 数组先转为字符串 '1,2'

转换为数字

减号 -、乘号 *、除号 / 会尝试把操作数转为数字:

console.log('5' - 3);       // 2 —— '5' 转为数字 5
console.log('5' * '2');     // 10
console.log('5' - 'abc');   // NaN —— 'abc' 无法转为数字
console.log(true + 1);      // 2 —— true 转为 1
console.log(false + 1);     // 1 —— false 转为 0
console.log(null + 1);      // 1 —— null 转为 0(这是一个常见陷阱!)
console.log(undefined + 1); // NaN —— undefined 转为 NaN

重点记忆null 转数字是 0undefined 转数字是 NaN。它们的转换结果不同,是很多 bug 的来源。

转换为布尔值

在条件判断(ifwhile、三元运算符)中,值会被转为布尔值。以下六个值转为 false其余所有值都转为 true

  • false
  • 0
  • ''""(空字符串)
  • null
  • undefined
  • NaN

这六个值统称为 falsy(假值)。其他所有值(包括空数组 []、空对象 {}、字符串 '0'、字符串 'false')都是 truthy(真值)

if ([]) { console.log('空数组是真值'); }     // 会执行
if ({}) { console.log('空对象是真值'); }     // 会执行
if ('0') { console.log('字符串0是真值'); }   // 会执行
if ('false') { console.log('字符串false是真值'); } // 会执行

== vs ===:松散相等与严格相等

===(严格相等)不会做类型转换,类型不同直接返回 false

==(松散相等)会先做类型转换再比较。它的转换规则非常复杂,这里是几个最坑的例子:

console.log(0 == false);       // true —— false 转数字为 0
console.log('' == false);      // true —— 两边都转数字,'' 转 0,false 转 0
console.log(null == undefined);// true —— 规范明确规定它们相等
console.log(null == 0);        // false —— null 只等于 undefined 和自己
console.log([] == false);      // true —— [] 转字符串 '',再转数字 0
console.log([] == 0);          // true —— [] 转字符串 '',再转数字 0
console.log([1,2] == '1,2');   // true —— 数组转字符串为 '1,2'

原则:始终使用 ===,除非你完全清楚 == 的转换规则并且有意利用它(比如判断 value == null 可以同时匹配 nullundefined)。

六、浮点数精度:0.1 + 0.2 !== 0.3

这是 JavaScript 中最经典的数值陷阱,但它不是 JavaScript 的 bug,而是所有使用 IEEE 754 标准的语言共有的特性

原因

计算机用二进制存储数字。十进制的 0.10.2 在二进制中是无限循环小数,就像十进制的 1/3 是 0.33333… 无限循环一样。计算机只能用有限的位数来近似表示它们。当两个近似值相加时,误差累积,结果就不等于精确的 0.3

console.log(0.1 + 0.2);        // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false

解决方案

方案一:使用容差比较

不要求绝对相等,而是判断差值是否在一个极小范围内:

function isApproximatelyEqual(a, b, epsilon = 1e-10) {
  return Math.abs(a - b) < epsilon;
}

console.log(isApproximatelyEqual(0.1 + 0.2, 0.3)); // true

ES6 引入了 Number.EPSILON,代表 JavaScript 能表示的最小精度差值:

console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON); // true

方案二:将小数转为整数计算

这在金额计算中常用——把元转成分(整数),计算完再转回元:

let result = (0.1 * 100 + 0.2 * 100) / 100;  // 0.3
console.log(result); // 0.3

方案三:使用第三方库

对于高精度需求(如金融计算),使用 decimal.jsbig.js 等库,它们用字符串或大整数来模拟精确的十进制运算。

七、SymbolBigInt:ES6 后的两个新成员

Symbol:独一无二的值

Symbol 是 ES6 引入的基本类型。每个 Symbol() 调用返回的值都是独一无二的,即使传入相同的描述字符串:

let sym1 = Symbol('id');
let sym2 = Symbol('id');
console.log(sym1 === sym2); // false —— 每个 Symbol 都是唯一的

// Symbol 主要用作对象属性的键,避免属性名冲突
let obj = {
  [sym1]: 'value1',
  [sym2]: 'value2'
};
console.log(obj[sym1]); // 'value1'

Symbol.for() 可以创建全局共享的 Symbol:

let globalSym1 = Symbol.for('app.id');
let globalSym2 = Symbol.for('app.id');
console.log(globalSym1 === globalSym2); // true —— 同一个全局 Symbol

BigInt:突破安全整数限制

JavaScript 的 number 类型使用 64 位浮点数存储,能精确表示的整数范围是 -(2^53 - 1)2^53 - 1(即 Number.MIN_SAFE_INTEGERNumber.MAX_SAFE_INTEGER)。超过这个范围的整数计算会丢失精度。

BigInt(ES2020 引入)解决了这个问题,可以表示任意大小的整数:

let bigNum = 9007199254740993n;  // 末尾加 n 表示 BigInt
let anotherBig = BigInt('9007199254740993');  // 也可以用 BigInt() 函数

console.log(bigNum + 1n);  // 9007199254740994n —— 精确计算
console.log(bigNum + 1);   // TypeError —— BigInt 不能和普通 number 混合运算

注意BigInt 和普通 number 不能直接混合运算,必须显式转换。而且 Math 对象的方法不支持 BigInt

八、综合演示:一个类型检测工具函数

下面这个函数综合运用了本篇学到的各种类型检测方法,可以作为你的工具箱储备:

/**
 * 精确获取任意值的类型
 * 返回规范中的 [[Class]] 类型名称
 */
function getType(value) {
  // null 需要特殊处理,因为 typeof null === 'object'
  if (value === null) return 'null';

  // undefined 用 typeof 即可
  if (value === undefined) return 'undefined';

  // 基本类型用 typeof(但要注意 typeof 无法区分 null,null 已在上面处理)
  let type = typeof value;
  if (type !== 'object' && type !== 'function') {
    return type;
  }

  // 对象类型用 Object.prototype.toString 获取精确类型
  let classType = Object.prototype.toString.call(value);
  // '[object Array]' → 'array'
  return classType.slice(8, -1).toLowerCase();
}

// 测试
console.log(getType(42));          // 'number'
console.log(getType('hello'));     // 'string'
console.log(getType(true));        // 'boolean'
console.log(getType(null));        // 'null'
console.log(getType(undefined));   // 'undefined'
console.log(getType(Symbol()));    // 'symbol'
console.log(getType(123n));        // 'bigint'
console.log(getType([]));          // 'array'
console.log(getType({}));          // 'object'
console.log(getType(new Date()));  // 'date'
console.log(getType(/regex/));     // 'regexp'
console.log(getType(new Map()));   // 'map'
console.log(getType(new Set()));   // 'set'
console.log(getType(function(){}));// 'function'
console.log(getType(NaN));         // 'number' —— NaN 类型确实是 number

九、本篇小结

这一篇我们拆解了 JavaScript 的数据类型系统:

  • 栈与堆:基本类型存栈中(按值访问),引用类型存堆中(按引用访问)。这是理解赋值、传参、比较行为的基础。
  • typeof:最常用的类型检测,但有三个坑——null 返回 'object'、无法区分数组、NaN 返回 'number'
  • instanceof:沿着原型链检测,不能判断基本类型,跨窗口会失效。
  • Object.prototype.toString.call():终极类型检测方案,可以精确区分所有内置类型。
  • 隐式类型转换+ 优先转字符串,-/*// 转数字。记住六个 falsy 值。始终使用 ===
  • 浮点数精度0.1 + 0.2 !== 0.3 是因为 IEEE 754 的二进制近似。用 Number.EPSILON 或整数运算解决。
  • SymbolBigInt:ES6 后新增的两个基本类型,前者用于唯一标识,后者用于大整数精确计算。

数据类型是 JavaScript 的底层土壤。后面要学的执行上下文、作用域链、闭包、异步编程,都建立在你对数据如何存储和传递的理解之上。

下一篇预告

下一篇——《JavaScript 对象剖析》:我们将深入对象这一核心数据结构。对象属性的描述符(writableenumerableconfigurable)是什么?Object.defineProperty 能做什么?gettersetter 如何工作?如何用 Object.create() 实现精准的原型继承?这些问题,下一篇用大量代码实例逐一解答。

前端,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞13 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容